From 4e8216841e1e840412b43d14aeda77969edc27a3 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 05:21:26 +0300 Subject: [PATCH 01/62] Add parsed rule types --- src/types/form/NewRulePromptForm.ts | 19 ++++++++++++++ src/types/form/index.ts | 1 + src/types/onyx/ParsedPolicyRule.ts | 40 +++++++++++++++++++++++++++++ src/types/onyx/index.ts | 2 ++ 4 files changed, 62 insertions(+) create mode 100644 src/types/form/NewRulePromptForm.ts create mode 100644 src/types/onyx/ParsedPolicyRule.ts diff --git a/src/types/form/NewRulePromptForm.ts b/src/types/form/NewRulePromptForm.ts new file mode 100644 index 000000000000..f7d791ddcbfd --- /dev/null +++ b/src/types/form/NewRulePromptForm.ts @@ -0,0 +1,19 @@ +import type {ValueOf} from 'type-fest'; + +import type Form from './Form'; + +const INPUT_IDS = { + PROMPT: 'prompt', +} as const; + +type InputID = ValueOf; + +type NewRulePromptForm = Form< + InputID, + { + [INPUT_IDS.PROMPT]: string; + } +>; + +export type {NewRulePromptForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 799f1ba69f04..282d00ccdc25 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -128,6 +128,7 @@ export type {AddAgentForm} from './AddAgentForm'; export type {EditAgentNameForm} from './EditAgentNameForm'; export type {EditAgentPromptForm} from './EditAgentPromptForm'; export type {AddAgentRuleForm} from './AddAgentRuleForm'; +export type {NewRulePromptForm} from './NewRulePromptForm'; export type {EditAgentRuleForm} from './EditAgentRuleForm'; export type {RilletCredentialsForm} from './RilletCredentialsForm'; export type {DualEntryCredentialsForm} from './DualEntryCredentialsForm'; diff --git a/src/types/onyx/ParsedPolicyRule.ts b/src/types/onyx/ParsedPolicyRule.ts new file mode 100644 index 000000000000..71ef643a764b --- /dev/null +++ b/src/types/onyx/ParsedPolicyRule.ts @@ -0,0 +1,40 @@ +import type CONST from '@src/CONST'; +import type {FlagForReviewRuleForm, MerchantRuleForm, RequireFieldsRuleForm, SpendRuleForm} from '@src/types/form'; + +import type {ValueOf} from 'type-fest'; + +/** How far along the parse of an admin's rule description is, or why no rule was built */ +type ParsedPolicyRuleState = ValueOf; + +/** The rule type a description was mapped to */ +type ParsedPolicyRuleType = ValueOf; + +/** + * The form values Concierge filled in. Every field is optional and the field names are unique across the four + * rule forms, so this seeds whichever draft ruleType names. + */ +type ParsedPolicyRuleValues = Partial & Partial & Partial & Partial; + +/** Model of the rule Concierge built from an admin's plain-English description */ +type ParsedPolicyRule = { + /** The attempt this answer belongs to, so an earlier one is not mistaken for it */ + parseID: string; + + /** Current state of the parse */ + state: ParsedPolicyRuleState; + + /** The rule type the description was mapped to, when state is rule */ + ruleType?: ParsedPolicyRuleType; + + /** The form values to seed the matching rule draft with, when state is rule */ + rule?: ParsedPolicyRuleValues; + + /** Short plain-English description of the rule that was built */ + summary?: string; + + /** The area the deterministic rule types cannot express, when state is unsupported */ + unsupportedArea?: string; +}; + +export default ParsedPolicyRule; +export type {ParsedPolicyRuleState, ParsedPolicyRuleType, ParsedPolicyRuleValues}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 88fd9fe74a9c..18d30e334c6f 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -120,6 +120,7 @@ import type OnyxInputOrEntry from './OnyxInputOrEntry'; import type {AnyOnyxUpdatesFromServer, OnyxUpdateEvent, OnyxUpdatesFromServer} from './OnyxUpdatesFromServer'; import type {DecisionName, OriginalMessageIOU} from './OriginalMessage'; import type Pages from './Pages'; +import type ParsedPolicyRule from './ParsedPolicyRule'; import type PendingConciergeResponse from './PendingConciergeResponse'; import type {PendingContactAction} from './PendingContactAction'; import type PersonalBankAccount from './PersonalBankAccount'; @@ -243,6 +244,7 @@ export type { Domain, Download, ExportDownload, + ParsedPolicyRule, DuplicateWorkspace, CopyPolicySettingsNVP, CopyPolicySettings, From 62630832b31c075c0711a3a2cb92c336cb681a76 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 05:21:28 +0300 Subject: [PATCH 02/62] Add parsed rule keys --- src/CONST/index.ts | 19 +++++++++++++++++++ src/ONYXKEYS.ts | 11 +++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3b7858de4539..93b9818f6d04 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -494,6 +494,23 @@ const CONST = { MAX_LENGTH: 83, }, + PARSED_POLICY_RULE: { + STATE: { + PARSING: 'parsing', + RULE: 'rule', + UNSUPPORTED: 'unsupported', + MULTIPLE_RULES: 'multipleRules', + UNINTELLIGIBLE: 'unintelligible', + FAILED: 'failed', + }, + RULE_TYPE: { + REQUIRE_FIELDS: 'requireFields', + FLAG_FOR_REVIEW: 'flagForReview', + RESTRICT_CARD_SPEND: 'restrictCardSpend', + EXPENSE_DEFAULTS: 'expenseDefaults', + }, + }, + EXPORT_DOWNLOAD: { STATE: { PREPARING: 'preparing', @@ -9208,6 +9225,8 @@ const CONST = { NEW_RULE_MENU_ITEM_REQUIRE_FIELDS: 'WorkspaceRules-NewRuleMenuItem-RequireFields', NEW_RULE_MENU_ITEM_APPLY_EXPENSE_DEFAULTS: 'WorkspaceRules-NewRuleMenuItem-ApplyExpenseDefaults', NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE: 'WorkspaceRules-NewRuleMenuItem-CreateAgentRule', + NEW_RULE_BUILD_MANUALLY: 'WorkspaceRules-NewRule-BuildManually', + NEW_RULE_CREATE_AGENT_RULE: 'WorkspaceRules-NewRule-CreateAgentRule', REQUIRE_RECEIPTS_SAVE: 'WorkspaceRules-RequireReceiptsSave', REQUIRE_FIELDS_SAVE: 'WorkspaceRules-RequireFieldsSave', FLAG_RECEIPT_LINE_ITEMS_SAVE: 'WorkspaceRules-FlagReceiptLineItemsSave', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index defbc51b0d66..b8205eaab2b7 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -477,6 +477,12 @@ const ONYXKEYS = { /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ IS_LOADING_AGENT_RULE_SUGGESTIONS: 'isLoadingAgentRuleSuggestions', + /** Whether the request asking Concierge to build a rule is in flight */ + IS_LOADING_PARSED_POLICY_RULE: 'isLoadingParsedPolicyRule', + + /** The rule Concierge built from an admin's description, tagged with the attempt it belongs to */ + NVP_PARSED_POLICY_RULE: 'nvp_parsedPolicyRule', + /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ IS_LOADING_AGENT_TEMPLATES: 'isLoadingAgentTemplates', @@ -1306,6 +1312,8 @@ const ONYXKEYS = { EDIT_AGENT_PROMPT_FORM_DRAFT: 'editAgentPromptFormDraft', ADD_AGENT_RULE_FORM: 'addAgentRuleForm', ADD_AGENT_RULE_FORM_DRAFT: 'addAgentRuleFormDraft', + NEW_RULE_PROMPT_FORM: 'newRulePromptForm', + NEW_RULE_PROMPT_FORM_DRAFT: 'newRulePromptFormDraft', EDIT_AGENT_RULE_FORM: 'editAgentRuleForm', EDIT_AGENT_RULE_FORM_DRAFT: 'editAgentRuleFormDraft', RILLET_CREDENTIALS_FORM: 'rilletCredentialsForm', @@ -1461,6 +1469,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.EDIT_AGENT_NAME_FORM]: FormTypes.EditAgentNameForm; [ONYXKEYS.FORMS.EDIT_AGENT_PROMPT_FORM]: FormTypes.EditAgentPromptForm; [ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM]: FormTypes.AddAgentRuleForm; + [ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM]: FormTypes.NewRulePromptForm; [ONYXKEYS.FORMS.EDIT_AGENT_RULE_FORM]: FormTypes.EditAgentRuleForm; [ONYXKEYS.FORMS.RILLET_CREDENTIALS_FORM]: FormTypes.RilletCredentialsForm; [ONYXKEYS.FORMS.DUALENTRY_CREDENTIALS_FORM]: FormTypes.DualEntryCredentialsForm; @@ -1693,6 +1702,8 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; [ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS]: boolean; + [ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE]: boolean; + [ONYXKEYS.NVP_PARSED_POLICY_RULE]: OnyxTypes.ParsedPolicyRule; [ONYXKEYS.IS_LOADING_AGENT_TEMPLATES]: boolean; [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean; From 2b7dd9e80e0d1d824c0a5bcc8420cbe5743d6622 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 05:21:29 +0300 Subject: [PATCH 03/62] Add ParsePolicyRule command --- src/libs/API/parameters/ParsePolicyRuleParams.ts | 7 +++++++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 ++ 3 files changed, 10 insertions(+) create mode 100644 src/libs/API/parameters/ParsePolicyRuleParams.ts diff --git a/src/libs/API/parameters/ParsePolicyRuleParams.ts b/src/libs/API/parameters/ParsePolicyRuleParams.ts new file mode 100644 index 000000000000..59a13d4a189b --- /dev/null +++ b/src/libs/API/parameters/ParsePolicyRuleParams.ts @@ -0,0 +1,7 @@ +type ParsePolicyRuleParams = { + policyID: string; + parseID: string; + prompt: string; +}; + +export default ParsePolicyRuleParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 928f16e03d59..60bdb2ca4511 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -409,6 +409,7 @@ export type {default as SetPolicyExpenseMaxAmountNoItemizedReceipt} from './SetP export type {default as SetPolicyExpenseMaxAmount} from './SetPolicyExpenseMaxAmount'; export type {default as SetPolicyExpenseMaxAge} from './SetPolicyExpenseMaxAge'; export type {default as AddPolicyAgentRuleParams} from './AddPolicyAgentRuleParams'; +export type {default as ParsePolicyRuleParams} from './ParsePolicyRuleParams'; export type {default as UpdatePolicyAgentRuleParams} from './UpdatePolicyAgentRuleParams'; export type {default as DeletePolicyAgentRuleParams} from './DeletePolicyAgentRuleParams'; export type {default as UpdateCustomRules} from './UpdateCustomRules'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 0d61044516e2..8367cb4177b3 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -290,6 +290,7 @@ const WRITE_COMMANDS = { DISABLE_POLICY_COMMUTER_EXCLUSIONS: 'DisablePolicyCommuterExclusions', SET_POLICY_EXPENSE_MAX_AGE: 'SetPolicyExpenseMaxAge', ADD_POLICY_AGENT_RULE: 'AddPolicyAgentRule', + PARSE_POLICY_RULE: 'ParsePolicyRule', UPDATE_POLICY_AGENT_RULE: 'UpdatePolicyAgentRule', DELETE_POLICY_AGENT_RULE: 'DeletePolicyAgentRule', UPDATE_CUSTOM_RULES: 'UpdateCustomRules', @@ -1003,6 +1004,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT]: Parameters.SetPolicyExpenseMaxAmount; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AGE]: Parameters.SetPolicyExpenseMaxAge; [WRITE_COMMANDS.ADD_POLICY_AGENT_RULE]: Parameters.AddPolicyAgentRuleParams; + [WRITE_COMMANDS.PARSE_POLICY_RULE]: Parameters.ParsePolicyRuleParams; [WRITE_COMMANDS.UPDATE_POLICY_AGENT_RULE]: Parameters.UpdatePolicyAgentRuleParams; [WRITE_COMMANDS.DELETE_POLICY_AGENT_RULE]: Parameters.DeletePolicyAgentRuleParams; [WRITE_COMMANDS.UPDATE_CUSTOM_RULES]: Parameters.UpdateCustomRules; From 196417ab3912f35141dd42375c1481411f57660e Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 05:21:29 +0300 Subject: [PATCH 04/62] Add parsePolicyRule action --- src/libs/actions/Policy/Rules.ts | 70 +++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 25498d54b9f5..49bcd1d6a061 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -5,6 +5,7 @@ import type { DeletePolicyAgentRuleParams, GetAgentRuleSuggestionsParams, ImportMerchantRulesSpreadsheetParams, + ParsePolicyRuleParams, UpdatePolicyAgentRuleParams, } from '@libs/API/parameters'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; @@ -18,10 +19,11 @@ import Parser from '@libs/Parser'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleForm} from '@src/types/form'; +import NEW_RULE_PROMPT_INPUT_IDS from '@src/types/form/NewRulePromptForm'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; import type Policy from '@src/types/onyx/Policy'; import type {AgentRule, CodingRule, CodingRuleFilter, CodingRuleTax} from '@src/types/onyx/Policy'; -import type {OnyxData} from '@src/types/onyx/Request'; +import type {AnyOnyxUpdate, OnyxData} from '@src/types/onyx/Request'; import type {OnyxUpdate} from 'react-native-onyx'; @@ -417,6 +419,68 @@ function deletePolicyCodingRule(policy: Policy, ruleID: string) { API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); } +/** + * Asks Concierge to turn a plain-English rule description into a structured rule. + * + * The answer arrives asynchronously on the returned parseID's Onyx key, since a background job does the work. + * + * @returns the parseID to read the answer under + */ +function parsePolicyRule(policyID: string, prompt: string): string { + const parseID = NumberUtils.rand64(); + + const optimisticData: AnyOnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE, + value: true, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_PARSED_POLICY_RULE, + value: {parseID, state: CONST.PARSED_POLICY_RULE.STATE.PARSING}, + }, + ]; + + const failureData: AnyOnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE, + value: false, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_PARSED_POLICY_RULE, + value: {parseID, state: CONST.PARSED_POLICY_RULE.STATE.FAILED}, + }, + ]; + + const parameters: ParsePolicyRuleParams = {policyID, parseID, prompt}; + + // No successData clears the loading flag: queueing the job is not the same as having the rule, so Auth + // clears it when it answers and the job clears it if it dies first. + API.write(WRITE_COMMANDS.PARSE_POLICY_RULE, parameters, {optimisticData, failureData}); + + return parseID; +} + +/** + * Shows Concierge's answer as an inline error under the prompt. + */ +function setNewRulePromptError(message: string) { + Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, { + errorFields: {[NEW_RULE_PROMPT_INPUT_IDS.PROMPT]: ErrorUtils.getMicroSecondOnyxErrorWithMessage(message)}, + }); +} + +function clearNewRulePromptError() { + Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, {errors: null, errorFields: null}); +} + +function clearParsedPolicyRule() { + Onyx.set(ONYXKEYS.NVP_PARSED_POLICY_RULE, null); +} + function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: string) { if (!policyID || !agentRuleID || !prompt) { Log.warn('Invalid params for addPolicyAgentRule', {policyID, agentRuleID, prompt}); @@ -693,6 +757,10 @@ export { deletePolicyCodingRule, getTransactionsMatchingCodingRule, addPolicyAgentRule, + parsePolicyRule, + clearParsedPolicyRule, + setNewRulePromptError, + clearNewRulePromptError, updatePolicyAgentRule, deletePolicyAgentRule, clearPolicyCodingRuleErrors, From 770be425edf98e80a9dcaf9fd78a21175f1b50f5 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 05:21:38 +0300 Subject: [PATCH 05/62] Add rule prompt copy --- src/languages/de.ts | 12 ++++++++++++ src/languages/el.ts | 12 ++++++++++++ src/languages/en.ts | 14 +++++++++++++- src/languages/es.ts | 12 ++++++++++++ src/languages/fr.ts | 12 ++++++++++++ src/languages/it.ts | 12 ++++++++++++ src/languages/ja.ts | 12 ++++++++++++ src/languages/nl.ts | 12 ++++++++++++ src/languages/params.ts | 3 +++ src/languages/pl.ts | 12 ++++++++++++ src/languages/pt-BR.ts | 12 ++++++++++++ src/languages/zh-hans.ts | 9 +++++++++ 12 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 099d90c61989..682c94c8d6ff 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8205,6 +8205,18 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc requireFieldsDescription: 'Bestimmte Spesenfelder vorschreiben oder auf ihre Pflicht verzichten.', createAgentRule: 'Agentenregel', createAgentRuleDescription: 'Beschreiben Sie flexible Regeln, die bei Bedarf ausgeführt werden.', + createRuleHeadline: 'Neue Regel erstellen', + describeRule: 'Beschreibe deine Regel und Concierge erstellt sie', + buildManually: 'Regel manuell erstellen', + createAgentRuleInstead: 'Stattdessen eine Agent-Regel erstellen', + promptErrors: { + unsupported: ({area}) => + area + ? `${area}-Regeln werden noch nicht unterstützt – wir arbeiten daran. Bitte versuche vorerst eine andere Regel.` + : 'Diese Regel wird noch nicht unterstützt – wir arbeiten daran. Bitte versuche vorerst eine andere Regel.', + unintelligible: 'Formuliere deine Regel etwa so: Wenn Ausgaben über 100 $ liegen, ist ein Beleg erforderlich.', + multipleRules: 'Bitte eine Regel nach der anderen – beschreibe eine einzelne Regel, z. B.: Käufe von Geschenkkarten blockieren.', + }, }, expenseDefaultsTable: { tableColumnType: 'Typ', diff --git a/src/languages/el.ts b/src/languages/el.ts index 07098ff81d2f..86586c1aa8fd 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8236,6 +8236,18 @@ ${reportName}`, applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', createAgentRule: 'Κανόνας αντιπροσώπου', createAgentRuleDescription: 'Περιγράψτε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', + createRuleHeadline: 'Δημιουργία νέου κανόνα', + describeRule: 'Περιγράψτε τον κανόνα σας και ο Concierge θα τον δημιουργήσει', + buildManually: 'Μη αυτόματη δημιουργία κανόνα', + createAgentRuleInstead: 'Δημιουργία κανόνα πράκτορα αντ’ αυτού', + promptErrors: { + unsupported: ({area}) => + area + ? `Οι κανόνες ${area} δεν υποστηρίζονται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.` + : 'Αυτός ο κανόνας δεν υποστηρίζεται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.', + unintelligible: 'Δοκιμάστε να διατυπώσετε τον κανόνα σας έτσι: αν οι δαπάνες ξεπερνούν τα 100 $, να απαιτείται απόδειξη.', + multipleRules: 'Έναν κανόνα τη φορά — δοκιμάστε να περιγράψετε έναν μόνο κανόνα, π.χ.: αποκλεισμός αγοράς δωροκαρτών.', + }, }, expenseDefaultsTable: { tableColumnType: 'Τύπος', diff --git a/src/languages/en.ts b/src/languages/en.ts index 6287e2d5a6ee..37bfe851987c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7,7 +7,7 @@ import type {ValueOf} from 'type-fest'; import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; -import type {ExportAgainModalDescriptionParams, ExportIntegrationSelectedParams} from './params'; +import type {ExportAgainModalDescriptionParams, ExportIntegrationSelectedParams, NewRulePromptUnsupportedParams} from './params'; import type { AllConnectionName, ConnectionName, @@ -8154,6 +8154,18 @@ const translations = { applyExpenseDefaultsDescription: 'Update fields without submitter doing anything', createAgentRule: 'Agent rule', createAgentRuleDescription: 'Describe flexible rules that run when you need.', + createRuleHeadline: 'Create a new rule', + describeRule: 'Describe your rule and Concierge will build it', + buildManually: 'Build rule manually', + createAgentRuleInstead: 'Create an agent rule instead', + promptErrors: { + unsupported: ({area}: NewRulePromptUnsupportedParams) => + area + ? `${area} rules aren't supported yet — we're working on it. Please try a different rule for now.` + : "That rule isn't supported yet — we're working on it. Please try a different rule for now.", + unintelligible: 'Try phrasing your rule like: if expenses have a value over $100, then require a receipt.', + multipleRules: 'One rule at a time, please — try describing a single rule, like: block gift card purchases.', + }, }, expenseDefaultsTable: { tableColumnType: 'Type', diff --git a/src/languages/es.ts b/src/languages/es.ts index fc6e7adee416..5dbb5d364b29 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8159,6 +8159,18 @@ ${reportName}`, requireFieldsDescription: 'Exige campos específicos de gasto o renuncia a exigirlos.', createAgentRule: 'Regla de agente', createAgentRuleDescription: 'Describe reglas flexibles que se ejecutan cuando las necesitas.', + createRuleHeadline: 'Crear una nueva regla', + describeRule: 'Describe tu regla y Concierge la creará', + buildManually: 'Crear la regla manualmente', + createAgentRuleInstead: 'Crear una regla de agente en su lugar', + promptErrors: { + unsupported: ({area}) => + area + ? `Las reglas de ${area} aún no son compatibles: estamos trabajando en ello. Prueba con otra regla por ahora.` + : 'Esa regla aún no es compatible: estamos trabajando en ello. Prueba con otra regla por ahora.', + unintelligible: 'Prueba a formular tu regla así: si los gastos superan los $100, exige un recibo.', + multipleRules: 'Una regla a la vez, por favor. Prueba a describir una sola regla, como: bloquear la compra de tarjetas regalo.', + }, }, expenseDefaultsTable: { tableColumnType: 'Tipo', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index aa9c91c3e6f5..154b0d27de34 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8230,6 +8230,18 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e requireFieldsDescription: 'Rendre certains champs de dépense obligatoires ou renoncer à les exiger.', createAgentRule: 'Règle d’agent', createAgentRuleDescription: 'Décrivez des règles flexibles qui s’exécutent quand vous en avez besoin.', + createRuleHeadline: 'Créer une nouvelle règle', + describeRule: 'Décrivez votre règle et Concierge la créera', + buildManually: 'Créer la règle manuellement', + createAgentRuleInstead: 'Créer plutôt une règle d’agent', + promptErrors: { + unsupported: ({area}) => + area + ? `Les règles ${area} ne sont pas encore prises en charge — nous y travaillons. Essayez une autre règle pour le moment.` + : 'Cette règle n’est pas encore prise en charge — nous y travaillons. Essayez une autre règle pour le moment.', + unintelligible: 'Essayez de formuler votre règle ainsi : si les dépenses dépassent 100 $, exigez un reçu.', + multipleRules: 'Une règle à la fois, s’il vous plaît — essayez de décrire une seule règle, comme : bloquer les achats de cartes-cadeaux.', + }, }, expenseDefaultsTable: { tableColumnType: 'Type', diff --git a/src/languages/it.ts b/src/languages/it.ts index 0bfd2a9d44a1..e725edf5ef89 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8171,6 +8171,18 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, requireFieldsDescription: 'Richiedi campi specifici per le spese oppure rinuncia a renderli obbligatori.', createAgentRule: 'Regola agente', createAgentRuleDescription: 'Descrivi regole flessibili che vengono eseguite quando ne hai bisogno.', + createRuleHeadline: 'Crea una nuova regola', + describeRule: 'Descrivi la tua regola e Concierge la creerà', + buildManually: 'Crea la regola manualmente', + createAgentRuleInstead: 'Crea invece una regola agente', + promptErrors: { + unsupported: ({area}) => + area + ? `Le regole ${area} non sono ancora supportate: ci stiamo lavorando. Per ora prova una regola diversa.` + : 'Questa regola non è ancora supportata: ci stiamo lavorando. Per ora prova una regola diversa.', + unintelligible: 'Prova a formulare la regola così: se le spese superano i 100 $, richiedi una ricevuta.', + multipleRules: 'Una regola alla volta, per favore: prova a descrivere una singola regola, ad esempio bloccare l’acquisto di carte regalo.', + }, }, expenseDefaultsTable: { tableColumnType: 'Tipo', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index edeac1248c9c..219c1ea5315e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8064,6 +8064,18 @@ ${reportName}`, requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', createAgentRule: 'エージェントルール', createAgentRuleDescription: '必要なときに実行できる柔軟なルールを設定します。', + createRuleHeadline: '新しいルールを作成', + describeRule: 'ルールを説明すると、Conciergeが作成します', + buildManually: '手動でルールを作成', + createAgentRuleInstead: '代わりにエージェントルールを作成', + promptErrors: { + unsupported: ({area}) => + area + ? `${area}のルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。` + : 'そのルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。', + unintelligible: '「経費が100ドルを超える場合は領収書を必須にする」のように書いてみてください。', + multipleRules: '一度に1つのルールのみです。「ギフトカードの購入をブロックする」のように、1つのルールを説明してください。', + }, }, expenseDefaultsTable: { tableColumnType: '種類', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a48f7965ec28..5de3a3b61716 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8149,6 +8149,18 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, requireFieldsDescription: 'Specifieke onkostenvakken verplicht stellen of afzien van het verplicht stellen ervan.', createAgentRule: 'Agentregel', createAgentRuleDescription: 'Beschrijf flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', + createRuleHeadline: 'Een nieuwe regel maken', + describeRule: 'Beschrijf je regel en Concierge bouwt hem', + buildManually: 'Regel handmatig maken', + createAgentRuleInstead: 'Maak in plaats daarvan een agentregel', + promptErrors: { + unsupported: ({area}) => + area + ? `${area}-regels worden nog niet ondersteund — we werken eraan. Probeer voorlopig een andere regel.` + : 'Die regel wordt nog niet ondersteund — we werken eraan. Probeer voorlopig een andere regel.', + unintelligible: 'Formuleer je regel bijvoorbeeld zo: als uitgaven hoger zijn dan $100, vraag dan een bon.', + multipleRules: 'Eén regel per keer graag — beschrijf één enkele regel, bijvoorbeeld: cadeaukaarten blokkeren.', + }, }, expenseDefaultsTable: { tableColumnType: 'Type', diff --git a/src/languages/params.ts b/src/languages/params.ts index bde06b208cd8..f4f13b20500a 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -1,5 +1,7 @@ import type {ConnectionName} from './TranslationTypes'; +type NewRulePromptUnsupportedParams = {area: string}; + type StepCounterParams = {step: number; total?: number; text?: string}; type ParentNavigationSummaryParams = {reportName?: string; workspaceName?: string}; @@ -36,6 +38,7 @@ type ExportAgainModalDescriptionParams = { type ExportIntegrationSelectedParams = {connectionName: ConnectionName; connectionNameFriendly?: string}; export type { + NewRulePromptUnsupportedParams, ParentNavigationSummaryParams, StepCounterParams, ChangeFieldParams, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 5e319a5c9344..49815afb68be 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8171,6 +8171,18 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, requireFieldsDescription: 'Wymagaj konkretnych pól wydatku lub zrezygnuj z ich wymagania.', createAgentRule: 'Reguła agenta', createAgentRuleDescription: 'Opisuj elastyczne reguły, które uruchamiają się wtedy, kiedy tego potrzebujesz.', + createRuleHeadline: 'Utwórz nową regułę', + describeRule: 'Opisz swoją regułę, a Concierge ją utworzy', + buildManually: 'Utwórz regułę ręcznie', + createAgentRuleInstead: 'Utwórz zamiast tego regułę agenta', + promptErrors: { + unsupported: ({area}) => + area + ? `Reguły „${area}” nie są jeszcze obsługiwane — pracujemy nad tym. Na razie spróbuj innej reguły.` + : 'Ta reguła nie jest jeszcze obsługiwana — pracujemy nad tym. Na razie spróbuj innej reguły.', + unintelligible: 'Spróbuj sformułować regułę tak: jeśli wydatek przekracza 100 $, wymagaj paragonu.', + multipleRules: 'Jedna reguła naraz — opisz pojedynczą regułę, na przykład: zablokuj zakup kart podarunkowych.', + }, }, expenseDefaultsTable: { tableColumnType: 'Typ', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9a7cf3345f1e..60dc0fc00097 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8150,6 +8150,18 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, requireFieldsDescription: 'Exigir campos específicos de despesa ou dispensar a exigência deles.', createAgentRule: 'Regra do agente', createAgentRuleDescription: 'Descreva regras flexíveis que são executadas quando você precisar.', + createRuleHeadline: 'Criar uma nova regra', + describeRule: 'Descreva sua regra e o Concierge vai criá-la', + buildManually: 'Criar regra manualmente', + createAgentRuleInstead: 'Criar uma regra de agente em vez disso', + promptErrors: { + unsupported: ({area}) => + area + ? `Regras de ${area} ainda não têm suporte — estamos trabalhando nisso. Tente uma regra diferente por enquanto.` + : 'Essa regra ainda não tem suporte — estamos trabalhando nisso. Tente uma regra diferente por enquanto.', + unintelligible: 'Tente formular sua regra assim: se a despesa for maior que $100, exija um recibo.', + multipleRules: 'Uma regra por vez, por favor — tente descrever uma única regra, como: bloquear a compra de cartões-presente.', + }, }, expenseDefaultsTable: { tableColumnType: 'Tipo', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 3385283d873d..c612f0ddd252 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7857,6 +7857,15 @@ ${reportName}`, requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', createAgentRule: '代理规则', createAgentRuleDescription: '按需运行的灵活规则描述', + createRuleHeadline: '创建新规则', + describeRule: '描述你的规则,Concierge 会帮你创建', + buildManually: '手动创建规则', + createAgentRuleInstead: '改为创建代理规则', + promptErrors: { + unsupported: ({area}) => (area ? `${area}规则暂不支持,我们正在开发中。请先尝试其他规则。` : '该规则暂不支持,我们正在开发中。请先尝试其他规则。'), + unintelligible: '可以这样描述规则:如果支出超过 100 美元,则需要收据。', + multipleRules: '请一次描述一条规则,例如:阻止购买礼品卡。', + }, }, expenseDefaultsTable: { tableColumnType: '类型', From bdddafad008249f61cf978f97c2d451cb72d1a09 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 05:21:39 +0300 Subject: [PATCH 06/62] Add isPrefilled route param --- src/ROUTES.ts | 19 +++++++++++++++++-- src/libs/Navigation/types.ts | 4 ++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2aa339fe2255..a050ec83a703 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -37,6 +37,17 @@ function getOptionalCategoryNameQuery(categoryName?: string): string { return categoryName ? `?categoryName=${encodeURIComponent(categoryName)}` : ''; } +function getRuleCreateQuery(categoryName?: string, isPrefilled?: boolean): string { + const params = []; + if (categoryName) { + params.push(`categoryName=${encodeURIComponent(categoryName)}`); + } + if (isPrefilled) { + params.push('isPrefilled=true'); + } + return params.length ? `?${params.join('&')}` : ''; +} + function getOptionalIsCategoryLockedQuery(isCategoryLocked?: boolean): string { return isCategoryLocked ? '?isCategoryLocked=true' : ''; } @@ -3738,7 +3749,9 @@ const ROUTES = { }, RULES_REQUIRE_FIELDS_RULE_NEW: { route: 'workspaces/:policyID/rules/require-fields-rules/new', - getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/require-fields-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, + /** @param isPrefilled the draft is already seeded, so the page must not reset it on mount */ + getRoute: (policyID: string, categoryName?: string, isPrefilled?: boolean) => + `workspaces/${policyID}/rules/require-fields-rules/new${getRuleCreateQuery(categoryName, isPrefilled)}` as const, }, RULES_REQUIRE_FIELDS_RULE_EDIT: { route: 'workspaces/:policyID/rules/require-fields-rules/edit/:categoryName', @@ -3755,7 +3768,9 @@ const ROUTES = { }, RULES_FLAG_FOR_REVIEW_RULE_NEW: { route: 'workspaces/:policyID/rules/flag-for-review-rules/new', - getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/flag-for-review-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, + /** @param isPrefilled the draft is already seeded, so the page must not reset it on mount */ + getRoute: (policyID: string, categoryName?: string, isPrefilled?: boolean) => + `workspaces/${policyID}/rules/flag-for-review-rules/new${getRuleCreateQuery(categoryName, isPrefilled)}` as const, }, RULES_FLAG_FOR_REVIEW_RULE_EDIT: { route: 'workspaces/:policyID/rules/flag-for-review-rules/edit/:categoryName', diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ceac1fb57cc9..a8ce846a681d 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1650,6 +1650,8 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_NEW]: { policyID: string; categoryName?: string; + /** When 'true', the draft is already seeded and must not be reset on mount. */ + isPrefilled?: string; }; [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_EDIT]: { policyID: string; @@ -1667,6 +1669,8 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_NEW]: { policyID: string; categoryName?: string; + /** When 'true', the draft is already seeded and must not be reset on mount. */ + isPrefilled?: string; }; [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_EDIT]: { policyID: string; From 8b2fe3068e140b9899335e45e525a686293c0092 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 05:21:40 +0300 Subject: [PATCH 07/62] Keep prefilled rule drafts --- .../AddFlagForReviewRulePage.tsx | 2 ++ .../FlagForReviewRulePageBase.tsx | 9 ++++++-- .../AddRequireFieldsRulePage.tsx | 2 ++ .../RequireFieldsRulePageBase.tsx | 22 +++++++++++++------ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx b/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx index 58b8ba4822cf..56139a61e7a4 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx @@ -17,6 +17,7 @@ type AddFlagForReviewRulePageProps = function AddFlagForReviewRulePage({route}: AddFlagForReviewRulePageProps) { const {policyID, categoryName} = route.params; + const isPrefilled = 'isPrefilled' in route.params && route.params.isPrefilled === 'true'; const isCategoryScopedFlow = route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_FOR_REVIEW_RULE_NEW; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -35,6 +36,7 @@ function AddFlagForReviewRulePage({route}: AddFlagForReviewRulePageProps) { initialCategoryName={categoryName} isCategoryLocked={isCategoryScopedFlow ? true : undefined} isCategoryScopedFlow={isCategoryScopedFlow} + isPrefilled={isPrefilled} testID="AddFlagForReviewRulePage" /> ); diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index f3971e850e69..b8066eac40e4 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -49,6 +49,8 @@ type FlagForReviewRulePageBaseProps = { isCategoryLocked?: boolean; /** When true, nested create pages use category dynamic routes (keeps Categories underlay). */ isCategoryScopedFlow?: boolean; + /** When true, the draft was seeded before navigating here, so creating must not reset it. */ + isPrefilled?: boolean; testID: string; }; @@ -66,6 +68,7 @@ function FlagForReviewRulePageBase({ initialCategoryName, isCategoryLocked: isCategoryLockedProp, isCategoryScopedFlow = false, + isPrefilled = false, testID, }: FlagForReviewRulePageBaseProps) { const {translate} = useLocalize(); @@ -102,7 +105,9 @@ function FlagForReviewRulePageBase({ if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - setDraftFlagForReviewRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + if (!isPrefilled) { + setDraftFlagForReviewRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + } } return; } @@ -126,7 +131,7 @@ function FlagForReviewRulePageBase({ initializedDraftForRuleKeyRef.current = categoryName; setDraftFlagForReviewRule(getFlagForReviewFormFromCategory(category, getCurrencyDecimals, policyCurrency)); - }, [category, categoryName, draftMaxExpenseAmount, getCurrencyDecimals, initialCategoryName, isEditing, policyCurrency, selectedCategoryName]); + }, [category, categoryName, draftMaxExpenseAmount, getCurrencyDecimals, initialCategoryName, isEditing, isPrefilled, policyCurrency, selectedCategoryName]); const fetchPolicyData = useCallback(() => { if (!policy?.areCategoriesEnabled || policyCategories) { return; diff --git a/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx b/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx index 90e3ecbf2996..cf196035505a 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx @@ -18,6 +18,7 @@ type AddRequireFieldsRulePageProps = function AddRequireFieldsRulePage({route}: AddRequireFieldsRulePageProps) { const {policyID, categoryName} = route.params; const isCategoryScopedFlow = route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRE_FIELDS_RULE_NEW; + const isPrefilled = 'isPrefilled' in route.params && route.params.isPrefilled === 'true'; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const scopedCategory = categoryName ? policyCategories?.[categoryName] : undefined; @@ -34,6 +35,7 @@ function AddRequireFieldsRulePage({route}: AddRequireFieldsRulePageProps) { categoryName={hasExistingRule ? categoryName : undefined} initialCategoryName={categoryName} isCategoryLocked={isCategoryScopedFlow ? true : undefined} + isPrefilled={isPrefilled} testID="AddRequireFieldsRulePage" /> ); diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 2d84a7cfed2b..0be56ce74d9f 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -52,6 +52,8 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +const SETTING_FIELD_KEYS = [INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const; + type RequireFieldsRulePageBaseProps = { policyID: string; categoryName?: string; @@ -59,10 +61,12 @@ type RequireFieldsRulePageBaseProps = { initialCategoryName?: string; /** When true, the category field is non-interactive (category-scoped create/edit). */ isCategoryLocked?: boolean; + /** When true, the draft was seeded before navigating here, so creating must not reset it. */ + isPrefilled?: boolean; testID: string; }; -function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, isCategoryLocked: isCategoryLockedProp, testID}: RequireFieldsRulePageBaseProps) { +function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, isCategoryLocked: isCategoryLockedProp, isPrefilled = false, testID}: RequireFieldsRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyData = usePolicyData(policyID); @@ -116,7 +120,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Preserve whatever is currently shown (edit often displays category overrides // without those fields being in touchedFields yet). - for (const fieldKey of [INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const) { + for (const fieldKey of SETTING_FIELD_KEYS) { const displayedSetting = getRequireFieldsDisplayedSetting({ fieldKey, category: previousCategory, @@ -153,9 +157,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Remount after a category change loses local touched state — rebuild it from the draft. if (isEditing && categoryName && selectedCategoryName && selectedCategoryName !== categoryName && form) { - const draftSettingKeys = ([INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const).filter( - (fieldKey) => form[fieldKey] !== undefined, - ); + const draftSettingKeys = SETTING_FIELD_KEYS.filter((fieldKey) => form[fieldKey] !== undefined); if (draftSettingKeys.some((fieldKey) => !touchedFields.has(fieldKey))) { setTouchedFields(new Set([...touchedFields, ...draftSettingKeys])); } @@ -168,7 +170,13 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - setDraftRequireFieldsRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + if (isPrefilled) { + // A seeded setting is a chosen one, and the receipt fields only save when they are touched. + // eslint-disable-next-line react-hooks/set-state-in-effect -- seeds local selection state from the seeded draft + setTouchedFields(new Set(SETTING_FIELD_KEYS.filter((fieldKey) => form?.[fieldKey] !== undefined))); + } else { + setDraftRequireFieldsRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + } } return; } @@ -301,7 +309,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, const nextDraft: Partial = { [INPUT_IDS.CATEGORY]: form[INPUT_IDS.CATEGORY], }; - for (const settingFieldKey of [INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const) { + for (const settingFieldKey of SETTING_FIELD_KEYS) { if (keysToClear.includes(settingFieldKey) || form[settingFieldKey] === undefined) { continue; } From 135ef4b7f501123dfbb700ba6574c34e1ba06c90 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 05:21:41 +0300 Subject: [PATCH 08/62] Add rule prompt screen --- src/pages/workspace/rules/RulesNewPage.tsx | 167 +++++++++++++++--- .../workspace/rules/RulesNewPromptForm.tsx | 138 +++++++++++++++ 2 files changed, 280 insertions(+), 25 deletions(-) create mode 100644 src/pages/workspace/rules/RulesNewPromptForm.tsx diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index c6a48218fa5b..91dc0b1d0eef 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -1,3 +1,4 @@ +import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -6,9 +7,12 @@ import Text from '@components/Text'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setDraftValues} from '@libs/actions/FormActions'; +import {setDraftFlagForReviewRule, setDraftMerchantRule, setDraftRequireFieldsRule, setDraftSpendRule} from '@libs/actions/User'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -18,14 +22,21 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; +import {clearNewRulePromptError, clearParsedPolicyRule, parsePolicyRule, setNewRulePromptError} from '@userActions/Policy/Rules'; + import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; +import type {ParsedPolicyRule} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; -import React from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; +import RulesNewPromptForm from './RulesNewPromptForm'; + type RulesNewPageProps = | PlatformStackScreenProps | PlatformStackScreenProps; @@ -51,6 +62,99 @@ function RulesNewPage({route}: RulesNewPageProps) { const illustrations = useMemoizedLazyIllustrations(['CardReaderAlt', 'Flag', 'CheckboxText', 'ReportReceipt', 'AiBot']); const isCategoryScopedCreate = route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_RULES_NEW || !!categoryName; + // The category-scoped flow already knows the category and offers fewer types, so it opens on the list. + const canDescribeRule = isRulesRevampEnabled && !isCategoryScopedCreate; + const [shouldShowRuleTypes, setShouldShowRuleTypes] = useState(!canDescribeRule); + const [parseID, setParseID] = useState(); + + const [submittedPrompt, setSubmittedPrompt] = useState(); + + // Set when the deterministic rule types cannot express the prompt, which an agent rule still can. + const [canOfferAgentRule, setCanOfferAgentRule] = useState(false); + const [parsedRule] = useOnyx(ONYXKEYS.NVP_PARSED_POLICY_RULE); + const [isBuildingRule] = useOnyx(ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE); + + const seedDraftAndNavigate = (rule: ParsedPolicyRule) => { + switch (rule.ruleType) { + case CONST.PARSED_POLICY_RULE.RULE_TYPE.REQUIRE_FIELDS: + setDraftRequireFieldsRule(rule.rule ?? {}); + Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, undefined, true)); + return; + case CONST.PARSED_POLICY_RULE.RULE_TYPE.FLAG_FOR_REVIEW: + setDraftFlagForReviewRule(rule.rule ?? {}); + Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, undefined, true)); + return; + case CONST.PARSED_POLICY_RULE.RULE_TYPE.RESTRICT_CARD_SPEND: + setDraftSpendRule(rule.rule ?? {}); + Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + return; + case CONST.PARSED_POLICY_RULE.RULE_TYPE.EXPENSE_DEFAULTS: + setDraftMerchantRule(rule.rule ?? {}); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + return; + default: + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); + } + }; + + const applyParsedRule = (rule: ParsedPolicyRule) => { + if (rule.state === CONST.PARSED_POLICY_RULE.STATE.PARSING) { + return; + } + + setParseID(undefined); + clearParsedPolicyRule(); + + switch (rule.state) { + case CONST.PARSED_POLICY_RULE.STATE.RULE: + seedDraftAndNavigate(rule); + return; + case CONST.PARSED_POLICY_RULE.STATE.UNSUPPORTED: + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported', {area: rule.unsupportedArea ?? ''})); + setCanOfferAgentRule(true); + return; + case CONST.PARSED_POLICY_RULE.STATE.MULTIPLE_RULES: + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); + return; + case CONST.PARSED_POLICY_RULE.STATE.UNINTELLIGIBLE: + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); + return; + default: + setNewRulePromptError(translate('common.genericErrorMessage')); + } + }; + + useEffect(() => { + if (!parseID || parsedRule?.parseID !== parseID) { + return; + } + applyParsedRule(parsedRule); + // eslint-disable-next-line react-hooks/exhaustive-deps -- applyParsedRule reads only what it is given + }, [parseID, parsedRule]); + + const describeRule = (values: FormOnyxValues) => { + const prompt = values.prompt.trim(); + clearNewRulePromptError(); + setCanOfferAgentRule(false); + setSubmittedPrompt(prompt); + setParseID(parsePolicyRule(policyID, prompt)); + }; + + // An agent enforces in natural language what the deterministic rule types cannot express, so the prompt + // carries over instead of the admin retyping it. + const createAgentRuleFromPrompt = (prompt: string) => { + setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[AGENT_RULE_INPUT_IDS.PROMPT]: prompt}); + Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)); + }; + + const handleBackButtonPress = () => { + if (canDescribeRule && shouldShowRuleTypes) { + setShouldShowRuleTypes(false); + return; + } + Navigation.goBack(); + }; + const newRuleOptions: NewRuleOption[] = [ { key: 'restrictCardSpend', @@ -125,31 +229,44 @@ function RulesNewPage({route}: RulesNewPageProps) { - - - {translate('workspace.rules.newRule.subtitle')} - - {visibleNewRuleOptions.map((option) => ( - - ))} - - + + {canDescribeRule && !shouldShowRuleTypes ? ( + setShouldShowRuleTypes(true)} + onCreateAgentRule={isCustomAgentBetaEnabled && canOfferAgentRule && submittedPrompt ? () => createAgentRuleFromPrompt(submittedPrompt) : undefined} + isLoading={!!isBuildingRule} + /> + ) : ( + + {translate('workspace.rules.newRule.subtitle')} + + {visibleNewRuleOptions.map((option) => ( + + ))} + + + )} ); diff --git a/src/pages/workspace/rules/RulesNewPromptForm.tsx b/src/pages/workspace/rules/RulesNewPromptForm.tsx new file mode 100644 index 000000000000..ea2f70cbaa65 --- /dev/null +++ b/src/pages/workspace/rules/RulesNewPromptForm.tsx @@ -0,0 +1,138 @@ +import Button from '@components/ButtonComposed'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearNewRulePromptError} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/NewRulePromptForm'; + +import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native'; + +import React, {useRef} from 'react'; +import {View} from 'react-native'; + +type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; + +type RulesNewPromptFormProps = { + /** Called with the form values when the admin asks Concierge to build the rule */ + onSubmit: (values: FormOnyxValues) => void; + + /** Called when the admin chooses to pick a rule type themselves */ + onBuildManually: () => void; + + /** Whether Concierge is still building the rule */ + isLoading: boolean; + + /** Offers an agent rule instead, when the deterministic rule types cannot express the prompt */ + onCreateAgentRule?: () => void; +}; + +function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgentRule}: RulesNewPromptFormProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const shouldUseScrollableLayout = useIsInLandscapeMode(); + const formRef = useRef(null); + const describeRuleLabel = translate('workspace.rules.newRule.describeRule'); + + const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in event)) { + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + // The markdown input inserts a line break for any Enter keydown whose default is not already prevented, so the submit combo has to claim it first. + event.preventDefault(); + formRef.current?.submit(); + } + }; + + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!values[INPUT_IDS.PROMPT]?.trim()) { + errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); + } + return errors; + }; + + const inputWrapperStyles: StyleProp = shouldUseScrollableLayout ? [styles.flex1, styles.minHeight42] : [styles.flex1, styles.mnh0, styles.agentRulePromptInput]; + + return ( + + {!!onCreateAgentRule && ( + + )} + + + } + > + + {translate('workspace.rules.newRule.createRuleHeadline')} + {describeRuleLabel} + + + + {translate('workspace.rules.agentRules.disclaimer')} + + + ); +} + +RulesNewPromptForm.displayName = 'RulesNewPromptForm'; + +export default RulesNewPromptForm; +export type {NewRulePromptFormID}; From c7404140f81b0913351fdaa42798d4f4304292e5 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 05:21:42 +0300 Subject: [PATCH 09/62] Open agent rule editor --- src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index 39b01e56f079..3b5909d48068 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -78,6 +78,13 @@ function AddAgentRulePage({ const hasDraftPrompt = !!draftValues?.[INPUT_IDS.PROMPT]?.trim(); + // A prompt already in the draft was handed over from the New rule flow, so it opens on the tab showing it. + const [didOpenTabForDraft, setDidOpenTabForDraft] = useState(false); + if (!didOpenTabForDraft && hasDraftPrompt) { + setDidOpenTabForDraft(true); + setActiveTab(CONST.TAB.AGENT_RULE.WRITE); + } + // Reset the active tab to Suggestions when the workspace changes. if (activeTabPolicyID !== policyID) { setActiveTabPolicyID(policyID); From 51b0b3e54756f2da252ab89525ba987ee91cbdee Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 05:24:54 +0300 Subject: [PATCH 10/62] Fix multiple rules example --- src/languages/de.ts | 2 +- src/languages/el.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 682c94c8d6ff..0957470e92db 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8215,7 +8215,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc ? `${area}-Regeln werden noch nicht unterstützt – wir arbeiten daran. Bitte versuche vorerst eine andere Regel.` : 'Diese Regel wird noch nicht unterstützt – wir arbeiten daran. Bitte versuche vorerst eine andere Regel.', unintelligible: 'Formuliere deine Regel etwa so: Wenn Ausgaben über 100 $ liegen, ist ein Beleg erforderlich.', - multipleRules: 'Bitte eine Regel nach der anderen – beschreibe eine einzelne Regel, z. B.: Käufe von Geschenkkarten blockieren.', + multipleRules: 'Bitte eine Regel nach der anderen – beschreibe eine einzelne Regel, z. B.: Einzelhandelskäufe auf allen Karten blockieren.', }, }, expenseDefaultsTable: { diff --git a/src/languages/el.ts b/src/languages/el.ts index 86586c1aa8fd..d1bdd2df172d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8246,7 +8246,7 @@ ${reportName}`, ? `Οι κανόνες ${area} δεν υποστηρίζονται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.` : 'Αυτός ο κανόνας δεν υποστηρίζεται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.', unintelligible: 'Δοκιμάστε να διατυπώσετε τον κανόνα σας έτσι: αν οι δαπάνες ξεπερνούν τα 100 $, να απαιτείται απόδειξη.', - multipleRules: 'Έναν κανόνα τη φορά — δοκιμάστε να περιγράψετε έναν μόνο κανόνα, π.χ.: αποκλεισμός αγοράς δωροκαρτών.', + multipleRules: 'Έναν κανόνα τη φορά — δοκιμάστε να περιγράψετε έναν μόνο κανόνα, π.χ.: αποκλεισμός αγορών λιανικής σε όλες τις κάρτες.', }, }, expenseDefaultsTable: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 37bfe851987c..84da63d1d459 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8164,7 +8164,7 @@ const translations = { ? `${area} rules aren't supported yet — we're working on it. Please try a different rule for now.` : "That rule isn't supported yet — we're working on it. Please try a different rule for now.", unintelligible: 'Try phrasing your rule like: if expenses have a value over $100, then require a receipt.', - multipleRules: 'One rule at a time, please — try describing a single rule, like: block gift card purchases.', + multipleRules: 'One rule at a time, please — try describing a single rule, like: block retail purchases on all cards.', }, }, expenseDefaultsTable: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 5dbb5d364b29..61f1b0b792c1 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8169,7 +8169,7 @@ ${reportName}`, ? `Las reglas de ${area} aún no son compatibles: estamos trabajando en ello. Prueba con otra regla por ahora.` : 'Esa regla aún no es compatible: estamos trabajando en ello. Prueba con otra regla por ahora.', unintelligible: 'Prueba a formular tu regla así: si los gastos superan los $100, exige un recibo.', - multipleRules: 'Una regla a la vez, por favor. Prueba a describir una sola regla, como: bloquear la compra de tarjetas regalo.', + multipleRules: 'Una regla a la vez, por favor. Prueba a describir una sola regla, como: bloquear compras en comercios minoristas en todas las tarjetas.', }, }, expenseDefaultsTable: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 154b0d27de34..5c7a8e31854c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8240,7 +8240,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e ? `Les règles ${area} ne sont pas encore prises en charge — nous y travaillons. Essayez une autre règle pour le moment.` : 'Cette règle n’est pas encore prise en charge — nous y travaillons. Essayez une autre règle pour le moment.', unintelligible: 'Essayez de formuler votre règle ainsi : si les dépenses dépassent 100 $, exigez un reçu.', - multipleRules: 'Une règle à la fois, s’il vous plaît — essayez de décrire une seule règle, comme : bloquer les achats de cartes-cadeaux.', + multipleRules: 'Une règle à la fois, s’il vous plaît — essayez de décrire une seule règle, comme : bloquer les achats en magasin sur toutes les cartes.', }, }, expenseDefaultsTable: { diff --git a/src/languages/it.ts b/src/languages/it.ts index e725edf5ef89..cbd2c9aa863b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8181,7 +8181,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, ? `Le regole ${area} non sono ancora supportate: ci stiamo lavorando. Per ora prova una regola diversa.` : 'Questa regola non è ancora supportata: ci stiamo lavorando. Per ora prova una regola diversa.', unintelligible: 'Prova a formulare la regola così: se le spese superano i 100 $, richiedi una ricevuta.', - multipleRules: 'Una regola alla volta, per favore: prova a descrivere una singola regola, ad esempio bloccare l’acquisto di carte regalo.', + multipleRules: 'Una regola alla volta, per favore: prova a descrivere una singola regola, ad esempio bloccare gli acquisti al dettaglio su tutte le carte.', }, }, expenseDefaultsTable: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 219c1ea5315e..19d3de5f36a8 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8074,7 +8074,7 @@ ${reportName}`, ? `${area}のルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。` : 'そのルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。', unintelligible: '「経費が100ドルを超える場合は領収書を必須にする」のように書いてみてください。', - multipleRules: '一度に1つのルールのみです。「ギフトカードの購入をブロックする」のように、1つのルールを説明してください。', + multipleRules: '一度に1つのルールのみです。「すべてのカードで小売店での購入をブロックする」のように、1つのルールを説明してください。', }, }, expenseDefaultsTable: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5de3a3b61716..7943ec52261a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8159,7 +8159,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, ? `${area}-regels worden nog niet ondersteund — we werken eraan. Probeer voorlopig een andere regel.` : 'Die regel wordt nog niet ondersteund — we werken eraan. Probeer voorlopig een andere regel.', unintelligible: 'Formuleer je regel bijvoorbeeld zo: als uitgaven hoger zijn dan $100, vraag dan een bon.', - multipleRules: 'Eén regel per keer graag — beschrijf één enkele regel, bijvoorbeeld: cadeaukaarten blokkeren.', + multipleRules: 'Eén regel per keer graag — beschrijf één enkele regel, bijvoorbeeld: retailaankopen op alle kaarten blokkeren.', }, }, expenseDefaultsTable: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 49815afb68be..28f686edd241 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8181,7 +8181,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, ? `Reguły „${area}” nie są jeszcze obsługiwane — pracujemy nad tym. Na razie spróbuj innej reguły.` : 'Ta reguła nie jest jeszcze obsługiwana — pracujemy nad tym. Na razie spróbuj innej reguły.', unintelligible: 'Spróbuj sformułować regułę tak: jeśli wydatek przekracza 100 $, wymagaj paragonu.', - multipleRules: 'Jedna reguła naraz — opisz pojedynczą regułę, na przykład: zablokuj zakup kart podarunkowych.', + multipleRules: 'Jedna reguła naraz — opisz pojedynczą regułę, na przykład: zablokuj zakupy detaliczne na wszystkich kartach.', }, }, expenseDefaultsTable: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 60dc0fc00097..567b3251efb9 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8160,7 +8160,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, ? `Regras de ${area} ainda não têm suporte — estamos trabalhando nisso. Tente uma regra diferente por enquanto.` : 'Essa regra ainda não tem suporte — estamos trabalhando nisso. Tente uma regra diferente por enquanto.', unintelligible: 'Tente formular sua regra assim: se a despesa for maior que $100, exija um recibo.', - multipleRules: 'Uma regra por vez, por favor — tente descrever uma única regra, como: bloquear a compra de cartões-presente.', + multipleRules: 'Uma regra por vez, por favor — tente descrever uma única regra, como: bloquear compras no varejo em todos os cartões.', }, }, expenseDefaultsTable: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c612f0ddd252..2cd9553fd313 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7864,7 +7864,7 @@ ${reportName}`, promptErrors: { unsupported: ({area}) => (area ? `${area}规则暂不支持,我们正在开发中。请先尝试其他规则。` : '该规则暂不支持,我们正在开发中。请先尝试其他规则。'), unintelligible: '可以这样描述规则:如果支出超过 100 美元,则需要收据。', - multipleRules: '请一次描述一条规则,例如:阻止购买礼品卡。', + multipleRules: '请一次描述一条规则,例如:在所有卡上阻止零售消费。', }, }, expenseDefaultsTable: { From e26a9a8840df374da246ccee1ddfb3dfced0774d Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 06:05:11 +0300 Subject: [PATCH 11/62] Replace switches with guards --- src/pages/workspace/rules/RulesNewPage.tsx | 82 +++++++++++++--------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 91dc0b1d0eef..194dba988949 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -75,26 +75,33 @@ function RulesNewPage({route}: RulesNewPageProps) { const [isBuildingRule] = useOnyx(ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE); const seedDraftAndNavigate = (rule: ParsedPolicyRule) => { - switch (rule.ruleType) { - case CONST.PARSED_POLICY_RULE.RULE_TYPE.REQUIRE_FIELDS: - setDraftRequireFieldsRule(rule.rule ?? {}); - Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, undefined, true)); - return; - case CONST.PARSED_POLICY_RULE.RULE_TYPE.FLAG_FOR_REVIEW: - setDraftFlagForReviewRule(rule.rule ?? {}); - Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, undefined, true)); - return; - case CONST.PARSED_POLICY_RULE.RULE_TYPE.RESTRICT_CARD_SPEND: - setDraftSpendRule(rule.rule ?? {}); - Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); - return; - case CONST.PARSED_POLICY_RULE.RULE_TYPE.EXPENSE_DEFAULTS: - setDraftMerchantRule(rule.rule ?? {}); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - return; - default: - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); + const draft = rule.rule ?? {}; + + if (rule.ruleType === CONST.PARSED_POLICY_RULE.RULE_TYPE.REQUIRE_FIELDS) { + setDraftRequireFieldsRule(draft); + Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, undefined, true)); + return; + } + + if (rule.ruleType === CONST.PARSED_POLICY_RULE.RULE_TYPE.FLAG_FOR_REVIEW) { + setDraftFlagForReviewRule(draft); + Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, undefined, true)); + return; + } + + if (rule.ruleType === CONST.PARSED_POLICY_RULE.RULE_TYPE.RESTRICT_CARD_SPEND) { + setDraftSpendRule(draft); + Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + return; + } + + if (rule.ruleType === CONST.PARSED_POLICY_RULE.RULE_TYPE.EXPENSE_DEFAULTS) { + setDraftMerchantRule(draft); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + return; } + + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); }; const applyParsedRule = (rule: ParsedPolicyRule) => { @@ -105,23 +112,28 @@ function RulesNewPage({route}: RulesNewPageProps) { setParseID(undefined); clearParsedPolicyRule(); - switch (rule.state) { - case CONST.PARSED_POLICY_RULE.STATE.RULE: - seedDraftAndNavigate(rule); - return; - case CONST.PARSED_POLICY_RULE.STATE.UNSUPPORTED: - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported', {area: rule.unsupportedArea ?? ''})); - setCanOfferAgentRule(true); - return; - case CONST.PARSED_POLICY_RULE.STATE.MULTIPLE_RULES: - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); - return; - case CONST.PARSED_POLICY_RULE.STATE.UNINTELLIGIBLE: - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); - return; - default: - setNewRulePromptError(translate('common.genericErrorMessage')); + if (rule.state === CONST.PARSED_POLICY_RULE.STATE.RULE) { + seedDraftAndNavigate(rule); + return; + } + + if (rule.state === CONST.PARSED_POLICY_RULE.STATE.UNSUPPORTED) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported', {area: rule.unsupportedArea ?? ''})); + setCanOfferAgentRule(true); + return; } + + if (rule.state === CONST.PARSED_POLICY_RULE.STATE.MULTIPLE_RULES) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); + return; + } + + if (rule.state === CONST.PARSED_POLICY_RULE.STATE.UNINTELLIGIBLE) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); + return; + } + + setNewRulePromptError(translate('common.genericErrorMessage')); }; useEffect(() => { From 526c93fb558b9f0a06c93272c2e086aed0bc58de Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 06:10:53 +0300 Subject: [PATCH 12/62] Fix knip and onyx buckets --- src/libs/ExportOnyxState/common.ts | 2 ++ src/pages/workspace/rules/RulesNewPromptForm.tsx | 1 - src/types/onyx/ParsedPolicyRule.ts | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 0419155fb542..b5c09c32156d 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -228,6 +228,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.IS_LOADING_APP, ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE, + ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE, ONYXKEYS.IS_LOADING_PAYMENT_METHODS, ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW, ONYXKEYS.IS_LOADING_REPORT_DATA, @@ -280,6 +281,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_MUTED_PLATFORMS, ONYXKEYS.NVP_ONBOARDING, ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, + ONYXKEYS.NVP_PARSED_POLICY_RULE, ONYXKEYS.NVP_PERSONAL_OFFSETS, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.NVP_PRIORITY_MODE, diff --git a/src/pages/workspace/rules/RulesNewPromptForm.tsx b/src/pages/workspace/rules/RulesNewPromptForm.tsx index ea2f70cbaa65..74dc3fc50eae 100644 --- a/src/pages/workspace/rules/RulesNewPromptForm.tsx +++ b/src/pages/workspace/rules/RulesNewPromptForm.tsx @@ -135,4 +135,3 @@ function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgent RulesNewPromptForm.displayName = 'RulesNewPromptForm'; export default RulesNewPromptForm; -export type {NewRulePromptFormID}; diff --git a/src/types/onyx/ParsedPolicyRule.ts b/src/types/onyx/ParsedPolicyRule.ts index 71ef643a764b..75c39677a84f 100644 --- a/src/types/onyx/ParsedPolicyRule.ts +++ b/src/types/onyx/ParsedPolicyRule.ts @@ -37,4 +37,3 @@ type ParsedPolicyRule = { }; export default ParsedPolicyRule; -export type {ParsedPolicyRuleState, ParsedPolicyRuleType, ParsedPolicyRuleValues}; From d5d162ca8892e6c2f18124a24bc00ca0568f0697 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 06:30:06 +0300 Subject: [PATCH 13/62] Only explain why --- src/libs/actions/Policy/Rules.ts | 3 +-- src/pages/workspace/rules/RulesNewPage.tsx | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 49bcd1d6a061..5538710e8c05 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -457,8 +457,7 @@ function parsePolicyRule(policyID: string, prompt: string): string { const parameters: ParsePolicyRuleParams = {policyID, parseID, prompt}; - // No successData clears the loading flag: queueing the job is not the same as having the rule, so Auth - // clears it when it answers and the job clears it if it dies first. + // Queueing the job is not the same as having the rule, so nothing here clears the loading flag. API.write(WRITE_COMMANDS.PARSE_POLICY_RULE, parameters, {optimisticData, failureData}); return parseID; diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 194dba988949..faad2dda999e 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -152,8 +152,7 @@ function RulesNewPage({route}: RulesNewPageProps) { setParseID(parsePolicyRule(policyID, prompt)); }; - // An agent enforces in natural language what the deterministic rule types cannot express, so the prompt - // carries over instead of the admin retyping it. + // The prompt carries over so the admin does not retype it. const createAgentRuleFromPrompt = (prompt: string) => { setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[AGENT_RULE_INPUT_IDS.PROMPT]: prompt}); Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)); From c9c7e5d8555a2273ba22b192f009ebaa428e5d3d Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 21:25:39 +0300 Subject: [PATCH 14/62] Drop parsing state --- src/CONST/index.ts | 1 - src/libs/actions/Policy/Rules.ts | 5 ----- src/pages/workspace/rules/RulesNewPage.tsx | 4 ---- src/types/onyx/ParsedPolicyRule.ts | 10 +++++----- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 93b9818f6d04..93045d3df279 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -496,7 +496,6 @@ const CONST = { PARSED_POLICY_RULE: { STATE: { - PARSING: 'parsing', RULE: 'rule', UNSUPPORTED: 'unsupported', MULTIPLE_RULES: 'multipleRules', diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 5538710e8c05..91d63d098663 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -435,11 +435,6 @@ function parsePolicyRule(policyID: string, prompt: string): string { key: ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE, value: true, }, - { - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.NVP_PARSED_POLICY_RULE, - value: {parseID, state: CONST.PARSED_POLICY_RULE.STATE.PARSING}, - }, ]; const failureData: AnyOnyxUpdate[] = [ diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index faad2dda999e..1972574bdbac 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -105,10 +105,6 @@ function RulesNewPage({route}: RulesNewPageProps) { }; const applyParsedRule = (rule: ParsedPolicyRule) => { - if (rule.state === CONST.PARSED_POLICY_RULE.STATE.PARSING) { - return; - } - setParseID(undefined); clearParsedPolicyRule(); diff --git a/src/types/onyx/ParsedPolicyRule.ts b/src/types/onyx/ParsedPolicyRule.ts index 75c39677a84f..2c136e45d58a 100644 --- a/src/types/onyx/ParsedPolicyRule.ts +++ b/src/types/onyx/ParsedPolicyRule.ts @@ -3,7 +3,7 @@ import type {FlagForReviewRuleForm, MerchantRuleForm, RequireFieldsRuleForm, Spe import type {ValueOf} from 'type-fest'; -/** How far along the parse of an admin's rule description is, or why no rule was built */ +/** The rule that was built, or why no rule was built */ type ParsedPolicyRuleState = ValueOf; /** The rule type a description was mapped to */ @@ -20,19 +20,19 @@ type ParsedPolicyRule = { /** The attempt this answer belongs to, so an earlier one is not mistaken for it */ parseID: string; - /** Current state of the parse */ + /** The rule that was built, or why no rule was built */ state: ParsedPolicyRuleState; - /** The rule type the description was mapped to, when state is rule */ + /** The rule type the description was mapped to, set when state is rule */ ruleType?: ParsedPolicyRuleType; - /** The form values to seed the matching rule draft with, when state is rule */ + /** The form values to seed the matching rule draft with, set when state is rule */ rule?: ParsedPolicyRuleValues; /** Short plain-English description of the rule that was built */ summary?: string; - /** The area the deterministic rule types cannot express, when state is unsupported */ + /** The area the deterministic rule types cannot express, set when state is unsupported */ unsupportedArea?: string; }; From 7a2d33be2f3e352012d36fec1db8b3896c5b9790 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 27 Aug 2026 21:51:15 +0300 Subject: [PATCH 15/62] Cap prompt length --- src/CONST/index.ts | 1 + src/pages/workspace/rules/RulesNewPage.tsx | 1 + src/pages/workspace/rules/RulesNewPromptForm.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 93045d3df279..ceddb64697a3 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -495,6 +495,7 @@ const CONST = { }, PARSED_POLICY_RULE: { + PROMPT_MAX_LENGTH: 6000, STATE: { RULE: 'rule', UNSUPPORTED: 'unsupported', diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 1972574bdbac..50c3a6be3785 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -143,6 +143,7 @@ function RulesNewPage({route}: RulesNewPageProps) { const describeRule = (values: FormOnyxValues) => { const prompt = values.prompt.trim(); clearNewRulePromptError(); + clearParsedPolicyRule(); setCanOfferAgentRule(false); setSubmittedPrompt(prompt); setParseID(parsePolicyRule(policyID, prompt)); diff --git a/src/pages/workspace/rules/RulesNewPromptForm.tsx b/src/pages/workspace/rules/RulesNewPromptForm.tsx index 74dc3fc50eae..5e58712210ee 100644 --- a/src/pages/workspace/rules/RulesNewPromptForm.tsx +++ b/src/pages/workspace/rules/RulesNewPromptForm.tsx @@ -117,6 +117,7 @@ function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgent excludedMarkdownStyles={['mentionReport']} onKeyPress={submitFormOnModEnter} onValueChange={clearNewRulePromptError} + maxLength={CONST.PARSED_POLICY_RULE.PROMPT_MAX_LENGTH} multiline shouldSaveDraft shouldLabelStayOnSingleLine From 9d088fdc5e61d37dd6db42896d99bd85112674f7 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Sat, 29 Aug 2026 00:54:28 +0300 Subject: [PATCH 16/62] Rename GenerateRule --- src/CONST/index.ts | 2 +- src/ONYXKEYS.ts | 8 ++-- src/libs/API/parameters/GenerateRuleParams.ts | 7 +++ .../API/parameters/ParsePolicyRuleParams.ts | 7 --- src/libs/API/parameters/index.ts | 2 +- src/libs/API/types.ts | 4 +- src/libs/ExportOnyxState/common.ts | 4 +- src/libs/actions/Policy/Rules.ts | 32 ++++++------- src/pages/workspace/rules/RulesNewPage.tsx | 46 +++++++++---------- .../workspace/rules/RulesNewPromptForm.tsx | 2 +- .../{ParsedPolicyRule.ts => GeneratedRule.ts} | 18 ++++---- src/types/onyx/index.ts | 4 +- 12 files changed, 68 insertions(+), 68 deletions(-) create mode 100644 src/libs/API/parameters/GenerateRuleParams.ts delete mode 100644 src/libs/API/parameters/ParsePolicyRuleParams.ts rename src/types/onyx/{ParsedPolicyRule.ts => GeneratedRule.ts} (69%) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ceddb64697a3..2650b43423f5 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -494,7 +494,7 @@ const CONST = { MAX_LENGTH: 83, }, - PARSED_POLICY_RULE: { + GENERATED_RULE: { PROMPT_MAX_LENGTH: 6000, STATE: { RULE: 'rule', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b8205eaab2b7..517ef8699934 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -478,10 +478,10 @@ const ONYXKEYS = { IS_LOADING_AGENT_RULE_SUGGESTIONS: 'isLoadingAgentRuleSuggestions', /** Whether the request asking Concierge to build a rule is in flight */ - IS_LOADING_PARSED_POLICY_RULE: 'isLoadingParsedPolicyRule', + IS_LOADING_GENERATED_RULE: 'isLoadingGeneratedRule', /** The rule Concierge built from an admin's description, tagged with the attempt it belongs to */ - NVP_PARSED_POLICY_RULE: 'nvp_parsedPolicyRule', + NVP_GENERATED_RULE: 'nvp_generatedRule', /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ IS_LOADING_AGENT_TEMPLATES: 'isLoadingAgentTemplates', @@ -1702,8 +1702,8 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; [ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS]: boolean; - [ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE]: boolean; - [ONYXKEYS.NVP_PARSED_POLICY_RULE]: OnyxTypes.ParsedPolicyRule; + [ONYXKEYS.IS_LOADING_GENERATED_RULE]: boolean; + [ONYXKEYS.NVP_GENERATED_RULE]: OnyxTypes.GeneratedRule; [ONYXKEYS.IS_LOADING_AGENT_TEMPLATES]: boolean; [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean; diff --git a/src/libs/API/parameters/GenerateRuleParams.ts b/src/libs/API/parameters/GenerateRuleParams.ts new file mode 100644 index 000000000000..dfaea644466c --- /dev/null +++ b/src/libs/API/parameters/GenerateRuleParams.ts @@ -0,0 +1,7 @@ +type GenerateRuleParams = { + policyID: string; + generationID: string; + prompt: string; +}; + +export default GenerateRuleParams; diff --git a/src/libs/API/parameters/ParsePolicyRuleParams.ts b/src/libs/API/parameters/ParsePolicyRuleParams.ts deleted file mode 100644 index 59a13d4a189b..000000000000 --- a/src/libs/API/parameters/ParsePolicyRuleParams.ts +++ /dev/null @@ -1,7 +0,0 @@ -type ParsePolicyRuleParams = { - policyID: string; - parseID: string; - prompt: string; -}; - -export default ParsePolicyRuleParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 60bdb2ca4511..76d92c02c4bd 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -409,7 +409,7 @@ export type {default as SetPolicyExpenseMaxAmountNoItemizedReceipt} from './SetP export type {default as SetPolicyExpenseMaxAmount} from './SetPolicyExpenseMaxAmount'; export type {default as SetPolicyExpenseMaxAge} from './SetPolicyExpenseMaxAge'; export type {default as AddPolicyAgentRuleParams} from './AddPolicyAgentRuleParams'; -export type {default as ParsePolicyRuleParams} from './ParsePolicyRuleParams'; +export type {default as GenerateRuleParams} from './GenerateRuleParams'; export type {default as UpdatePolicyAgentRuleParams} from './UpdatePolicyAgentRuleParams'; export type {default as DeletePolicyAgentRuleParams} from './DeletePolicyAgentRuleParams'; export type {default as UpdateCustomRules} from './UpdateCustomRules'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 8367cb4177b3..8f049385811a 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -290,7 +290,7 @@ const WRITE_COMMANDS = { DISABLE_POLICY_COMMUTER_EXCLUSIONS: 'DisablePolicyCommuterExclusions', SET_POLICY_EXPENSE_MAX_AGE: 'SetPolicyExpenseMaxAge', ADD_POLICY_AGENT_RULE: 'AddPolicyAgentRule', - PARSE_POLICY_RULE: 'ParsePolicyRule', + GENERATE_RULE: 'GenerateRule', UPDATE_POLICY_AGENT_RULE: 'UpdatePolicyAgentRule', DELETE_POLICY_AGENT_RULE: 'DeletePolicyAgentRule', UPDATE_CUSTOM_RULES: 'UpdateCustomRules', @@ -1004,7 +1004,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT]: Parameters.SetPolicyExpenseMaxAmount; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AGE]: Parameters.SetPolicyExpenseMaxAge; [WRITE_COMMANDS.ADD_POLICY_AGENT_RULE]: Parameters.AddPolicyAgentRuleParams; - [WRITE_COMMANDS.PARSE_POLICY_RULE]: Parameters.ParsePolicyRuleParams; + [WRITE_COMMANDS.GENERATE_RULE]: Parameters.GenerateRuleParams; [WRITE_COMMANDS.UPDATE_POLICY_AGENT_RULE]: Parameters.UpdatePolicyAgentRuleParams; [WRITE_COMMANDS.DELETE_POLICY_AGENT_RULE]: Parameters.DeletePolicyAgentRuleParams; [WRITE_COMMANDS.UPDATE_CUSTOM_RULES]: Parameters.UpdateCustomRules; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index b5c09c32156d..84be4179ddac 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -228,7 +228,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.IS_LOADING_APP, ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE, - ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE, + ONYXKEYS.IS_LOADING_GENERATED_RULE, ONYXKEYS.IS_LOADING_PAYMENT_METHODS, ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW, ONYXKEYS.IS_LOADING_REPORT_DATA, @@ -281,7 +281,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_MUTED_PLATFORMS, ONYXKEYS.NVP_ONBOARDING, ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, - ONYXKEYS.NVP_PARSED_POLICY_RULE, + ONYXKEYS.NVP_GENERATED_RULE, ONYXKEYS.NVP_PERSONAL_OFFSETS, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.NVP_PRIORITY_MODE, diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 91d63d098663..c7520147d62e 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -5,7 +5,7 @@ import type { DeletePolicyAgentRuleParams, GetAgentRuleSuggestionsParams, ImportMerchantRulesSpreadsheetParams, - ParsePolicyRuleParams, + GenerateRuleParams, UpdatePolicyAgentRuleParams, } from '@libs/API/parameters'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; @@ -422,17 +422,17 @@ function deletePolicyCodingRule(policy: Policy, ruleID: string) { /** * Asks Concierge to turn a plain-English rule description into a structured rule. * - * The answer arrives asynchronously on the returned parseID's Onyx key, since a background job does the work. + * The answer arrives asynchronously on the returned generationID's Onyx key, since a background job does the work. * - * @returns the parseID to read the answer under + * @returns the generationID to read the answer under */ -function parsePolicyRule(policyID: string, prompt: string): string { - const parseID = NumberUtils.rand64(); +function generateRule(policyID: string, prompt: string): string { + const generationID = NumberUtils.rand64(); const optimisticData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE, + key: ONYXKEYS.IS_LOADING_GENERATED_RULE, value: true, }, ]; @@ -440,22 +440,22 @@ function parsePolicyRule(policyID: string, prompt: string): string { const failureData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE, + key: ONYXKEYS.IS_LOADING_GENERATED_RULE, value: false, }, { onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.NVP_PARSED_POLICY_RULE, - value: {parseID, state: CONST.PARSED_POLICY_RULE.STATE.FAILED}, + key: ONYXKEYS.NVP_GENERATED_RULE, + value: {generationID, state: CONST.GENERATED_RULE.STATE.FAILED}, }, ]; - const parameters: ParsePolicyRuleParams = {policyID, parseID, prompt}; + const parameters: GenerateRuleParams = {policyID, generationID, prompt}; // Queueing the job is not the same as having the rule, so nothing here clears the loading flag. - API.write(WRITE_COMMANDS.PARSE_POLICY_RULE, parameters, {optimisticData, failureData}); + API.write(WRITE_COMMANDS.GENERATE_RULE, parameters, {optimisticData, failureData}); - return parseID; + return generationID; } /** @@ -471,8 +471,8 @@ function clearNewRulePromptError() { Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, {errors: null, errorFields: null}); } -function clearParsedPolicyRule() { - Onyx.set(ONYXKEYS.NVP_PARSED_POLICY_RULE, null); +function clearGeneratedRule() { + Onyx.set(ONYXKEYS.NVP_GENERATED_RULE, null); } function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: string) { @@ -751,8 +751,8 @@ export { deletePolicyCodingRule, getTransactionsMatchingCodingRule, addPolicyAgentRule, - parsePolicyRule, - clearParsedPolicyRule, + generateRule, + clearGeneratedRule, setNewRulePromptError, clearNewRulePromptError, updatePolicyAgentRule, diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 50c3a6be3785..8936ab50f3b7 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -22,14 +22,14 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; -import {clearNewRulePromptError, clearParsedPolicyRule, parsePolicyRule, setNewRulePromptError} from '@userActions/Policy/Rules'; +import {clearNewRulePromptError, clearGeneratedRule, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; -import type {ParsedPolicyRule} from '@src/types/onyx'; +import type {GeneratedRule} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; import React, {useEffect, useState} from 'react'; @@ -65,37 +65,37 @@ function RulesNewPage({route}: RulesNewPageProps) { // The category-scoped flow already knows the category and offers fewer types, so it opens on the list. const canDescribeRule = isRulesRevampEnabled && !isCategoryScopedCreate; const [shouldShowRuleTypes, setShouldShowRuleTypes] = useState(!canDescribeRule); - const [parseID, setParseID] = useState(); + const [generationID, setGenerationID] = useState(); const [submittedPrompt, setSubmittedPrompt] = useState(); // Set when the deterministic rule types cannot express the prompt, which an agent rule still can. const [canOfferAgentRule, setCanOfferAgentRule] = useState(false); - const [parsedRule] = useOnyx(ONYXKEYS.NVP_PARSED_POLICY_RULE); - const [isBuildingRule] = useOnyx(ONYXKEYS.IS_LOADING_PARSED_POLICY_RULE); + const [generatedRule] = useOnyx(ONYXKEYS.NVP_GENERATED_RULE); + const [isBuildingRule] = useOnyx(ONYXKEYS.IS_LOADING_GENERATED_RULE); - const seedDraftAndNavigate = (rule: ParsedPolicyRule) => { + const seedDraftAndNavigate = (rule: GeneratedRule) => { const draft = rule.rule ?? {}; - if (rule.ruleType === CONST.PARSED_POLICY_RULE.RULE_TYPE.REQUIRE_FIELDS) { + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS) { setDraftRequireFieldsRule(draft); Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, undefined, true)); return; } - if (rule.ruleType === CONST.PARSED_POLICY_RULE.RULE_TYPE.FLAG_FOR_REVIEW) { + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW) { setDraftFlagForReviewRule(draft); Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, undefined, true)); return; } - if (rule.ruleType === CONST.PARSED_POLICY_RULE.RULE_TYPE.RESTRICT_CARD_SPEND) { + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND) { setDraftSpendRule(draft); Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); return; } - if (rule.ruleType === CONST.PARSED_POLICY_RULE.RULE_TYPE.EXPENSE_DEFAULTS) { + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS) { setDraftMerchantRule(draft); Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); return; @@ -104,27 +104,27 @@ function RulesNewPage({route}: RulesNewPageProps) { setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); }; - const applyParsedRule = (rule: ParsedPolicyRule) => { - setParseID(undefined); - clearParsedPolicyRule(); + const applyGeneratedRule = (rule: GeneratedRule) => { + setGenerationID(undefined); + clearGeneratedRule(); - if (rule.state === CONST.PARSED_POLICY_RULE.STATE.RULE) { + if (rule.state === CONST.GENERATED_RULE.STATE.RULE) { seedDraftAndNavigate(rule); return; } - if (rule.state === CONST.PARSED_POLICY_RULE.STATE.UNSUPPORTED) { + if (rule.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED) { setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported', {area: rule.unsupportedArea ?? ''})); setCanOfferAgentRule(true); return; } - if (rule.state === CONST.PARSED_POLICY_RULE.STATE.MULTIPLE_RULES) { + if (rule.state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES) { setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); return; } - if (rule.state === CONST.PARSED_POLICY_RULE.STATE.UNINTELLIGIBLE) { + if (rule.state === CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE) { setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); return; } @@ -133,20 +133,20 @@ function RulesNewPage({route}: RulesNewPageProps) { }; useEffect(() => { - if (!parseID || parsedRule?.parseID !== parseID) { + if (!generationID || generatedRule?.generationID !== generationID) { return; } - applyParsedRule(parsedRule); - // eslint-disable-next-line react-hooks/exhaustive-deps -- applyParsedRule reads only what it is given - }, [parseID, parsedRule]); + applyGeneratedRule(generatedRule); + // eslint-disable-next-line react-hooks/exhaustive-deps -- applyGeneratedRule reads only what it is given + }, [generationID, generatedRule]); const describeRule = (values: FormOnyxValues) => { const prompt = values.prompt.trim(); clearNewRulePromptError(); - clearParsedPolicyRule(); + clearGeneratedRule(); setCanOfferAgentRule(false); setSubmittedPrompt(prompt); - setParseID(parsePolicyRule(policyID, prompt)); + setGenerationID(generateRule(policyID, prompt)); }; // The prompt carries over so the admin does not retype it. diff --git a/src/pages/workspace/rules/RulesNewPromptForm.tsx b/src/pages/workspace/rules/RulesNewPromptForm.tsx index 5e58712210ee..f389c66e1546 100644 --- a/src/pages/workspace/rules/RulesNewPromptForm.tsx +++ b/src/pages/workspace/rules/RulesNewPromptForm.tsx @@ -117,7 +117,7 @@ function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgent excludedMarkdownStyles={['mentionReport']} onKeyPress={submitFormOnModEnter} onValueChange={clearNewRulePromptError} - maxLength={CONST.PARSED_POLICY_RULE.PROMPT_MAX_LENGTH} + maxLength={CONST.GENERATED_RULE.PROMPT_MAX_LENGTH} multiline shouldSaveDraft shouldLabelStayOnSingleLine diff --git a/src/types/onyx/ParsedPolicyRule.ts b/src/types/onyx/GeneratedRule.ts similarity index 69% rename from src/types/onyx/ParsedPolicyRule.ts rename to src/types/onyx/GeneratedRule.ts index 2c136e45d58a..6b03ef5a9374 100644 --- a/src/types/onyx/ParsedPolicyRule.ts +++ b/src/types/onyx/GeneratedRule.ts @@ -4,30 +4,30 @@ import type {FlagForReviewRuleForm, MerchantRuleForm, RequireFieldsRuleForm, Spe import type {ValueOf} from 'type-fest'; /** The rule that was built, or why no rule was built */ -type ParsedPolicyRuleState = ValueOf; +type GeneratedRuleState = ValueOf; /** The rule type a description was mapped to */ -type ParsedPolicyRuleType = ValueOf; +type GeneratedRuleType = ValueOf; /** * The form values Concierge filled in. Every field is optional and the field names are unique across the four * rule forms, so this seeds whichever draft ruleType names. */ -type ParsedPolicyRuleValues = Partial & Partial & Partial & Partial; +type GeneratedRuleValues = Partial & Partial & Partial & Partial; /** Model of the rule Concierge built from an admin's plain-English description */ -type ParsedPolicyRule = { +type GeneratedRule = { /** The attempt this answer belongs to, so an earlier one is not mistaken for it */ - parseID: string; + generationID: string; /** The rule that was built, or why no rule was built */ - state: ParsedPolicyRuleState; + state: GeneratedRuleState; /** The rule type the description was mapped to, set when state is rule */ - ruleType?: ParsedPolicyRuleType; + ruleType?: GeneratedRuleType; /** The form values to seed the matching rule draft with, set when state is rule */ - rule?: ParsedPolicyRuleValues; + rule?: GeneratedRuleValues; /** Short plain-English description of the rule that was built */ summary?: string; @@ -36,4 +36,4 @@ type ParsedPolicyRule = { unsupportedArea?: string; }; -export default ParsedPolicyRule; +export default GeneratedRule; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 18d30e334c6f..e11c0527bcae 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -86,6 +86,7 @@ import type ExportTemplate from './ExportTemplate'; import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji'; import type {FundList} from './Fund'; import type Fund from './Fund'; +import type GeneratedRule from './GeneratedRule'; import type GovernmentMileageRate from './GovernmentMileageRate'; import type GpsDraftDetails from './GpsDraftDetails'; import type HybridApp from './HybridApp'; @@ -120,7 +121,6 @@ import type OnyxInputOrEntry from './OnyxInputOrEntry'; import type {AnyOnyxUpdatesFromServer, OnyxUpdateEvent, OnyxUpdatesFromServer} from './OnyxUpdatesFromServer'; import type {DecisionName, OriginalMessageIOU} from './OriginalMessage'; import type Pages from './Pages'; -import type ParsedPolicyRule from './ParsedPolicyRule'; import type PendingConciergeResponse from './PendingConciergeResponse'; import type {PendingContactAction} from './PendingContactAction'; import type PersonalBankAccount from './PersonalBankAccount'; @@ -244,7 +244,7 @@ export type { Domain, Download, ExportDownload, - ParsedPolicyRule, + GeneratedRule, DuplicateWorkspace, CopyPolicySettingsNVP, CopyPolicySettings, From 978d69d5fb207bd409c2c39fda9ebfa339ca3c8e Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 3 Sep 2026 15:18:32 +0200 Subject: [PATCH 17/62] Match workspace category --- src/pages/workspace/rules/RulesNewPage.tsx | 29 +++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 8936ab50f3b7..d93ccbe1e28c 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -7,12 +7,16 @@ import Text from '@components/Text'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {setDraftValues} from '@libs/actions/FormActions'; +import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {setDraftFlagForReviewRule, setDraftMerchantRule, setDraftRequireFieldsRule, setDraftSpendRule} from '@libs/actions/User'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -32,6 +36,7 @@ import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; import type {GeneratedRule} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; +import {useFocusEffect} from '@react-navigation/native'; import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; @@ -73,9 +78,31 @@ function RulesNewPage({route}: RulesNewPageProps) { const [canOfferAgentRule, setCanOfferAgentRule] = useState(false); const [generatedRule] = useOnyx(ONYXKEYS.NVP_GENERATED_RULE); const [isBuildingRule] = useOnyx(ONYXKEYS.IS_LOADING_GENERATED_RULE); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const policy = usePolicy(policyID); + + // Fetch categories if they're not loaded (e.g. after cache clear), so a generated rule can name one. + const fetchPolicyCategories = () => { + if (!policy?.areCategoriesEnabled || policyCategories) { + return; + } + openPolicyCategoriesPage(policyID); + }; + + useNetwork({onReconnect: fetchPolicyCategories}); + + useFocusEffect(() => { + fetchPolicyCategories(); + }); const seedDraftAndNavigate = (rule: GeneratedRule) => { - const draft = rule.rule ?? {}; + const {category, ...ruleValues} = rule.rule ?? {}; + + // Concierge answers with the category name the admin typed, so it only seeds a draft once it matches a category they can pick. + const matchedCategory = category + ? Object.values(policyCategories ?? {}).find((policyCategory) => policyCategory.enabled && getDecodedCategoryName(policyCategory.name) === getDecodedCategoryName(category)) + : undefined; + const draft: NonNullable = matchedCategory ? {...ruleValues, category: matchedCategory.name} : ruleValues; if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS) { setDraftRequireFieldsRule(draft); From e02d09396b8aa4135b2ccbd19eea9c71912dde8b Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 4 Sep 2026 00:52:52 +0200 Subject: [PATCH 18/62] Push generated rule --- src/ONYXKEYS.ts | 8 ++------ src/libs/ExportOnyxState/common.ts | 3 +-- src/libs/actions/Policy/Rules.ts | 20 +++----------------- src/pages/workspace/rules/RulesNewPage.tsx | 5 ++--- 4 files changed, 8 insertions(+), 28 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 87c9ae72f466..f41e69090878 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -484,11 +484,8 @@ const ONYXKEYS = { /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ IS_LOADING_AGENT_RULE_SUGGESTIONS: 'isLoadingAgentRuleSuggestions', - /** Whether the request asking Concierge to build a rule is in flight */ - IS_LOADING_GENERATED_RULE: 'isLoadingGeneratedRule', - /** The rule Concierge built from an admin's description, tagged with the attempt it belongs to */ - NVP_GENERATED_RULE: 'nvp_generatedRule', + GENERATED_RULE: 'generatedRule', /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ IS_LOADING_AGENT_TEMPLATES: 'isLoadingAgentTemplates', @@ -1715,8 +1712,7 @@ type OnyxValuesMapping = { [ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; [ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS]: boolean; - [ONYXKEYS.IS_LOADING_GENERATED_RULE]: boolean; - [ONYXKEYS.NVP_GENERATED_RULE]: OnyxTypes.GeneratedRule; + [ONYXKEYS.GENERATED_RULE]: OnyxTypes.GeneratedRule; [ONYXKEYS.IS_LOADING_AGENT_TEMPLATES]: boolean; [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 39d72f916e9f..59a9b1a96058 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -213,6 +213,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID, ONYXKEYS.EXPENSIFY_CARD_STATEMENT, ONYXKEYS.FULLSCREEN_VISIBILITY, + ONYXKEYS.GENERATED_RULE, ONYXKEYS.GOVERNMENT_MILEAGE_RATES, ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT, ONYXKEYS.HAS_LOADED_APP, @@ -230,7 +231,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.IS_LOADING_APP, ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE, - ONYXKEYS.IS_LOADING_GENERATED_RULE, ONYXKEYS.IS_LOADING_PAYMENT_METHODS, ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW, ONYXKEYS.IS_LOADING_REPORT_DATA, @@ -284,7 +284,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_MUTED_PLATFORMS, ONYXKEYS.NVP_ONBOARDING, ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, - ONYXKEYS.NVP_GENERATED_RULE, ONYXKEYS.NVP_PERSONAL_OFFSETS, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.NVP_PRIORITY_MODE, diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index c7520147d62e..7f98322bfb56 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -429,31 +429,17 @@ function deletePolicyCodingRule(policy: Policy, ruleID: string) { function generateRule(policyID: string, prompt: string): string { const generationID = NumberUtils.rand64(); - const optimisticData: AnyOnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IS_LOADING_GENERATED_RULE, - value: true, - }, - ]; - const failureData: AnyOnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IS_LOADING_GENERATED_RULE, - value: false, - }, { onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.NVP_GENERATED_RULE, + key: ONYXKEYS.GENERATED_RULE, value: {generationID, state: CONST.GENERATED_RULE.STATE.FAILED}, }, ]; const parameters: GenerateRuleParams = {policyID, generationID, prompt}; - // Queueing the job is not the same as having the rule, so nothing here clears the loading flag. - API.write(WRITE_COMMANDS.GENERATE_RULE, parameters, {optimisticData, failureData}); + API.write(WRITE_COMMANDS.GENERATE_RULE, parameters, {failureData}); return generationID; } @@ -472,7 +458,7 @@ function clearNewRulePromptError() { } function clearGeneratedRule() { - Onyx.set(ONYXKEYS.NVP_GENERATED_RULE, null); + Onyx.set(ONYXKEYS.GENERATED_RULE, null); } function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: string) { diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index d93ccbe1e28c..028e0f7af324 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -76,8 +76,7 @@ function RulesNewPage({route}: RulesNewPageProps) { // Set when the deterministic rule types cannot express the prompt, which an agent rule still can. const [canOfferAgentRule, setCanOfferAgentRule] = useState(false); - const [generatedRule] = useOnyx(ONYXKEYS.NVP_GENERATED_RULE); - const [isBuildingRule] = useOnyx(ONYXKEYS.IS_LOADING_GENERATED_RULE); + const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const policy = usePolicy(policyID); @@ -275,7 +274,7 @@ function RulesNewPage({route}: RulesNewPageProps) { onSubmit={describeRule} onBuildManually={() => setShouldShowRuleTypes(true)} onCreateAgentRule={isCustomAgentBetaEnabled && canOfferAgentRule && submittedPrompt ? () => createAgentRuleFromPrompt(submittedPrompt) : undefined} - isLoading={!!isBuildingRule} + isLoading={!!generationID} /> ) : ( Date: Thu, 10 Sep 2026 21:58:23 +0200 Subject: [PATCH 19/62] Fix Button import --- src/pages/workspace/rules/RulesNewPromptForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/RulesNewPromptForm.tsx b/src/pages/workspace/rules/RulesNewPromptForm.tsx index f389c66e1546..7b177c0c03d1 100644 --- a/src/pages/workspace/rules/RulesNewPromptForm.tsx +++ b/src/pages/workspace/rules/RulesNewPromptForm.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +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'; From ab466b212e8a113d021a303496387176bcb38f59 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 10 Sep 2026 21:58:23 +0200 Subject: [PATCH 20/62] Disable review modal --- src/hooks/useProactiveAppReview.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useProactiveAppReview.ts b/src/hooks/useProactiveAppReview.ts index 7d66d83c64d7..88835afd52c3 100644 --- a/src/hooks/useProactiveAppReview.ts +++ b/src/hooks/useProactiveAppReview.ts @@ -26,7 +26,7 @@ function useProactiveAppReview(): UseProactiveAppReviewReturn { // Capture once so render stays pure (Date.now is impure). Fine for a 30-day cool-down gate. const [timeAtMount] = useState(Date.now); - let shouldShowModal = true; + let shouldShowModal = false; if (shouldSuppressPromotionalUI) { // Supportal agents and copilots should not leave reviews on behalf of another account. shouldShowModal = false; From aba5c7014aad908ac48e33b7804d7eac644f580c Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 10 Sep 2026 22:37:19 +0200 Subject: [PATCH 21/62] Revert "Disable review modal" This reverts commit ab466b212e8a113d021a303496387176bcb38f59. --- src/hooks/useProactiveAppReview.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useProactiveAppReview.ts b/src/hooks/useProactiveAppReview.ts index 88835afd52c3..7d66d83c64d7 100644 --- a/src/hooks/useProactiveAppReview.ts +++ b/src/hooks/useProactiveAppReview.ts @@ -26,7 +26,7 @@ function useProactiveAppReview(): UseProactiveAppReviewReturn { // Capture once so render stays pure (Date.now is impure). Fine for a 30-day cool-down gate. const [timeAtMount] = useState(Date.now); - let shouldShowModal = false; + let shouldShowModal = true; if (shouldSuppressPromotionalUI) { // Supportal agents and copilots should not leave reviews on behalf of another account. shouldShowModal = false; From 64f5ed0311be51073c8d1a775454e23a0ed50feb Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 10 Sep 2026 22:51:16 +0200 Subject: [PATCH 22/62] Fix expense defaults prefill --- src/pages/workspace/rules/RulesNewPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 3887e4987eeb..bbd0834bdde3 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -121,7 +121,7 @@ function RulesNewPage({route}: RulesNewPageProps) { } if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS) { - setDraftMerchantRule(draft); + setDraftMerchantRule({...draft, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); return; } @@ -268,7 +268,7 @@ function RulesNewPage({route}: RulesNewPageProps) { setShouldShowRuleTypes(true)} - onCreateAgentRule={isCustomAgentBetaEnabled && canOfferAgentRule && submittedPrompt ? () => createAgentRuleFromPrompt(submittedPrompt) : undefined} + onCreateAgentRule={canOfferAgentRule && submittedPrompt ? () => createAgentRuleFromPrompt(submittedPrompt) : undefined} isLoading={!!generationID} /> ) : ( From 9388ccd2c90bfca67ff683fa15829cb402d64ecf Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 10 Sep 2026 23:01:21 +0200 Subject: [PATCH 23/62] Clear prompt draft --- src/pages/workspace/rules/RulesNewPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index bbd0834bdde3..dc4040761f90 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -13,7 +13,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setDraftValues} from '@libs/actions/FormActions'; +import {clearDraftValues, setDraftValues} from '@libs/actions/FormActions'; import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {setDraftFlagForReviewRule, setDraftMerchantRule, setDraftRequireFieldsRule, setDraftSpendRule} from '@libs/actions/User'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; @@ -134,6 +134,7 @@ function RulesNewPage({route}: RulesNewPageProps) { clearGeneratedRule(); if (rule.state === CONST.GENERATED_RULE.STATE.RULE) { + clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); seedDraftAndNavigate(rule); return; } From 03a4b794992239cc8cf68d2edf6e5f64311e96d5 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 10 Sep 2026 23:01:21 +0200 Subject: [PATCH 24/62] Honor seeded spend draft --- src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 1cc88c360688..15264f73ae4b 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -103,8 +103,10 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID, upgradeBackTo}: const [isErrorVisible, setIsErrorVisible] = useState(false); const [isRestrictMerchantsOff, setIsRestrictMerchantsOff] = useState(() => { - const hasNoMerchantRestrictions = !existingFormValues?.merchantNames.length && !existingFormValues?.categories?.length; - return isNewRule || hasNoMerchantRestrictions; + if (isNewRule) { + return !spendRuleForm?.categories?.length && !spendRuleForm?.merchantNames?.length; + } + return !existingFormValues?.merchantNames.length && !existingFormValues?.categories?.length; }); useEffect(() => () => clearDraftSpendRule(), []); From 3ee7e1fc834e226742d77c78aef892458b980d07 Mon Sep 17 00:00:00 2001 From: "Rushat Gabhane (via MelvinBot)" Date: Thu, 17 Sep 2026 12:25:57 +0000 Subject: [PATCH 25/62] Fix lint errors surfaced by new react-hooks rules from main --- .../rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx | 3 +-- src/pages/workspace/rules/RulesNewPage.tsx | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 898fb7772443..6f254dee787d 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -199,7 +199,6 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Always reseed from the category so a leftover new-rule draft cannot leave a stale Require/Don't require. initializedDraftForRuleKeyRef.current = ruleKey; - // eslint-disable-next-line react-hooks/set-state-in-effect -- Seed local selection state when opening an edit rule. setTouchedFields(new Set()); setClearedFields(new Set()); setCouplingInteractionFields(new Set()); @@ -207,7 +206,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, [INPUT_IDS.CATEGORY]: categoryName, ...getRequireFieldsFormFromCategory(category), }); - }, [category, categoryName, form, initialCategoryName, isEditing]); + }, [category, categoryName, form, initialCategoryName, isEditing, isPrefilled]); const fetchPolicyData = useCallback(() => { if (!policy?.areCategoriesEnabled || policyCategories) { diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index f0d50d07684a..8e26fe542ed0 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -159,6 +159,7 @@ function RulesNewPage({route}: RulesNewPageProps) { if (!generationID || generatedRule?.generationID !== generationID) { return; } + // eslint-disable-next-line react-hooks/set-state-in-effect -- Concierge answers asynchronously on an Onyx key, so this effect is how that external update reaches local state applyGeneratedRule(generatedRule); // eslint-disable-next-line react-hooks/exhaustive-deps -- applyGeneratedRule reads only what it is given }, [generationID, generatedRule]); From d279541ceb7bdd63fae429ae4601386fc472f0ed Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 17 Sep 2026 15:25:28 +0200 Subject: [PATCH 26/62] Remove comments, eslint disables --- src/ONYXKEYS.ts | 2 +- src/ROUTES.ts | 2 - src/libs/Navigation/types.ts | 4 +- src/libs/actions/Policy/Rules.ts | 11 +- .../rules/AgentRules/AddAgentRulePage.tsx | 1 - .../FlagForReviewRulePageBase.tsx | 2 +- .../RequireFieldsRulePageBase.tsx | 3 +- src/pages/workspace/rules/RulesNewPage.tsx | 147 +++++++++--------- .../workspace/rules/RulesNewPromptForm.tsx | 9 +- src/types/onyx/GeneratedRule.ts | 21 ++- 10 files changed, 91 insertions(+), 111 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 94d82423ec44..171114560fd7 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -477,7 +477,7 @@ const ONYXKEYS = { /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ IS_LOADING_AGENT_RULE_SUGGESTIONS: 'isLoadingAgentRuleSuggestions', - /** The rule Concierge built from an admin's description, tagged with the attempt it belongs to */ + /** The rule generated from an admin's description */ GENERATED_RULE: 'generatedRule', /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ diff --git a/src/ROUTES.ts b/src/ROUTES.ts index d6e2537056bd..a709791af9d2 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4031,7 +4031,6 @@ const ROUTES = { }, RULES_REQUIRE_FIELDS_RULE_NEW: { route: 'workspaces/:policyID/rules/require-fields-rules/new', - /** @param isPrefilled the draft is already seeded, so the page must not reset it on mount */ getRoute: (policyID: string, categoryName?: string, isPrefilled?: boolean) => `workspaces/${policyID}/rules/require-fields-rules/new${getRuleCreateQuery(categoryName, isPrefilled)}` as const, }, @@ -4050,7 +4049,6 @@ const ROUTES = { }, RULES_FLAG_FOR_REVIEW_RULE_NEW: { route: 'workspaces/:policyID/rules/flag-for-review-rules/new', - /** @param isPrefilled the draft is already seeded, so the page must not reset it on mount */ getRoute: (policyID: string, categoryName?: string, isPrefilled?: boolean) => `workspaces/${policyID}/rules/flag-for-review-rules/new${getRuleCreateQuery(categoryName, isPrefilled)}` as const, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 683291c0cfe6..a51bd6ee71e4 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1796,7 +1796,7 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_NEW]: { policyID: string; categoryName?: string; - /** When 'true', the draft is already seeded and must not be reset on mount. */ + /** Whether the draft is already seeded */ isPrefilled?: string; }; [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_EDIT]: { @@ -1815,7 +1815,7 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_NEW]: { policyID: string; categoryName?: string; - /** When 'true', the draft is already seeded and must not be reset on mount. */ + /** Whether the draft is already seeded */ isPrefilled?: string; }; [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_EDIT]: { diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 7f98322bfb56..900253841e17 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -419,13 +419,7 @@ function deletePolicyCodingRule(policy: Policy, ruleID: string) { API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); } -/** - * Asks Concierge to turn a plain-English rule description into a structured rule. - * - * The answer arrives asynchronously on the returned generationID's Onyx key, since a background job does the work. - * - * @returns the generationID to read the answer under - */ +/** @returns the generationID the answer is written under in Onyx */ function generateRule(policyID: string, prompt: string): string { const generationID = NumberUtils.rand64(); @@ -444,9 +438,6 @@ function generateRule(policyID: string, prompt: string): string { return generationID; } -/** - * Shows Concierge's answer as an inline error under the prompt. - */ function setNewRulePromptError(message: string) { Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, { errorFields: {[NEW_RULE_PROMPT_INPUT_IDS.PROMPT]: ErrorUtils.getMicroSecondOnyxErrorWithMessage(message)}, diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index 37037c87bd0a..c7cd292535ce 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -75,7 +75,6 @@ function AddAgentRulePage({ const hasDraftPrompt = !!draftValues?.[INPUT_IDS.PROMPT]?.trim(); - // A prompt already in the draft was handed over from the New rule flow, so it opens on the tab showing it. const [didOpenTabForDraft, setDidOpenTabForDraft] = useState(false); if (!didOpenTabForDraft && hasDraftPrompt) { setDidOpenTabForDraft(true); diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index b57e35dbda0a..0a998145e31e 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -49,7 +49,7 @@ type FlagForReviewRulePageBaseProps = { isCategoryLocked?: boolean; /** When true, nested create pages use category dynamic routes (keeps Categories underlay). */ isCategoryScopedFlow?: boolean; - /** When true, the draft was seeded before navigating here, so creating must not reset it. */ + /** Whether the draft is already seeded */ isPrefilled?: boolean; testID: string; }; diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 6f254dee787d..0e5e302fb057 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -63,7 +63,7 @@ type RequireFieldsRulePageBaseProps = { initialCategoryName?: string; /** When true, the category field is non-interactive (category-scoped create/edit). */ isCategoryLocked?: boolean; - /** When true, the draft was seeded before navigating here, so creating must not reset it. */ + /** Whether the draft is already seeded */ isPrefilled?: boolean; testID: string; }; @@ -171,7 +171,6 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; if (isPrefilled) { - // A seeded setting is a chosen one, and the receipt fields only save when they are touched. // eslint-disable-next-line react-hooks/set-state-in-effect -- seeds local selection state from the seeded draft setTouchedFields(new Set(SETTING_FIELD_KEYS.filter((fieldKey) => form?.[fieldKey] !== undefined))); } else { diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 8e26fe542ed0..1dc2fe4909b4 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -36,7 +36,7 @@ import type {GeneratedRule} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import RulesNewPromptForm from './RulesNewPromptForm'; @@ -63,20 +63,15 @@ function RulesNewPage({route}: RulesNewPageProps) { const illustrations = useMemoizedLazyIllustrations(['CardReaderAlt', 'Flag', 'CheckboxText', 'ReportReceipt', 'AiBot']); const isCategoryScopedCreate = route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_RULES_NEW || !!categoryName; - // The category-scoped flow already knows the category and offers fewer types, so it opens on the list. const canDescribeRule = !isCategoryScopedCreate; const [shouldShowRuleTypes, setShouldShowRuleTypes] = useState(!canDescribeRule); const [generationID, setGenerationID] = useState(); const [submittedPrompt, setSubmittedPrompt] = useState(); - - // Set when the deterministic rule types cannot express the prompt, which an agent rule still can. - const [canOfferAgentRule, setCanOfferAgentRule] = useState(false); const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const policy = usePolicy(policyID); - // Fetch categories if they're not loaded (e.g. after cache clear), so a generated rule can name one. const fetchPolicyCategories = () => { if (!policy?.areCategoriesEnabled || policyCategories) { return; @@ -90,90 +85,92 @@ function RulesNewPage({route}: RulesNewPageProps) { fetchPolicyCategories(); }); - const seedDraftAndNavigate = (rule: GeneratedRule) => { - const {category, ...ruleValues} = rule.rule ?? {}; - - // Concierge answers with the category name the admin typed, so it only seeds a draft once it matches a category they can pick. - const matchedCategory = category - ? Object.values(policyCategories ?? {}).find((policyCategory) => policyCategory.enabled && getDecodedCategoryName(policyCategory.name) === getDecodedCategoryName(category)) - : undefined; - const draft: NonNullable = matchedCategory ? {...ruleValues, category: matchedCategory.name} : ruleValues; - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS) { - setDraftRequireFieldsRule(draft); - Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, undefined, true)); - return; - } - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW) { - setDraftFlagForReviewRule(draft); - Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, undefined, true)); - return; - } - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND) { - setDraftSpendRule(draft); - Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); - return; - } - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS) { - setDraftMerchantRule({...draft, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - return; - } - - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); - }; - - const applyGeneratedRule = (rule: GeneratedRule) => { - setGenerationID(undefined); - clearGeneratedRule(); + const appliedGenerationIDRef = useRef(undefined); + const generatedRuleForCurrentPrompt = generationID && generatedRule?.generationID === generationID ? generatedRule : undefined; + const canOfferAgentRule = generatedRuleForCurrentPrompt?.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED; + + const seedDraftAndNavigate = useCallback( + (rule: GeneratedRule) => { + const {category, ...ruleValues} = rule.rule ?? {}; + + const matchedCategory = category + ? Object.values(policyCategories ?? {}).find((policyCategory) => policyCategory.enabled && getDecodedCategoryName(policyCategory.name) === getDecodedCategoryName(category)) + : undefined; + const draft: NonNullable = matchedCategory ? {...ruleValues, category: matchedCategory.name} : ruleValues; + + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS) { + setDraftRequireFieldsRule(draft); + Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, undefined, true)); + return; + } + + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW) { + setDraftFlagForReviewRule(draft); + Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, undefined, true)); + return; + } + + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND) { + setDraftSpendRule(draft); + Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + return; + } + + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS) { + setDraftMerchantRule({...draft, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + return; + } - if (rule.state === CONST.GENERATED_RULE.STATE.RULE) { - clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); - seedDraftAndNavigate(rule); - return; - } - - if (rule.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported', {area: rule.unsupportedArea ?? ''})); - setCanOfferAgentRule(true); - return; - } - - if (rule.state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); - return; - } - - if (rule.state === CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE) { setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); - return; - } + }, + [policyCategories, policyID, translate], + ); - setNewRulePromptError(translate('common.genericErrorMessage')); - }; + const applyGeneratedRule = useCallback( + (rule: GeneratedRule) => { + if (rule.state === CONST.GENERATED_RULE.STATE.RULE) { + clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + seedDraftAndNavigate(rule); + return; + } + + if (rule.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported', {area: rule.unsupportedArea ?? ''})); + return; + } + + if (rule.state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); + return; + } + + if (rule.state === CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); + return; + } + + setNewRulePromptError(translate('common.genericErrorMessage')); + }, + [seedDraftAndNavigate, translate], + ); useEffect(() => { - if (!generationID || generatedRule?.generationID !== generationID) { + if (!generatedRuleForCurrentPrompt || appliedGenerationIDRef.current === generatedRuleForCurrentPrompt.generationID) { return; } - // eslint-disable-next-line react-hooks/set-state-in-effect -- Concierge answers asynchronously on an Onyx key, so this effect is how that external update reaches local state - applyGeneratedRule(generatedRule); - // eslint-disable-next-line react-hooks/exhaustive-deps -- applyGeneratedRule reads only what it is given - }, [generationID, generatedRule]); + appliedGenerationIDRef.current = generatedRuleForCurrentPrompt.generationID; + applyGeneratedRule(generatedRuleForCurrentPrompt); + }, [generatedRuleForCurrentPrompt, applyGeneratedRule]); const describeRule = (values: FormOnyxValues) => { const prompt = values.prompt.trim(); clearNewRulePromptError(); clearGeneratedRule(); - setCanOfferAgentRule(false); setSubmittedPrompt(prompt); setGenerationID(generateRule(policyID, prompt)); }; - // The prompt carries over so the admin does not retype it. const createAgentRuleFromPrompt = (prompt: string) => { setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[AGENT_RULE_INPUT_IDS.PROMPT]: prompt}); Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)); @@ -267,7 +264,7 @@ function RulesNewPage({route}: RulesNewPageProps) { onSubmit={describeRule} onBuildManually={() => setShouldShowRuleTypes(true)} onCreateAgentRule={canOfferAgentRule && submittedPrompt ? () => createAgentRuleFromPrompt(submittedPrompt) : undefined} - isLoading={!!generationID} + isLoading={!!generationID && !generatedRuleForCurrentPrompt} /> ) : ( ) => void; - /** Called when the admin chooses to pick a rule type themselves */ + /** Called when the admin picks a rule type themselves */ onBuildManually: () => void; - /** Whether Concierge is still building the rule */ + /** Whether the rule is still being generated */ isLoading: boolean; - /** Offers an agent rule instead, when the deterministic rule types cannot express the prompt */ + /** Called when the admin creates an agent rule instead */ onCreateAgentRule?: () => void; }; @@ -48,7 +48,6 @@ function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgent return; } if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { - // The markdown input inserts a line break for any Enter keydown whose default is not already prevented, so the submit combo has to claim it first. event.preventDefault(); formRef.current?.submit(); } diff --git a/src/types/onyx/GeneratedRule.ts b/src/types/onyx/GeneratedRule.ts index 6b03ef5a9374..0d690e439d06 100644 --- a/src/types/onyx/GeneratedRule.ts +++ b/src/types/onyx/GeneratedRule.ts @@ -3,36 +3,33 @@ import type {FlagForReviewRuleForm, MerchantRuleForm, RequireFieldsRuleForm, Spe import type {ValueOf} from 'type-fest'; -/** The rule that was built, or why no rule was built */ +/** The outcome of a generation attempt */ type GeneratedRuleState = ValueOf; /** The rule type a description was mapped to */ type GeneratedRuleType = ValueOf; -/** - * The form values Concierge filled in. Every field is optional and the field names are unique across the four - * rule forms, so this seeds whichever draft ruleType names. - */ +/** The generated form values, seeding whichever draft ruleType names */ type GeneratedRuleValues = Partial & Partial & Partial & Partial; -/** Model of the rule Concierge built from an admin's plain-English description */ +/** Model of a rule generated from an admin's description */ type GeneratedRule = { - /** The attempt this answer belongs to, so an earlier one is not mistaken for it */ + /** The generation attempt this answer belongs to */ generationID: string; - /** The rule that was built, or why no rule was built */ + /** The outcome of the generation attempt */ state: GeneratedRuleState; - /** The rule type the description was mapped to, set when state is rule */ + /** The rule type the description was mapped to */ ruleType?: GeneratedRuleType; - /** The form values to seed the matching rule draft with, set when state is rule */ + /** The form values to seed the rule draft with */ rule?: GeneratedRuleValues; - /** Short plain-English description of the rule that was built */ + /** Short description of the generated rule */ summary?: string; - /** The area the deterministic rule types cannot express, set when state is unsupported */ + /** The area no rule type supports */ unsupportedArea?: string; }; From 195df065be2a63eae0371464d2381136cc9be8f2 Mon Sep 17 00:00:00 2001 From: "Rushat Gabhane (via MelvinBot)" Date: Tue, 22 Sep 2026 15:01:45 +0000 Subject: [PATCH 27/62] Remove useCallback from RulesNewPage, hoist rule dispatch to module scope RulesNewPage compiles with React Compiler, so manual memoization is not needed. seedDraftAndNavigate and applyGeneratedRule are only used by the generated-rule effect and read no component state, so they move to module-level pure functions that take policyID, policyCategories and translate as params. That drops both useCallback wrappers without leaving the effect with a function dependency that changes every render. Co-authored-by: Rushat Gabhane --- src/pages/workspace/rules/RulesNewPage.tsx | 142 +++++++++++---------- 1 file changed, 72 insertions(+), 70 deletions(-) diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 1dc2fe4909b4..e6ca629ec895 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -1,5 +1,6 @@ import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -32,11 +33,13 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; -import type {GeneratedRule} from '@src/types/onyx'; +import type {GeneratedRule, PolicyCategories} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {OnyxEntry} from 'react-native-onyx'; + import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import RulesNewPromptForm from './RulesNewPromptForm'; @@ -56,6 +59,71 @@ type NewRuleOption = { isWorkspaceOnly?: boolean; }; +/** + * Seeds the draft form for the rule type Concierge picked and opens that rule's page. + * Falls back to the unintelligible error when the rule type is missing or unrecognized. + */ +function seedDraftAndNavigate(rule: GeneratedRule, policyID: string, policyCategories: OnyxEntry, translate: LocaleContextProps['translate']) { + const {category, ...ruleValues} = rule.rule ?? {}; + + const matchedCategory = category + ? Object.values(policyCategories ?? {}).find((policyCategory) => policyCategory.enabled && getDecodedCategoryName(policyCategory.name) === getDecodedCategoryName(category)) + : undefined; + const draft: NonNullable = matchedCategory ? {...ruleValues, category: matchedCategory.name} : ruleValues; + + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS) { + setDraftRequireFieldsRule(draft); + Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, undefined, true)); + return; + } + + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW) { + setDraftFlagForReviewRule(draft); + Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, undefined, true)); + return; + } + + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND) { + setDraftSpendRule(draft); + Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + return; + } + + if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS) { + setDraftMerchantRule({...draft, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + return; + } + + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); +} + +/** Routes a generated rule to either the matching rule form or the inline error explaining why it could not be used. */ +function applyGeneratedRule(rule: GeneratedRule, policyID: string, policyCategories: OnyxEntry, translate: LocaleContextProps['translate']) { + if (rule.state === CONST.GENERATED_RULE.STATE.RULE) { + clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + seedDraftAndNavigate(rule, policyID, policyCategories, translate); + return; + } + + if (rule.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported', {area: rule.unsupportedArea ?? ''})); + return; + } + + if (rule.state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); + return; + } + + if (rule.state === CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); + return; + } + + setNewRulePromptError(translate('common.genericErrorMessage')); +} + function RulesNewPage({route}: RulesNewPageProps) { const {policyID, categoryName} = route.params; const {translate} = useLocalize(); @@ -89,79 +157,13 @@ function RulesNewPage({route}: RulesNewPageProps) { const generatedRuleForCurrentPrompt = generationID && generatedRule?.generationID === generationID ? generatedRule : undefined; const canOfferAgentRule = generatedRuleForCurrentPrompt?.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED; - const seedDraftAndNavigate = useCallback( - (rule: GeneratedRule) => { - const {category, ...ruleValues} = rule.rule ?? {}; - - const matchedCategory = category - ? Object.values(policyCategories ?? {}).find((policyCategory) => policyCategory.enabled && getDecodedCategoryName(policyCategory.name) === getDecodedCategoryName(category)) - : undefined; - const draft: NonNullable = matchedCategory ? {...ruleValues, category: matchedCategory.name} : ruleValues; - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS) { - setDraftRequireFieldsRule(draft); - Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, undefined, true)); - return; - } - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW) { - setDraftFlagForReviewRule(draft); - Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, undefined, true)); - return; - } - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND) { - setDraftSpendRule(draft); - Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); - return; - } - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS) { - setDraftMerchantRule({...draft, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - return; - } - - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); - }, - [policyCategories, policyID, translate], - ); - - const applyGeneratedRule = useCallback( - (rule: GeneratedRule) => { - if (rule.state === CONST.GENERATED_RULE.STATE.RULE) { - clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); - seedDraftAndNavigate(rule); - return; - } - - if (rule.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported', {area: rule.unsupportedArea ?? ''})); - return; - } - - if (rule.state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); - return; - } - - if (rule.state === CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); - return; - } - - setNewRulePromptError(translate('common.genericErrorMessage')); - }, - [seedDraftAndNavigate, translate], - ); - useEffect(() => { if (!generatedRuleForCurrentPrompt || appliedGenerationIDRef.current === generatedRuleForCurrentPrompt.generationID) { return; } appliedGenerationIDRef.current = generatedRuleForCurrentPrompt.generationID; - applyGeneratedRule(generatedRuleForCurrentPrompt); - }, [generatedRuleForCurrentPrompt, applyGeneratedRule]); + applyGeneratedRule(generatedRuleForCurrentPrompt, policyID, policyCategories, translate); + }, [generatedRuleForCurrentPrompt, policyID, policyCategories, translate]); const describeRule = (values: FormOnyxValues) => { const prompt = values.prompt.trim(); From 667e0502520c849bfb5a9243179b4e4070407645 Mon Sep 17 00:00:00 2001 From: "Rushat Gabhane (via MelvinBot)" Date: Tue, 22 Sep 2026 15:09:04 +0000 Subject: [PATCH 28/62] Drop unknown spend rule categories from generated rules SpendRuleForm.categories is a closed enum, but GeneratedRuleValues types the model's output as already valid. A value outside the enum has no row in SpendRuleCategoryBase (built from SPEND_RULE_CATEGORIES), so the admin can neither see nor deselect it, and it still reaches translate('workspace.rules.spendRules.categoryOptions.'), which has no key for it. Filter through the existing isSpendRuleCategory guard, matching how getSpendRuleFormValuesFromCardRule already sanitizes the same field. Co-authored-by: Rushat Gabhane --- src/pages/workspace/rules/RulesNewPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index e6ca629ec895..0765152dd828 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -33,6 +33,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; +import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; import type {GeneratedRule, PolicyCategories} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -84,7 +85,10 @@ function seedDraftAndNavigate(rule: GeneratedRule, policyID: string, policyCateg } if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND) { - setDraftSpendRule(draft); + // `categories` is a closed enum, but it arrives as unvalidated model output already typed as valid. + // A value outside the enum has no row in the category picker, so the admin can neither see nor remove it, + // and it still reaches translate('...categoryOptions.'), which has no key for it. Drop unknown values. + setDraftSpendRule(draft.categories ? {...draft, categories: draft.categories.filter(isSpendRuleCategory)} : draft); Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); return; } From fe45a0afaa0e5fb07e975294f4203856fc37b704 Mon Sep 17 00:00:00 2001 From: "Rushat Gabhane (via MelvinBot)" Date: Tue, 22 Sep 2026 16:29:58 +0000 Subject: [PATCH 29/62] Make the agent rule page back button navigate back instead of switching tab The header back button switched from the Write tab to the Suggestions tab instead of leaving the page. That is wrong in general, and worse now that this flow can open straight on the Write tab from the New rule prompt: back sent the admin to a tab they never chose rather than back to the prompt. HeaderWithBackButton already defaults onBackButtonPress to Navigation.goBack(), so the override is just removed. Tabs stay switchable via the tab selector, and dropping the early return also lets the unsaved-prompt discard confirmation run on back, as it does on every other form page. Co-authored-by: Rushat Gabhane --- .../workspace/rules/AgentRules/AddAgentRulePage.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index c7cd292535ce..5a7e2d86ca5a 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -132,14 +132,6 @@ function AddAgentRulePage({ setActiveTab(key); }; - const handleBackButtonPress = () => { - if (activeTab === CONST.TAB.AGENT_RULE.WRITE) { - setActiveTab(CONST.TAB.AGENT_RULE.SUGGESTIONS); - return; - } - Navigation.goBack(); - }; - const navigateBackToAgentsTab = () => { Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.AGENTS); Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); @@ -216,7 +208,6 @@ function AddAgentRulePage({ From 5aac55e05d7c8a8a94bd18c491332d17212d9d31 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 23 Sep 2026 20:36:06 +0200 Subject: [PATCH 30/62] Add generation timeout --- src/CONST/index.ts | 1 + src/libs/actions/Policy/Rules.ts | 5 +++++ src/pages/workspace/rules/RulesNewPage.tsx | 12 ++++++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index aa65ec87795e..6831943acbf3 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -575,6 +575,7 @@ const CONST = { GENERATED_RULE: { PROMPT_MAX_LENGTH: 6000, + TIMEOUT_MS: 60000, STATE: { RULE: 'rule', UNSUPPORTED: 'unsupported', diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index c7fd42b88402..302f89262932 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -457,6 +457,10 @@ function clearGeneratedRule() { Onyx.set(ONYXKEYS.GENERATED_RULE, null); } +function failGeneratedRule(generationID: string) { + Onyx.set(ONYXKEYS.GENERATED_RULE, {generationID, state: CONST.GENERATED_RULE.STATE.FAILED}); +} + function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: string) { if (!policyID || !agentRuleID || !prompt) { Log.warn('Invalid params for addPolicyAgentRule', {policyID, agentRuleID, prompt}); @@ -734,6 +738,7 @@ export { getTransactionsMatchingCodingRule, addPolicyAgentRule, generateRule, + failGeneratedRule, clearGeneratedRule, setNewRulePromptError, clearNewRulePromptError, diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 0765152dd828..db06f77c9506 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -26,7 +26,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; -import {clearNewRulePromptError, clearGeneratedRule, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; +import {clearNewRulePromptError, clearGeneratedRule, failGeneratedRule, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -151,7 +151,7 @@ function RulesNewPage({route}: RulesNewPageProps) { openPolicyCategoriesPage(policyID); }; - useNetwork({onReconnect: fetchPolicyCategories}); + const {isOffline} = useNetwork({onReconnect: fetchPolicyCategories}); useFocusEffect(() => { fetchPolicyCategories(); @@ -169,6 +169,14 @@ function RulesNewPage({route}: RulesNewPageProps) { applyGeneratedRule(generatedRuleForCurrentPrompt, policyID, policyCategories, translate); }, [generatedRuleForCurrentPrompt, policyID, policyCategories, translate]); + useEffect(() => { + if (!generationID || generatedRuleForCurrentPrompt || isOffline) { + return; + } + const timeoutID = setTimeout(() => failGeneratedRule(generationID), CONST.GENERATED_RULE.TIMEOUT_MS); + return () => clearTimeout(timeoutID); + }, [generationID, generatedRuleForCurrentPrompt, isOffline]); + const describeRule = (values: FormOnyxValues) => { const prompt = values.prompt.trim(); clearNewRulePromptError(); From 9c9bd59cba3f2605ad666140923015b165e4052f Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 23 Sep 2026 20:40:59 +0200 Subject: [PATCH 31/62] Remove generation timeout --- src/CONST/index.ts | 1 - src/libs/actions/Policy/Rules.ts | 5 ----- src/pages/workspace/rules/RulesNewPage.tsx | 12 ++---------- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6831943acbf3..aa65ec87795e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -575,7 +575,6 @@ const CONST = { GENERATED_RULE: { PROMPT_MAX_LENGTH: 6000, - TIMEOUT_MS: 60000, STATE: { RULE: 'rule', UNSUPPORTED: 'unsupported', diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 302f89262932..c7fd42b88402 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -457,10 +457,6 @@ function clearGeneratedRule() { Onyx.set(ONYXKEYS.GENERATED_RULE, null); } -function failGeneratedRule(generationID: string) { - Onyx.set(ONYXKEYS.GENERATED_RULE, {generationID, state: CONST.GENERATED_RULE.STATE.FAILED}); -} - function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: string) { if (!policyID || !agentRuleID || !prompt) { Log.warn('Invalid params for addPolicyAgentRule', {policyID, agentRuleID, prompt}); @@ -738,7 +734,6 @@ export { getTransactionsMatchingCodingRule, addPolicyAgentRule, generateRule, - failGeneratedRule, clearGeneratedRule, setNewRulePromptError, clearNewRulePromptError, diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index db06f77c9506..0765152dd828 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -26,7 +26,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; -import {clearNewRulePromptError, clearGeneratedRule, failGeneratedRule, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; +import {clearNewRulePromptError, clearGeneratedRule, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -151,7 +151,7 @@ function RulesNewPage({route}: RulesNewPageProps) { openPolicyCategoriesPage(policyID); }; - const {isOffline} = useNetwork({onReconnect: fetchPolicyCategories}); + useNetwork({onReconnect: fetchPolicyCategories}); useFocusEffect(() => { fetchPolicyCategories(); @@ -169,14 +169,6 @@ function RulesNewPage({route}: RulesNewPageProps) { applyGeneratedRule(generatedRuleForCurrentPrompt, policyID, policyCategories, translate); }, [generatedRuleForCurrentPrompt, policyID, policyCategories, translate]); - useEffect(() => { - if (!generationID || generatedRuleForCurrentPrompt || isOffline) { - return; - } - const timeoutID = setTimeout(() => failGeneratedRule(generationID), CONST.GENERATED_RULE.TIMEOUT_MS); - return () => clearTimeout(timeoutID); - }, [generationID, generatedRuleForCurrentPrompt, isOffline]); - const describeRule = (values: FormOnyxValues) => { const prompt = values.prompt.trim(); clearNewRulePromptError(); From 3165738b8e686a394c1458e7416b7b6905e4ea5d Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 23 Sep 2026 20:50:05 +0200 Subject: [PATCH 32/62] Address review comments --- src/libs/ExportOnyxState/common.ts | 2 +- src/pages/workspace/rules/RulesNewPage.tsx | 1 + src/pages/workspace/rules/RulesNewPromptForm.tsx | 10 ++++++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 8e8145247364..935f0059c08c 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -227,7 +227,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID, ONYXKEYS.EXPENSIFY_CARD_STATEMENT, ONYXKEYS.FULLSCREEN_VISIBILITY, - ONYXKEYS.GENERATED_RULE, ONYXKEYS.GOVERNMENT_MILEAGE_RATES, ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT, ONYXKEYS.HAS_LOADED_APP, @@ -438,6 +437,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.DUPLICATE_WORKSPACE, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.FUND_LIST, + ONYXKEYS.GENERATED_RULE, ONYXKEYS.GPS_DRAFT_DETAILS, ONYXKEYS.IMPORTED_SPREADSHEET, ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_DATA, diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 0765152dd828..40637e344abf 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -270,6 +270,7 @@ function RulesNewPage({route}: RulesNewPageProps) { onSubmit={describeRule} onBuildManually={() => setShouldShowRuleTypes(true)} onCreateAgentRule={canOfferAgentRule && submittedPrompt ? () => createAgentRuleFromPrompt(submittedPrompt) : undefined} + onPromptChange={() => setSubmittedPrompt(undefined)} isLoading={!!generationID && !generatedRuleForCurrentPrompt} /> ) : ( diff --git a/src/pages/workspace/rules/RulesNewPromptForm.tsx b/src/pages/workspace/rules/RulesNewPromptForm.tsx index c308a90ce634..6f5c7b4f9620 100644 --- a/src/pages/workspace/rules/RulesNewPromptForm.tsx +++ b/src/pages/workspace/rules/RulesNewPromptForm.tsx @@ -34,9 +34,12 @@ type RulesNewPromptFormProps = { /** Called when the admin creates an agent rule instead */ onCreateAgentRule?: () => void; + + /** Called when the admin edits the prompt */ + onPromptChange: () => void; }; -function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgentRule}: RulesNewPromptFormProps) { +function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgentRule, onPromptChange}: RulesNewPromptFormProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const shouldUseScrollableLayout = useIsInLandscapeMode(); @@ -115,7 +118,10 @@ function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgent type="markdown" excludedMarkdownStyles={['mentionReport']} onKeyPress={submitFormOnModEnter} - onValueChange={clearNewRulePromptError} + onValueChange={() => { + clearNewRulePromptError(); + onPromptChange(); + }} maxLength={CONST.GENERATED_RULE.PROMPT_MAX_LENGTH} multiline shouldSaveDraft From eae23aa9c9bedcd0e5e0513e840886df8c60c826 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 23 Sep 2026 20:53:06 +0200 Subject: [PATCH 33/62] Use current prompt --- src/pages/workspace/rules/RulesNewPage.tsx | 6 ++---- src/pages/workspace/rules/RulesNewPromptForm.tsx | 10 ++-------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 40637e344abf..f97e654f081d 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -139,7 +139,7 @@ function RulesNewPage({route}: RulesNewPageProps) { const [shouldShowRuleTypes, setShouldShowRuleTypes] = useState(!canDescribeRule); const [generationID, setGenerationID] = useState(); - const [submittedPrompt, setSubmittedPrompt] = useState(); + const [promptDraft] = useOnyx(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM_DRAFT); const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const policy = usePolicy(policyID); @@ -173,7 +173,6 @@ function RulesNewPage({route}: RulesNewPageProps) { const prompt = values.prompt.trim(); clearNewRulePromptError(); clearGeneratedRule(); - setSubmittedPrompt(prompt); setGenerationID(generateRule(policyID, prompt)); }; @@ -269,8 +268,7 @@ function RulesNewPage({route}: RulesNewPageProps) { setShouldShowRuleTypes(true)} - onCreateAgentRule={canOfferAgentRule && submittedPrompt ? () => createAgentRuleFromPrompt(submittedPrompt) : undefined} - onPromptChange={() => setSubmittedPrompt(undefined)} + onCreateAgentRule={canOfferAgentRule ? () => createAgentRuleFromPrompt(promptDraft?.prompt?.trim() ?? '') : undefined} isLoading={!!generationID && !generatedRuleForCurrentPrompt} /> ) : ( diff --git a/src/pages/workspace/rules/RulesNewPromptForm.tsx b/src/pages/workspace/rules/RulesNewPromptForm.tsx index 6f5c7b4f9620..c308a90ce634 100644 --- a/src/pages/workspace/rules/RulesNewPromptForm.tsx +++ b/src/pages/workspace/rules/RulesNewPromptForm.tsx @@ -34,12 +34,9 @@ type RulesNewPromptFormProps = { /** Called when the admin creates an agent rule instead */ onCreateAgentRule?: () => void; - - /** Called when the admin edits the prompt */ - onPromptChange: () => void; }; -function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgentRule, onPromptChange}: RulesNewPromptFormProps) { +function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgentRule}: RulesNewPromptFormProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const shouldUseScrollableLayout = useIsInLandscapeMode(); @@ -118,10 +115,7 @@ function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgent type="markdown" excludedMarkdownStyles={['mentionReport']} onKeyPress={submitFormOnModEnter} - onValueChange={() => { - clearNewRulePromptError(); - onPromptChange(); - }} + onValueChange={clearNewRulePromptError} maxLength={CONST.GENERATED_RULE.PROMPT_MAX_LENGTH} multiline shouldSaveDraft From 84d903f6b2f574f10f7de1e25feab88826e98cac Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 25 Sep 2026 10:26:11 +0200 Subject: [PATCH 34/62] Keep prefilled draft --- src/ROUTES.ts | 17 ++--------------- src/libs/Navigation/types.ts | 4 ---- .../AddFlagForReviewRulePage.tsx | 2 -- .../FlagForReviewRulePageBase.tsx | 9 ++++----- .../AddRequireFieldsRulePage.tsx | 2 -- .../RequireFieldsRulePageBase.tsx | 9 ++++----- src/pages/workspace/rules/RulesNewPage.tsx | 4 ++-- 7 files changed, 12 insertions(+), 35 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 1c68634be176..f6338375d029 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -38,17 +38,6 @@ function getOptionalCategoryNameQuery(categoryName?: string): string { return categoryName ? `?categoryName=${encodeURIComponent(categoryName)}` : ''; } -function getRuleCreateQuery(categoryName?: string, isPrefilled?: boolean): string { - const params = []; - if (categoryName) { - params.push(`categoryName=${encodeURIComponent(categoryName)}`); - } - if (isPrefilled) { - params.push('isPrefilled=true'); - } - return params.length ? `?${params.join('&')}` : ''; -} - function getOptionalIsCategoryLockedQuery(isCategoryLocked?: boolean): string { return isCategoryLocked ? '?isCategoryLocked=true' : ''; } @@ -4056,8 +4045,7 @@ const ROUTES = { }, RULES_REQUIRE_FIELDS_RULE_NEW: { route: 'workspaces/:policyID/rules/require-fields-rules/new', - getRoute: (policyID: string, categoryName?: string, isPrefilled?: boolean) => - `workspaces/${policyID}/rules/require-fields-rules/new${getRuleCreateQuery(categoryName, isPrefilled)}` as const, + getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/require-fields-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, RULES_REQUIRE_FIELDS_RULE_EDIT: { route: 'workspaces/:policyID/rules/require-fields-rules/edit/:categoryName', @@ -4074,8 +4062,7 @@ const ROUTES = { }, RULES_FLAG_FOR_REVIEW_RULE_NEW: { route: 'workspaces/:policyID/rules/flag-for-review-rules/new', - getRoute: (policyID: string, categoryName?: string, isPrefilled?: boolean) => - `workspaces/${policyID}/rules/flag-for-review-rules/new${getRuleCreateQuery(categoryName, isPrefilled)}` as const, + getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/flag-for-review-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, RULES_FLAG_FOR_REVIEW_RULE_EDIT: { route: 'workspaces/:policyID/rules/flag-for-review-rules/edit/:categoryName', diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 0813916f9a97..30f52b7a98c5 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1848,8 +1848,6 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_NEW]: { policyID: string; categoryName?: string; - /** Whether the draft is already seeded */ - isPrefilled?: string; }; [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_EDIT]: { policyID: string; @@ -1867,8 +1865,6 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_NEW]: { policyID: string; categoryName?: string; - /** Whether the draft is already seeded */ - isPrefilled?: string; }; [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_EDIT]: { policyID: string; diff --git a/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx b/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx index 56139a61e7a4..58b8ba4822cf 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx @@ -17,7 +17,6 @@ type AddFlagForReviewRulePageProps = function AddFlagForReviewRulePage({route}: AddFlagForReviewRulePageProps) { const {policyID, categoryName} = route.params; - const isPrefilled = 'isPrefilled' in route.params && route.params.isPrefilled === 'true'; const isCategoryScopedFlow = route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_FOR_REVIEW_RULE_NEW; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -36,7 +35,6 @@ function AddFlagForReviewRulePage({route}: AddFlagForReviewRulePageProps) { initialCategoryName={categoryName} isCategoryLocked={isCategoryScopedFlow ? true : undefined} isCategoryScopedFlow={isCategoryScopedFlow} - isPrefilled={isPrefilled} testID="AddFlagForReviewRulePage" /> ); diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 0a998145e31e..554db97f8c25 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -35,6 +35,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES, getFlagForReviewRuleAmountRoute, getFlagForReviewRuleCategoryRoute, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type {FlagForReviewRuleForm} from '@src/types/form/FlagForReviewRuleForm'; import INPUT_IDS from '@src/types/form/FlagForReviewRuleForm'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -49,8 +50,6 @@ type FlagForReviewRulePageBaseProps = { isCategoryLocked?: boolean; /** When true, nested create pages use category dynamic routes (keeps Categories underlay). */ isCategoryScopedFlow?: boolean; - /** Whether the draft is already seeded */ - isPrefilled?: boolean; testID: string; }; @@ -68,7 +67,6 @@ function FlagForReviewRulePageBase({ initialCategoryName, isCategoryLocked: isCategoryLockedProp, isCategoryScopedFlow = false, - isPrefilled = false, testID, }: FlagForReviewRulePageBaseProps) { const {translate} = useLocalize(); @@ -88,6 +86,7 @@ function FlagForReviewRulePageBase({ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [shouldShowError, setShouldShowError] = useState(false); const initializedDraftForRuleKeyRef = useRef(null); + const hasDraft = !isEmptyObject(form); const category = categoryName ? policyCategories?.[categoryName] : undefined; const selectedCategoryName = form?.[INPUT_IDS.CATEGORY]; @@ -103,7 +102,7 @@ function FlagForReviewRulePageBase({ if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - if (!isPrefilled) { + if (!hasDraft) { setDraftFlagForReviewRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); } } @@ -129,7 +128,7 @@ function FlagForReviewRulePageBase({ initializedDraftForRuleKeyRef.current = categoryName; setDraftFlagForReviewRule(getFlagForReviewFormFromCategory(category, getCurrencyDecimals, policyCurrency)); - }, [category, categoryName, draftMaxExpenseAmount, getCurrencyDecimals, initialCategoryName, isEditing, isPrefilled, policyCurrency, selectedCategoryName]); + }, [category, categoryName, draftMaxExpenseAmount, getCurrencyDecimals, hasDraft, initialCategoryName, isEditing, policyCurrency, selectedCategoryName]); const fetchPolicyData = useCallback(() => { if (!policy?.areCategoriesEnabled || policyCategories) { return; diff --git a/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx b/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx index cf196035505a..90e3ecbf2996 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx @@ -18,7 +18,6 @@ type AddRequireFieldsRulePageProps = function AddRequireFieldsRulePage({route}: AddRequireFieldsRulePageProps) { const {policyID, categoryName} = route.params; const isCategoryScopedFlow = route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRE_FIELDS_RULE_NEW; - const isPrefilled = 'isPrefilled' in route.params && route.params.isPrefilled === 'true'; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const scopedCategory = categoryName ? policyCategories?.[categoryName] : undefined; @@ -35,7 +34,6 @@ function AddRequireFieldsRulePage({route}: AddRequireFieldsRulePageProps) { categoryName={hasExistingRule ? categoryName : undefined} initialCategoryName={categoryName} isCategoryLocked={isCategoryScopedFlow ? true : undefined} - isPrefilled={isPrefilled} testID="AddRequireFieldsRulePage" /> ); diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 824c9ddf6d83..6e2af6641078 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -50,6 +50,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES, getRequireFieldsRuleCategoryRoute, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src/types/form/RequireFieldsRuleForm'; import INPUT_IDS from '@src/types/form/RequireFieldsRuleForm'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -64,12 +65,10 @@ type RequireFieldsRulePageBaseProps = { initialCategoryName?: string; /** When true, the category field is non-interactive (category-scoped create/edit). */ isCategoryLocked?: boolean; - /** Whether the draft is already seeded */ - isPrefilled?: boolean; testID: string; }; -function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, isCategoryLocked: isCategoryLockedProp, isPrefilled = false, testID}: RequireFieldsRulePageBaseProps) { +function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, isCategoryLocked: isCategoryLockedProp, testID}: RequireFieldsRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {isBetaEnabledOrUnknown} = usePermissions(); @@ -173,7 +172,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - if (isPrefilled) { + if (!isEmptyObject(form)) { // eslint-disable-next-line react-hooks/set-state-in-effect -- seeds local selection state from the seeded draft setTouchedFields(new Set(SETTING_FIELD_KEYS.filter((fieldKey) => form?.[fieldKey] !== undefined))); } else { @@ -208,7 +207,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, [INPUT_IDS.CATEGORY]: categoryName, ...getRequireFieldsFormFromCategory(category), }); - }, [category, categoryName, form, initialCategoryName, isEditing, isPrefilled]); + }, [category, categoryName, form, initialCategoryName, isEditing]); const fetchPolicyData = useCallback(() => { if (!policy?.areCategoriesEnabled || policyCategories) { diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index f97e654f081d..c3b407442845 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -74,13 +74,13 @@ function seedDraftAndNavigate(rule: GeneratedRule, policyID: string, policyCateg if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS) { setDraftRequireFieldsRule(draft); - Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, undefined, true)); + Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID)); return; } if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW) { setDraftFlagForReviewRule(draft); - Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, undefined, true)); + Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID)); return; } From 95a5432e01ca0a434d7b0290b4be3c6135b53418 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 25 Sep 2026 11:02:34 +0200 Subject: [PATCH 35/62] Prompt back fixes --- src/pages/workspace/rules/RulesNewPage.tsx | 15 +++++++++++++-- src/pages/workspace/rules/RulesNewPromptForm.tsx | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index c3b407442845..20e3a3b0dd16 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -6,6 +6,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -105,7 +106,6 @@ function seedDraftAndNavigate(rule: GeneratedRule, policyID: string, policyCateg /** Routes a generated rule to either the matching rule form or the inline error explaining why it could not be used. */ function applyGeneratedRule(rule: GeneratedRule, policyID: string, policyCategories: OnyxEntry, translate: LocaleContextProps['translate']) { if (rule.state === CONST.GENERATED_RULE.STATE.RULE) { - clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); seedDraftAndNavigate(rule, policyID, policyCategories, translate); return; } @@ -169,6 +169,14 @@ function RulesNewPage({route}: RulesNewPageProps) { applyGeneratedRule(generatedRuleForCurrentPrompt, policyID, policyCategories, translate); }, [generatedRuleForCurrentPrompt, policyID, policyCategories, translate]); + useEffect( + () => () => { + clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + clearNewRulePromptError(); + }, + [], + ); + const describeRule = (values: FormOnyxValues) => { const prompt = values.prompt.trim(); clearNewRulePromptError(); @@ -184,11 +192,14 @@ function RulesNewPage({route}: RulesNewPageProps) { const handleBackButtonPress = () => { if (canDescribeRule && shouldShowRuleTypes) { setShouldShowRuleTypes(false); - return; + return true; } Navigation.goBack(); + return true; }; + useAndroidBackButtonHandler(handleBackButtonPress); + const newRuleOptions: NewRuleOption[] = [ { key: 'restrictCardSpend', diff --git a/src/pages/workspace/rules/RulesNewPromptForm.tsx b/src/pages/workspace/rules/RulesNewPromptForm.tsx index c308a90ce634..bf063aec8b03 100644 --- a/src/pages/workspace/rules/RulesNewPromptForm.tsx +++ b/src/pages/workspace/rules/RulesNewPromptForm.tsx @@ -68,6 +68,7 @@ function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgent ref={formRef} formID={ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM} validate={validate} + addBottomSafeAreaPadding onSubmit={onSubmit} isLoading={isLoading} submitButtonText={translate('common.next')} From 02603dea4c2470e17b0e628ebda638268b32e199 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 29 Sep 2026 15:01:11 +0200 Subject: [PATCH 36/62] Drop unsupported area --- src/languages/de.ts | 5 +---- src/languages/el.ts | 5 +---- src/languages/en.ts | 7 ++----- src/languages/es.ts | 5 +---- src/languages/fr.ts | 5 +---- src/languages/it.ts | 5 +---- src/languages/ja.ts | 5 +---- src/languages/nl.ts | 5 +---- src/languages/params.ts | 3 --- src/languages/pl.ts | 5 +---- src/languages/pt-BR.ts | 5 +---- src/languages/zh-hans.ts | 2 +- src/pages/workspace/rules/RulesNewPage.tsx | 2 +- 13 files changed, 13 insertions(+), 46 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index b006b4f11260..ab7556fb96b4 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8564,10 +8564,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc buildManually: 'Regel manuell erstellen', createAgentRuleInstead: 'Stattdessen eine Agent-Regel erstellen', promptErrors: { - unsupported: ({area}) => - area - ? `${area}-Regeln werden noch nicht unterstützt – wir arbeiten daran. Bitte versuche vorerst eine andere Regel.` - : 'Diese Regel wird noch nicht unterstützt – wir arbeiten daran. Bitte versuche vorerst eine andere Regel.', + unsupported: 'Diese Regel wird noch nicht unterstützt – wir arbeiten daran. Bitte versuche vorerst eine andere Regel.', unintelligible: 'Formuliere deine Regel etwa so: Wenn Ausgaben über 100 $ liegen, ist ein Beleg erforderlich.', multipleRules: 'Bitte eine Regel nach der anderen – beschreibe eine einzelne Regel, z. B.: Einzelhandelskäufe auf allen Karten blockieren.', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index 661982dede0b..61079bf1cfc8 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8651,10 +8651,7 @@ ${reportName}`, buildManually: 'Μη αυτόματη δημιουργία κανόνα', createAgentRuleInstead: 'Δημιουργία κανόνα πράκτορα αντ’ αυτού', promptErrors: { - unsupported: ({area}) => - area - ? `Οι κανόνες ${area} δεν υποστηρίζονται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.` - : 'Αυτός ο κανόνας δεν υποστηρίζεται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.', + unsupported: 'Αυτός ο κανόνας δεν υποστηρίζεται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.', unintelligible: 'Δοκιμάστε να διατυπώσετε τον κανόνα σας έτσι: αν οι δαπάνες ξεπερνούν τα 100 $, να απαιτείται απόδειξη.', multipleRules: 'Έναν κανόνα τη φορά — δοκιμάστε να περιγράψετε έναν μόνο κανόνα, π.χ.: αποκλεισμός αγορών λιανικής σε όλες τις κάρτες.', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 33f34b9fb79f..3c13650fe70a 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7,7 +7,7 @@ import type {ValueOf} from 'type-fest'; import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; -import type {ExportAgainModalDescriptionParams, ExportIntegrationSelectedParams, NewRulePromptUnsupportedParams} from './params'; +import type {ExportAgainModalDescriptionParams, ExportIntegrationSelectedParams} from './params'; import type { AllConnectionName, ConnectionName, @@ -8714,10 +8714,7 @@ const translations = { buildManually: 'Build rule manually', createAgentRuleInstead: 'Create an agent rule instead', promptErrors: { - unsupported: ({area}: NewRulePromptUnsupportedParams) => - area - ? `${area} rules aren't supported yet — we're working on it. Please try a different rule for now.` - : "That rule isn't supported yet — we're working on it. Please try a different rule for now.", + unsupported: "That rule isn't supported yet — we're working on it. Please try a different rule for now.", unintelligible: 'Try phrasing your rule like: if expenses have a value over $100, then require a receipt.', multipleRules: 'One rule at a time, please — try describing a single rule, like: block retail purchases on all cards.', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index f1cea320f2a9..b13b345b65aa 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8565,10 +8565,7 @@ ${reportName}`, buildManually: 'Crear la regla manualmente', createAgentRuleInstead: 'Crear una regla de agente en su lugar', promptErrors: { - unsupported: ({area}) => - area - ? `Las reglas de ${area} aún no son compatibles: estamos trabajando en ello. Prueba con otra regla por ahora.` - : 'Esa regla aún no es compatible: estamos trabajando en ello. Prueba con otra regla por ahora.', + unsupported: 'Esa regla aún no es compatible: estamos trabajando en ello. Prueba con otra regla por ahora.', unintelligible: 'Prueba a formular tu regla así: si los gastos superan los $100, exige un recibo.', multipleRules: 'Una regla a la vez, por favor. Prueba a describir una sola regla, como: bloquear compras en comercios minoristas en todas las tarjetas.', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0125b1933fa5..c308110f87be 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8586,10 +8586,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e buildManually: 'Créer la règle manuellement', createAgentRuleInstead: 'Créer plutôt une règle d’agent', promptErrors: { - unsupported: ({area}) => - area - ? `Les règles ${area} ne sont pas encore prises en charge — nous y travaillons. Essayez une autre règle pour le moment.` - : 'Cette règle n’est pas encore prise en charge — nous y travaillons. Essayez une autre règle pour le moment.', + unsupported: 'Cette règle n’est pas encore prise en charge — nous y travaillons. Essayez une autre règle pour le moment.', unintelligible: 'Essayez de formuler votre règle ainsi : si les dépenses dépassent 100 $, exigez un reçu.', multipleRules: 'Une règle à la fois, s’il vous plaît — essayez de décrire une seule règle, comme : bloquer les achats en magasin sur toutes les cartes.', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index a250357860e8..e52e6fdfe1ad 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8516,10 +8516,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, buildManually: 'Crea la regola manualmente', createAgentRuleInstead: 'Crea invece una regola agente', promptErrors: { - unsupported: ({area}) => - area - ? `Le regole ${area} non sono ancora supportate: ci stiamo lavorando. Per ora prova una regola diversa.` - : 'Questa regola non è ancora supportata: ci stiamo lavorando. Per ora prova una regola diversa.', + unsupported: 'Questa regola non è ancora supportata: ci stiamo lavorando. Per ora prova una regola diversa.', unintelligible: 'Prova a formulare la regola così: se le spese superano i 100 $, richiedi una ricevuta.', multipleRules: 'Una regola alla volta, per favore: prova a descrivere una singola regola, ad esempio bloccare gli acquisti al dettaglio su tutte le carte.', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d6fa7efbc575..74419de76fee 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8408,10 +8408,7 @@ ${reportName}`, buildManually: '手動でルールを作成', createAgentRuleInstead: '代わりにエージェントルールを作成', promptErrors: { - unsupported: ({area}) => - area - ? `${area}のルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。` - : 'そのルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。', + unsupported: 'そのルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。', unintelligible: '「経費が100ドルを超える場合は領収書を必須にする」のように書いてみてください。', multipleRules: '一度に1つのルールのみです。「すべてのカードで小売店での購入をブロックする」のように、1つのルールを説明してください。', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 2c704de7c493..84aa25911128 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8492,10 +8492,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, buildManually: 'Regel handmatig maken', createAgentRuleInstead: 'Maak in plaats daarvan een agentregel', promptErrors: { - unsupported: ({area}) => - area - ? `${area}-regels worden nog niet ondersteund — we werken eraan. Probeer voorlopig een andere regel.` - : 'Die regel wordt nog niet ondersteund — we werken eraan. Probeer voorlopig een andere regel.', + unsupported: 'Die regel wordt nog niet ondersteund — we werken eraan. Probeer voorlopig een andere regel.', unintelligible: 'Formuleer je regel bijvoorbeeld zo: als uitgaven hoger zijn dan $100, vraag dan een bon.', multipleRules: 'Eén regel per keer graag — beschrijf één enkele regel, bijvoorbeeld: retailaankopen op alle kaarten blokkeren.', }, diff --git a/src/languages/params.ts b/src/languages/params.ts index f4f13b20500a..bde06b208cd8 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -1,7 +1,5 @@ import type {ConnectionName} from './TranslationTypes'; -type NewRulePromptUnsupportedParams = {area: string}; - type StepCounterParams = {step: number; total?: number; text?: string}; type ParentNavigationSummaryParams = {reportName?: string; workspaceName?: string}; @@ -38,7 +36,6 @@ type ExportAgainModalDescriptionParams = { type ExportIntegrationSelectedParams = {connectionName: ConnectionName; connectionNameFriendly?: string}; export type { - NewRulePromptUnsupportedParams, ParentNavigationSummaryParams, StepCounterParams, ChangeFieldParams, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 3e51f833ca41..bafe1b133160 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8516,10 +8516,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, buildManually: 'Utwórz regułę ręcznie', createAgentRuleInstead: 'Utwórz zamiast tego regułę agenta', promptErrors: { - unsupported: ({area}) => - area - ? `Reguły „${area}” nie są jeszcze obsługiwane — pracujemy nad tym. Na razie spróbuj innej reguły.` - : 'Ta reguła nie jest jeszcze obsługiwana — pracujemy nad tym. Na razie spróbuj innej reguły.', + unsupported: 'Ta reguła nie jest jeszcze obsługiwana — pracujemy nad tym. Na razie spróbuj innej reguły.', unintelligible: 'Spróbuj sformułować regułę tak: jeśli wydatek przekracza 100 $, wymagaj paragonu.', multipleRules: 'Jedna reguła naraz — opisz pojedynczą regułę, na przykład: zablokuj zakupy detaliczne na wszystkich kartach.', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 1071594fb2f7..38db645f25ad 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8499,10 +8499,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, buildManually: 'Criar regra manualmente', createAgentRuleInstead: 'Criar uma regra de agente em vez disso', promptErrors: { - unsupported: ({area}) => - area - ? `Regras de ${area} ainda não têm suporte — estamos trabalhando nisso. Tente uma regra diferente por enquanto.` - : 'Essa regra ainda não tem suporte — estamos trabalhando nisso. Tente uma regra diferente por enquanto.', + unsupported: 'Essa regra ainda não tem suporte — estamos trabalhando nisso. Tente uma regra diferente por enquanto.', unintelligible: 'Tente formular sua regra assim: se a despesa for maior que $100, exija um recibo.', multipleRules: 'Uma regra por vez, por favor — tente descrever uma única regra, como: bloquear compras no varejo em todos os cartões.', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 24209aef3676..0773b991b8bf 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8195,7 +8195,7 @@ ${reportName}`, buildManually: '手动创建规则', createAgentRuleInstead: '改为创建代理规则', promptErrors: { - unsupported: ({area}) => (area ? `${area}规则暂不支持,我们正在开发中。请先尝试其他规则。` : '该规则暂不支持,我们正在开发中。请先尝试其他规则。'), + unsupported: '该规则暂不支持,我们正在开发中。请先尝试其他规则。', unintelligible: '可以这样描述规则:如果支出超过 100 美元,则需要收据。', multipleRules: '请一次描述一条规则,例如:在所有卡上阻止零售消费。', }, diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 20e3a3b0dd16..22ffbf3a5480 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -111,7 +111,7 @@ function applyGeneratedRule(rule: GeneratedRule, policyID: string, policyCategor } if (rule.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported', {area: rule.unsupportedArea ?? ''})); + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported')); return; } From 7908cfee39d989feea5e06acef34f2b59c7661f9 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 2 Oct 2026 13:41:07 +0200 Subject: [PATCH 37/62] Describe a rule from each rule page instead of a separate prompt page Each rule editor gets a Describe button beside Save that opens a bottom sheet. The generated values fill the current form's draft, so the rule type is sent with the prompt and the response's rule type is ignored. Drops the agent rule offer. --- src/CONST/index.ts | 6 +- src/languages/de.ts | 7 +- src/languages/el.ts | 7 +- src/languages/en.ts | 7 +- src/languages/es.ts | 7 +- src/languages/fr.ts | 7 +- src/languages/it.ts | 7 +- src/languages/ja.ts | 7 +- src/languages/nl.ts | 7 +- src/languages/pl.ts | 7 +- src/languages/pt-BR.ts | 7 +- src/languages/zh-hans.ts | 7 +- src/libs/API/parameters/GenerateRuleParams.ts | 5 + src/libs/actions/Policy/Rules.ts | 5 +- .../rules/AgentRules/AddAgentRulePage.tsx | 19 +- .../workspace/rules/DescribeRuleButton.tsx | 192 ++++++++++++++++ .../FlagForReviewRulePageBase.tsx | 24 +- .../MerchantRules/MerchantRulePageBase.tsx | 16 ++ .../RequireFieldsRulePageBase.tsx | 31 ++- src/pages/workspace/rules/RulesNewPage.tsx | 214 ++---------------- .../workspace/rules/RulesNewPromptForm.tsx | 138 ----------- .../rules/SpendRules/SpendRulePageBase.tsx | 28 ++- 22 files changed, 356 insertions(+), 399 deletions(-) create mode 100644 src/pages/workspace/rules/DescribeRuleButton.tsx delete mode 100644 src/pages/workspace/rules/RulesNewPromptForm.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 07446bc3a342..223fc14c36ea 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9904,12 +9904,14 @@ const CONST = { MERCHANT_RULE_ITEM: 'WorkspaceRules-MerchantRuleItem', REQUIRE_FIELDS_RULE_ITEM: 'WorkspaceRules-RequireFieldsRuleItem', REQUIRE_FIELDS_RULE_SAVE: 'WorkspaceRules-RequireFieldsRuleSave', + REQUIRE_FIELDS_RULE_DESCRIBE: 'WorkspaceRules-RequireFieldsRuleDescribe', REQUIRE_FIELDS_RULE_DELETE: 'WorkspaceRules-RequireFieldsRuleDelete', REQUIRE_FIELDS_RULE_CATEGORY: 'WorkspaceRules-RequireFieldsRuleCategory', REQUIRE_FIELDS_RULE_FIELD_TOGGLE: 'WorkspaceRules-RequireFieldsRuleFieldToggle', REQUIRE_FIELDS_RULE_DIRECTION_TOGGLE: 'WorkspaceRules-RequireFieldsRuleDirectionToggle', FLAG_FOR_REVIEW_RULE_ITEM: 'WorkspaceRules-FlagForReviewRuleItem', FLAG_FOR_REVIEW_RULE_SAVE: 'WorkspaceRules-FlagForReviewRuleSave', + FLAG_FOR_REVIEW_RULE_DESCRIBE: 'WorkspaceRules-FlagForReviewRuleDescribe', FLAG_FOR_REVIEW_RULE_DELETE: 'WorkspaceRules-FlagForReviewRuleDelete', FLAG_FOR_REVIEW_RULE_CATEGORY: 'WorkspaceRules-FlagForReviewRuleCategory', FLAG_FOR_REVIEW_RULE_AMOUNT: 'WorkspaceRules-FlagForReviewRuleAmount', @@ -9922,12 +9924,14 @@ const CONST = { IMPORT_MERCHANT_RULES: 'WorkspaceRules-ImportMerchantRules', MERCHANT_RULE_SECTION_ITEM: 'WorkspaceRules-MerchantRuleSectionItem', MERCHANT_RULE_SAVE: 'WorkspaceRules-MerchantRuleSave', + MERCHANT_RULE_DESCRIBE: 'WorkspaceRules-MerchantRuleDescribe', MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches', MERCHANT_RULE_DELETE: 'WorkspaceRules-MerchantRuleDelete', CATEGORY_SELECTOR: 'WorkspaceRules-CategorySelector', CURRENCY_SELECTOR: 'WorkspaceRules-CurrencySelector', SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem', SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave', + SPEND_RULE_DESCRIBE: 'WorkspaceRules-SpendRuleDescribe', SPEND_RULE_DELETE: 'WorkspaceRules-SpendRuleDelete', SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', AGENT_RULE_ITEM: 'WorkspaceRules-AgentRuleItem', @@ -9941,8 +9945,6 @@ const CONST = { NEW_RULE_MENU_ITEM_REQUIRE_FIELDS: 'WorkspaceRules-NewRuleMenuItem-RequireFields', NEW_RULE_MENU_ITEM_APPLY_EXPENSE_DEFAULTS: 'WorkspaceRules-NewRuleMenuItem-ApplyExpenseDefaults', NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE: 'WorkspaceRules-NewRuleMenuItem-CreateAgentRule', - NEW_RULE_BUILD_MANUALLY: 'WorkspaceRules-NewRule-BuildManually', - NEW_RULE_CREATE_AGENT_RULE: 'WorkspaceRules-NewRule-CreateAgentRule', REQUIRE_RECEIPTS_SAVE: 'WorkspaceRules-RequireReceiptsSave', REQUIRE_FIELDS_SAVE: 'WorkspaceRules-RequireFieldsSave', FLAG_RECEIPT_LINE_ITEMS_SAVE: 'WorkspaceRules-FlagReceiptLineItemsSave', diff --git a/src/languages/de.ts b/src/languages/de.ts index b1e4e0074685..b44fe6ce7999 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8589,10 +8589,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc requireFieldsDescription: 'Bestimmte Spesenfelder vorschreiben oder auf ihre Pflicht verzichten.', createAgentRule: 'Agentenregel beschreiben', createAgentRuleDescription: 'Erstellen Sie flexible Regeln, die ausgeführt werden, wenn Sie sie benötigen.', - createRuleHeadline: 'Neue Regel erstellen', - describeRule: 'Beschreibe deine Regel und Concierge erstellt sie', - buildManually: 'Regel manuell erstellen', - createAgentRuleInstead: 'Stattdessen eine Agent-Regel erstellen', + describe: 'Beschreiben', + describeRule: 'Beschreibe deine Regel in einfachen Worten und Concierge erstellt sie für dich', + describeRuleInputLabel: 'Beschreibe die Regel', promptErrors: { unsupported: 'Diese Regel wird noch nicht unterstützt – wir arbeiten daran. Bitte versuche vorerst eine andere Regel.', unintelligible: 'Formuliere deine Regel etwa so: Wenn Ausgaben über 100 $ liegen, ist ein Beleg erforderlich.', diff --git a/src/languages/el.ts b/src/languages/el.ts index 69657f63ed06..09a37399778f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8675,10 +8675,9 @@ ${reportName}`, applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', - createRuleHeadline: 'Δημιουργία νέου κανόνα', - describeRule: 'Περιγράψτε τον κανόνα σας και ο Concierge θα τον δημιουργήσει', - buildManually: 'Μη αυτόματη δημιουργία κανόνα', - createAgentRuleInstead: 'Δημιουργία κανόνα πράκτορα αντ’ αυτού', + describe: 'Περιγραφή', + describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', + describeRuleInputLabel: 'Περιγράψτε τον κανόνα', promptErrors: { unsupported: 'Αυτός ο κανόνας δεν υποστηρίζεται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.', unintelligible: 'Δοκιμάστε να διατυπώσετε τον κανόνα σας έτσι: αν οι δαπάνες ξεπερνούν τα 100 $, να απαιτείται απόδειξη.', diff --git a/src/languages/en.ts b/src/languages/en.ts index fc4ef60a319c..afcdd4086e92 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8823,10 +8823,9 @@ const translations = { applyExpenseDefaultsDescription: 'Update fields without submitter doing anything', createAgentRule: 'Describe agent rule', createAgentRuleDescription: 'Create flexible rules that run when you need.', - createRuleHeadline: 'Create a new rule', - describeRule: 'Describe your rule and Concierge will build it', - buildManually: 'Build rule manually', - createAgentRuleInstead: 'Create an agent rule instead', + describe: 'Describe', + describeRule: 'Describe your rule in plain English and have Concierge build the rule for you', + describeRuleInputLabel: 'Describe the rule', promptErrors: { unsupported: "That rule isn't supported yet — we're working on it. Please try a different rule for now.", unintelligible: 'Try phrasing your rule like: if expenses have a value over $100, then require a receipt.', diff --git a/src/languages/es.ts b/src/languages/es.ts index e1f1dca1ff1b..23539c7efada 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8590,10 +8590,9 @@ ${reportName}`, requireFieldsDescription: 'Exige campos específicos de gasto o renuncia a exigirlos.', createAgentRule: 'Describir regla de agente', createAgentRuleDescription: 'Crea reglas flexibles que se ejecuten cuando las necesites.', - createRuleHeadline: 'Crear una nueva regla', - describeRule: 'Describe tu regla y Concierge la creará', - buildManually: 'Crear la regla manualmente', - createAgentRuleInstead: 'Crear una regla de agente en su lugar', + describe: 'Describir', + describeRule: 'Describe tu regla con tus propias palabras y Concierge la creará por ti', + describeRuleInputLabel: 'Describe la regla', promptErrors: { unsupported: 'Esa regla aún no es compatible: estamos trabajando en ello. Prueba con otra regla por ahora.', unintelligible: 'Prueba a formular tu regla así: si los gastos superan los $100, exige un recibo.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 949d9e304d53..ab4b1af5763e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8610,10 +8610,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e requireFieldsDescription: 'Rendre certains champs de dépense obligatoires ou renoncer à les exiger.', createAgentRule: 'Décrire la règle de l’agent', createAgentRuleDescription: 'Créez des règles flexibles qui s’exécutent quand vous en avez besoin.', - createRuleHeadline: 'Créer une nouvelle règle', - describeRule: 'Décrivez votre règle et Concierge la créera', - buildManually: 'Créer la règle manuellement', - createAgentRuleInstead: 'Créer plutôt une règle d’agent', + describe: 'Décrire', + describeRule: 'Décrivez votre règle avec vos propres mots et Concierge la créera pour vous', + describeRuleInputLabel: 'Décrivez la règle', promptErrors: { unsupported: 'Cette règle n’est pas encore prise en charge — nous y travaillons. Essayez une autre règle pour le moment.', unintelligible: 'Essayez de formuler votre règle ainsi : si les dépenses dépassent 100 $, exigez un reçu.', diff --git a/src/languages/it.ts b/src/languages/it.ts index c241c3c66d22..c2fc86be11eb 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8541,10 +8541,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, requireFieldsDescription: 'Richiedi campi specifici per le spese oppure rinuncia a renderli obbligatori.', createAgentRule: 'Descrivi regola agente', createAgentRuleDescription: 'Crea regole flessibili che vengono eseguite quando ne hai bisogno.', - createRuleHeadline: 'Crea una nuova regola', - describeRule: 'Descrivi la tua regola e Concierge la creerà', - buildManually: 'Crea la regola manualmente', - createAgentRuleInstead: 'Crea invece una regola agente', + describe: 'Descrivi', + describeRule: 'Descrivi la tua regola con parole semplici e Concierge la creerà per te', + describeRuleInputLabel: 'Descrivi la regola', promptErrors: { unsupported: 'Questa regola non è ancora supportata: ci stiamo lavorando. Per ora prova una regola diversa.', unintelligible: 'Prova a formulare la regola così: se le spese superano i 100 $, richiedi una ricevuta.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d45ba868fab7..dcf55ec36def 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8432,10 +8432,9 @@ ${reportName}`, requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', createAgentRule: 'エージェントルールの説明', createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', - createRuleHeadline: '新しいルールを作成', - describeRule: 'ルールを説明すると、Conciergeが作成します', - buildManually: '手動でルールを作成', - createAgentRuleInstead: '代わりにエージェントルールを作成', + describe: '説明する', + describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', + describeRuleInputLabel: 'ルールを説明', promptErrors: { unsupported: 'そのルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。', unintelligible: '「経費が100ドルを超える場合は領収書を必須にする」のように書いてみてください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0f6ad88d039f..d35d8342c449 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8516,10 +8516,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, requireFieldsDescription: 'Specifieke onkostenvakken verplicht stellen of afzien van het verplicht stellen ervan.', createAgentRule: 'Agentregel beschrijven', createAgentRuleDescription: 'Maak flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', - createRuleHeadline: 'Een nieuwe regel maken', - describeRule: 'Beschrijf je regel en Concierge bouwt hem', - buildManually: 'Regel handmatig maken', - createAgentRuleInstead: 'Maak in plaats daarvan een agentregel', + describe: 'Beschrijven', + describeRule: 'Beschrijf je regel in gewone taal en Concierge maakt de regel voor je', + describeRuleInputLabel: 'Beschrijf de regel', promptErrors: { unsupported: 'Die regel wordt nog niet ondersteund — we werken eraan. Probeer voorlopig een andere regel.', unintelligible: 'Formuleer je regel bijvoorbeeld zo: als uitgaven hoger zijn dan $100, vraag dan een bon.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 83316ceadc8b..1ead213a7ddd 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8540,10 +8540,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, requireFieldsDescription: 'Wymagaj konkretnych pól wydatku lub zrezygnuj z ich wymagania.', createAgentRule: 'Opisz regułę agenta', createAgentRuleDescription: 'Twórz elastyczne reguły, które uruchamiają się wtedy, gdy ich potrzebujesz.', - createRuleHeadline: 'Utwórz nową regułę', - describeRule: 'Opisz swoją regułę, a Concierge ją utworzy', - buildManually: 'Utwórz regułę ręcznie', - createAgentRuleInstead: 'Utwórz zamiast tego regułę agenta', + describe: 'Opisz', + describeRule: 'Opisz swoją regułę własnymi słowami, a Concierge utworzy ją za Ciebie', + describeRuleInputLabel: 'Opisz regułę', promptErrors: { unsupported: 'Ta reguła nie jest jeszcze obsługiwana — pracujemy nad tym. Na razie spróbuj innej reguły.', unintelligible: 'Spróbuj sformułować regułę tak: jeśli wydatek przekracza 100 $, wymagaj paragonu.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c474c4eb85a7..1c67cf7ecfe4 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8523,10 +8523,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, requireFieldsDescription: 'Exigir campos específicos de despesa ou dispensar a exigência deles.', createAgentRule: 'Descrever regra do agente', createAgentRuleDescription: 'Crie regras flexíveis que sejam executadas quando você precisar.', - createRuleHeadline: 'Criar uma nova regra', - describeRule: 'Descreva sua regra e o Concierge vai criá-la', - buildManually: 'Criar regra manualmente', - createAgentRuleInstead: 'Criar uma regra de agente em vez disso', + describe: 'Descrever', + describeRule: 'Descreva sua regra em linguagem simples e o Concierge criará a regra para você', + describeRuleInputLabel: 'Descreva a regra', promptErrors: { unsupported: 'Essa regra ainda não tem suporte — estamos trabalhando nisso. Tente uma regra diferente por enquanto.', unintelligible: 'Tente formular sua regra assim: se a despesa for maior que $100, exija um recibo.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d7f2b5540aa8..008f6bccdd1f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8221,10 +8221,9 @@ ${reportName}`, requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', createAgentRule: '描述代理规则', createAgentRuleDescription: '创建可按需运行的灵活规则。', - createRuleHeadline: '创建新规则', - describeRule: '描述你的规则,Concierge 会帮你创建', - buildManually: '手动创建规则', - createAgentRuleInstead: '改为创建代理规则', + describe: '描述', + describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', + describeRuleInputLabel: '描述规则', promptErrors: { unsupported: '该规则暂不支持,我们正在开发中。请先尝试其他规则。', unintelligible: '可以这样描述规则:如果支出超过 100 美元,则需要收据。', diff --git a/src/libs/API/parameters/GenerateRuleParams.ts b/src/libs/API/parameters/GenerateRuleParams.ts index dfaea644466c..d1e5c58baaa2 100644 --- a/src/libs/API/parameters/GenerateRuleParams.ts +++ b/src/libs/API/parameters/GenerateRuleParams.ts @@ -1,6 +1,11 @@ +import type CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + type GenerateRuleParams = { policyID: string; generationID: string; + ruleType: ValueOf; prompt: string; }; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 1e7c21633f9e..7440fd0fc212 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -31,6 +31,7 @@ import type {AnyOnyxUpdate, OnyxData} from '@src/types/onyx/Request'; import type Rule from '@src/types/onyx/Rule'; import type {OnyxUpdate} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -402,7 +403,7 @@ function deleteMerchantRule(policyID: string, ruleID: string, rule: Rule | undef } /** @returns the generationID the answer is written under in Onyx */ -function generateRule(policyID: string, prompt: string): string { +function generateRule(policyID: string, ruleType: ValueOf, prompt: string): string { const generationID = rand64(); const failureData: AnyOnyxUpdate[] = [ @@ -413,7 +414,7 @@ function generateRule(policyID: string, prompt: string): string { }, ]; - const parameters: GenerateRuleParams = {policyID, generationID, prompt}; + const parameters: GenerateRuleParams = {policyID, generationID, ruleType, prompt}; API.write(WRITE_COMMANDS.GENERATE_RULE, parameters, {failureData}); diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index 8564525b4fd5..7e8109e39e21 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -76,12 +76,6 @@ function AddAgentRulePage({ const hasDraftPrompt = !!draftValues?.[INPUT_IDS.PROMPT]?.trim(); - const [didOpenTabForDraft, setDidOpenTabForDraft] = useState(false); - if (!didOpenTabForDraft && hasDraftPrompt) { - setDidOpenTabForDraft(true); - setActiveTab(CONST.TAB.AGENT_RULE.WRITE); - } - // Reset the active tab to Suggestions when the workspace changes. if (activeTabPolicyID !== policyID) { setActiveTabPolicyID(policyID); @@ -133,6 +127,14 @@ function AddAgentRulePage({ setActiveTab(key); }; + const handleBackButtonPress = () => { + if (activeTab === CONST.TAB.AGENT_RULE.WRITE) { + setActiveTab(CONST.TAB.AGENT_RULE.SUGGESTIONS); + return; + } + Navigation.goBack(); + }; + const navigateBackToAgentsTab = () => { Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.AGENTS); Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); @@ -206,7 +208,10 @@ function AddAgentRulePage({ shouldEnableMaxHeight > - + diff --git a/src/pages/workspace/rules/DescribeRuleButton.tsx b/src/pages/workspace/rules/DescribeRuleButton.tsx new file mode 100644 index 000000000000..4ea0104075d2 --- /dev/null +++ b/src/pages/workspace/rules/DescribeRuleButton.tsx @@ -0,0 +1,192 @@ +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 Modal from '@components/Modal'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearDraftValues} from '@libs/actions/FormActions'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; + +import variables from '@styles/variables'; + +import {clearGeneratedRule, clearNewRulePromptError, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/NewRulePromptForm'; +import type {GeneratedRule} from '@src/types/onyx'; + +import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; + +import React, {useEffect, useRef, useState} from 'react'; + +type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; + +type GeneratedRuleValues = NonNullable; + +type DescribeRuleButtonProps = { + policyID: string; + + /** The rule type of the page the button sits on, so Concierge builds values for that form */ + ruleType: ValueOf; + + /** Called with the generated form values, with the category matched to the workspace's spelling */ + onRuleGenerated: (values: GeneratedRuleValues) => void; + + style?: StyleProp; + + sentryLabel: string; +}; + +function DescribeRuleButton({policyID, ruleType, onRuleGenerated, style, sentryLabel}: DescribeRuleButtonProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + // The modal type depends on the physical screen width, which shouldUseNarrowLayout doesn't track. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const formRef = useRef(null); + const [isModalVisible, setIsModalVisible] = useState(false); + const [generationID, setGenerationID] = useState(); + const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const appliedGenerationIDRef = useRef(undefined); + const inputLabel = translate('workspace.rules.newRule.describeRuleInputLabel'); + + const generatedRuleForCurrentPrompt = generationID && generatedRule?.generationID === generationID ? generatedRule : undefined; + const isGenerating = !!generationID && !generatedRuleForCurrentPrompt; + + const closeModal = () => { + setIsModalVisible(false); + setGenerationID(undefined); + clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + clearNewRulePromptError(); + clearGeneratedRule(); + }; + + useEffect(() => { + if (!generatedRuleForCurrentPrompt || appliedGenerationIDRef.current === generatedRuleForCurrentPrompt.generationID) { + return; + } + appliedGenerationIDRef.current = generatedRuleForCurrentPrompt.generationID; + + if (generatedRuleForCurrentPrompt.state === CONST.GENERATED_RULE.STATE.RULE) { + const {category, ...ruleValues} = generatedRuleForCurrentPrompt.rule ?? {}; + const matchedCategory = category + ? Object.values(policyCategories ?? {}).find((policyCategory) => policyCategory.enabled && getDecodedCategoryName(policyCategory.name) === getDecodedCategoryName(category)) + : undefined; + onRuleGenerated(matchedCategory ? {...ruleValues, category: matchedCategory.name} : ruleValues); + closeModal(); + return; + } + + if (generatedRuleForCurrentPrompt.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported')); + return; + } + + if (generatedRuleForCurrentPrompt.state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); + return; + } + + if (generatedRuleForCurrentPrompt.state === CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE) { + setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); + return; + } + + setNewRulePromptError(translate('common.genericErrorMessage')); + }, [closeModal, generatedRuleForCurrentPrompt, onRuleGenerated, policyCategories, translate]); + + const describeRule = (values: FormOnyxValues) => { + clearNewRulePromptError(); + clearGeneratedRule(); + setGenerationID(generateRule(policyID, ruleType, values[INPUT_IDS.PROMPT].trim())); + }; + + const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in event)) { + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + formRef.current?.submit(); + } + }; + + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!values[INPUT_IDS.PROMPT]?.trim()) { + errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); + } + return errors; + }; + + return ( + <> + + + + {translate('workspace.rules.newRule.describeRule')} + + {translate('workspace.rules.agentRules.disclaimer')} + + + + ); +} + +DescribeRuleButton.displayName = 'DescribeRuleButton'; + +export default DescribeRuleButton; +export type {GeneratedRuleValues}; diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 59ed184bba96..d67389864cf9 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -27,6 +27,7 @@ import Navigation from '@libs/Navigation/Navigation'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -36,7 +37,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES, getFlagForReviewRuleAmountRoute, getFlagForReviewRuleCategoryRoute, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type {FlagForReviewRuleForm} from '@src/types/form/FlagForReviewRuleForm'; import INPUT_IDS from '@src/types/form/FlagForReviewRuleForm'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -80,6 +80,7 @@ function FlagForReviewRulePageBase({ const isEditing = !!categoryName; const isCategoryLocked = isCategoryLockedProp ?? !!initialCategoryName; const canEditCategory = canWriteRules && !isCategoryLocked; + const shouldShowDescribeRule = !isEditing && !isCategoryLocked; const categorySettingsBackPath = useCategoryRuleCreateBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_FLAG_FOR_REVIEW_NEW.path); const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; @@ -87,7 +88,6 @@ function FlagForReviewRulePageBase({ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [shouldShowError, setShouldShowError] = useState(false); const initializedDraftForRuleKeyRef = useRef(null); - const hasDraft = !isEmptyObject(form); const category = categoryName ? policyCategories?.[categoryName] : undefined; const selectedCategoryName = form?.[INPUT_IDS.CATEGORY]; @@ -103,9 +103,7 @@ function FlagForReviewRulePageBase({ if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - if (!hasDraft) { - setDraftFlagForReviewRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); - } + setDraftFlagForReviewRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); } return; } @@ -129,7 +127,7 @@ function FlagForReviewRulePageBase({ initializedDraftForRuleKeyRef.current = categoryName; setDraftFlagForReviewRule(getFlagForReviewFormFromCategory(category, getCurrencyDecimals, policyCurrency)); - }, [category, categoryName, draftMaxExpenseAmount, getCurrencyDecimals, hasDraft, initialCategoryName, isEditing, policyCurrency, selectedCategoryName]); + }, [category, categoryName, draftMaxExpenseAmount, getCurrencyDecimals, initialCategoryName, isEditing, policyCurrency, selectedCategoryName]); const fetchPolicyData = useCallback(() => { if (!policy?.areCategoriesEnabled || policyCategories) { return; @@ -217,6 +215,20 @@ function FlagForReviewRulePageBase({ shouldShowLoadingImmediatelyOnPress={false} enabledWhenOffline sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.FLAG_FOR_REVIEW_RULE_SAVE} + buttonStyles={shouldShowDescribeRule && styles.flex1} + buttonAndFooterContainerStyles={shouldShowDescribeRule && [styles.flexRow, styles.gap2]} + shouldRenderFooterAboveSubmit + footerContent={ + shouldShowDescribeRule && ( + + ) + } /> ) : null; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 344f5c687b6f..e5a6a5dcf5b8 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -43,6 +43,8 @@ import {getTagArrayFromName} from '@libs/TransactionUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; +import type {GeneratedRuleValues} from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -672,6 +674,11 @@ function MerchantRulePageBase({ return ; } + const shouldShowDescribeRule = !isEditingSavedRule && !isCategoryLocked; + const applyGeneratedRule = (values: GeneratedRuleValues) => { + setDraftMerchantRule({...values, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); + }; + const footer = canWriteRules ? ( {translate('workspace.rules.merchantRules.previewMatches')} )} + {shouldShowDescribeRule && ( + + )} } /> diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 9b83d3a68dd0..68fa2a72992e 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -42,6 +42,8 @@ import type {FieldRequirementsDirection} from '@libs/RequireFieldsRulesUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; +import type {GeneratedRuleValues} from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -51,7 +53,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES, getRequireFieldsRuleCategoryRoute, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src/types/form/RequireFieldsRuleForm'; import INPUT_IDS from '@src/types/form/RequireFieldsRuleForm'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -81,6 +82,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, const icons = useMemoizedLazyExpensifyIcons(['Folder']); const isEditing = !!categoryName; const isCategoryLocked = isCategoryLockedProp ?? !!initialCategoryName; + const shouldShowDescribeRule = !isEditing && !isCategoryLocked; const canEditCategory = canWriteRules && !isCategoryLocked; const categorySettingsBackPath = useCategoryRuleCreateBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_REQUIRE_FIELDS_NEW.path); @@ -173,12 +175,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - if (!isEmptyObject(form)) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- seeds local selection state from the seeded draft - setTouchedFields(new Set(SETTING_FIELD_KEYS.filter((fieldKey) => form?.[fieldKey] !== undefined))); - } else { - setDraftRequireFieldsRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); - } + setDraftRequireFieldsRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); } return; } @@ -201,6 +198,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Always reseed from the category so a leftover new-rule draft cannot leave a stale Require/Don't require. initializedDraftForRuleKeyRef.current = ruleKey; + // eslint-disable-next-line react-hooks/set-state-in-effect -- Seed local selection state when opening an edit rule. setTouchedFields(new Set()); setClearedFields(new Set()); setCouplingInteractionFields(new Set()); @@ -429,6 +427,11 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, return ; } + const applyGeneratedRule = (values: GeneratedRuleValues) => { + setDraftRequireFieldsRule(values); + setTouchedFields(new Set(SETTING_FIELD_KEYS.filter((fieldKey) => values[fieldKey] !== undefined))); + }; + const footer = canWriteRules ? ( + ) + } /> ) : null; diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 625092e6da65..5cc21f7f416b 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -1,25 +1,16 @@ -import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; -import type {LocaleContextProps} from '@components/LocaleContextProvider'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; -import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import {clearDraftValues, setDraftValues} from '@libs/actions/FormActions'; -import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; -import {setDraftFlagForReviewRule, setDraftMerchantRule, setDraftRequireFieldsRule, setDraftSpendRule} from '@libs/actions/User'; import {categoryHasTaxRule, hasSelectableCategoryTaxRate, hasUsableTaxRates} from '@libs/CategoryTaxRulesUtils'; -import {getDecodedCategoryName} from '@libs/CategoryUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -29,25 +20,14 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; -import {clearNewRulePromptError, clearGeneratedRule, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; - import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; -import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; -import type {GeneratedRule, PolicyCategories} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {OnyxEntry} from 'react-native-onyx'; - -import {useFocusEffect} from '@react-navigation/native'; -import React, {useEffect, useRef, useState} from 'react'; +import React from 'react'; import {View} from 'react-native'; -import RulesNewPromptForm from './RulesNewPromptForm'; - type RulesNewPageProps = | PlatformStackScreenProps | PlatformStackScreenProps; @@ -63,73 +43,6 @@ type NewRuleOption = { isWorkspaceOnly?: boolean; }; -/** - * Seeds the draft form for the rule type Concierge picked and opens that rule's page. - * Falls back to the unintelligible error when the rule type is missing or unrecognized. - */ -function seedDraftAndNavigate(rule: GeneratedRule, policyID: string, policyCategories: OnyxEntry, translate: LocaleContextProps['translate']) { - const {category, ...ruleValues} = rule.rule ?? {}; - - const matchedCategory = category - ? Object.values(policyCategories ?? {}).find((policyCategory) => policyCategory.enabled && getDecodedCategoryName(policyCategory.name) === getDecodedCategoryName(category)) - : undefined; - const draft: NonNullable = matchedCategory ? {...ruleValues, category: matchedCategory.name} : ruleValues; - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS) { - setDraftRequireFieldsRule(draft); - Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID)); - return; - } - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW) { - setDraftFlagForReviewRule(draft); - Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID)); - return; - } - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND) { - // `categories` is a closed enum, but it arrives as unvalidated model output already typed as valid. - // A value outside the enum has no row in the category picker, so the admin can neither see nor remove it, - // and it still reaches translate('...categoryOptions.'), which has no key for it. Drop unknown values. - setDraftSpendRule(draft.categories ? {...draft, categories: draft.categories.filter(isSpendRuleCategory)} : draft); - Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); - return; - } - - if (rule.ruleType === CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS) { - setDraftMerchantRule({...draft, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - return; - } - - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); -} - -/** Routes a generated rule to either the matching rule form or the inline error explaining why it could not be used. */ -function applyGeneratedRule(rule: GeneratedRule, policyID: string, policyCategories: OnyxEntry, translate: LocaleContextProps['translate']) { - if (rule.state === CONST.GENERATED_RULE.STATE.RULE) { - seedDraftAndNavigate(rule, policyID, policyCategories, translate); - return; - } - - if (rule.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported')); - return; - } - - if (rule.state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); - return; - } - - if (rule.state === CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); - return; - } - - setNewRulePromptError(translate('common.genericErrorMessage')); -} - function RulesNewPage({route}: RulesNewPageProps) { const {policyID, categoryName} = route.params; const {translate} = useLocalize(); @@ -157,70 +70,6 @@ function RulesNewPage({route}: RulesNewPageProps) { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_TAX_NEW.path)); }; - const canDescribeRule = !isCategoryScopedCreate; - const [shouldShowRuleTypes, setShouldShowRuleTypes] = useState(!canDescribeRule); - const [generationID, setGenerationID] = useState(); - - const [promptDraft] = useOnyx(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM_DRAFT); - const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - - const fetchPolicyCategories = () => { - if (!policy?.areCategoriesEnabled || policyCategories) { - return; - } - openPolicyCategoriesPage(policyID); - }; - - useNetwork({onReconnect: fetchPolicyCategories}); - - useFocusEffect(() => { - fetchPolicyCategories(); - }); - - const appliedGenerationIDRef = useRef(undefined); - const generatedRuleForCurrentPrompt = generationID && generatedRule?.generationID === generationID ? generatedRule : undefined; - const canOfferAgentRule = generatedRuleForCurrentPrompt?.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED; - - useEffect(() => { - if (!generatedRuleForCurrentPrompt || appliedGenerationIDRef.current === generatedRuleForCurrentPrompt.generationID) { - return; - } - appliedGenerationIDRef.current = generatedRuleForCurrentPrompt.generationID; - applyGeneratedRule(generatedRuleForCurrentPrompt, policyID, policyCategories, translate); - }, [generatedRuleForCurrentPrompt, policyID, policyCategories, translate]); - - useEffect( - () => () => { - clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); - clearNewRulePromptError(); - }, - [], - ); - - const describeRule = (values: FormOnyxValues) => { - const prompt = values.prompt.trim(); - clearNewRulePromptError(); - clearGeneratedRule(); - setGenerationID(generateRule(policyID, prompt)); - }; - - const createAgentRuleFromPrompt = (prompt: string) => { - setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[AGENT_RULE_INPUT_IDS.PROMPT]: prompt}); - Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)); - }; - - const handleBackButtonPress = () => { - if (canDescribeRule && shouldShowRuleTypes) { - setShouldShowRuleTypes(false); - return true; - } - Navigation.goBack(); - return true; - }; - - useAndroidBackButtonHandler(handleBackButtonPress); - const newRuleOptions: NewRuleOption[] = [ { key: 'restrictCardSpend', @@ -291,44 +140,31 @@ function RulesNewPage({route}: RulesNewPageProps) { - - {canDescribeRule && !shouldShowRuleTypes ? ( - setShouldShowRuleTypes(true)} - onCreateAgentRule={canOfferAgentRule ? () => createAgentRuleFromPrompt(promptDraft?.prompt?.trim() ?? '') : undefined} - isLoading={!!generationID && !generatedRuleForCurrentPrompt} - /> - ) : ( - - {translate('workspace.rules.newRule.subtitle')} - - {visibleNewRuleOptions.map((option) => ( - - ))} - - - )} + + + {translate('workspace.rules.newRule.subtitle')} + + {visibleNewRuleOptions.map((option) => ( + + ))} + + ); diff --git a/src/pages/workspace/rules/RulesNewPromptForm.tsx b/src/pages/workspace/rules/RulesNewPromptForm.tsx deleted file mode 100644 index bf063aec8b03..000000000000 --- a/src/pages/workspace/rules/RulesNewPromptForm.tsx +++ /dev/null @@ -1,138 +0,0 @@ -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 Text from '@components/Text'; -import TextInput from '@components/TextInput'; - -import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {clearNewRulePromptError} from '@userActions/Policy/Rules'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import INPUT_IDS from '@src/types/form/NewRulePromptForm'; - -import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native'; - -import React, {useRef} from 'react'; -import {View} from 'react-native'; - -type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; - -type RulesNewPromptFormProps = { - /** Called with the form values on submit */ - onSubmit: (values: FormOnyxValues) => void; - - /** Called when the admin picks a rule type themselves */ - onBuildManually: () => void; - - /** Whether the rule is still being generated */ - isLoading: boolean; - - /** Called when the admin creates an agent rule instead */ - onCreateAgentRule?: () => void; -}; - -function RulesNewPromptForm({onSubmit, onBuildManually, isLoading, onCreateAgentRule}: RulesNewPromptFormProps) { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const shouldUseScrollableLayout = useIsInLandscapeMode(); - const formRef = useRef(null); - const describeRuleLabel = translate('workspace.rules.newRule.describeRule'); - - const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { - if (!('key' in event)) { - return; - } - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - formRef.current?.submit(); - } - }; - - const validate = (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - if (!values[INPUT_IDS.PROMPT]?.trim()) { - errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); - } - return errors; - }; - - const inputWrapperStyles: StyleProp = shouldUseScrollableLayout ? [styles.flex1, styles.minHeight42] : [styles.flex1, styles.mnh0, styles.agentRulePromptInput]; - - return ( - - {!!onCreateAgentRule && ( - - )} - - - } - > - - {translate('workspace.rules.newRule.createRuleHeadline')} - {describeRuleLabel} - - - - {translate('workspace.rules.agentRules.disclaimer')} - - - ); -} - -RulesNewPromptForm.displayName = 'RulesNewPromptForm'; - -export default RulesNewPromptForm; diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 8609b73eed6d..c35330cea61c 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -33,6 +33,8 @@ import {getSpendRuleFormValuesFromCardRule, getTruncatedSpendRuleSummary} from ' import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; +import type {GeneratedRuleValues} from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -43,6 +45,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {SpendRuleCategory} from '@src/types/form/SpendRuleForm'; +import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ValueOf} from 'type-fest'; @@ -98,14 +101,18 @@ function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRuleP const [isErrorVisible, setIsErrorVisible] = useState(false); const [isRestrictMerchantsOff, setIsRestrictMerchantsOff] = useState(() => { - if (isNewRule) { - return !spendRuleForm?.categories?.length && !spendRuleForm?.merchantNames?.length; - } - return !existingFormValues?.merchantNames.length && !existingFormValues?.categories?.length; + const hasNoMerchantRestrictions = !existingFormValues?.merchantNames.length && !existingFormValues?.categories?.length; + return isNewRule || hasNoMerchantRestrictions; }); useEffect(() => () => clearDraftSpendRule(), []); + const applyGeneratedRule = (values: GeneratedRuleValues) => { + const generatedCategories = values.categories?.filter(isSpendRuleCategory); + setDraftSpendRule(generatedCategories ? {...values, categories: generatedCategories} : values); + setIsRestrictMerchantsOff(!generatedCategories?.length && !values.merchantNames?.length); + }; + useEffect(() => { if (!isEditingRule || !existingFormValues) { return; @@ -464,7 +471,20 @@ function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRuleP shouldShowLoadingImmediatelyOnPress={false} enabledWhenOffline sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_SAVE} + buttonStyles={isNewRule && styles.flex1} + buttonAndFooterContainerStyles={isNewRule && [styles.flexRow, styles.gap2]} shouldRenderFooterAboveSubmit + footerContent={ + isNewRule && ( + + ) + } /> )} From 46a5c8eb23b41f1702f7e8e6176884ca543dcded Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 2 Oct 2026 13:41:20 +0200 Subject: [PATCH 38/62] Restore compiled GitHub action bundles to match main --- .../isDeployChecklistLocked/index.js | 151 ++++++++++++++---- .../javascript/proposalPoliceComment/index.js | 151 ++++++++++++++---- 2 files changed, 246 insertions(+), 56 deletions(-) diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index b1e68e9965bf..ebdf2bf6961c 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -23112,6 +23112,7 @@ var require_ExpensiMark = __commonJS({ var Constants = __importStar(require_CONST()); var UrlPatterns = __importStar(require_Url()); var Logger_1 = __importDefault(require_Logger()); + var tlds_1 = __importDefault(require_tlds()); var Utils = __importStar(require_utils()); var EXTRAS_DEFAULT = {}; var ASCII_DIGIT_START = "0".charCodeAt(0); @@ -23124,6 +23125,9 @@ var require_ExpensiMark = __commonJS({ var NON_BREAKING_SPACE_CODE = 160; var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var URL_TLD_LIST = tlds_1.default.toLowerCase().split("|"); + var URL_TLDS = new Set(URL_TLD_LIST); + var MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length)); var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); @@ -23169,6 +23173,9 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function canUseCandidateScanning(text, shouldEscapeText) { + return shouldEscapeText || !text.includes("<") && !text.includes(">"); + } function isAsciiAlphaNumeric(character) { if (!character) { return false; @@ -23240,15 +23247,59 @@ var require_ExpensiMark = __commonJS({ } return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); } - function findHostnameEnd(text, hostnameStart, dotPosition) { - let hostnameEnd = dotPosition + 1; - while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { - hostnameEnd++; + function isValidHostnameLabel(text, start, end) { + if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { + return false; } - if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { - return void 0; + for (let index = start + 1; index < end - 1; index++) { + if (!isAsciiAlphaNumeric(text[index]) && text[index] !== "-") { + return false; + } + } + return true; + } + function findHostnameStart(text, dotPosition) { + let hostnameStart = dotPosition; + let labelEnd = dotPosition; + while (labelEnd > 0) { + let rawLabelStart = labelEnd - 1; + while (rawLabelStart >= 0 && text[rawLabelStart] !== "." && isHostnameCharacter(text[rawLabelStart])) { + rawLabelStart--; + } + rawLabelStart++; + let labelStart = rawLabelStart; + while (labelStart < labelEnd && text[labelStart] === "-") { + labelStart++; + } + if (!isValidHostnameLabel(text, labelStart, labelEnd)) { + break; + } + hostnameStart = labelStart; + if (labelStart !== rawLabelStart) { + break; + } + const separatorPosition = labelStart - 1; + if (separatorPosition < 0 || text[separatorPosition] !== ".") { + break; + } + labelEnd = separatorPosition; } - return hostnameEnd; + return hostnameStart === dotPosition ? void 0 : hostnameStart; + } + function findKnownTldEnd(text, dotPosition) { + const maximumEnd = Math.min(text.length, dotPosition + 1 + MAX_URL_TLD_LENGTH); + for (let end = dotPosition + 2; end <= maximumEnd; end++) { + const currentCharacter = text[end - 1]; + if (!isAsciiAlphaNumeric(currentCharacter) && currentCharacter !== "-") { + break; + } + const nextCharacter = text[end]; + const hasValidBoundary = !nextCharacter || nextCharacter === ":" || nextCharacter === "_" || !isWordCharacter(nextCharacter); + if (hasValidBoundary && URL_TLDS.has(text.slice(dotPosition + 1, end).toLowerCase())) { + return end; + } + } + return void 0; } function extendUrlCandidateBoundaries(text, start, end) { let candidateStart = start; @@ -23261,24 +23312,65 @@ var require_ExpensiMark = __commonJS({ } return { start: candidateStart, end: candidateEnd }; } + function startsWithIgnoreCase(text, expected, position) { + return text.slice(position, position + expected.length).toLowerCase() === expected; + } + function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) { + var _a; + if (candidates.length === 0 || !text.includes("<") && !text.includes(">")) { + return candidates; + } + const validCandidates = []; + let candidateIndex = candidates.length - 1; + let nextLessThan = text.length; + let nextGreaterThan = text.length; + let nextOpeningAnchor = text.length; + let nextClosingAnchor = text.length; + for (let index = text.length; index >= 0 && candidateIndex >= 0; index--) { + if (text[index] === "<") { + nextLessThan = index; + if (((_a = text[index + 1]) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === "a") { + nextOpeningAnchor = index; + } else if (startsWithIgnoreCase(text, "", index)) { + nextClosingAnchor = index; + } + } else if (text[index] === ">") { + nextGreaterThan = index; + } + while (candidateIndex >= 0 && candidates[candidateIndex].end === index) { + const firstHtmlBoundaryIsClosingTag = nextLessThan < nextGreaterThan && text.startsWith("", nextLessThan); + const firstTagIsProtectedClosingTag = startsWithIgnoreCase(text, "", nextLessThan) || startsWithIgnoreCase(text, "", nextLessThan); + const isBlockedByFollowingHtml = ( + // Mirrors `(?![^<]*>)`: reject when `>` appears before the next `<`. + nextGreaterThan < nextLessThan || // Mirrors `[^<>]*<\/(?!h1>)`: reject a later closing tag other than ``. + firstHtmlBoundaryIsClosingTag || // Mirrors `((?:(?!`: reject `` unless another `` appears first. + nextClosingAnchor < nextOpeningAnchor || // Mirrors `[^<]*(<\/pre>|<\/code>)`: reject a later protected closing tag. + firstTagIsProtectedClosingTag + ); + if (!isBlockedByFollowingHtml) { + validCandidates.push(candidates[candidateIndex]); + } + candidateIndex--; + } + } + return validCandidates.reverse(); + } function findUrlCandidates(text) { const candidates = []; const protectedTags = []; let index = 0; - let hostnameRunStart = 0; while (index < text.length) { if (text[index] === "<") { const nextIndex = updateProtectedTagStack(text, index, protectedTags); if (nextIndex === void 0) { - break; + index++; + continue; } index = nextIndex; - hostnameRunStart = index; continue; } if (protectedTags.length > 0) { index++; - hostnameRunStart = index; continue; } const matchedProtocol = getProtocolAt(text, index); @@ -23286,29 +23378,23 @@ var require_ExpensiMark = __commonJS({ const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); candidates.push(candidate2); index = candidate2.end; - hostnameRunStart = candidate2.end; - continue; - } - if (!isHostnameCharacter(text[index])) { - hostnameRunStart = index + 1; - index++; continue; } if (text[index] !== ".") { index++; continue; } - const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); - if (hostnameEnd === void 0) { + const tldEnd = findKnownTldEnd(text, index); + const hostnameStart = tldEnd === void 0 ? void 0 : findHostnameStart(text, index); + if (tldEnd === void 0 || hostnameStart === void 0) { index++; continue; } - const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd); candidates.push(candidate); index = candidate.end; - hostnameRunStart = candidate.end; } - return candidates; + return filterUrlCandidatesBlockedByFollowingHtml(text, candidates); } function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { if (!text.includes(marker)) { @@ -23381,6 +23467,14 @@ var require_ExpensiMark = __commonJS({ output.push(text.slice(outputStart)); return output.join(""); } + function processMarkdownRule(regex2, marker, canOpen) { + return (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { + if (canUseCandidateScanning(textToProcess, shouldEscapeText)) { + return replaceMarkdownCandidates(textToProcess, regex2, replacement, marker, canOpen); + } + return replaceTextWithExtras(textToProcess, regex2, EXTRAS_DEFAULT, replacement); + }; + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
> (|<\/div>||\n<\/comment>|<\/comment>|

|<\/h1>|

|<\/h2>|

|<\/h3>|

|<\/h4>|

|<\/h5>|
|<\/h6>|

|<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -23799,9 +23893,9 @@ var require_ExpensiMark = __commonJS({ */ { name: "autolink", - process: (textToProcess, replacement) => { + process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText)); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -23870,7 +23964,8 @@ ${"
    ".repeat(i)}`, "\n"); name: "autoEmail", regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!|[^<>]*<\\/(?!em|h1|blockquote))`, "gim"), replacement: '$1$2', - rawInputReplacement: '$1$2' + rawInputReplacement: '$1$2', + shouldSkipProcessing: (textToCheck) => !textToCheck.includes("@") }, /** * This regex matches a short user mention in a string. @@ -23906,7 +24001,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), + process: processMarkdownRule(BOLD_MARKDOWN_REGEX, "*", canOpenBoldMarkdown), replacement: (_extras, match, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -23916,7 +24011,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), + process: processMarkdownRule(STRIKETHROUGH_MARKDOWN_REGEX, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match, g1) => g1.includes("") || containsNonPairTag(g1) ? match : `${g1}` }, { @@ -24285,7 +24380,7 @@ ${g2} } const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; if ("process" in rule) { - replacedText = rule.process(replacedText, replacement, shouldKeepRawInput); + replacedText = rule.process(replacedText, replacement, shouldKeepRawInput, shouldEscapeText); } else { replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement); } diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 6c66aba05ee9..126b34462df4 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -43926,6 +43926,7 @@ var require_ExpensiMark = __commonJS({ var Constants = __importStar(require_CONST()); var UrlPatterns = __importStar(require_Url()); var Logger_1 = __importDefault(require_Logger()); + var tlds_1 = __importDefault(require_tlds()); var Utils = __importStar(require_utils2()); var EXTRAS_DEFAULT = {}; var ASCII_DIGIT_START = "0".charCodeAt(0); @@ -43938,6 +43939,9 @@ var require_ExpensiMark = __commonJS({ var NON_BREAKING_SPACE_CODE = 160; var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var URL_TLD_LIST = tlds_1.default.toLowerCase().split("|"); + var URL_TLDS = new Set(URL_TLD_LIST); + var MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length)); var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); @@ -43983,6 +43987,9 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function canUseCandidateScanning(text, shouldEscapeText) { + return shouldEscapeText || !text.includes("<") && !text.includes(">"); + } function isAsciiAlphaNumeric(character) { if (!character) { return false; @@ -44054,15 +44061,59 @@ var require_ExpensiMark = __commonJS({ } return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); } - function findHostnameEnd(text, hostnameStart, dotPosition) { - let hostnameEnd = dotPosition + 1; - while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { - hostnameEnd++; + function isValidHostnameLabel(text, start, end) { + if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { + return false; } - if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { - return void 0; + for (let index = start + 1; index < end - 1; index++) { + if (!isAsciiAlphaNumeric(text[index]) && text[index] !== "-") { + return false; + } + } + return true; + } + function findHostnameStart(text, dotPosition) { + let hostnameStart = dotPosition; + let labelEnd = dotPosition; + while (labelEnd > 0) { + let rawLabelStart = labelEnd - 1; + while (rawLabelStart >= 0 && text[rawLabelStart] !== "." && isHostnameCharacter(text[rawLabelStart])) { + rawLabelStart--; + } + rawLabelStart++; + let labelStart = rawLabelStart; + while (labelStart < labelEnd && text[labelStart] === "-") { + labelStart++; + } + if (!isValidHostnameLabel(text, labelStart, labelEnd)) { + break; + } + hostnameStart = labelStart; + if (labelStart !== rawLabelStart) { + break; + } + const separatorPosition = labelStart - 1; + if (separatorPosition < 0 || text[separatorPosition] !== ".") { + break; + } + labelEnd = separatorPosition; } - return hostnameEnd; + return hostnameStart === dotPosition ? void 0 : hostnameStart; + } + function findKnownTldEnd(text, dotPosition) { + const maximumEnd = Math.min(text.length, dotPosition + 1 + MAX_URL_TLD_LENGTH); + for (let end = dotPosition + 2; end <= maximumEnd; end++) { + const currentCharacter = text[end - 1]; + if (!isAsciiAlphaNumeric(currentCharacter) && currentCharacter !== "-") { + break; + } + const nextCharacter = text[end]; + const hasValidBoundary = !nextCharacter || nextCharacter === ":" || nextCharacter === "_" || !isWordCharacter(nextCharacter); + if (hasValidBoundary && URL_TLDS.has(text.slice(dotPosition + 1, end).toLowerCase())) { + return end; + } + } + return void 0; } function extendUrlCandidateBoundaries(text, start, end) { let candidateStart = start; @@ -44075,24 +44126,65 @@ var require_ExpensiMark = __commonJS({ } return { start: candidateStart, end: candidateEnd }; } + function startsWithIgnoreCase(text, expected, position) { + return text.slice(position, position + expected.length).toLowerCase() === expected; + } + function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) { + var _a3; + if (candidates.length === 0 || !text.includes("<") && !text.includes(">")) { + return candidates; + } + const validCandidates = []; + let candidateIndex = candidates.length - 1; + let nextLessThan = text.length; + let nextGreaterThan = text.length; + let nextOpeningAnchor = text.length; + let nextClosingAnchor = text.length; + for (let index = text.length; index >= 0 && candidateIndex >= 0; index--) { + if (text[index] === "<") { + nextLessThan = index; + if (((_a3 = text[index + 1]) === null || _a3 === void 0 ? void 0 : _a3.toLowerCase()) === "a") { + nextOpeningAnchor = index; + } else if (startsWithIgnoreCase(text, "", index)) { + nextClosingAnchor = index; + } + } else if (text[index] === ">") { + nextGreaterThan = index; + } + while (candidateIndex >= 0 && candidates[candidateIndex].end === index) { + const firstHtmlBoundaryIsClosingTag = nextLessThan < nextGreaterThan && text.startsWith("", nextLessThan); + const firstTagIsProtectedClosingTag = startsWithIgnoreCase(text, "", nextLessThan) || startsWithIgnoreCase(text, "", nextLessThan); + const isBlockedByFollowingHtml = ( + // Mirrors `(?![^<]*>)`: reject when `>` appears before the next `<`. + nextGreaterThan < nextLessThan || // Mirrors `[^<>]*<\/(?!h1>)`: reject a later closing tag other than `
  • `. + firstHtmlBoundaryIsClosingTag || // Mirrors `((?:(?!`: reject `` unless another `` appears first. + nextClosingAnchor < nextOpeningAnchor || // Mirrors `[^<]*(<\/pre>|<\/code>)`: reject a later protected closing tag. + firstTagIsProtectedClosingTag + ); + if (!isBlockedByFollowingHtml) { + validCandidates.push(candidates[candidateIndex]); + } + candidateIndex--; + } + } + return validCandidates.reverse(); + } function findUrlCandidates(text) { const candidates = []; const protectedTags = []; let index = 0; - let hostnameRunStart = 0; while (index < text.length) { if (text[index] === "<") { const nextIndex = updateProtectedTagStack(text, index, protectedTags); if (nextIndex === void 0) { - break; + index++; + continue; } index = nextIndex; - hostnameRunStart = index; continue; } if (protectedTags.length > 0) { index++; - hostnameRunStart = index; continue; } const matchedProtocol = getProtocolAt(text, index); @@ -44100,29 +44192,23 @@ var require_ExpensiMark = __commonJS({ const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); candidates.push(candidate2); index = candidate2.end; - hostnameRunStart = candidate2.end; - continue; - } - if (!isHostnameCharacter(text[index])) { - hostnameRunStart = index + 1; - index++; continue; } if (text[index] !== ".") { index++; continue; } - const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); - if (hostnameEnd === void 0) { + const tldEnd = findKnownTldEnd(text, index); + const hostnameStart = tldEnd === void 0 ? void 0 : findHostnameStart(text, index); + if (tldEnd === void 0 || hostnameStart === void 0) { index++; continue; } - const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd); candidates.push(candidate); index = candidate.end; - hostnameRunStart = candidate.end; } - return candidates; + return filterUrlCandidatesBlockedByFollowingHtml(text, candidates); } function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { if (!text.includes(marker)) { @@ -44195,6 +44281,14 @@ var require_ExpensiMark = __commonJS({ output.push(text.slice(outputStart)); return output.join(""); } + function processMarkdownRule(regex2, marker, canOpen) { + return (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { + if (canUseCandidateScanning(textToProcess, shouldEscapeText)) { + return replaceMarkdownCandidates(textToProcess, regex2, replacement, marker, canOpen); + } + return replaceTextWithExtras(textToProcess, regex2, EXTRAS_DEFAULT, replacement); + }; + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
    > (|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -44613,9 +44707,9 @@ var require_ExpensiMark = __commonJS({ */ { name: "autolink", - process: (textToProcess, replacement) => { + process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText)); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -44684,7 +44778,8 @@ ${"
    ".repeat(i)}`, "\n"); name: "autoEmail", regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!|[^<>]*<\\/(?!em|h1|blockquote))`, "gim"), replacement: '$1$2', - rawInputReplacement: '$1$2' + rawInputReplacement: '$1$2', + shouldSkipProcessing: (textToCheck) => !textToCheck.includes("@") }, /** * This regex matches a short user mention in a string. @@ -44720,7 +44815,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), + process: processMarkdownRule(BOLD_MARKDOWN_REGEX, "*", canOpenBoldMarkdown), replacement: (_extras, match2, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -44730,7 +44825,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), + process: processMarkdownRule(STRIKETHROUGH_MARKDOWN_REGEX, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match2, g1) => g1.includes("") || containsNonPairTag(g1) ? match2 : `${g1}` }, { @@ -45099,7 +45194,7 @@ ${g2} } const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; if ("process" in rule) { - replacedText = rule.process(replacedText, replacement, shouldKeepRawInput); + replacedText = rule.process(replacedText, replacement, shouldKeepRawInput, shouldEscapeText); } else { replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement); } From daf89b16683c7637b8d62e404d21ab69dee13049 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 2 Oct 2026 13:49:54 +0200 Subject: [PATCH 39/62] Drive the describe-rule flow through the prompt form's own loading and error state The action sets isLoading and errors on the prompt form like other form actions, so the sheet needs no local request tracking. The sheet is a plain modal whose visibility the rule page owns, and each page renders its own Describe button. --- src/CONST/index.ts | 1 - src/libs/API/parameters/GenerateRuleParams.ts | 6 +- src/libs/actions/Policy/Rules.ts | 69 +++++-- .../workspace/rules/DescribeRuleButton.tsx | 192 ------------------ .../workspace/rules/DescribeRuleModal.tsx | 154 ++++++++++++++ .../FlagForReviewRulePageBase.tsx | 28 ++- .../MerchantRules/MerchantRulePageBase.tsx | 25 ++- .../RequireFieldsRulePageBase.tsx | 30 ++- .../rules/SpendRules/SpendRulePageBase.tsx | 30 ++- src/types/onyx/GeneratedRule.ts | 1 + 10 files changed, 285 insertions(+), 251 deletions(-) delete mode 100644 src/pages/workspace/rules/DescribeRuleButton.tsx create mode 100644 src/pages/workspace/rules/DescribeRuleModal.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 223fc14c36ea..340cc7c88972 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -606,7 +606,6 @@ const CONST = { UNSUPPORTED: 'unsupported', MULTIPLE_RULES: 'multipleRules', UNINTELLIGIBLE: 'unintelligible', - FAILED: 'failed', }, RULE_TYPE: { REQUIRE_FIELDS: 'requireFields', diff --git a/src/libs/API/parameters/GenerateRuleParams.ts b/src/libs/API/parameters/GenerateRuleParams.ts index d1e5c58baaa2..211b9e4ca5cb 100644 --- a/src/libs/API/parameters/GenerateRuleParams.ts +++ b/src/libs/API/parameters/GenerateRuleParams.ts @@ -1,11 +1,9 @@ -import type CONST from '@src/CONST'; - -import type {ValueOf} from 'type-fest'; +import type {GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; type GenerateRuleParams = { policyID: string; generationID: string; - ruleType: ValueOf; + ruleType: GeneratedRuleType; prompt: string; }; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 7440fd0fc212..4a3a6891eeab 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,3 +1,4 @@ +import {clearDraftValues} from '@libs/actions/FormActions'; import {getImportFailedFinalModal} from '@libs/actions/ImportSpreadsheet'; // Namespace import on purpose. `rulesdir/no-api-side-effects-method` only matches member calls, so importing // `makeRequestWithSideEffects` by name would quietly switch that guardrail off. @@ -13,7 +14,7 @@ import type { import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; import type SetPolicyCodingRuleParams from '@libs/API/parameters/SetPolicyCodingRuleParams'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import {getMicroSecondOnyxErrorWithMessage, getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {buildMerchantRule, isExpenseDefaultTaxValue} from '@libs/ExpenseDefaultRuleUtils'; import type {BuiltMerchantRule, MerchantRuleFormValues} from '@libs/ExpenseDefaultRuleUtils'; import Log from '@libs/Log'; @@ -21,17 +22,17 @@ import {getIsOffline} from '@libs/NetworkState'; import {rand64} from '@libs/NumberUtils'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import NEW_RULE_PROMPT_INPUT_IDS from '@src/types/form/NewRulePromptForm'; import type {ExpenseDefaultAction} from '@src/types/onyx/ExpenseDefaultRules'; +import type {GeneratedRuleState, GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; import type Policy from '@src/types/onyx/Policy'; import type {AgentRule, CodingRule, CodingRuleFilter} from '@src/types/onyx/Policy'; -import type {AnyOnyxUpdate, OnyxData} from '@src/types/onyx/Request'; +import type {OnyxData} from '@src/types/onyx/Request'; import type Rule from '@src/types/onyx/Rule'; import type {OnyxUpdate} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -402,39 +403,65 @@ function deleteMerchantRule(policyID: string, ruleID: string, rule: Rule | undef API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); } -/** @returns the generationID the answer is written under in Onyx */ -function generateRule(policyID: string, ruleType: ValueOf, prompt: string): string { - const generationID = rand64(); +const PROMPT_ERROR_BY_STATE: Partial> = { + [CONST.GENERATED_RULE.STATE.UNSUPPORTED]: 'workspace.rules.newRule.promptErrors.unsupported', + [CONST.GENERATED_RULE.STATE.MULTIPLE_RULES]: 'workspace.rules.newRule.promptErrors.multipleRules', + [CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE]: 'workspace.rules.newRule.promptErrors.unintelligible', +}; + +/** Asks Concierge to turn a description into values for the given rule form. The answer arrives under `ONYXKEYS.GENERATED_RULE`. */ +function generateRule(policyID: string, ruleType: GeneratedRuleType, prompt: string) { + type GenerateRuleKey = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM | typeof ONYXKEYS.GENERATED_RULE; - const failureData: AnyOnyxUpdate[] = [ + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, + value: {isLoading: true, errors: null}, + }, { onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.GENERATED_RULE, - value: {generationID, state: CONST.GENERATED_RULE.STATE.FAILED}, + value: null, + }, + ]; + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, + value: {isLoading: false}, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, + value: {isLoading: false, errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, }, ]; - const parameters: GenerateRuleParams = {policyID, generationID, ruleType, prompt}; - - API.write(WRITE_COMMANDS.GENERATE_RULE, parameters, {failureData}); + const parameters: GenerateRuleParams = {policyID, generationID: rand64(), ruleType, prompt}; - return generationID; + API.write(WRITE_COMMANDS.GENERATE_RULE, parameters, {optimisticData, successData, failureData}); } -function setNewRulePromptError(message: string) { +/** Shows on the prompt form why a description did not become a rule */ +function setNewRulePromptError(state: GeneratedRuleState) { Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, { - errorFields: {[NEW_RULE_PROMPT_INPUT_IDS.PROMPT]: getMicroSecondOnyxErrorWithMessage(message)}, + errors: getMicroSecondOnyxErrorWithTranslationKey(PROMPT_ERROR_BY_STATE[state] ?? 'common.genericErrorMessage'), }); } -function clearNewRulePromptError() { - Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, {errors: null, errorFields: null}); -} - function clearGeneratedRule() { Onyx.set(ONYXKEYS.GENERATED_RULE, null); } +function clearNewRulePrompt() { + Onyx.set(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, null); + clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + clearGeneratedRule(); +} + function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: string) { if (!policyID || !agentRuleID || !prompt) { Log.warn('Invalid params for addPolicyAgentRule', {policyID, agentRuleID, prompt}); @@ -699,9 +726,9 @@ export { getTransactionsMatchingCodingRule, addPolicyAgentRule, generateRule, - clearGeneratedRule, setNewRulePromptError, - clearNewRulePromptError, + clearGeneratedRule, + clearNewRulePrompt, updatePolicyAgentRule, deletePolicyAgentRule, clearMerchantRuleErrors, diff --git a/src/pages/workspace/rules/DescribeRuleButton.tsx b/src/pages/workspace/rules/DescribeRuleButton.tsx deleted file mode 100644 index 4ea0104075d2..000000000000 --- a/src/pages/workspace/rules/DescribeRuleButton.tsx +++ /dev/null @@ -1,192 +0,0 @@ -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 Modal from '@components/Modal'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; - -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {clearDraftValues} from '@libs/actions/FormActions'; -import {getDecodedCategoryName} from '@libs/CategoryUtils'; - -import variables from '@styles/variables'; - -import {clearGeneratedRule, clearNewRulePromptError, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import INPUT_IDS from '@src/types/form/NewRulePromptForm'; -import type {GeneratedRule} from '@src/types/onyx'; - -import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native'; -import type {ValueOf} from 'type-fest'; - -import React, {useEffect, useRef, useState} from 'react'; - -type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; - -type GeneratedRuleValues = NonNullable; - -type DescribeRuleButtonProps = { - policyID: string; - - /** The rule type of the page the button sits on, so Concierge builds values for that form */ - ruleType: ValueOf; - - /** Called with the generated form values, with the category matched to the workspace's spelling */ - onRuleGenerated: (values: GeneratedRuleValues) => void; - - style?: StyleProp; - - sentryLabel: string; -}; - -function DescribeRuleButton({policyID, ruleType, onRuleGenerated, style, sentryLabel}: DescribeRuleButtonProps) { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - // The modal type depends on the physical screen width, which shouldUseNarrowLayout doesn't track. - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - const formRef = useRef(null); - const [isModalVisible, setIsModalVisible] = useState(false); - const [generationID, setGenerationID] = useState(); - const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - const appliedGenerationIDRef = useRef(undefined); - const inputLabel = translate('workspace.rules.newRule.describeRuleInputLabel'); - - const generatedRuleForCurrentPrompt = generationID && generatedRule?.generationID === generationID ? generatedRule : undefined; - const isGenerating = !!generationID && !generatedRuleForCurrentPrompt; - - const closeModal = () => { - setIsModalVisible(false); - setGenerationID(undefined); - clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); - clearNewRulePromptError(); - clearGeneratedRule(); - }; - - useEffect(() => { - if (!generatedRuleForCurrentPrompt || appliedGenerationIDRef.current === generatedRuleForCurrentPrompt.generationID) { - return; - } - appliedGenerationIDRef.current = generatedRuleForCurrentPrompt.generationID; - - if (generatedRuleForCurrentPrompt.state === CONST.GENERATED_RULE.STATE.RULE) { - const {category, ...ruleValues} = generatedRuleForCurrentPrompt.rule ?? {}; - const matchedCategory = category - ? Object.values(policyCategories ?? {}).find((policyCategory) => policyCategory.enabled && getDecodedCategoryName(policyCategory.name) === getDecodedCategoryName(category)) - : undefined; - onRuleGenerated(matchedCategory ? {...ruleValues, category: matchedCategory.name} : ruleValues); - closeModal(); - return; - } - - if (generatedRuleForCurrentPrompt.state === CONST.GENERATED_RULE.STATE.UNSUPPORTED) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unsupported')); - return; - } - - if (generatedRuleForCurrentPrompt.state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.multipleRules')); - return; - } - - if (generatedRuleForCurrentPrompt.state === CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE) { - setNewRulePromptError(translate('workspace.rules.newRule.promptErrors.unintelligible')); - return; - } - - setNewRulePromptError(translate('common.genericErrorMessage')); - }, [closeModal, generatedRuleForCurrentPrompt, onRuleGenerated, policyCategories, translate]); - - const describeRule = (values: FormOnyxValues) => { - clearNewRulePromptError(); - clearGeneratedRule(); - setGenerationID(generateRule(policyID, ruleType, values[INPUT_IDS.PROMPT].trim())); - }; - - const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { - if (!('key' in event)) { - return; - } - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - formRef.current?.submit(); - } - }; - - const validate = (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - if (!values[INPUT_IDS.PROMPT]?.trim()) { - errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); - } - return errors; - }; - - return ( - <> - - - - {translate('workspace.rules.newRule.describeRule')} - - {translate('workspace.rules.agentRules.disclaimer')} - - - - ); -} - -DescribeRuleButton.displayName = 'DescribeRuleButton'; - -export default DescribeRuleButton; -export type {GeneratedRuleValues}; diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx new file mode 100644 index 000000000000..41535e2a094f --- /dev/null +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -0,0 +1,154 @@ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; +import Modal from '@components/Modal'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getDecodedCategoryName} from '@libs/CategoryUtils'; + +import variables from '@styles/variables'; + +import {clearGeneratedRule, clearNewRulePrompt, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/NewRulePromptForm'; +import type {PolicyCategories} from '@src/types/onyx'; +import type {GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; + +import type {TextInputKeyPressEvent} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useEffect, useRef} from 'react'; + +type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; + +type DescribeRuleModalProps = { + isVisible: boolean; + + onClose: () => void; + + policyID: string; + + /** The rule type of the editor the modal opens from, so Concierge fills in that form */ + ruleType: GeneratedRuleType; + + /** Called with the generated form values once Concierge has built the rule */ + onRuleGenerated: (values: GeneratedRuleValues) => void; +}; + +/** Replaces the generated category with the workspace's own spelling of it, or drops it when the workspace has no such category */ +function withPolicyCategory(values: GeneratedRuleValues, policyCategories: OnyxEntry): GeneratedRuleValues { + const {category, ...rest} = values; + if (!category) { + return values; + } + const policyCategory = Object.values(policyCategories ?? {}).find((candidate) => candidate.enabled && getDecodedCategoryName(candidate.name) === getDecodedCategoryName(category)); + return policyCategory ? {...rest, category: policyCategory.name} : rest; +} + +function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenerated}: DescribeRuleModalProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + // We need to use isSmallScreenWidth here because the Modal breaks in RHP with shouldUseNarrowLayout. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const formRef = useRef(null); + const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const inputLabel = translate('workspace.rules.newRule.describeRuleInputLabel'); + + const close = () => { + clearNewRulePrompt(); + onClose(); + }; + + useEffect(() => { + if (!isVisible || !generatedRule) { + return; + } + if (generatedRule.state !== CONST.GENERATED_RULE.STATE.RULE) { + setNewRulePromptError(generatedRule.state); + clearGeneratedRule(); + return; + } + onRuleGenerated(withPolicyCategory(generatedRule.rule ?? {}, policyCategories)); + close(); + }, [close, generatedRule, isVisible, onRuleGenerated, policyCategories]); + + const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in event)) { + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + formRef.current?.submit(); + } + }; + + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!values[INPUT_IDS.PROMPT]?.trim()) { + errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); + } + return errors; + }; + + const submit = (values: FormOnyxValues) => { + generateRule(policyID, ruleType, values[INPUT_IDS.PROMPT].trim()); + }; + + return ( + + + {translate('workspace.rules.newRule.describeRule')} + + {translate('workspace.rules.agentRules.disclaimer')} + + + ); +} + +DescribeRuleModal.displayName = 'DescribeRuleModal'; + +export default DescribeRuleModal; diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index d67389864cf9..655f31dedb27 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -1,3 +1,4 @@ +import Button from '@components/Button'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import Header from '@components/Header'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; @@ -27,7 +28,7 @@ import Navigation from '@libs/Navigation/Navigation'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; +import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -81,6 +82,7 @@ function FlagForReviewRulePageBase({ const isCategoryLocked = isCategoryLockedProp ?? !!initialCategoryName; const canEditCategory = canWriteRules && !isCategoryLocked; const shouldShowDescribeRule = !isEditing && !isCategoryLocked; + const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const categorySettingsBackPath = useCategoryRuleCreateBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_FLAG_FOR_REVIEW_NEW.path); const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; @@ -215,18 +217,19 @@ function FlagForReviewRulePageBase({ shouldShowLoadingImmediatelyOnPress={false} enabledWhenOffline sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.FLAG_FOR_REVIEW_RULE_SAVE} - buttonStyles={shouldShowDescribeRule && styles.flex1} - buttonAndFooterContainerStyles={shouldShowDescribeRule && [styles.flexRow, styles.gap2]} + buttonStyles={styles.flex1} + buttonAndFooterContainerStyles={[styles.flexRow, styles.gap2]} shouldRenderFooterAboveSubmit footerContent={ shouldShowDescribeRule && ( - setIsDescribeRuleModalVisible(true)} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.FLAG_FOR_REVIEW_RULE_DESCRIBE} - /> + > + {translate('workspace.rules.newRule.describe')} + ) } /> @@ -298,6 +301,15 @@ function FlagForReviewRulePageBase({ /> {footer} + {shouldShowDescribeRule && ( + setIsDescribeRuleModalVisible(false)} + policyID={policyID} + ruleType={CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW} + onRuleGenerated={setDraftFlagForReviewRule} + /> + )} ); diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index e5a6a5dcf5b8..e18999e7012c 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -43,8 +43,7 @@ import {getTagArrayFromName} from '@libs/TransactionUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; -import type {GeneratedRuleValues} from '@pages/workspace/rules/DescribeRuleButton'; +import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -57,6 +56,7 @@ import type {MerchantRuleForm} from '@src/types/form'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; import type {ExpenseDefaultRuleType} from '@src/types/form/MerchantRuleForm'; import type {PolicyTagLists} from '@src/types/onyx'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import type IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -190,6 +190,7 @@ function MerchantRulePageBase({ const [shouldShowError, setShouldShowError] = useState(false); const {showConfirmModal} = useConfirmModal(); const [shouldUpdateMatchingTransactions, setShouldUpdateMatchingTransactions] = useState(false); + const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const seededCategoryTaxRuleRef = useRef(undefined); const didSeedInitialCategoryRef = useRef(false); @@ -723,13 +724,14 @@ function MerchantRulePageBase({ )} {shouldShowDescribeRule && ( - setIsDescribeRuleModalVisible(true)} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_DESCRIBE} - /> + > + {translate('workspace.rules.newRule.describe')} + )} } @@ -810,6 +812,15 @@ function MerchantRulePageBase({ {isInLandscapeMode && footer} {!isInLandscapeMode && footer} + {shouldShowDescribeRule && ( + setIsDescribeRuleModalVisible(false)} + policyID={policyID} + ruleType={CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS} + onRuleGenerated={applyGeneratedRule} + /> + )} ); diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 68fa2a72992e..92c33ed53a7b 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -1,3 +1,4 @@ +import Button from '@components/Button'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import Header from '@components/Header'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; @@ -42,8 +43,7 @@ import type {FieldRequirementsDirection} from '@libs/RequireFieldsRulesUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; -import type {GeneratedRuleValues} from '@pages/workspace/rules/DescribeRuleButton'; +import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -53,6 +53,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES, getRequireFieldsRuleCategoryRoute, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src/types/form/RequireFieldsRuleForm'; import INPUT_IDS from '@src/types/form/RequireFieldsRuleForm'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -83,6 +84,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, const isEditing = !!categoryName; const isCategoryLocked = isCategoryLockedProp ?? !!initialCategoryName; const shouldShowDescribeRule = !isEditing && !isCategoryLocked; + const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const canEditCategory = canWriteRules && !isCategoryLocked; const categorySettingsBackPath = useCategoryRuleCreateBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_REQUIRE_FIELDS_NEW.path); @@ -442,18 +444,19 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, shouldShowLoadingImmediatelyOnPress={false} enabledWhenOffline sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.REQUIRE_FIELDS_RULE_SAVE} - buttonStyles={shouldShowDescribeRule && styles.flex1} - buttonAndFooterContainerStyles={shouldShowDescribeRule && [styles.flexRow, styles.gap2]} + buttonStyles={styles.flex1} + buttonAndFooterContainerStyles={[styles.flexRow, styles.gap2]} shouldRenderFooterAboveSubmit footerContent={ shouldShowDescribeRule && ( - setIsDescribeRuleModalVisible(true)} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.REQUIRE_FIELDS_RULE_DESCRIBE} - /> + > + {translate('workspace.rules.newRule.describe')} + ) } /> @@ -516,6 +519,15 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, ))} {footer} + {shouldShowDescribeRule && ( + setIsDescribeRuleModalVisible(false)} + policyID={policyID} + ruleType={CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS} + onRuleGenerated={applyGeneratedRule} + /> + )} ); diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index c35330cea61c..799c5e107c66 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -1,3 +1,4 @@ +import Button from '@components/Button'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import Header from '@components/Header'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; @@ -33,8 +34,7 @@ import {getSpendRuleFormValuesFromCardRule, getTruncatedSpendRuleSummary} from ' import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; -import type {GeneratedRuleValues} from '@pages/workspace/rules/DescribeRuleButton'; +import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -46,6 +46,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {SpendRuleCategory} from '@src/types/form/SpendRuleForm'; import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ValueOf} from 'type-fest'; @@ -100,6 +101,7 @@ function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRuleP const existingFormValues = useMemo(() => getSpendRuleFormValuesFromCardRule(existingRule), [existingRule]); const [isErrorVisible, setIsErrorVisible] = useState(false); + const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const [isRestrictMerchantsOff, setIsRestrictMerchantsOff] = useState(() => { const hasNoMerchantRestrictions = !existingFormValues?.merchantNames.length && !existingFormValues?.categories?.length; return isNewRule || hasNoMerchantRestrictions; @@ -471,22 +473,32 @@ function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRuleP shouldShowLoadingImmediatelyOnPress={false} enabledWhenOffline sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_SAVE} - buttonStyles={isNewRule && styles.flex1} - buttonAndFooterContainerStyles={isNewRule && [styles.flexRow, styles.gap2]} + buttonStyles={styles.flex1} + buttonAndFooterContainerStyles={[styles.flexRow, styles.gap2]} shouldRenderFooterAboveSubmit footerContent={ isNewRule && ( - setIsDescribeRuleModalVisible(true)} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_DESCRIBE} - /> + > + {translate('workspace.rules.newRule.describe')} + ) } /> )} + {isNewRule && ( + setIsDescribeRuleModalVisible(false)} + policyID={policyID} + ruleType={CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND} + onRuleGenerated={applyGeneratedRule} + /> + )} ); diff --git a/src/types/onyx/GeneratedRule.ts b/src/types/onyx/GeneratedRule.ts index 0d690e439d06..51daaa1e9058 100644 --- a/src/types/onyx/GeneratedRule.ts +++ b/src/types/onyx/GeneratedRule.ts @@ -34,3 +34,4 @@ type GeneratedRule = { }; export default GeneratedRule; +export type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues}; From 16072322202ca8286ea504340a1b2c61ba3eb821 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 2 Oct 2026 13:55:16 +0200 Subject: [PATCH 40/62] Keep the failed generation state the GenerateRule job pushes to Onyx --- src/CONST/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 340cc7c88972..223fc14c36ea 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -606,6 +606,7 @@ const CONST = { UNSUPPORTED: 'unsupported', MULTIPLE_RULES: 'multipleRules', UNINTELLIGIBLE: 'unintelligible', + FAILED: 'failed', }, RULE_TYPE: { REQUIRE_FIELDS: 'requireFields', From 2f9ea79948ef2a2db87ebc35132e330cf5c02d1a Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 2 Oct 2026 14:00:58 +0200 Subject: [PATCH 41/62] Open the describe-rule input at full height --- .../workspace/rules/DescribeRuleModal.tsx | 33 ++++++++++--------- src/styles/index.ts | 4 +++ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx index 41535e2a094f..24047f41c7fa 100644 --- a/src/pages/workspace/rules/DescribeRuleModal.tsx +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -12,8 +12,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import variables from '@styles/variables'; - import {clearGeneratedRule, clearNewRulePrompt, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; @@ -26,6 +24,7 @@ import type {TextInputKeyPressEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; @@ -130,19 +129,23 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} > {translate('workspace.rules.newRule.describeRule')} - + + + {translate('workspace.rules.agentRules.disclaimer')} diff --git a/src/styles/index.ts b/src/styles/index.ts index 5639a99fbb52..737594e58241 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6110,6 +6110,10 @@ const staticStyles = (theme: ThemeColors) => maxHeight: variables.agentRulePromptInputHeight, }, + describeRulePromptInput: { + height: variables.textInputAutoGrowMaxHeight, + }, + emptyStateSamlIllustration: { width: 183, height: 160, From 55b2939c363092c98a63ee52d91771e28e64c199 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 2 Oct 2026 14:01:13 +0200 Subject: [PATCH 42/62] Drop the AI disclaimer from the describe-rule sheet --- src/pages/workspace/rules/DescribeRuleModal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx index 24047f41c7fa..64b7a8ce89c3 100644 --- a/src/pages/workspace/rules/DescribeRuleModal.tsx +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -146,7 +146,6 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera inputStyle={[styles.flex1, styles.textAlignVerticalTop]} /> - {translate('workspace.rules.agentRules.disclaimer')} ); From 8759dada95923e4cca24549b87955afd0d6c31ef Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 2 Oct 2026 14:04:27 +0200 Subject: [PATCH 43/62] Use supporting text color for the describe-rule sheet copy --- src/pages/workspace/rules/DescribeRuleModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx index 64b7a8ce89c3..f294ba5940f6 100644 --- a/src/pages/workspace/rules/DescribeRuleModal.tsx +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -128,7 +128,7 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera shouldValidateOnBlur keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} > - {translate('workspace.rules.newRule.describeRule')} + {translate('workspace.rules.newRule.describeRule')} Date: Fri, 2 Oct 2026 14:06:45 +0200 Subject: [PATCH 44/62] Show expense default preview as a text row with a small button beside Describe and Save --- src/languages/de.ts | 2 + src/languages/el.ts | 2 + src/languages/en.ts | 2 + src/languages/es.ts | 2 + src/languages/fr.ts | 2 + src/languages/it.ts | 2 + src/languages/ja.ts | 2 + src/languages/nl.ts | 2 + src/languages/pl.ts | 2 + src/languages/pt-BR.ts | 2 + src/languages/zh-hans.ts | 2 + .../MerchantRules/MerchantRulePageBase.tsx | 99 ++++++++++--------- 12 files changed, 73 insertions(+), 48 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index b44fe6ce7999..d1fa71c8a738 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8254,6 +8254,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und applyUpdates: 'Diese Aktualisierungen anwenden:', saveRule: 'Regel speichern', previewMatches: 'Übereinstimmungen anzeigen', + previewMatchesDescription: 'Sieh dir an, wie diese Regel auf deine Ausgaben angewendet wird', + preview: 'Vorschau', confirmError: 'Gib ein Händlerunternehmen ein und nimm mindestens eine Aktualisierung vor', confirmErrorMerchant: 'Bitte Händler eingeben', confirmErrorUpdate: 'Bitte wende mindestens ein Update an', diff --git a/src/languages/el.ts b/src/languages/el.ts index 09a37399778f..262eef3a3632 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8609,6 +8609,8 @@ ${reportName}`, applyUpdates: 'Εφαρμόστε αυτές τις ενημερώσεις:', saveRule: 'Αποθήκευση κανόνα', previewMatches: 'Προεπισκόπηση αντιστοιχιών', + previewMatchesDescription: 'Δείτε πώς εφαρμόζεται αυτός ο κανόνας στις δαπάνες σας', + preview: 'Προεπισκόπηση', confirmError: 'Εισαγάγετε τον έμπορο και εφαρμόστε τουλάχιστον μία ενημέρωση', confirmErrorMerchant: 'Παρακαλούμε εισαγάγετε τον έμπορο', confirmErrorUpdate: 'Παρακαλούμε εφαρμόστε τουλάχιστον μία ενημέρωση', diff --git a/src/languages/en.ts b/src/languages/en.ts index afcdd4086e92..c676b712996f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8765,6 +8765,8 @@ const translations = { applyUpdates: 'Apply these updates:', saveRule: 'Save rule', previewMatches: 'Preview matches', + previewMatchesDescription: 'See how this rule applies to your expenses', + preview: 'Preview', confirmError: 'Enter merchant and apply at least one update', confirmErrorMerchant: 'Please enter merchant', confirmErrorUpdate: 'Please apply at least one update', diff --git a/src/languages/es.ts b/src/languages/es.ts index 23539c7efada..2d60a35251bf 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8282,6 +8282,8 @@ ${reportName}`, applyUpdates: 'Aplicar estas actualizaciones:', saveRule: 'Guardar regla', previewMatches: 'Vista previa de coincidencias', + previewMatchesDescription: 'Mira cómo se aplica esta regla a tus gastos', + preview: 'Vista previa', confirmError: 'Ingresa comerciante y aplica al menos una actualización', confirmErrorMerchant: 'Por favor ingresa comerciante', confirmErrorUpdate: 'Por favor aplica al menos una actualización', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ab4b1af5763e..59fb08de5ca5 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8274,6 +8274,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip applyUpdates: 'Appliquer ces mises à jour :', saveRule: 'Enregistrer la règle', previewMatches: 'Aperçu des correspondances', + previewMatchesDescription: 'Voyez comment cette règle s’applique à vos dépenses', + preview: 'Aperçu', confirmError: 'Saisissez un commerçant et appliquez au moins une mise à jour', confirmErrorMerchant: 'Veuillez saisir le commerçant', confirmErrorUpdate: 'Veuillez appliquer au moins une mise à jour', diff --git a/src/languages/it.ts b/src/languages/it.ts index c2fc86be11eb..60683b8610d8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8206,6 +8206,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo applyUpdates: 'Applica questi aggiornamenti:', saveRule: 'Salva regola', previewMatches: 'Anteprima corrispondenze', + previewMatchesDescription: 'Guarda come questa regola si applica alle tue spese', + preview: 'Anteprima', confirmError: 'Inserisci l’esercente e applica almeno un aggiornamento', confirmErrorMerchant: 'Inserisci l’esercente', confirmErrorUpdate: 'Applica almeno un aggiornamento', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index dcf55ec36def..35f441871b0e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8101,6 +8101,8 @@ ${reportName}`, applyUpdates: 'これらの更新を適用する', saveRule: 'ルールを保存', previewMatches: 'プレビュー候補を表示', + previewMatchesDescription: 'このルールが経費にどう適用されるかを確認', + preview: 'プレビュー', confirmError: '店舗名を入力し、少なくとも1つの更新を適用してください', confirmErrorMerchant: '加盟店を入力してください', confirmErrorUpdate: '少なくとも 1 つの更新を適用してください', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d35d8342c449..7d22c34438d8 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8183,6 +8183,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar applyUpdates: 'Pas deze updates toe:', saveRule: 'Regel opslaan', previewMatches: 'Voorbeelden van overeenkomsten bekijken', + previewMatchesDescription: 'Bekijk hoe deze regel op je uitgaven wordt toegepast', + preview: 'Voorbeeld', confirmError: 'Voer handelaar in en pas minstens één wijziging toe', confirmErrorMerchant: 'Voer handelaar in', confirmErrorUpdate: 'Pas ten minste één wijziging toe', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 1ead213a7ddd..241d0d79659e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8206,6 +8206,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i applyUpdates: 'Zastosuj te aktualizacje:', saveRule: 'Zapisz regułę', previewMatches: 'Podgląd dopasowań', + previewMatchesDescription: 'Zobacz, jak ta reguła stosuje się do Twoich wydatków', + preview: 'Podgląd', confirmError: 'Wprowadź sprzedawcę i zastosuj co najmniej jedną zmianę', confirmErrorMerchant: 'Wprowadź sprzedawcę', confirmErrorUpdate: 'Zastosuj co najmniej jedną aktualizację', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 1c67cf7ecfe4..f8c5e63e6aa0 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8189,6 +8189,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e applyUpdates: 'Aplicar estas atualizações:', saveRule: 'Salvar regra', previewMatches: 'Pré-visualizar correspondências', + previewMatchesDescription: 'Veja como esta regra se aplica às suas despesas', + preview: 'Pré-visualizar', confirmError: 'Insira o comerciante e aplique pelo menos uma atualização', confirmErrorMerchant: 'Insira o comerciante', confirmErrorUpdate: 'Aplicar pelo menos uma atualização', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 008f6bccdd1f..251c563cd9ea 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7903,6 +7903,8 @@ ${reportName}`, applyUpdates: '应用这些更新:', saveRule: '保存规则', previewMatches: '预览匹配项', + previewMatchesDescription: '查看此规则如何应用于你的费用', + preview: '预览', confirmError: '输入商户并应用至少一项更新', confirmErrorMerchant: '请输入商户', confirmErrorUpdate: '请至少应用一个更新', diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index e18999e7012c..cd8bd99bdc5e 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -681,61 +681,64 @@ function MerchantRulePageBase({ }; const footer = canWriteRules ? ( - - - - {translate('workspace.rules.merchantRules.applyToExistingUnsubmittedExpenses')} - - {/* A category tax default only applies to expenses created after the rule is saved, so the switch - is locked off. `disabled` draws the lock inside the thumb and routes the press to the explainer. */} - - - {/* There is no set of existing expenses for a category rule to preview, so the button is hidden rather than locked. */} - {!isCategoryRule && ( - - )} - {shouldShowDescribeRule && ( + + + + {translate('workspace.rules.merchantRules.applyToExistingUnsubmittedExpenses')} + + {/* A category tax default only applies to expenses created after the rule is saved, so the switch + is locked off. `disabled` draws the lock inside the thumb and routes the press to the explainer. */} + + + {/* There is no set of existing expenses for a category rule to preview, so the row is hidden rather than locked. */} + {!isCategoryRule && ( + + {translate('workspace.rules.merchantRules.previewMatchesDescription')} + + + )} + setIsDescribeRuleModalVisible(true)} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_DESCRIBE} > {translate('workspace.rules.newRule.describe')} - )} - - } - /> + ) + } + /> + ) : null; const renderSectionItem = (item: SectionItemType) => { From de31f18a9de436046192eae65aafe710df41bfd3 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 2 Oct 2026 14:12:01 +0200 Subject: [PATCH 45/62] Keep the describe-rule form loading until the generated rule arrives --- src/libs/actions/Policy/Rules.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 4a3a6891eeab..5a8c990b3b9f 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -409,7 +409,10 @@ const PROMPT_ERROR_BY_STATE: Partial> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, - value: {isLoading: false}, - }, - ]; const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -442,12 +438,13 @@ function generateRule(policyID: string, ruleType: GeneratedRuleType, prompt: str const parameters: GenerateRuleParams = {policyID, generationID: rand64(), ruleType, prompt}; - API.write(WRITE_COMMANDS.GENERATE_RULE, parameters, {optimisticData, successData, failureData}); + API.write(WRITE_COMMANDS.GENERATE_RULE, parameters, {optimisticData, failureData}); } /** Shows on the prompt form why a description did not become a rule */ function setNewRulePromptError(state: GeneratedRuleState) { Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, { + isLoading: false, errors: getMicroSecondOnyxErrorWithTranslationKey(PROMPT_ERROR_BY_STATE[state] ?? 'common.genericErrorMessage'), }); } From e13182ac34696cefe75140b52f7358304f5707f3 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 2 Oct 2026 14:13:33 +0200 Subject: [PATCH 46/62] Inset the expense default preview row to match the design --- .../workspace/rules/MerchantRules/MerchantRulePageBase.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index cd8bd99bdc5e..87c8ec0f2a0a 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -702,7 +702,7 @@ function MerchantRulePageBase({ {/* There is no set of existing expenses for a category rule to preview, so the row is hidden rather than locked. */} {!isCategoryRule && ( - + {translate('workspace.rules.merchantRules.previewMatchesDescription')} + setIsModalVisible(false)} + policyID={policyID} + ruleType={ruleType} + onRuleGenerated={onRuleGenerated} + /> + + ); +} + +DescribeRuleButton.displayName = 'DescribeRuleButton'; + +export default DescribeRuleButton; diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 5d11aca5667b..2ecce17a3397 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -1,4 +1,3 @@ -import Button from '@components/Button'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import Header from '@components/Header'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; @@ -30,7 +29,7 @@ import Navigation from '@libs/Navigation/Navigation'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; @@ -84,7 +83,6 @@ function FlagForReviewRulePageBase({ const isCategoryLocked = isCategoryLockedProp ?? !!initialCategoryName; const canEditCategory = canWriteRules && !isCategoryLocked; const shouldShowDescribeRule = !isEditing && !isCategoryLocked; - const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const categorySettingsBackPath = useCategoryRuleCreateBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_FLAG_FOR_REVIEW_NEW.path); const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; @@ -224,14 +222,12 @@ function FlagForReviewRulePageBase({ shouldRenderFooterAboveSubmit footerContent={ shouldShowDescribeRule && ( - + onRuleGenerated={setDraftFlagForReviewRule} + /> ) } /> @@ -329,15 +325,6 @@ function FlagForReviewRulePageBase({ /> {footer} - {shouldShowDescribeRule && ( - setIsDescribeRuleModalVisible(false)} - policyID={policyID} - ruleType={CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW} - onRuleGenerated={setDraftFlagForReviewRule} - /> - )} ); diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 3b1bd0a98f90..7afbab4b3051 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -43,7 +43,7 @@ import {getTagArrayFromName} from '@libs/TransactionUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -190,7 +190,6 @@ function MerchantRulePageBase({ const [shouldShowError, setShouldShowError] = useState(false); const {showConfirmModal} = useConfirmModal(); const [shouldUpdateMatchingTransactions, setShouldUpdateMatchingTransactions] = useState(false); - const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const seededCategoryTaxRuleRef = useRef(undefined); const didSeedInitialCategoryRef = useRef(false); @@ -708,14 +707,12 @@ function MerchantRulePageBase({ shouldRenderFooterAboveSubmit footerContent={ shouldShowDescribeRule && ( - + onRuleGenerated={applyGeneratedRule} + /> ) } /> @@ -813,15 +810,6 @@ function MerchantRulePageBase({ {isInLandscapeMode && footer} {!isInLandscapeMode && footer} - {shouldShowDescribeRule && ( - setIsDescribeRuleModalVisible(false)} - policyID={policyID} - ruleType={CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS} - onRuleGenerated={applyGeneratedRule} - /> - )} ); diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index bd8c6a02a7db..9d46807f4976 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -1,4 +1,3 @@ -import Button from '@components/Button'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import Header from '@components/Header'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; @@ -44,7 +43,7 @@ import type {FieldRequirementsDirection} from '@libs/RequireFieldsRulesUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; @@ -85,7 +84,6 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, const isEditing = !!categoryName; const isCategoryLocked = isCategoryLockedProp ?? !!initialCategoryName; const shouldShowDescribeRule = !isEditing && !isCategoryLocked; - const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const canEditCategory = canWriteRules && !isCategoryLocked; const categorySettingsBackPath = useCategoryRuleCreateBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_REQUIRE_FIELDS_NEW.path); @@ -450,14 +448,12 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, shouldRenderFooterAboveSubmit footerContent={ shouldShowDescribeRule && ( - + onRuleGenerated={applyGeneratedRule} + /> ) } /> @@ -537,15 +533,6 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, ))} {footer} - {shouldShowDescribeRule && ( - setIsDescribeRuleModalVisible(false)} - policyID={policyID} - ruleType={CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS} - onRuleGenerated={applyGeneratedRule} - /> - )} ); diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 799c5e107c66..4fce96f34e6c 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -1,4 +1,3 @@ -import Button from '@components/Button'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import Header from '@components/Header'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; @@ -34,7 +33,7 @@ import {getSpendRuleFormValuesFromCardRule, getTruncatedSpendRuleSummary} from ' import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -101,7 +100,6 @@ function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRuleP const existingFormValues = useMemo(() => getSpendRuleFormValuesFromCardRule(existingRule), [existingRule]); const [isErrorVisible, setIsErrorVisible] = useState(false); - const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const [isRestrictMerchantsOff, setIsRestrictMerchantsOff] = useState(() => { const hasNoMerchantRestrictions = !existingFormValues?.merchantNames.length && !existingFormValues?.categories?.length; return isNewRule || hasNoMerchantRestrictions; @@ -478,27 +476,16 @@ function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRuleP shouldRenderFooterAboveSubmit footerContent={ isNewRule && ( - + onRuleGenerated={applyGeneratedRule} + /> ) } /> )} - {isNewRule && ( - setIsDescribeRuleModalVisible(false)} - policyID={policyID} - ruleType={CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND} - onRuleGenerated={applyGeneratedRule} - /> - )} ); From c5f1844930a21fb792d08b86cec85bfeb7462f39 Mon Sep 17 00:00:00 2001 From: "Rushat Gabhane (via MelvinBot)" Date: Fri, 9 Oct 2026 14:13:13 +0000 Subject: [PATCH 60/62] Keep the Merchant editor's Describe modal open when the device rotates Co-authored-by: Rushat Gabhane --- .../workspace/rules/DescribeRuleButton.tsx | 47 ++++++++++++------- .../MerchantRules/MerchantRulePageBase.tsx | 16 +++++-- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/pages/workspace/rules/DescribeRuleButton.tsx b/src/pages/workspace/rules/DescribeRuleButton.tsx index addf948c1441..c0a2bcd09114 100644 --- a/src/pages/workspace/rules/DescribeRuleButton.tsx +++ b/src/pages/workspace/rules/DescribeRuleButton.tsx @@ -14,19 +14,28 @@ import React, {useState} from 'react'; import DescribeRuleModal from './DescribeRuleModal'; type DescribeRuleButtonProps = { - policyID: string; - - /** The rule type of the editor the button is in, so Concierge fills in that form */ - ruleType: GeneratedRuleType; - /** Sentry label for the button */ sentryLabel: string; +} & ( + | { + policyID: string; + + /** The rule type of the editor the button is in, so Concierge fills in that form */ + ruleType: GeneratedRuleType; + + /** Called with the generated form values once Concierge has built the rule */ + onRuleGenerated: (values: GeneratedRuleValues) => void; - /** Called with the generated form values once Concierge has built the rule */ - onRuleGenerated: (values: GeneratedRuleValues) => void; -}; + onPress?: never; + } + | { + /** Opens a DescribeRuleModal that the editor renders itself. Use it when the footer can remount while the modal is open, + * for example a footer that moves on rotation, because the button's own modal would close with it. */ + onPress: () => void; + } +); -function DescribeRuleButton({policyID, ruleType, sentryLabel, onRuleGenerated}: DescribeRuleButtonProps) { +function DescribeRuleButton(props: DescribeRuleButtonProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [isModalVisible, setIsModalVisible] = useState(false); @@ -36,18 +45,20 @@ function DescribeRuleButton({policyID, ruleType, sentryLabel, onRuleGenerated}: - setIsModalVisible(false)} - policyID={policyID} - ruleType={ruleType} - onRuleGenerated={onRuleGenerated} - /> + {!props.onPress && ( + setIsModalVisible(false)} + policyID={props.policyID} + ruleType={props.ruleType} + onRuleGenerated={props.onRuleGenerated} + /> + )} ); } diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 7afbab4b3051..5bc4bf6bb7f8 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -44,6 +44,7 @@ import {getTagArrayFromName} from '@libs/TransactionUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; +import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -190,6 +191,8 @@ function MerchantRulePageBase({ const [shouldShowError, setShouldShowError] = useState(false); const {showConfirmModal} = useConfirmModal(); const [shouldUpdateMatchingTransactions, setShouldUpdateMatchingTransactions] = useState(false); + // The footer remounts when it moves on rotation, so the page owns the modal to keep it open + const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const seededCategoryTaxRuleRef = useRef(undefined); const didSeedInitialCategoryRef = useRef(false); @@ -708,10 +711,8 @@ function MerchantRulePageBase({ footerContent={ shouldShowDescribeRule && ( setIsDescribeRuleModalVisible(true)} /> ) } @@ -810,6 +811,15 @@ function MerchantRulePageBase({ {isInLandscapeMode && footer} {!isInLandscapeMode && footer} + {shouldShowDescribeRule && ( + setIsDescribeRuleModalVisible(false)} + policyID={policyID} + ruleType={CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS} + onRuleGenerated={applyGeneratedRule} + /> + )} ); From 11f2e9ec096eb7991f31325c7ecd39c0988ec726 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 9 Oct 2026 21:25:26 +0200 Subject: [PATCH 61/62] Keep a generated rule's category from carrying over the previous category's settings --- .../rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 9d46807f4976..b4dbf4882a3d 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -96,6 +96,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Fields the user toggled directly — category reassignment marks touchedFields for save/display // without meaning the user caused coupling, so tooltips key off this set instead. const [couplingInteractionFields, setCouplingInteractionFields] = useState>(() => new Set()); + const [generatedCategoryName, setGeneratedCategoryName] = useState(); const initializedDraftForRuleKeyRef = useRef(null); const category = categoryName ? policyCategories?.[categoryName] : undefined; @@ -115,8 +116,10 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, setSelectionCategoryName(selectedCategoryName); } else if (selectionCategoryName !== selectedCategoryName) { const previousCategoryName = selectionCategoryName; - const didChangeSelectedCategory = previousCategoryName !== undefined && selectedCategoryName !== undefined; + const isGeneratedCategory = selectedCategoryName === generatedCategoryName; + const didChangeSelectedCategory = previousCategoryName !== undefined && selectedCategoryName !== undefined && !isGeneratedCategory; setSelectionCategoryName(selectedCategoryName); + setGeneratedCategoryName(undefined); if (didChangeSelectedCategory) { const previousCategory = previousCategoryName ? policyCategories?.[previousCategoryName] : undefined; @@ -430,6 +433,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, const applyGeneratedRule = (values: GeneratedRuleValues) => { setDraftRequireFieldsRule(values); + setGeneratedCategoryName(values.category); setTouchedFields(new Set(SETTING_FIELD_KEYS.filter((fieldKey) => values[fieldKey] !== undefined))); }; From 4850253fa641ec6b844a0c47628e01a2a65f7e01 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 9 Oct 2026 21:25:41 +0200 Subject: [PATCH 62/62] Clear a leftover describe-rule loading state when the modal opens --- src/pages/workspace/rules/DescribeRuleModal.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx index 28a129be0fe2..89762fb17839 100644 --- a/src/pages/workspace/rules/DescribeRuleModal.tsx +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -67,6 +67,7 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera const inputRef = useRef(null); const pendingGenerationIDRef = useRef(undefined); const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); + const [newRulePromptForm] = useOnyx(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const generatedRuleID = generatedRule?.generationID; const generatedRuleState = generatedRule?.state; @@ -92,6 +93,13 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera close(); }, [close, generatedRuleID, generatedRuleState, generatedRuleValues, onRuleGenerated, policyCategories]); + useEffect(() => { + if (!isVisible || !newRulePromptForm?.isLoading || pendingGenerationIDRef.current) { + return; + } + clearNewRulePrompt(); + }, [isVisible, newRulePromptForm?.isLoading]); + // Focus only after the open animation, because focusing mid-animation blurs the input on mWeb and native useFocusEffect(() => { if (!isVisible) {