From 771f73af8cad20ef22704a7c8211cb09de2c8f9d Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Mon, 12 Oct 2026 12:02:37 -0700 Subject: [PATCH 01/20] feat(rules): describe a rule from New rule and fall back to an agent rule --- src/CONST/index.ts | 2 +- src/ROUTES.ts | 2 +- src/languages/de.ts | 7 +- src/languages/el.ts | 8 +- src/languages/en.ts | 7 +- src/languages/es.ts | 8 +- src/languages/fr.ts | 8 +- src/languages/it.ts | 8 +- 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 | 6 +- src/libs/API/parameters/GenerateRuleParams.ts | 2 +- src/libs/ExpenseDefaultRuleUtils.ts | 8 + src/libs/Navigation/types.ts | 3 + src/libs/SpendRulesUtils.ts | 23 +- src/libs/actions/Policy/Rules.ts | 23 +- .../rules/AgentRules/AddAgentRulePage.tsx | 43 +- .../workspace/rules/DescribeRuleModal.tsx | 60 ++- .../FlagForReviewRulePageBase.tsx | 8 +- .../MerchantRules/MerchantRulePageBase.tsx | 4 +- .../RequireFieldsRulePageBase.tsx | 18 +- src/pages/workspace/rules/RulesNewPage.tsx | 104 ++++- .../rules/SpendRules/SpendRulePageBase.tsx | 14 +- tests/ui/AddAgentRulePageTest.tsx | 289 ++++++++++++ tests/ui/RuleEditorSeededDraftTest.tsx | 216 +++++++++ tests/ui/RulesNewPageDescribeRuleTest.tsx | 438 ++++++++++++++++++ 28 files changed, 1256 insertions(+), 81 deletions(-) create mode 100644 tests/ui/AddAgentRulePageTest.tsx create mode 100644 tests/ui/RuleEditorSeededDraftTest.tsx create mode 100644 tests/ui/RulesNewPageDescribeRuleTest.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 875e26e7bb2e..2e35a1afa21a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -10285,7 +10285,7 @@ const CONST = { NEW_RULE_MENU_ITEM_FLAG_FOR_REVIEW: 'WorkspaceRules-NewRuleMenuItem-FlagForReview', 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_MENU_ITEM_DESCRIBE_RULE: 'WorkspaceRules-NewRuleMenuItem-DescribeRule', REQUIRE_RECEIPTS_SAVE: 'WorkspaceRules-RequireReceiptsSave', REQUIRE_FIELDS_SAVE: 'WorkspaceRules-RequireFieldsSave', FLAG_RECEIPT_LINE_ITEMS_SAVE: 'WorkspaceRules-FlagReceiptLineItemsSave', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 6d8f0867ef1d..7f618d29c396 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4498,7 +4498,7 @@ const ROUTES = { }, RULES_AGENT_NEW: { route: 'workspaces/:policyID/rules/agent-rules/new', - getRoute: (policyID: string) => `workspaces/${policyID}/rules/agent-rules/new` as const, + getRoute: (policyID: string, generationID?: string) => getUrlWithParams(`workspaces/${policyID}/rules/agent-rules/new` as const, {generationID}), }, RULES_AGENT_EDIT: { route: 'workspaces/:policyID/rules/agent-rules/:ruleID', diff --git a/src/languages/de.ts b/src/languages/de.ts index 68db0747d0a7..f8a85991c60f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8742,6 +8742,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc describeRuleForConcierge: 'Beschreiben Sie Ihre Regel und Concierge erstellt sie', gotIt: 'Verstanden', createRule: 'Regel erstellen', + fallbackBanner: + 'Das ist keine Standardregel, daher haben wir sie als Agentenregel entworfen. Agentenregeln greifen, nachdem sich eine Ausgabe oder ein Bericht ändert, zum Beispiel indem sie die Ausgabe zurückhalten oder den Bericht ablehnen. Prüfen und speichern Sie die Regel.', }, tabs: { general: 'Allgemein', @@ -8812,8 +8814,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc flagForReviewDescription: 'Genehmigende benachrichtigen, wenn Ausgaben Kategorienlimits überschreiten', requireFields: 'Feldanforderungen festlegen', 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.', + describeYourRule: 'Beschreibe deine Regel', + describeYourRuleDescription: 'Sag Concierge, was du brauchst, und Concierge erstellt die Regel', describe: 'Beschreiben', describeRule: 'Beschreibe deine Regel in einfachen Worten und Concierge erstellt sie für dich', describeRuleInputLabel: 'Beschreibe die Regel', @@ -8821,6 +8823,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc 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.', + agentRuleAdminOnly: 'Dafür ist eine Agentenregel nötig, und nur Workspace-Admins können sie erstellen. Bitte einen Workspace-Admin darum, sie hinzuzufügen.', }, }, expenseDefaultsTable: { diff --git a/src/languages/el.ts b/src/languages/el.ts index f87fb4f3514f..bc3ff1a7dadc 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8904,8 +8904,8 @@ ${reportName}`, requireFieldsDescription: 'Απαιτήστε συγκεκριμένα πεδία εξόδων ή παραλείψτε την απαίτησή τους.', applyExpenseDefaults: 'Εφαρμογή προεπιλογών δαπανών', applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', - createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', - createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', + describeYourRule: 'Περιγράψτε τον κανόνα σας', + describeYourRuleDescription: 'Πείτε στον Concierge τι χρειάζεστε και θα δημιουργήσει τον κανόνα', describe: 'Περιγραφή', describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', describeRuleInputLabel: 'Περιγράψτε τον κανόνα', @@ -8913,6 +8913,8 @@ ${reportName}`, unsupported: 'Αυτός ο κανόνας δεν υποστηρίζεται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.', unintelligible: 'Δοκιμάστε να διατυπώσετε τον κανόνα σας έτσι: αν οι δαπάνες ξεπερνούν τα 100 $, να απαιτείται απόδειξη.', multipleRules: 'Έναν κανόνα τη φορά — δοκιμάστε να περιγράψετε έναν μόνο κανόνα, π.χ.: αποκλεισμός αγορών λιανικής σε όλες τις κάρτες.', + agentRuleAdminOnly: + 'Αυτό απαιτεί κανόνα πράκτορα, τον οποίο μπορούν να δημιουργήσουν μόνο οι διαχειριστές του χώρου εργασίας. Ζητήστε από έναν διαχειριστή του χώρου εργασίας να τον προσθέσει.', }, }, expenseDefaultsTable: { @@ -9196,6 +9198,8 @@ ${reportName}`, describeRuleHeadline: 'Περιγράψτε τον κανόνα σας', describeRuleForConcierge: 'Περιγράψτε τον κανόνα σας και το Concierge θα τον δημιουργήσει', createRule: 'Δημιουργία κανόνα', + fallbackBanner: + 'Αυτός δεν είναι τυπικός κανόνας, οπότε τον συντάξαμε ως κανόνα πράκτορα. Οι κανόνες πράκτορα ενεργούν αφού αλλάξει μια δαπάνη ή μια αναφορά, για παράδειγμα θέτοντας τη δαπάνη σε αναστολή ή απορρίπτοντας την αναφορά. Ελέγξτε τον και αποθηκεύστε τον.', suggestionsTab: 'Προτεινόμενο', writeTab: 'Επεξεργασία', findSuggestion: 'Βρείτε έναν κανόνα', diff --git a/src/languages/en.ts b/src/languages/en.ts index 7a8346af8420..c71bb506a951 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9089,8 +9089,8 @@ const translations = { requireFieldsDescription: 'Require specific expense fields or waive requiring them.', applyExpenseDefaults: 'Apply expense defaults', applyExpenseDefaultsDescription: 'Update fields without submitter doing anything', - createAgentRule: 'Describe agent rule', - createAgentRuleDescription: 'Create flexible rules that run when you need.', + describeYourRule: 'Describe your rule', + describeYourRuleDescription: 'Tell Concierge what you need and it will build the rule', describe: 'Describe', describeRule: 'Describe your rule in plain English and have Concierge build the rule for you', describeRuleInputLabel: 'Describe the rule', @@ -9098,6 +9098,7 @@ const translations = { 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.', + agentRuleAdminOnly: 'This needs an agent rule, and only workspace admins can create those. Ask a workspace admin to add it.', }, }, expenseDefaultsTable: { @@ -9396,6 +9397,8 @@ const translations = { describeRuleForConcierge: 'Describe your rule and Concierge will build it', disclaimer: 'AI agents can make mistakes.', createRule: 'Create rule', + fallbackBanner: + "This isn't a standard rule, so we drafted it as an agent rule. Agent rules act after an expense or report changes, for example by holding the expense or rejecting the report. Review and save.", agentCreatedTitle: 'RuleBot has been added to your workspace!', agentCreatedDescription: (agentsRoute: string) => `To enforce your agent rules, we’ve created an agent for you and added it as an admin to your workspace.

Edit your agent’s details in Account > Agents.
`, diff --git a/src/languages/es.ts b/src/languages/es.ts index c6557c7f461b..4058d623cb82 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8747,6 +8747,8 @@ ${reportName}`, describeRuleForConcierge: 'Describe tu regla y Concierge la creará', gotIt: 'Entendido', createRule: 'Crear regla', + fallbackBanner: + 'Esta no es una regla estándar, así que la redactamos como regla de agente. Las reglas de agente actúan después de que cambia un gasto o un informe, por ejemplo, reteniendo el gasto o rechazando el informe. Revísala y guárdala.', }, tabs: { general: 'General', @@ -8817,8 +8819,8 @@ ${reportName}`, flagForReviewDescription: 'Notificar a los aprobadores cuando los gastos superen los límites de categoría', requireFields: 'Configurar requisitos de campos', 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.', + describeYourRule: 'Describe tu regla', + describeYourRuleDescription: 'Dile a Concierge lo que necesitas y creará la regla', describe: 'Describir', describeRule: 'Describe tu regla con tus propias palabras y Concierge la creará por ti', describeRuleInputLabel: 'Describe la regla', @@ -8826,6 +8828,8 @@ ${reportName}`, 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.', + agentRuleAdminOnly: + 'Esto requiere una regla de agente, y solo los administradores del espacio de trabajo pueden crearlas. Pide a un administrador del espacio de trabajo que la añada.', }, }, expenseDefaultsTable: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index f5a34db5f595..cef4cbb000bc 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8765,6 +8765,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e describeRuleForConcierge: 'Décrivez votre règle et Concierge la créera', gotIt: 'Compris', createRule: 'Créer une règle', + fallbackBanner: + 'Ce n’est pas une règle standard, nous l’avons donc rédigée comme une règle d’agent. Les règles d’agent agissent après la modification d’une dépense ou d’une note de frais, par exemple en mettant la dépense en attente ou en rejetant la note de frais. Vérifiez-la et enregistrez-la.', }, tabs: { general: 'Général', @@ -8835,8 +8837,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e flagForReviewDescription: 'Avertir les approbateurs lorsque les dépenses dépassent les limites de catégorie', requireFields: 'Définir les exigences des champs', 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.', + describeYourRule: 'Décrivez votre règle', + describeYourRuleDescription: 'Dites à Concierge ce dont vous avez besoin et il créera la règle', 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', @@ -8844,6 +8846,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e 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.', + agentRuleAdminOnly: + 'Cela nécessite une règle d’agent, et seuls les administrateurs de l’espace de travail peuvent en créer. Demandez à un administrateur de l’espace de travail de l’ajouter.', }, }, expenseDefaultsTable: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 69d51a069f95..d3265cce79ca 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8693,6 +8693,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, describeRuleForConcierge: 'Descrivi la tua regola e Concierge la creerà', gotIt: 'Capito', createRule: 'Crea regola', + fallbackBanner: + 'Questa non è una regola standard, quindi l’abbiamo preparata come regola agente. Le regole agente intervengono dopo che una spesa o un rapporto cambia, ad esempio mettendo in sospeso la spesa o rifiutando il rapporto. Controllala e salvala.', }, tabs: { general: 'Generale', @@ -8763,8 +8765,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, flagForReviewDescription: 'Avvisa gli approvatori quando le spese superano i limiti di categoria', requireFields: 'Imposta i requisiti del campo', 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.', + describeYourRule: 'Descrivi la tua regola', + describeYourRuleDescription: 'Di’ a Concierge di cosa hai bisogno e creerà la regola', describe: 'Descrivi', describeRule: 'Descrivi la tua regola con parole semplici e Concierge la creerà per te', describeRuleInputLabel: 'Descrivi la regola', @@ -8772,6 +8774,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, 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.', + agentRuleAdminOnly: + 'Serve una regola agente, e solo gli amministratori dello spazio di lavoro possono crearla. Chiedi a un amministratore dello spazio di lavoro di aggiungerla.', }, }, expenseDefaultsTable: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 6b6c4d3f203f..dab2c546013f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8582,6 +8582,8 @@ ${reportName}`, describeRuleForConcierge: 'ルールの内容を入力すると、Concierge が自動作成します', gotIt: '了解しました', createRule: 'ルールを作成', + fallbackBanner: + 'これは標準ルールではないため、エージェントルールとして下書きしました。エージェントルールは、経費やレポートが変更された後に動作します。たとえば、経費を保留したり、レポートを却下したりします。内容を確認して保存してください。', }, tabs: { general: '一般', @@ -8652,8 +8654,8 @@ ${reportName}`, flagForReviewDescription: '条件が満たされたときに通知します。', requireFields: 'フィールドの必須条件を設定', requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', - createAgentRule: 'エージェントルールの説明', - createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', + describeYourRule: 'ルールを説明する', + describeYourRuleDescription: '必要なことを伝えると、Concierge がルールを作成します', describe: '説明する', describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', describeRuleInputLabel: 'ルールを説明', @@ -8661,6 +8663,7 @@ ${reportName}`, unsupported: 'そのルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。', unintelligible: '「経費が100ドルを超える場合は領収書を必須にする」のように書いてみてください。', multipleRules: '一度に1つのルールのみです。「すべてのカードで小売店での購入をブロックする」のように、1つのルールを説明してください。', + agentRuleAdminOnly: 'これにはエージェントルールが必要で、作成できるのはワークスペース管理者のみです。ワークスペース管理者に追加を依頼してください。', }, }, expenseDefaultsTable: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 918d43d115b2..41107e488ba1 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8667,6 +8667,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, describeRuleForConcierge: 'Beschrijf je regel en Concierge bouwt hem voor je', gotIt: 'Begrepen', createRule: 'Regel maken', + fallbackBanner: + 'Dit is geen standaardregel, dus we hebben er een agentregel van gemaakt. Agentregels treden in werking nadat een uitgave of rapport verandert, bijvoorbeeld door de uitgave vast te houden of het rapport af te wijzen. Controleer de regel en sla hem op.', }, tabs: { general: 'Algemeen', @@ -8737,8 +8739,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, flagForReviewDescription: 'Goedkeurders informeren wanneer onkosten de categorielimieten overschrijden', requireFields: 'Veldvereisten instellen', 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.', + describeYourRule: 'Beschrijf je regel', + describeYourRuleDescription: 'Vertel Concierge wat je nodig hebt en Concierge maakt de regel', describe: 'Beschrijven', describeRule: 'Beschrijf je regel in gewone taal en Concierge maakt de regel voor je', describeRuleInputLabel: 'Beschrijf de regel', @@ -8746,6 +8748,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, 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.', + agentRuleAdminOnly: 'Hiervoor is een agentregel nodig, en alleen werkruimtebeheerders kunnen die maken. Vraag een werkruimtebeheerder om de regel toe te voegen.', }, }, expenseDefaultsTable: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index acc4849c851b..094d321f90d8 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8692,6 +8692,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, describeRuleForConcierge: 'Opisz swoją regułę, a Concierge ją utworzy', gotIt: 'Jasne', createRule: 'Utwórz regułę', + fallbackBanner: + 'To nie jest standardowa reguła, więc przygotowaliśmy ją jako regułę agenta. Reguły agenta działają po zmianie wydatku lub raportu, na przykład wstrzymując wydatek lub odrzucając raport. Sprawdź ją i zapisz.', }, tabs: { general: 'Ogólne', @@ -8762,8 +8764,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, flagForReviewDescription: 'Powiadamiaj zatwierdzających, gdy wydatki przekraczają limity kategorii', requireFields: 'Ustaw wymagania pola', 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.', + describeYourRule: 'Opisz swoją regułę', + describeYourRuleDescription: 'Powiedz Concierge, czego potrzebujesz, a utworzy regułę', describe: 'Opisz', describeRule: 'Opisz swoją regułę własnymi słowami, a Concierge utworzy ją za Ciebie', describeRuleInputLabel: 'Opisz regułę', @@ -8771,6 +8773,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, 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.', + agentRuleAdminOnly: 'To wymaga reguły agenta, a tylko administratorzy przestrzeni roboczej mogą ją utworzyć. Poproś administratora przestrzeni roboczej o jej dodanie.', }, }, expenseDefaultsTable: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 74db296b9504..d8e85d821289 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8674,6 +8674,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, describeRuleForConcierge: 'Descreva sua regra e o Concierge vai criá-la', gotIt: 'Entendi', createRule: 'Criar regra', + fallbackBanner: + 'Esta não é uma regra padrão, então a redigimos como uma regra de agente. As regras de agente agem depois que uma despesa ou um relatório muda, por exemplo, retendo a despesa ou rejeitando o relatório. Revise e salve.', }, tabs: { general: 'Geral', @@ -8744,8 +8746,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, flagForReviewDescription: 'Notificar aprovadores quando as despesas excederem os limites da categoria', requireFields: 'Definir requisitos de campo', 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.', + describeYourRule: 'Descreva sua regra', + describeYourRuleDescription: 'Diga ao Concierge do que você precisa e ele criará a regra', describe: 'Descrever', describeRule: 'Descreva sua regra em linguagem simples e o Concierge criará a regra para você', describeRuleInputLabel: 'Descreva a regra', @@ -8753,6 +8755,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, 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.', + agentRuleAdminOnly: 'Isso exige uma regra de agente, e apenas administradores do workspace podem criá-las. Peça a um administrador do workspace para adicioná-la.', }, }, expenseDefaultsTable: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index cd85b735466e..ffa906f0f40e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8371,6 +8371,7 @@ ${reportName}`, describeRuleForConcierge: '描述你的规则,Concierge 会帮你创建', gotIt: '知道了', createRule: '创建规则', + fallbackBanner: '这不是标准规则,因此我们将其起草为代理规则。代理规则会在报销或报表发生变化后执行操作,例如暂挂该报销或拒绝该报表。请检查并保存。', }, tabs: {general: '常规', cardRestrictions: '卡片限制', expenseDefaults: '报销默认设置', requireFields: '字段要求', flagForReview: '标记以供审核', agents: '代理人'}, bulkActions: { @@ -8434,8 +8435,8 @@ ${reportName}`, flagForReviewDescription: '当费用超出类别限额时通知审批人', requireFields: '设置字段要求', requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', - createAgentRule: '描述代理规则', - createAgentRuleDescription: '创建可按需运行的灵活规则。', + describeYourRule: '描述你的规则', + describeYourRuleDescription: '告诉 Concierge 你的需求,它会为你创建规则', describe: '描述', describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', describeRuleInputLabel: '描述规则', @@ -8443,6 +8444,7 @@ ${reportName}`, unsupported: '该规则暂不支持,我们正在开发中。请先尝试其他规则。', unintelligible: '可以这样描述规则:如果支出超过 100 美元,则需要收据。', multipleRules: '请一次描述一条规则,例如:在所有卡上阻止零售消费。', + agentRuleAdminOnly: '这需要一条代理规则,而只有工作区管理员可以创建代理规则。请让工作区管理员添加。', }, }, expenseDefaultsTable: { diff --git a/src/libs/API/parameters/GenerateRuleParams.ts b/src/libs/API/parameters/GenerateRuleParams.ts index 211b9e4ca5cb..6e2f8da701c6 100644 --- a/src/libs/API/parameters/GenerateRuleParams.ts +++ b/src/libs/API/parameters/GenerateRuleParams.ts @@ -3,7 +3,7 @@ import type {GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; type GenerateRuleParams = { policyID: string; generationID: string; - ruleType: GeneratedRuleType; + ruleType?: GeneratedRuleType; prompt: string; }; diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index c1cee4959ef9..7ebc60aa14ff 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -5,6 +5,7 @@ */ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {MerchantRuleForm} from '@src/types/form'; import type {Policy, Rule} from '@src/types/onyx'; import type {ApprovalWorkflowAction} from '@src/types/onyx/ApprovalWorkflowRules'; import type { @@ -15,6 +16,7 @@ import type { ExpenseDefaultTaxValue, ExpenseDefaultTriggers, } from '@src/types/onyx/ExpenseDefaultRules'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; import type {OnyxCollection} from 'react-native-onyx'; @@ -451,6 +453,11 @@ function canEditMerchantRule(rule: Rule | undefined, policyID: string | undefine return isPolicyScopedRule(rule, policyID) && isEditableMerchantRule(rule); } +/** Returns the merchant rule draft for generated values */ +function getMerchantRuleDraftFromGeneratedValues(values: GeneratedRuleValues): Partial { + return {...values, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}; +} + export type {BuiltMerchantRule, MerchantRuleFormValues}; export { buildCopiedExpenseDefaultRules, @@ -458,6 +465,7 @@ export { canEditMerchantRule, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, + getMerchantRuleDraftFromGeneratedValues, getMerchantRuleFormValues, getPolicyExpenseDefaultRules, getRuleMerchantMatchSummary, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ae375caa37e1..5388a6700053 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2079,6 +2079,9 @@ type SettingsNavigatorParamList = { }; [SCREENS.WORKSPACE.RULES_AGENT_NEW]: { policyID: string; + + /** The generation that produced this draft, when Concierge could not build the description as a standard rule */ + generationID?: string; }; [SCREENS.WORKSPACE.RULES_AGENT_EDIT]: { policyID: string; diff --git a/src/libs/SpendRulesUtils.ts b/src/libs/SpendRulesUtils.ts index 994062fe36da..79e1246e0da2 100644 --- a/src/libs/SpendRulesUtils.ts +++ b/src/libs/SpendRulesUtils.ts @@ -6,6 +6,7 @@ import type {SpendRuleForm} from '@src/types/form'; import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; import type {ExpensifyCardSettings} from '@src/types/onyx'; import type {ExpensifyCardRule, ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -121,6 +122,17 @@ function buildSpendRuleAST(spendRuleValues: SpendRuleValues, existingCreated?: s }; } +/** Returns the spend rule draft for generated values, without the categories that a spend rule cannot hold */ +function getSpendRuleDraftFromGeneratedValues(values: GeneratedRuleValues): Partial { + const categories = values.categories?.filter(isSpendRuleCategory); + return categories ? {...values, categories} : values; +} + +/** Returns whether the spend rule restricts merchants or categories */ +function hasSpendRuleMerchantRestrictions(values: Partial | undefined): boolean { + return !!values?.merchantNames?.length || !!values?.categories?.length; +} + function getSpendRuleFormValuesFromCardRule(cardRule?: ExpensifyCardRule): SpendRuleForm | undefined { if (!cardRule || typeof cardRule !== 'object' || !('filters' in cardRule) || !('action' in cardRule)) { return undefined; @@ -356,4 +368,13 @@ function getSpendRuleSummaryText( } export type {SpendRuleSummaryPart}; -export {buildSpendRuleAST, getSpendRuleByCardID, getSpendRuleFormValuesFromCardRule, getSpendRuleSummaryParts, getSpendRuleSummaryText, getTruncatedSpendRuleSummary}; +export { + buildSpendRuleAST, + getSpendRuleByCardID, + getSpendRuleDraftFromGeneratedValues, + getSpendRuleFormValuesFromCardRule, + getSpendRuleSummaryParts, + getSpendRuleSummaryText, + getTruncatedSpendRuleSummary, + hasSpendRuleMerchantRestrictions, +}; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index f8d94fc18ae5..f3d8a8213b06 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -410,11 +410,12 @@ const PROMPT_ERROR_BY_STATE: Partial(CONST.TAB.AGENT_RULE.SUGGESTIONS); const [activeTabPolicyID, setActiveTabPolicyID] = useState(policyID); const [draftValues] = useOnyx(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT); - const tabIcons = useMemoizedLazyExpensifyIcons(['Feed', 'Pencil']); + const icons = useMemoizedLazyExpensifyIcons(['Feed', 'Pencil', 'Info']); const hasDraftPrompt = !!draftValues?.[INPUT_IDS.PROMPT]?.trim(); + const [didSelectSuggestion, setDidSelectSuggestion] = useState(false); + const isDraftFromFallback = !!generationID && !didSelectSuggestion; + + // The draft can load after the first render. Open Write one time only, because the admin must still be able to go + // to Suggestions. + 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) { @@ -103,12 +115,12 @@ function AddAgentRulePage({ { key: CONST.TAB.AGENT_RULE.SUGGESTIONS, title: translate('workspace.rules.agentRules.suggestionsTab'), - icon: tabIcons.Feed, + icon: icons.Feed, }, { key: CONST.TAB.AGENT_RULE.WRITE, title: translate('workspace.rules.agentRules.writeTab'), - icon: tabIcons.Pencil, + icon: icons.Pencil, }, ]; @@ -117,6 +129,7 @@ function AddAgentRulePage({ return; } setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[INPUT_IDS.PROMPT]: suggestion.prompt}); + setDidSelectSuggestion(true); setActiveTab(CONST.TAB.AGENT_RULE.WRITE); }; @@ -128,7 +141,7 @@ function AddAgentRulePage({ }; const handleBackButtonPress = () => { - if (activeTab === CONST.TAB.AGENT_RULE.WRITE) { + if (!generationID && activeTab === CONST.TAB.AGENT_RULE.WRITE) { setActiveTab(CONST.TAB.AGENT_RULE.SUGGESTIONS); return; } @@ -144,8 +157,12 @@ function AddAgentRulePage({ // When the workspace has no agent rules yet, the backend creates the "RuleBot" agent and adds it as // an admin. Surface a one-time modal explaining this side effect before navigating back. const isFirstRule = isEmptyObject(policy?.rules?.agentRules); + const agentRuleID = rand64(); suppressDiscardPrompt(); - addPolicyAgentRule(policyID, rand64(), values[INPUT_IDS.PROMPT]); + addPolicyAgentRule(policyID, agentRuleID, values[INPUT_IDS.PROMPT]); + if (isDraftFromFallback) { + Log.info('[GenerateRule] Saved agent rule from fallback', false, {policyID, generationID, agentRuleID}); + } clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); if (!isFirstRule) { navigateBackToAgentsTab(); @@ -233,7 +250,19 @@ function AddAgentRulePage({ onSelectSuggestion={selectSuggestion} /> - {activeTab === CONST.TAB.AGENT_RULE.WRITE && } + {activeTab === CONST.TAB.AGENT_RULE.WRITE && ( + <> + {isDraftFromFallback && ( + + )} + + + )} diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx index 8df9647d37be..70035fe27ed0 100644 --- a/src/pages/workspace/rules/DescribeRuleModal.tsx +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -22,7 +22,7 @@ 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 {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {TextInputKeyPressEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -40,13 +40,31 @@ type DescribeRuleModalProps = { policyID: string; - /** The rule type of the editor the modal opens from, so Concierge fills in that form */ - ruleType: GeneratedRuleType; + /** The rule type of the editor that opens the modal. Without it, the modal accepts an answer of any known rule type. */ + ruleType?: GeneratedRuleType; - /** Called with the generated form values once Concierge has built the rule */ - onRuleGenerated: (values: GeneratedRuleValues) => void; + /** Called with the generated form values and their rule type after Concierge builds the rule */ + onRuleGenerated: (values: GeneratedRuleValues, generatedRuleType: GeneratedRuleType) => void; + + /** + * Called in place of the inline error when the description is not one standard rule. The modal stays open. The + * caller closes it or shows an error. + */ + onAgentRuleFallback?: (prompt: string, generationID: string, state: GeneratedRuleState) => void; +}; + +type PendingGeneration = { + generationID: string; + prompt: string; }; +const GENERATED_RULE_TYPES = new Set(Object.values(CONST.GENERATED_RULE.RULE_TYPE)); + +/** Returns whether a description with this answer can become an agent rule */ +function isAgentRuleFallbackState(state: GeneratedRuleState): boolean { + return state === CONST.GENERATED_RULE.STATE.UNSUPPORTED || state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES; +} + /** 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; @@ -57,7 +75,7 @@ function withPolicyCategory(values: GeneratedRuleValues, policyCategories: OnyxE return policyCategory ? {...rest, category: policyCategory.name} : rest; } -function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenerated}: DescribeRuleModalProps) { +function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenerated, onAgentRuleFallback}: DescribeRuleModalProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); // We need to use isSmallScreenWidth here because the Modal breaks in RHP with shouldUseNarrowLayout. @@ -65,7 +83,7 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera const {isSmallScreenWidth} = useResponsiveLayout(); const formRef = useRef(null); const inputRef = useRef(null); - const pendingGenerationIDRef = useRef(undefined); + const pendingGenerationRef = 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}`); @@ -76,13 +94,22 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera const inputLabel = translate('workspace.rules.newRule.describeRuleInputLabel'); const close = () => { - pendingGenerationIDRef.current = undefined; + pendingGenerationRef.current = undefined; clearNewRulePrompt(); onClose(); }; useEffect(() => { - if (!generatedRuleID || !generatedRuleState || generatedRuleID !== pendingGenerationIDRef.current) { + const pendingGeneration = pendingGenerationRef.current; + if (!generatedRuleID || !generatedRuleState || generatedRuleID !== pendingGeneration?.generationID) { + return; + } + if (onAgentRuleFallback && isAgentRuleFallbackState(generatedRuleState)) { + // The effect can run again before Onyx clears the answer. Clear the pending generation first, because the + // caller must get the answer one time only. + pendingGenerationRef.current = undefined; + clearGeneratedRule(); + onAgentRuleFallback(pendingGeneration.prompt, generatedRuleID, generatedRuleState); return; } if (generatedRuleState !== CONST.GENERATED_RULE.STATE.RULE) { @@ -90,18 +117,20 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera clearGeneratedRule(); return; } - // Concierge picks the rule type itself. It can answer with a rule type other than the one this editor asked for. - if (generatedRuleType !== ruleType) { + // Concierge picks the rule type itself. It can answer with a rule type other than `ruleType`, or with a rule type + // that this app version does not know. + const canApplyRuleType = ruleType ? generatedRuleType === ruleType : GENERATED_RULE_TYPES.has(generatedRuleType ?? ''); + if (!generatedRuleType || !canApplyRuleType) { setNewRulePromptError(CONST.GENERATED_RULE.STATE.UNSUPPORTED); clearGeneratedRule(); return; } - onRuleGenerated(withPolicyCategory(generatedRuleValues ?? {}, policyCategories)); + onRuleGenerated(withPolicyCategory(generatedRuleValues ?? {}, policyCategories), generatedRuleType); close(); - }, [close, generatedRuleID, generatedRuleState, generatedRuleType, generatedRuleValues, onRuleGenerated, policyCategories, ruleType]); + }, [close, generatedRuleID, generatedRuleState, generatedRuleType, generatedRuleValues, onAgentRuleFallback, onRuleGenerated, policyCategories, ruleType]); useEffect(() => { - if (!isVisible || !newRulePromptForm?.isLoading || pendingGenerationIDRef.current) { + if (!isVisible || !newRulePromptForm?.isLoading || pendingGenerationRef.current) { return; } clearNewRulePrompt(); @@ -135,7 +164,8 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera }; const submit = (values: FormOnyxValues) => { - pendingGenerationIDRef.current = generateRule(policyID, ruleType, values[INPUT_IDS.PROMPT].trim()); + const prompt = values[INPUT_IDS.PROMPT].trim(); + pendingGenerationRef.current = {generationID: generateRule(policyID, prompt, ruleType), prompt}; }; return ( diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 2ecce17a3397..bf23fa1fbb59 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -39,6 +39,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'; @@ -90,6 +91,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]; @@ -105,7 +107,9 @@ function FlagForReviewRulePageBase({ if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - setDraftFlagForReviewRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + if (!hasDraft) { + setDraftFlagForReviewRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + } } return; } @@ -129,7 +133,7 @@ function FlagForReviewRulePageBase({ initializedDraftForRuleKeyRef.current = categoryName; setDraftFlagForReviewRule(getFlagForReviewFormFromCategory(category, getCurrencyDecimals, policyCurrency)); - }, [category, categoryName, draftMaxExpenseAmount, getCurrencyDecimals, initialCategoryName, isEditing, 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/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 10cfd1c1a20e..96abc7607ed3 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -33,7 +33,7 @@ import Tab from '@libs/actions/Tab'; import {clearDraftMerchantRule, setDraftMerchantRule} from '@libs/actions/User'; import {getCategoryTaxRuleTaxID, getTaxRateDisplayName, hasUsableTaxRates, isCategoryRuleDraft} from '@libs/CategoryTaxRulesUtils'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import {canEditMerchantRule, getMerchantRuleFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; +import {canEditMerchantRule, getMerchantRuleDraftFromGeneratedValues, getMerchantRuleFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; @@ -679,7 +679,7 @@ function MerchantRulePageBase({ const shouldShowDescribeRule = !isEditingSavedRule && !isCategoryLocked; const applyGeneratedRule = (values: GeneratedRuleValues) => { - setDraftMerchantRule({...values, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); + setDraftMerchantRule(getMerchantRuleDraftFromGeneratedValues(values)); }; const footer = canWriteRules ? ( diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index b4dbf4882a3d..7de3cc7dc49b 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -54,6 +54,7 @@ import ROUTES, {DYNAMIC_ROUTES, getRequireFieldsRuleCategoryRoute, getWorkspaceC 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 {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -61,6 +62,11 @@ 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; +/** Returns the setting fields that have a value in the given form values */ +function getDefinedSettingFieldKeys(values: Partial | undefined) { + return SETTING_FIELD_KEYS.filter((fieldKey) => values?.[fieldKey] !== undefined); +} + type RequireFieldsRulePageBaseProps = { policyID: string; categoryName?: string; @@ -166,7 +172,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 = SETTING_FIELD_KEYS.filter((fieldKey) => form[fieldKey] !== undefined); + const draftSettingKeys = getDefinedSettingFieldKeys(form); if (draftSettingKeys.some((fieldKey) => !touchedFields.has(fieldKey))) { setTouchedFields(new Set([...touchedFields, ...draftSettingKeys])); } @@ -179,7 +185,12 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - setDraftRequireFieldsRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + if (!isEmptyObject(form)) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Seed local selection state from the seeded draft. + setTouchedFields(new Set(getDefinedSettingFieldKeys(form))); + } else { + setDraftRequireFieldsRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + } } return; } @@ -202,7 +213,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()); @@ -434,7 +444,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))); + setTouchedFields(new Set(getDefinedSettingFieldKeys(values))); }; const footer = canWriteRules ? ( diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 5cc21f7f416b..944983249545 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -5,29 +5,48 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; 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 {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 {getMerchantRuleDraftFromGeneratedValues} from '@libs/ExpenseDefaultRuleUtils'; +import Log from '@libs/Log'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {canEditWorkspaceSettings, isControlPolicy} from '@libs/PolicyUtils'; +import {getSpendRuleDraftFromGeneratedValues} from '@libs/SpendRulesUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; +import {close as closeModal} from '@userActions/Modal'; +import {setAgentRuleAdminOnlyPromptError} 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 {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type IconAsset from '@src/types/utils/IconAsset'; -import React from 'react'; +import {useFocusEffect} from '@react-navigation/native'; +import React, {useState} from 'react'; import {View} from 'react-native'; +import DescribeRuleModal from './DescribeRuleModal'; + type RulesNewPageProps = | PlatformStackScreenProps | PlatformStackScreenProps; @@ -43,6 +62,30 @@ type NewRuleOption = { isWorkspaceOnly?: boolean; }; +/** Seeds the draft of the editor for the generated rule type and opens that editor */ +function seedDraftAndNavigate(values: GeneratedRuleValues, ruleType: GeneratedRuleType, policyID: string) { + switch (ruleType) { + case CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS: + setDraftRequireFieldsRule(values); + Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID)); + break; + case CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW: + setDraftFlagForReviewRule(values); + Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID)); + break; + case CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND: + setDraftSpendRule(getSpendRuleDraftFromGeneratedValues(values)); + Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + break; + case CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS: + setDraftMerchantRule(getMerchantRuleDraftFromGeneratedValues(values)); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + break; + default: + break; + } +} + function RulesNewPage({route}: RulesNewPageProps) { const {policyID, categoryName} = route.params; const {translate} = useLocalize(); @@ -52,6 +95,38 @@ function RulesNewPage({route}: RulesNewPageProps) { const isCategoryScopedCreate = isCategorySettingsFlow || !!categoryName; const policy = usePolicy(policyID); const {showConfirmModal} = useConfirmModal(); + const {login = ''} = useCurrentUserPersonalDetails(); + const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + + // The describe modal keeps a generated category only when the policy categories contain it. Therefore load the + // categories before Concierge answers. + const fetchPolicyCategories = () => { + if (!policy?.areCategoriesEnabled || policyCategories) { + return; + } + openPolicyCategoriesPage(policyID); + }; + + useNetwork({onReconnect: fetchPolicyCategories}); + + useFocusEffect(() => { + fetchPolicyCategories(); + }); + + // The agent rule page is open only to workspace admins on a Control workspace. A member with write access to + // rules can open this page but not that one. + const canCreateAgentRules = canEditWorkspaceSettings(policy, login) && isControlPolicy(policy); + + const offerAgentRule = (prompt: string, generationID: string, state: GeneratedRuleState) => { + if (!canCreateAgentRules) { + setAgentRuleAdminOnlyPromptError(); + return; + } + Log.info('[GenerateRule] Offered agent rule fallback', false, {policyID, generationID, state}); + setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[AGENT_RULE_INPUT_IDS.PROMPT]: prompt}); + closeModal(() => Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID, generationID))); + }; // From category settings the only expense default is the category's tax rate, so the type chooser is skipped. It // can't be set without a rate to pick, so that case gets the chooser's explainer instead of an empty picker. @@ -71,6 +146,15 @@ function RulesNewPage({route}: RulesNewPageProps) { }; const newRuleOptions: NewRuleOption[] = [ + { + key: 'describeRule', + icon: illustrations.AiBot, + title: translate('workspace.rules.newRule.describeYourRule'), + description: translate('workspace.rules.newRule.describeYourRuleDescription'), + onPress: () => setIsDescribeRuleModalVisible(true), + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_DESCRIBE_RULE, + isWorkspaceOnly: true, + }, { key: 'restrictCardSpend', icon: illustrations.CardReaderAlt, @@ -116,15 +200,6 @@ function RulesNewPage({route}: RulesNewPageProps) { // Only category settings can scope the editor to its category, so a category passed any other way drops it. isWorkspaceOnly: !isCategorySettingsFlow, }, - { - key: 'createAgentRule', - icon: illustrations.AiBot, - title: translate('workspace.rules.newRule.createAgentRule'), - description: translate('workspace.rules.newRule.createAgentRuleDescription'), - onPress: () => Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)), - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE, - isWorkspaceOnly: true, - }, ]; const visibleNewRuleOptions = isCategoryScopedCreate ? newRuleOptions.filter((option) => !option.isWorkspaceOnly) : newRuleOptions; @@ -165,6 +240,15 @@ function RulesNewPage({route}: RulesNewPageProps) { ))} + {!isCategoryScopedCreate && ( + setIsDescribeRuleModalVisible(false)} + policyID={policyID} + onRuleGenerated={(values, generatedRuleType) => seedDraftAndNavigate(values, generatedRuleType, policyID)} + onAgentRuleFallback={offerAgentRule} + /> + )} ); diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 4fce96f34e6c..6a6f1a487860 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -29,7 +29,7 @@ import {convertToBackendAmount} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import {getSpendRuleFormValuesFromCardRule, getTruncatedSpendRuleSummary} from '@libs/SpendRulesUtils'; +import {getSpendRuleDraftFromGeneratedValues, getSpendRuleFormValuesFromCardRule, getTruncatedSpendRuleSummary, hasSpendRuleMerchantRestrictions} from '@libs/SpendRulesUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -44,7 +44,6 @@ 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 {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -100,17 +99,14 @@ function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRuleP const existingFormValues = useMemo(() => getSpendRuleFormValuesFromCardRule(existingRule), [existingRule]); const [isErrorVisible, setIsErrorVisible] = useState(false); - const [isRestrictMerchantsOff, setIsRestrictMerchantsOff] = useState(() => { - const hasNoMerchantRestrictions = !existingFormValues?.merchantNames.length && !existingFormValues?.categories?.length; - return isNewRule || hasNoMerchantRestrictions; - }); + const [isRestrictMerchantsOff, setIsRestrictMerchantsOff] = useState(() => !hasSpendRuleMerchantRestrictions(isNewRule ? spendRuleForm : existingFormValues)); useEffect(() => () => clearDraftSpendRule(), []); const applyGeneratedRule = (values: GeneratedRuleValues) => { - const generatedCategories = values.categories?.filter(isSpendRuleCategory); - setDraftSpendRule(generatedCategories ? {...values, categories: generatedCategories} : values); - setIsRestrictMerchantsOff(!generatedCategories?.length && !values.merchantNames?.length); + const draft = getSpendRuleDraftFromGeneratedValues(values); + setDraftSpendRule(draft); + setIsRestrictMerchantsOff(!hasSpendRuleMerchantRestrictions(draft)); }; useEffect(() => { diff --git a/tests/ui/AddAgentRulePageTest.tsx b/tests/ui/AddAgentRulePageTest.tsx new file mode 100644 index 000000000000..603a2056f86d --- /dev/null +++ b/tests/ui/AddAgentRulePageTest.tsx @@ -0,0 +1,289 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import usePolicy from '@hooks/usePolicy'; + +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import AddAgentRulePage from '@pages/workspace/rules/AgentRules/AddAgentRulePage'; + +import {addPolicyAgentRule} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({Feed: 'Feed', Pencil: 'Pencil', Info: 'Info'})), +})); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn(() => Promise.resolve()), closeModal: jest.fn()}))); +jest.mock('@hooks/useDiscardChangesConfirmation', () => jest.fn(() => ({suppressDiscardPrompt: jest.fn()}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), + navigate: jest.fn(), + dismissModal: jest.fn(), +})); +jest.mock('@libs/actions/Tab', () => ({setSelectedTab: jest.fn()})); +jest.mock('@userActions/Policy/Rules', () => ({ + addPolicyAgentRule: jest.fn(), + getAgentRuleSuggestions: jest.fn(), +})); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/CollapsibleHeaderOnKeyboard', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/SidePanel/SidePanelButton', () => () => null); +jest.mock('@components/TabSelector/TabSelectorBase', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable} = jest.requireActual<{Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void}>}>('react-native'); + return ({tabs, onTabPress}: {tabs: Array<{key: string; title: string}>; onTabPress: (key: string) => void}) => + ReactModule.createElement( + ReactModule.Fragment, + null, + tabs.map((tab) => ReactModule.createElement(Pressable, {key: tab.key, accessibilityLabel: tab.title, onPress: () => onTabPress(tab.key)})), + ); +}); +jest.mock( + '@components/TabSelector/TabSelectorContext', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable} = jest.requireActual<{Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void}>}>('react-native'); + return ({onBackButtonPress}: {onBackButtonPress?: () => void}) => ReactModule.createElement(Pressable, {accessibilityLabel: 'Back', onPress: onBackButtonPress}); +}); +jest.mock('@components/Banner', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({text}: {text?: string}) => ReactModule.createElement(Text, null, text); +}); +jest.mock('@pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable} = jest.requireActual<{Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void}>}>('react-native'); + return ({onSelectSuggestion}: {onSelectSuggestion: (suggestion: {id: string; title: string; prompt: string}) => void}) => + ReactModule.createElement(Pressable, { + accessibilityLabel: 'PickSuggestion', + onPress: () => onSelectSuggestion({id: 'suggestion1', title: 'Hold expenses without a receipt', prompt: 'Hold any expense over $75 without a receipt'}), + }); +}); +jest.mock('@pages/workspace/rules/AgentRules/AddAgentRuleWriteTab', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + return ({onSave}: {onSave: (values: {prompt: string}) => void}) => + ReactModule.createElement(Pressable, {accessibilityLabel: 'WriteTab', onPress: () => onSave({prompt: 'Hold gift card expenses'})}, ReactModule.createElement(Text, null, 'WriteTab')); +}); + +type AddAgentRulePageProps = PlatformStackScreenProps; + +const POLICY_ID = 'policy1'; +const GENERATION_ID = '7654321'; +const PROMPT = 'Hold gift card expenses'; +const FALLBACK_BANNER = 'workspace.rules.agentRules.fallbackBanner'; + +const mockedUsePolicy = jest.mocked(usePolicy); + +function renderAddAgentRulePage(generationID?: string) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only screen props. The page reads only route.params. + const props = { + route: {key: 'Rules_Agent_New', name: SCREENS.WORKSPACE.RULES_AGENT_NEW, params: {policyID: POLICY_ID, generationID}}, + navigation: {}, + } as unknown as AddAgentRulePageProps; + return render(); +} + +async function setAgentRuleDraft(prompt: string) { + await act(async () => { + await Onyx.set(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT, {prompt}); + }); +} + +describe('AddAgentRulePage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedUsePolicy.mockReturnValue({ + ...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), + id: POLICY_ID, + rules: {agentRules: {existingRule: {ruleID: 'existingRule', prompt: 'Existing', created: '2026-10-01 00:00:00'}}}, + }); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('opens on the Suggestions tab when there is no draft prompt', async () => { + // Given no draft, because the admin opened the page from the Agents tab + + // When the page renders + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Write tab stays closed, so the admin starts from the suggested rules + expect(screen.queryByText('WriteTab')).toBeNull(); + }); + + it('opens on the Write tab when a draft prompt exists at mount', async () => { + // Given a draft prompt, because the describe flow wrote it before it opened this page + await setAgentRuleDraft(PROMPT); + + // When the page renders + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // Then the admin sees the drafted prompt and does not have to look for it + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + }); + + it('opens on the Write tab when the draft prompt arrives after the first render', async () => { + // Given the page renders before the draft loads + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByText('WriteTab')).toBeNull(); + + // When the draft prompt loads + await setAgentRuleDraft(PROMPT); + await waitForBatchedUpdatesWithAct(); + + // Then the page still switches to Write, because the first render must not decide the tab for good + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + }); + + it('shows the fallback banner when the page opens with a generationID', async () => { + // Given a draft that came from a description that was not a standard rule + await setAgentRuleDraft(PROMPT); + + // When the page renders with the generationID of that description + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // Then the banner tells the admin that agent rules act after the fact + expect(screen.getByText(FALLBACK_BANNER)).toBeOnTheScreen(); + }); + + it('does not show the fallback banner without a generationID', async () => { + // Given a draft that the admin picked from the suggestions, not one from the describe flow + await setAgentRuleDraft(PROMPT); + + // When the page renders without a generationID + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Write tab opens without the banner, because no description was turned into this rule + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + expect(screen.queryByText(FALLBACK_BANNER)).toBeNull(); + }); + + it('goes back to the previous screen from the Write tab when the page opens with a generationID', async () => { + // Given the describe flow opened the page on the Write tab + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // When the admin presses Back + fireEvent.press(screen.getByLabelText('Back')); + + // Then the page closes and the admin returns to the previous screen, because they did not come from the Suggestions tab + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + }); + + it('switches from the Write tab to Suggestions on Back without a generationID', async () => { + // Given the Write tab is open for a draft that did not come from the describe flow + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + + // When the admin presses Back + fireEvent.press(screen.getByLabelText('Back')); + + // Then the page returns to Suggestions and stays open, because the admin can pick another suggestion + expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(screen.queryByText('WriteTab')).toBeNull(); + }); + + it('drops the fallback banner and log after the admin picks a suggestion', async () => { + // Given the describe flow opened the page with a generationID and the fallback banner + const logSpy = jest.spyOn(Log, 'info'); + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(FALLBACK_BANNER)).toBeOnTheScreen(); + + // When the admin goes to Suggestions, picks a suggestion, and saves it + fireEvent.press(screen.getByLabelText('workspace.rules.agentRules.suggestionsTab')); + fireEvent.press(screen.getByLabelText('PickSuggestion')); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + expect(screen.queryByText(FALLBACK_BANNER)).toBeNull(); + fireEvent.press(screen.getByLabelText('WriteTab')); + + // Then the save is not counted as a fallback rule, because the prompt came from the suggestion + expect(addPolicyAgentRule).toHaveBeenCalledTimes(1); + expect(logSpy).not.toHaveBeenCalledWith('[GenerateRule] Saved agent rule from fallback', expect.anything(), expect.anything()); + logSpy.mockRestore(); + }); + + it('logs the generationID with the saved agent rule when the page opens with a generationID', async () => { + // Given the describe flow opened the page with a generationID + const logSpy = jest.spyOn(Log, 'info'); + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // When the admin saves the rule + fireEvent.press(screen.getByLabelText('WriteTab')); + + // Then the log joins the saved rule to the offer on the generationID + const agentRuleID = jest.mocked(addPolicyAgentRule).mock.calls.at(0)?.at(1); + expect(addPolicyAgentRule).toHaveBeenCalledWith(POLICY_ID, expect.any(String), PROMPT); + expect(logSpy).toHaveBeenCalledWith('[GenerateRule] Saved agent rule from fallback', false, {policyID: POLICY_ID, generationID: GENERATION_ID, agentRuleID}); + logSpy.mockRestore(); + }); +}); diff --git a/tests/ui/RuleEditorSeededDraftTest.tsx b/tests/ui/RuleEditorSeededDraftTest.tsx new file mode 100644 index 000000000000..db0365dce01b --- /dev/null +++ b/tests/ui/RuleEditorSeededDraftTest.tsx @@ -0,0 +1,216 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import usePolicy from '@hooks/usePolicy'; +import usePolicyData from '@hooks/usePolicyData'; +import type PolicyData from '@hooks/usePolicyData/types'; + +import FlagForReviewRulePageBase from '@pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase'; +import RequireFieldsRulePageBase from '@pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase'; +import SpendRulePageBase from '@pages/workspace/rules/SpendRules/SpendRulePageBase'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as ReactNavigationNative from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + formatPhoneNumber: (phoneNumber: string) => phoneNumber, + })), +); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn( + () => + new Proxy( + {}, + { + get: (_target, iconName) => iconName, + }, + ), + ), +})); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@hooks/usePolicyData', () => jest.fn()); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => jest.fn(() => ({canWrite: true, showReadOnlyModal: jest.fn()}))); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => false, isBetaEnabledOrUnknown: () => false}))); +jest.mock('@hooks/useCategoryRuleCreateBackPath', () => jest.fn(() => undefined)); +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListActions: () => ({convertToDisplayString: (amount: number) => String(amount), getCurrencyDecimals: () => 2}), +})); +jest.mock('@hooks/useCanWriteCardSpendRules', () => jest.fn(() => true)); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn()}))); +jest.mock('@hooks/useControlOnlyRuleUpgradeRedirect', () => jest.fn()); +jest.mock('@hooks/useDefaultFundID', () => jest.fn(() => 1)); +jest.mock('@hooks/usePressLoading', () => jest.fn(() => ({isLoading: false, startWithLoading: jest.fn()}))); +jest.mock('@pages/workspace/rules/useRuleDeleteHeaderProps', () => jest.fn(() => ({deleteIconButtonProps: undefined}))); +jest.mock('@pages/workspace/rules/DescribeRuleModal', () => () => null); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScrollView', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => () => null); +jest.mock('@components/FormAlertWithSubmitButton', () => () => null); +jest.mock('@components/Text', () => () => null); +jest.mock('@components/MenuItemWithTopDescription', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({description, title}: {description?: string; title?: string}) => ReactModule.createElement(Text, null, `${description}: ${title ?? ''}`); +}); +jest.mock('@components/RequireFieldsRules/FieldRequirementSettingRow', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({fieldKey, touchedFields}: {fieldKey: string; touchedFields: Set}) => + ReactModule.createElement(Text, null, `${fieldKey}: ${touchedFields.has(fieldKey) ? 'touched' : 'untouched'}`); +}); +jest.mock('@components/SpendRules/SpendRuleRestrictionTypeToggleRevamp', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({restrictionAction}: {restrictionAction: string | null}) => ReactModule.createElement(Text, null, `restriction: ${restrictionAction ?? 'off'}`); +}); + +const POLICY_ID = 'policy1'; +const TEST_ID = 'RuleEditorSeededDraftTest'; + +const mockedUsePolicy = jest.mocked(usePolicy); +const mockedUsePolicyData = jest.mocked(usePolicyData); + +async function seedDraft( + key: typeof ONYXKEYS.FORMS.FLAG_FOR_REVIEW_RULE_FORM | typeof ONYXKEYS.FORMS.REQUIRE_FIELDS_RULE_FORM | typeof ONYXKEYS.FORMS.SPEND_RULE_FORM, + draft: Record, +) { + await act(async () => { + await Onyx.set(key, draft); + }); +} + +describe('Rule editors opened with a draft from the describe flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + const policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID, role: CONST.POLICY.ROLE.ADMIN, areCategoriesEnabled: false}; + mockedUsePolicy.mockReturnValue(policy); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only policy data. The editors read only the policy and its categories. + mockedUsePolicyData.mockReturnValue({policy, categories: {}, tags: {}, reports: []} as unknown as PolicyData); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('keeps the seeded flag for review draft when the editor opens for a new rule', async () => { + // Given the describe flow seeded a flag for review draft + const draft = {category: 'Meals', maxExpenseAmount: '50'}; + await seedDraft(ONYXKEYS.FORMS.FLAG_FOR_REVIEW_RULE_FORM, draft); + + // When the editor opens for a new rule + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the editor shows the seeded values, because it must not reset a draft that already exists + expect(await getOnyxValue(ONYXKEYS.FORMS.FLAG_FOR_REVIEW_RULE_FORM)).toEqual(draft); + expect(screen.getByText('common.category: Meals')).toBeOnTheScreen(); + }); + + it('keeps the seeded require fields draft and marks its settings as touched when the editor opens for a new rule', async () => { + // Given the describe flow seeded a require fields draft + const draft = {category: 'Meals', receiptSetting: CONST.FIELD_REQUIREMENTS_DIRECTION.REQUIRE}; + await seedDraft(ONYXKEYS.FORMS.REQUIRE_FIELDS_RULE_FORM, draft); + + // When the editor opens for a new rule + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the draft survives and only the seeded setting counts as touched, the same as when Concierge fills in the open editor + expect(await getOnyxValue(ONYXKEYS.FORMS.REQUIRE_FIELDS_RULE_FORM)).toEqual(draft); + expect(screen.getByText('receiptSetting: touched')).toBeOnTheScreen(); + expect(screen.getByText('descriptionSetting: untouched')).toBeOnTheScreen(); + }); + + it('keeps merchant restrictions on when the seeded spend rule draft has merchants', async () => { + // Given the describe flow seeded a spend rule draft that blocks a merchant + const draft = {merchantNames: ['Walmart'], restrictionAction: CONST.SPEND_RULES.ACTION.BLOCK}; + await seedDraft(ONYXKEYS.FORMS.SPEND_RULE_FORM, draft); + + // When the editor opens for a new rule + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the merchant restriction shows, because the editor reads the toggle from the seeded draft + expect(await getOnyxValue(ONYXKEYS.FORMS.SPEND_RULE_FORM)).toEqual(draft); + expect(screen.getByText(`restriction: ${CONST.SPEND_RULES.ACTION.BLOCK}`)).toBeOnTheScreen(); + expect(screen.getByText(/workspace\.rules\.spendRules\.blockedMerchants/)).toBeOnTheScreen(); + }); + + it('opens a new spend rule with merchant restrictions off when there is no draft', async () => { + // Given no draft, because the admin picked the spend rule type without describing a rule + + // When the editor opens for a new rule + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then merchant restrictions start off, as they do for any new spend rule + expect(screen.getByText('restriction: off')).toBeOnTheScreen(); + expect(screen.queryByText(/workspace\.rules\.spendRules\.blockedMerchants/)).toBeNull(); + }); +}); diff --git a/tests/ui/RulesNewPageDescribeRuleTest.tsx b/tests/ui/RulesNewPageDescribeRuleTest.tsx new file mode 100644 index 000000000000..e25f573bd3f6 --- /dev/null +++ b/tests/ui/RulesNewPageDescribeRuleTest.tsx @@ -0,0 +1,438 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import usePolicy from '@hooks/usePolicy'; + +import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; +import * as API from '@libs/API'; +import type {GenerateRuleParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; +import RulesNewPage from '@pages/workspace/rules/RulesNewPage'; + +import type * as ModalActions from '@userActions/Modal'; +import {onModalDidClose} from '@userActions/Modal'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {GeneratedRule} from '@src/types/onyx'; +import type {GeneratedRuleState, GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; + +import type * as ReactNavigationNative from '@react-navigation/native'; +import type {ValueOf} from 'type-fest'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import getOnyxValue from '../utils/getOnyxValue'; +import {buildPersonalDetails, translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +let mockPrompt = ''; + +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + useFocusEffect: (callback: () => void) => { + callback(); + }, + }; +}); +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyIllustrations: jest.fn(() => ({CardReaderAlt: 'CardReaderAlt', Flag: 'Flag', CheckboxText: 'CheckboxText', ReportReceipt: 'ReportReceipt', AiBot: 'AiBot'})), +})); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@libs/actions/Policy/Category', () => ({ + ...jest.requireActual>('@libs/actions/Policy/Category'), + openPolicyCategoriesPage: jest.fn(), +})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn()}))); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({isSmallScreenWidth: false, shouldUseNarrowLayout: false}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), +})); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScrollView', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => () => null); +jest.mock('@components/Text', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({children}: {children: React.ReactNode}) => ReactModule.createElement(Text, null, children); +}); +jest.mock('@components/MenuItem', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{accessibilityLabel?: string; accessibilityRole?: string; onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + return ({title, onPress}: {title: string; onPress: () => void}) => + ReactModule.createElement(Pressable, {accessibilityLabel: title, accessibilityRole: 'button', onPress}, ReactModule.createElement(Text, null, title)); +}); +jest.mock('@components/Modal', () => { + const ReactModule = jest.requireActual('react'); + const {setCloseModal} = jest.requireActual('@userActions/Modal'); + // Register the close callback the same as BaseModal does, because the `close` action in `@userActions/Modal` must close this modal. + function MockModal({isVisible, onClose, children}: {isVisible: boolean; onClose: () => void; children: React.ReactNode}) { + ReactModule.useEffect(() => (isVisible ? setCloseModal(onClose) : undefined), [isVisible, onClose]); + return isVisible ? children : null; + } + return MockModal; +}); +jest.mock('@components/Form/InputWrapper', () => () => null); +jest.mock('@components/Form/FormProvider', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + return ({children, onSubmit, submitButtonText}: {children: React.ReactNode; onSubmit: (values: {prompt: string}) => void; submitButtonText: string}) => + ReactModule.createElement( + ReactModule.Fragment, + null, + children, + ReactModule.createElement( + Pressable, + {accessibilityLabel: submitButtonText, onPress: () => onSubmit({prompt: mockPrompt})}, + ReactModule.createElement(Text, null, submitButtonText), + ), + ); +}); + +type RulesNewPageProps = PlatformStackScreenProps; + +const POLICY_ID = 'policy1'; +const ADMIN_LOGIN = 'admin@example.com'; + +const mockedUsePolicy = jest.mocked(usePolicy); +const mockedUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); + +type WriteSpy = jest.SpiedFunction; + +function setPolicyRole(role: ValueOf, areCategoriesEnabled = false) { + mockedUsePolicy.mockReturnValue({...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID, role, areCategoriesEnabled}); +} + +async function renderRulesNewPage() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only screen props. The page reads only route.name and route.params. + const props = { + route: {key: 'Rules_New', name: SCREENS.WORKSPACE.RULES_NEW, params: {policyID: POLICY_ID}}, + navigation: {}, + } as unknown as RulesNewPageProps; + render(); + await waitForBatchedUpdatesWithAct(); +} + +async function openDescribeModal() { + await renderRulesNewPage(); + fireEvent.press(screen.getByLabelText('workspace.rules.newRule.describeYourRule')); + await waitForBatchedUpdatesWithAct(); +} + +function isGenerateRuleParams(parameters: unknown): parameters is GenerateRuleParams { + return typeof parameters === 'object' && parameters !== null && 'generationID' in parameters && 'prompt' in parameters; +} + +/** Submits the prompt through the describe modal and returns the parameters of the GenerateRule request */ +function describeRule(writeSpy: WriteSpy, prompt: string): GenerateRuleParams { + mockPrompt = prompt; + fireEvent.press(screen.getByLabelText('common.next')); + const parameters = writeSpy.mock.calls.find(([command]) => command === WRITE_COMMANDS.GENERATE_RULE)?.at(1); + if (!isGenerateRuleParams(parameters)) { + throw new Error('The describe modal did not request GenerateRule'); + } + return parameters; +} + +async function answerGeneration(answer: GeneratedRule) { + await act(async () => { + await Onyx.set(ONYXKEYS.GENERATED_RULE, answer); + }); + await waitForBatchedUpdatesWithAct(); +} + +async function getPromptErrors() { + const form = await getOnyxValue(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + return Object.values(form?.errors ?? {}); +} + +function getAddPolicyAgentRuleCalls(writeSpy: WriteSpy) { + return writeSpy.mock.calls.filter(([command]) => command === WRITE_COMMANDS.ADD_POLICY_AGENT_RULE); +} + +describe('RulesNewPage describe flow', () => { + let writeSpy: WriteSpy; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrompt = ''; + writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + mockedUseCurrentUserPersonalDetails.mockReturnValue(buildPersonalDetails(ADMIN_LOGIN, 1)); + setPolicyRole(CONST.POLICY.ROLE.ADMIN); + }); + + afterEach(async () => { + writeSpy.mockRestore(); + await act(async () => { + await Onyx.clear(); + }); + }); + + it('lists Describe your rule first, before the four rule types', async () => { + // Given an admin on the New rule page + + // When the page renders + await renderRulesNewPage(); + + // Then describing comes first, so the admin can describe the rule before picking a rule type + const options = screen.getAllByRole('button'); + const expectedOptionNames = [ + 'workspace.rules.newRule.describeYourRule', + 'workspace.rules.newRule.restrictCardSpend', + 'workspace.rules.newRule.flagForReview', + 'workspace.rules.newRule.requireFields', + 'workspace.rules.newRule.applyExpenseDefaults', + ]; + expect(options).toHaveLength(expectedOptionNames.length); + for (const [index, name] of expectedOptionNames.entries()) { + expect(options.at(index)).toHaveAccessibleName(name); + } + }); + + it('seeds the draft of the generated rule type and opens its editor', async () => { + // Given the admin described a flag for review rule from New rule, so the request lets Concierge pick the rule type + await openDescribeModal(); + const {generationID, ruleType} = describeRule(writeSpy, 'Flag expenses over $500'); + expect(ruleType).toBeUndefined(); + + // When Concierge answers with a flag for review rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW, + rule: {maxExpenseAmount: '500'}, + }); + + // Then the flag for review editor opens with the generated values, so the admin only reviews and saves + expect(await getOnyxValue(ONYXKEYS.FORMS.FLAG_FOR_REVIEW_RULE_FORM)).toEqual({maxExpenseAmount: '500'}); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(POLICY_ID)); + }); + + it('scopes an expense defaults draft to a merchant rule', async () => { + // Given the admin described an expense default for a merchant + await openDescribeModal(); + const {generationID} = describeRule(writeSpy, 'Set Uber expenses to not billable'); + + // When Concierge answers with an expense defaults rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS, + rule: {merchantToMatch: 'Uber'}, + }); + + // Then the draft carries the merchant rule type, because the merchant editor sends a draft without one to the type picker + expect(await getOnyxValue(ONYXKEYS.FORMS.MERCHANT_RULE_FORM)).toEqual({merchantToMatch: 'Uber', ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_MERCHANT_NEW.getRoute(POLICY_ID)); + }); + + it.each([CONST.GENERATED_RULE.STATE.UNSUPPORTED, CONST.GENERATED_RULE.STATE.MULTIPLE_RULES])( + 'drafts an agent rule with the exact prompt for an admin when the answer is %s', + async (state: GeneratedRuleState) => { + // Given an admin described something no standard rule can do + const logSpy = jest.spyOn(Log, 'info'); + const prompt = 'Block gift cards and require receipts over $50'; + await openDescribeModal(); + const {generationID} = describeRule(writeSpy, prompt); + + // When Concierge answers that it is not one standard rule + await answerGeneration({generationID, state}); + + // Then the modal closes with the prompt as an agent rule draft, and nothing opens while the modal hides + expect(screen.queryByLabelText('common.next')).toBeNull(); + expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toEqual({prompt}); + expect(logSpy).toHaveBeenCalledWith('[GenerateRule] Offered agent rule fallback', false, {policyID: POLICY_ID, generationID, state}); + expect(Navigation.navigate).not.toHaveBeenCalled(); + + // When the modal finishes its hide transition + act(() => { + onModalDidClose(); + }); + + // Then the agent rule page opens, joined to the description by its generationID + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_AGENT_NEW.getRoute(POLICY_ID, generationID)); + + // And nothing is saved, because the admin must still review the draft and press Create + expect(getAddPolicyAgentRuleCalls(writeSpy)).toHaveLength(0); + logSpy.mockRestore(); + }, + ); + + it.each([CONST.GENERATED_RULE.STATE.UNSUPPORTED, CONST.GENERATED_RULE.STATE.MULTIPLE_RULES])( + 'tells a member who cannot create agent rules to ask an admin when the answer is %s', + async (state: GeneratedRuleState) => { + // Given a member with write access to rules who is not a workspace admin + setPolicyRole(CONST.POLICY.ROLE.USER); + await openDescribeModal(); + const {generationID} = describeRule(writeSpy, 'Block gift cards'); + + // When Concierge answers that it is not one standard rule + await answerGeneration({generationID, state}); + + // Then the modal shows the admin-only error and nothing opens, because the agent rule page would refuse this member + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.agentRuleAdminOnly')]); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toBeUndefined(); + }, + ); + + it.each([undefined, 'futureRuleType'])('shows the unsupported error when a rule answer has the rule type %s', async (generatedRuleType) => { + // Given an admin described a rule from New rule + await openDescribeModal(); + const {generationID} = describeRule(writeSpy, 'Flag expenses over $500'); + + // When Concierge answers with a rule but with no rule type, or with a rule type that this app version does not know + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- simulates an answer from a server that knows more rule types than this app version + ruleType: generatedRuleType as GeneratedRuleType | undefined, + rule: {maxExpenseAmount: '500'}, + }); + + // Then the modal stays open with the unsupported error, because no editor can show these values + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); + expect(screen.getByLabelText('common.next')).toBeOnTheScreen(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('loads the policy categories when the workspace uses categories and none are loaded', async () => { + // Given a workspace that uses categories, with no categories loaded + setPolicyRole(CONST.POLICY.ROLE.ADMIN, true); + + // When the New rule page opens + await renderRulesNewPage(); + + // Then the page requests the categories, because the describe modal keeps only a category that the workspace has + expect(openPolicyCategoriesPage).toHaveBeenCalledWith(POLICY_ID); + }); + + it('keeps the inline error for an unintelligible description', async () => { + // Given an admin typed something Concierge cannot read + await openDescribeModal(); + const {generationID} = describeRule(writeSpy, 'asdf qwerty zxcv'); + + // When Concierge answers that the description is unintelligible + await answerGeneration({generationID, state: CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE}); + + // Then the modal asks for a clearer description, because an agent rule needs a prompt that makes sense + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unintelligible')]); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toBeUndefined(); + }); +}); + +describe('DescribeRuleModal in a rule editor', () => { + let writeSpy: WriteSpy; + + beforeEach(() => { + jest.clearAllMocks(); + writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + }); + + afterEach(async () => { + writeSpy.mockRestore(); + await act(async () => { + await Onyx.clear(); + }); + }); + + async function renderEditorModal(onRuleGenerated: jest.Mock) { + render( + , + ); + await waitForBatchedUpdatesWithAct(); + } + + it('shows the unsupported error when the answer has another rule type', async () => { + // Given the flag for review editor asked for a rule + const onRuleGenerated = jest.fn(); + await renderEditorModal(onRuleGenerated); + const {generationID} = describeRule(writeSpy, 'Block retail purchases on all cards'); + + // When Concierge answers with a card spend rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND, + rule: {merchantNames: ['Walmart']}, + }); + + // Then the editor keeps its form, because it cannot show the values of another rule type + expect(onRuleGenerated).not.toHaveBeenCalled(); + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); + }); + + it('keeps the inline error for an unsupported answer when no agent rule fallback is given', async () => { + // Given an editor, which does not offer the agent rule fallback + const onRuleGenerated = jest.fn(); + await renderEditorModal(onRuleGenerated); + const {generationID} = describeRule(writeSpy, 'Block gift cards'); + + // When Concierge answers that the rule is not supported + await answerGeneration({generationID, state: CONST.GENERATED_RULE.STATE.UNSUPPORTED}); + + // Then the editor shows the unsupported error, because only the New rule page routes to an agent rule + expect(onRuleGenerated).not.toHaveBeenCalled(); + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); +}); From 8b1b913dbc92c5d9d449d10f282b1cbc75ea8582 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Mon, 12 Oct 2026 12:10:08 -0700 Subject: [PATCH 02/20] test(rules): adapt describe-rule tests to main's modal and editors --- tests/ui/DescribeRuleModalTest.tsx | 18 ++++++- tests/ui/RuleEditorSeededDraftTest.tsx | 2 +- tests/ui/RulesNewPageDescribeRuleTest.tsx | 64 ----------------------- 3 files changed, 18 insertions(+), 66 deletions(-) diff --git a/tests/ui/DescribeRuleModalTest.tsx b/tests/ui/DescribeRuleModalTest.tsx index 0a2f84071357..ee2b539930c6 100644 --- a/tests/ui/DescribeRuleModalTest.tsx +++ b/tests/ui/DescribeRuleModalTest.tsx @@ -155,7 +155,7 @@ describe('DescribeRuleModal', () => { }); // Then the editor gets the values and the modal closes, because the editor can show them - expect(onRuleGenerated).toHaveBeenCalledWith({maxExpenseAmount: '500'}); + expect(onRuleGenerated).toHaveBeenCalledWith({maxExpenseAmount: '500'}, CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW); expect(onClose).toHaveBeenCalled(); }); @@ -199,4 +199,20 @@ describe('DescribeRuleModal', () => { expect(onClose).not.toHaveBeenCalled(); expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unintelligible')]); }); + + it('shows the unsupported error for an unsupported answer when the caller gives no agent rule fallback', async () => { + // Given the flag for review editor asked for a rule, and the editor does not offer the agent rule fallback + const onRuleGenerated = jest.fn(); + const onClose = jest.fn(); + await renderFlagForReviewModal(onRuleGenerated, onClose); + const generationID = describeRule(writeSpy, 'Block gift cards'); + + // When Concierge answers that the rule is not supported + await answerGeneration({generationID, state: CONST.GENERATED_RULE.STATE.UNSUPPORTED}); + + // Then the modal shows the unsupported error, because only a caller with a fallback turns this answer into an agent rule + expect(onRuleGenerated).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); + }); }); diff --git a/tests/ui/RuleEditorSeededDraftTest.tsx b/tests/ui/RuleEditorSeededDraftTest.tsx index db0365dce01b..e90c66ad8004 100644 --- a/tests/ui/RuleEditorSeededDraftTest.tsx +++ b/tests/ui/RuleEditorSeededDraftTest.tsx @@ -154,7 +154,7 @@ describe('Rule editors opened with a draft from the describe flow', () => { // Then the editor shows the seeded values, because it must not reset a draft that already exists expect(await getOnyxValue(ONYXKEYS.FORMS.FLAG_FOR_REVIEW_RULE_FORM)).toEqual(draft); - expect(screen.getByText('common.category: Meals')).toBeOnTheScreen(); + expect(screen.getByLabelText('common.category, Meals')).toBeOnTheScreen(); }); it('keeps the seeded require fields draft and marks its settings as touched when the editor opens for a new rule', async () => { diff --git a/tests/ui/RulesNewPageDescribeRuleTest.tsx b/tests/ui/RulesNewPageDescribeRuleTest.tsx index e25f573bd3f6..de600593bc55 100644 --- a/tests/ui/RulesNewPageDescribeRuleTest.tsx +++ b/tests/ui/RulesNewPageDescribeRuleTest.tsx @@ -12,7 +12,6 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; import RulesNewPage from '@pages/workspace/rules/RulesNewPage'; import type * as ModalActions from '@userActions/Modal'; @@ -373,66 +372,3 @@ describe('RulesNewPage describe flow', () => { expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toBeUndefined(); }); }); - -describe('DescribeRuleModal in a rule editor', () => { - let writeSpy: WriteSpy; - - beforeEach(() => { - jest.clearAllMocks(); - writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); - }); - - afterEach(async () => { - writeSpy.mockRestore(); - await act(async () => { - await Onyx.clear(); - }); - }); - - async function renderEditorModal(onRuleGenerated: jest.Mock) { - render( - , - ); - await waitForBatchedUpdatesWithAct(); - } - - it('shows the unsupported error when the answer has another rule type', async () => { - // Given the flag for review editor asked for a rule - const onRuleGenerated = jest.fn(); - await renderEditorModal(onRuleGenerated); - const {generationID} = describeRule(writeSpy, 'Block retail purchases on all cards'); - - // When Concierge answers with a card spend rule - await answerGeneration({ - generationID, - state: CONST.GENERATED_RULE.STATE.RULE, - ruleType: CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND, - rule: {merchantNames: ['Walmart']}, - }); - - // Then the editor keeps its form, because it cannot show the values of another rule type - expect(onRuleGenerated).not.toHaveBeenCalled(); - expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); - }); - - it('keeps the inline error for an unsupported answer when no agent rule fallback is given', async () => { - // Given an editor, which does not offer the agent rule fallback - const onRuleGenerated = jest.fn(); - await renderEditorModal(onRuleGenerated); - const {generationID} = describeRule(writeSpy, 'Block gift cards'); - - // When Concierge answers that the rule is not supported - await answerGeneration({generationID, state: CONST.GENERATED_RULE.STATE.UNSUPPORTED}); - - // Then the editor shows the unsupported error, because only the New rule page routes to an agent rule - expect(onRuleGenerated).not.toHaveBeenCalled(); - expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); - expect(Navigation.navigate).not.toHaveBeenCalled(); - }); -}); From a4520770ae11c103a7c2511a707c1e974b733955 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Mon, 12 Oct 2026 12:11:49 -0700 Subject: [PATCH 03/20] docs(rules): state the category constraint in RulesNewPage --- src/libs/actions/Policy/Rules.ts | 1 + src/pages/workspace/rules/RulesNewPage.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index f3d8a8213b06..e32774116b27 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -446,6 +446,7 @@ function generateRule(policyID: string, prompt: string, ruleType?: GeneratedRule return generationID; } +/** Stops the loading state of the prompt form and shows the translated error on it */ function setNewRulePromptErrorMessage(translationKey: TranslationPaths) { Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, { isLoading: false, diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 944983249545..55f46ae721b4 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -99,8 +99,8 @@ function RulesNewPage({route}: RulesNewPageProps) { const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - // The describe modal keeps a generated category only when the policy categories contain it. Therefore load the - // categories before Concierge answers. + // A generated category stays in the draft only when the policy categories contain it. Therefore this page loads + // the categories before the answer arrives. const fetchPolicyCategories = () => { if (!policy?.areCategoriesEnabled || policyCategories) { return; From 13beae7c4ae96008be370990a37ef86f6f997cd3 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Mon, 12 Oct 2026 18:07:10 -0700 Subject: [PATCH 04/20] fix(rules): wrap the agent rule fallback banner at word boundaries --- src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index f2aaaa2d7bac..a0917f767c07 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -258,6 +258,7 @@ function AddAgentRulePage({ icon={icons.Info} shouldShowIcon containerStyles={[styles.mh5, styles.mb3]} + textStyles={styles.breakWord} /> )} From 3fbb0a32443b09211a8b96edf356ea873b0b8a51 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Tue, 13 Oct 2026 16:14:16 -0700 Subject: [PATCH 05/20] refactor(rules): remove the unreachable non-admin agent rule error --- src/languages/de.ts | 1 - src/languages/el.ts | 2 -- src/languages/en.ts | 1 - src/languages/es.ts | 2 -- src/languages/fr.ts | 2 -- src/languages/it.ts | 2 -- src/languages/ja.ts | 1 - src/languages/nl.ts | 1 - src/languages/pl.ts | 1 - src/languages/pt-BR.ts | 1 - src/languages/zh-hans.ts | 1 - src/libs/actions/Policy/Rules.ts | 17 ++--------- src/pages/workspace/rules/RulesNewPage.tsx | 12 -------- tests/ui/RulesNewPageDescribeRuleTest.tsx | 34 ++++------------------ 14 files changed, 8 insertions(+), 70 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index f8a85991c60f..d4191142c7e7 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8823,7 +8823,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc 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.', - agentRuleAdminOnly: 'Dafür ist eine Agentenregel nötig, und nur Workspace-Admins können sie erstellen. Bitte einen Workspace-Admin darum, sie hinzuzufügen.', }, }, expenseDefaultsTable: { diff --git a/src/languages/el.ts b/src/languages/el.ts index bc3ff1a7dadc..45638e1de299 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8913,8 +8913,6 @@ ${reportName}`, unsupported: 'Αυτός ο κανόνας δεν υποστηρίζεται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.', unintelligible: 'Δοκιμάστε να διατυπώσετε τον κανόνα σας έτσι: αν οι δαπάνες ξεπερνούν τα 100 $, να απαιτείται απόδειξη.', multipleRules: 'Έναν κανόνα τη φορά — δοκιμάστε να περιγράψετε έναν μόνο κανόνα, π.χ.: αποκλεισμός αγορών λιανικής σε όλες τις κάρτες.', - agentRuleAdminOnly: - 'Αυτό απαιτεί κανόνα πράκτορα, τον οποίο μπορούν να δημιουργήσουν μόνο οι διαχειριστές του χώρου εργασίας. Ζητήστε από έναν διαχειριστή του χώρου εργασίας να τον προσθέσει.', }, }, expenseDefaultsTable: { diff --git a/src/languages/en.ts b/src/languages/en.ts index c71bb506a951..a19da1b73dc9 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9098,7 +9098,6 @@ const translations = { 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.', - agentRuleAdminOnly: 'This needs an agent rule, and only workspace admins can create those. Ask a workspace admin to add it.', }, }, expenseDefaultsTable: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 4058d623cb82..0b3de8cc5a90 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8828,8 +8828,6 @@ ${reportName}`, 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.', - agentRuleAdminOnly: - 'Esto requiere una regla de agente, y solo los administradores del espacio de trabajo pueden crearlas. Pide a un administrador del espacio de trabajo que la añada.', }, }, expenseDefaultsTable: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index cef4cbb000bc..73257edadb38 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8846,8 +8846,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e 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.', - agentRuleAdminOnly: - 'Cela nécessite une règle d’agent, et seuls les administrateurs de l’espace de travail peuvent en créer. Demandez à un administrateur de l’espace de travail de l’ajouter.', }, }, expenseDefaultsTable: { diff --git a/src/languages/it.ts b/src/languages/it.ts index d3265cce79ca..d99b52f5d74a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8774,8 +8774,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, 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.', - agentRuleAdminOnly: - 'Serve una regola agente, e solo gli amministratori dello spazio di lavoro possono crearla. Chiedi a un amministratore dello spazio di lavoro di aggiungerla.', }, }, expenseDefaultsTable: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index dab2c546013f..e1b1339053a6 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8663,7 +8663,6 @@ ${reportName}`, unsupported: 'そのルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。', unintelligible: '「経費が100ドルを超える場合は領収書を必須にする」のように書いてみてください。', multipleRules: '一度に1つのルールのみです。「すべてのカードで小売店での購入をブロックする」のように、1つのルールを説明してください。', - agentRuleAdminOnly: 'これにはエージェントルールが必要で、作成できるのはワークスペース管理者のみです。ワークスペース管理者に追加を依頼してください。', }, }, expenseDefaultsTable: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 41107e488ba1..3355afb905e4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8748,7 +8748,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, 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.', - agentRuleAdminOnly: 'Hiervoor is een agentregel nodig, en alleen werkruimtebeheerders kunnen die maken. Vraag een werkruimtebeheerder om de regel toe te voegen.', }, }, expenseDefaultsTable: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 094d321f90d8..dbf7969a449d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8773,7 +8773,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, 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.', - agentRuleAdminOnly: 'To wymaga reguły agenta, a tylko administratorzy przestrzeni roboczej mogą ją utworzyć. Poproś administratora przestrzeni roboczej o jej dodanie.', }, }, expenseDefaultsTable: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d8e85d821289..9ff885e54e73 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8755,7 +8755,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, 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.', - agentRuleAdminOnly: 'Isso exige uma regra de agente, e apenas administradores do workspace podem criá-las. Peça a um administrador do workspace para adicioná-la.', }, }, expenseDefaultsTable: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ffa906f0f40e..ce1845e3e5cb 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8444,7 +8444,6 @@ ${reportName}`, unsupported: '该规则暂不支持,我们正在开发中。请先尝试其他规则。', unintelligible: '可以这样描述规则:如果支出超过 100 美元,则需要收据。', multipleRules: '请一次描述一条规则,例如:在所有卡上阻止零售消费。', - agentRuleAdminOnly: '这需要一条代理规则,而只有工作区管理员可以创建代理规则。请让工作区管理员添加。', }, }, expenseDefaultsTable: { diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index e32774116b27..99f1418bcf4a 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -446,24 +446,14 @@ function generateRule(policyID: string, prompt: string, ruleType?: GeneratedRule return generationID; } -/** Stops the loading state of the prompt form and shows the translated error on it */ -function setNewRulePromptErrorMessage(translationKey: TranslationPaths) { +/** 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(translationKey), + errors: getMicroSecondOnyxErrorWithTranslationKey(PROMPT_ERROR_BY_STATE[state] ?? 'common.genericErrorMessage'), }); } -/** Shows on the prompt form why a description did not become a rule */ -function setNewRulePromptError(state: GeneratedRuleState) { - setNewRulePromptErrorMessage(PROMPT_ERROR_BY_STATE[state] ?? 'common.genericErrorMessage'); -} - -/** Shows on the prompt form that the description needs an agent rule, which only a workspace admin can create */ -function setAgentRuleAdminOnlyPromptError() { - setNewRulePromptErrorMessage('workspace.rules.newRule.promptErrors.agentRuleAdminOnly'); -} - function clearGeneratedRule() { Onyx.set(ONYXKEYS.GENERATED_RULE, null); } @@ -739,7 +729,6 @@ export { addPolicyAgentRule, generateRule, setNewRulePromptError, - setAgentRuleAdminOnlyPromptError, clearGeneratedRule, clearNewRulePrompt, updatePolicyAgentRule, diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 55f46ae721b4..c9d0c802d06e 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -5,7 +5,6 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -23,7 +22,6 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {canEditWorkspaceSettings, isControlPolicy} from '@libs/PolicyUtils'; import {getSpendRuleDraftFromGeneratedValues} from '@libs/SpendRulesUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -31,7 +29,6 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; import {close as closeModal} from '@userActions/Modal'; -import {setAgentRuleAdminOnlyPromptError} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -95,7 +92,6 @@ function RulesNewPage({route}: RulesNewPageProps) { const isCategoryScopedCreate = isCategorySettingsFlow || !!categoryName; const policy = usePolicy(policyID); const {showConfirmModal} = useConfirmModal(); - const {login = ''} = useCurrentUserPersonalDetails(); const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -114,15 +110,7 @@ function RulesNewPage({route}: RulesNewPageProps) { fetchPolicyCategories(); }); - // The agent rule page is open only to workspace admins on a Control workspace. A member with write access to - // rules can open this page but not that one. - const canCreateAgentRules = canEditWorkspaceSettings(policy, login) && isControlPolicy(policy); - const offerAgentRule = (prompt: string, generationID: string, state: GeneratedRuleState) => { - if (!canCreateAgentRules) { - setAgentRuleAdminOnlyPromptError(); - return; - } Log.info('[GenerateRule] Offered agent rule fallback', false, {policyID, generationID, state}); setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[AGENT_RULE_INPUT_IDS.PROMPT]: prompt}); closeModal(() => Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID, generationID))); diff --git a/tests/ui/RulesNewPageDescribeRuleTest.tsx b/tests/ui/RulesNewPageDescribeRuleTest.tsx index de600593bc55..7a80147ebd9c 100644 --- a/tests/ui/RulesNewPageDescribeRuleTest.tsx +++ b/tests/ui/RulesNewPageDescribeRuleTest.tsx @@ -1,6 +1,5 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import usePolicy from '@hooks/usePolicy'; import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; @@ -25,14 +24,13 @@ import type {GeneratedRule} from '@src/types/onyx'; import type {GeneratedRuleState, GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; import type * as ReactNavigationNative from '@react-navigation/native'; -import type {ValueOf} from 'type-fest'; import React from 'react'; import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import getOnyxValue from '../utils/getOnyxValue'; -import {buildPersonalDetails, translateLocal} from '../utils/TestHelper'; +import {translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; let mockPrompt = ''; @@ -59,7 +57,6 @@ jest.mock('@libs/actions/Policy/Category', () => ({ ...jest.requireActual>('@libs/actions/Policy/Category'), openPolicyCategoriesPage: jest.fn(), })); -jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn()}))); jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({isSmallScreenWidth: false, shouldUseNarrowLayout: false}))); jest.mock('@hooks/useThemeStyles', () => @@ -143,15 +140,13 @@ jest.mock('@components/Form/FormProvider', () => { type RulesNewPageProps = PlatformStackScreenProps; const POLICY_ID = 'policy1'; -const ADMIN_LOGIN = 'admin@example.com'; const mockedUsePolicy = jest.mocked(usePolicy); -const mockedUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); type WriteSpy = jest.SpiedFunction; -function setPolicyRole(role: ValueOf, areCategoriesEnabled = false) { - mockedUsePolicy.mockReturnValue({...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID, role, areCategoriesEnabled}); +function setAdminPolicy(areCategoriesEnabled = false) { + mockedUsePolicy.mockReturnValue({...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID, role: CONST.POLICY.ROLE.ADMIN, areCategoriesEnabled}); } async function renderRulesNewPage() { @@ -208,8 +203,7 @@ describe('RulesNewPage describe flow', () => { jest.clearAllMocks(); mockPrompt = ''; writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); - mockedUseCurrentUserPersonalDetails.mockReturnValue(buildPersonalDetails(ADMIN_LOGIN, 1)); - setPolicyRole(CONST.POLICY.ROLE.ADMIN); + setAdminPolicy(); }); afterEach(async () => { @@ -309,24 +303,6 @@ describe('RulesNewPage describe flow', () => { }, ); - it.each([CONST.GENERATED_RULE.STATE.UNSUPPORTED, CONST.GENERATED_RULE.STATE.MULTIPLE_RULES])( - 'tells a member who cannot create agent rules to ask an admin when the answer is %s', - async (state: GeneratedRuleState) => { - // Given a member with write access to rules who is not a workspace admin - setPolicyRole(CONST.POLICY.ROLE.USER); - await openDescribeModal(); - const {generationID} = describeRule(writeSpy, 'Block gift cards'); - - // When Concierge answers that it is not one standard rule - await answerGeneration({generationID, state}); - - // Then the modal shows the admin-only error and nothing opens, because the agent rule page would refuse this member - expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.agentRuleAdminOnly')]); - expect(Navigation.navigate).not.toHaveBeenCalled(); - expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toBeUndefined(); - }, - ); - it.each([undefined, 'futureRuleType'])('shows the unsupported error when a rule answer has the rule type %s', async (generatedRuleType) => { // Given an admin described a rule from New rule await openDescribeModal(); @@ -349,7 +325,7 @@ describe('RulesNewPage describe flow', () => { it('loads the policy categories when the workspace uses categories and none are loaded', async () => { // Given a workspace that uses categories, with no categories loaded - setPolicyRole(CONST.POLICY.ROLE.ADMIN, true); + setAdminPolicy(true); // When the New rule page opens await renderRulesNewPage(); From 9629a443f0277a933e82dfe41d0fd09b423dc7d4 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Tue, 13 Oct 2026 23:18:34 -0700 Subject: [PATCH 06/20] fix(rules): drop review and save from the agent rule fallback banner --- 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 d4191142c7e7..38a89aa92fa0 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8743,7 +8743,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc gotIt: 'Verstanden', createRule: 'Regel erstellen', fallbackBanner: - 'Das ist keine Standardregel, daher haben wir sie als Agentenregel entworfen. Agentenregeln greifen, nachdem sich eine Ausgabe oder ein Bericht ändert, zum Beispiel indem sie die Ausgabe zurückhalten oder den Bericht ablehnen. Prüfen und speichern Sie die Regel.', + 'Das ist keine Standardregel, daher haben wir sie als Agentenregel entworfen. Agentenregeln greifen, nachdem sich eine Ausgabe oder ein Bericht ändert, zum Beispiel indem sie die Ausgabe zurückhalten oder den Bericht ablehnen.', }, tabs: { general: 'Allgemein', diff --git a/src/languages/el.ts b/src/languages/el.ts index 45638e1de299..7db5091c1a07 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -9197,7 +9197,7 @@ ${reportName}`, describeRuleForConcierge: 'Περιγράψτε τον κανόνα σας και το Concierge θα τον δημιουργήσει', createRule: 'Δημιουργία κανόνα', fallbackBanner: - 'Αυτός δεν είναι τυπικός κανόνας, οπότε τον συντάξαμε ως κανόνα πράκτορα. Οι κανόνες πράκτορα ενεργούν αφού αλλάξει μια δαπάνη ή μια αναφορά, για παράδειγμα θέτοντας τη δαπάνη σε αναστολή ή απορρίπτοντας την αναφορά. Ελέγξτε τον και αποθηκεύστε τον.', + 'Αυτός δεν είναι τυπικός κανόνας, οπότε τον συντάξαμε ως κανόνα πράκτορα. Οι κανόνες πράκτορα ενεργούν αφού αλλάξει μια δαπάνη ή μια αναφορά, για παράδειγμα θέτοντας τη δαπάνη σε αναστολή ή απορρίπτοντας την αναφορά.', suggestionsTab: 'Προτεινόμενο', writeTab: 'Επεξεργασία', findSuggestion: 'Βρείτε έναν κανόνα', diff --git a/src/languages/en.ts b/src/languages/en.ts index a19da1b73dc9..a4592fb5c87f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9397,7 +9397,7 @@ const translations = { disclaimer: 'AI agents can make mistakes.', createRule: 'Create rule', fallbackBanner: - "This isn't a standard rule, so we drafted it as an agent rule. Agent rules act after an expense or report changes, for example by holding the expense or rejecting the report. Review and save.", + "This isn't a standard rule, so we drafted it as an agent rule. Agent rules act after an expense or report changes, for example by holding the expense or rejecting the report.", agentCreatedTitle: 'RuleBot has been added to your workspace!', agentCreatedDescription: (agentsRoute: string) => `To enforce your agent rules, we’ve created an agent for you and added it as an admin to your workspace.

Edit your agent’s details in Account > Agents.
`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 0b3de8cc5a90..0b480a3fff8a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8748,7 +8748,7 @@ ${reportName}`, gotIt: 'Entendido', createRule: 'Crear regla', fallbackBanner: - 'Esta no es una regla estándar, así que la redactamos como regla de agente. Las reglas de agente actúan después de que cambia un gasto o un informe, por ejemplo, reteniendo el gasto o rechazando el informe. Revísala y guárdala.', + 'Esta no es una regla estándar, así que la redactamos como regla de agente. Las reglas de agente actúan después de que cambia un gasto o un informe, por ejemplo, reteniendo el gasto o rechazando el informe.', }, tabs: { general: 'General', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 73257edadb38..41de57e627a7 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8766,7 +8766,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e gotIt: 'Compris', createRule: 'Créer une règle', fallbackBanner: - 'Ce n’est pas une règle standard, nous l’avons donc rédigée comme une règle d’agent. Les règles d’agent agissent après la modification d’une dépense ou d’une note de frais, par exemple en mettant la dépense en attente ou en rejetant la note de frais. Vérifiez-la et enregistrez-la.', + 'Ce n’est pas une règle standard, nous l’avons donc rédigée comme une règle d’agent. Les règles d’agent agissent après la modification d’une dépense ou d’une note de frais, par exemple en mettant la dépense en attente ou en rejetant la note de frais.', }, tabs: { general: 'Général', diff --git a/src/languages/it.ts b/src/languages/it.ts index d99b52f5d74a..63c183e7b396 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8694,7 +8694,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, gotIt: 'Capito', createRule: 'Crea regola', fallbackBanner: - 'Questa non è una regola standard, quindi l’abbiamo preparata come regola agente. Le regole agente intervengono dopo che una spesa o un rapporto cambia, ad esempio mettendo in sospeso la spesa o rifiutando il rapporto. Controllala e salvala.', + 'Questa non è una regola standard, quindi l’abbiamo preparata come regola agente. Le regole agente intervengono dopo che una spesa o un rapporto cambia, ad esempio mettendo in sospeso la spesa o rifiutando il rapporto.', }, tabs: { general: 'Generale', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index e1b1339053a6..1f9d7f44bfe9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8583,7 +8583,7 @@ ${reportName}`, gotIt: '了解しました', createRule: 'ルールを作成', fallbackBanner: - 'これは標準ルールではないため、エージェントルールとして下書きしました。エージェントルールは、経費やレポートが変更された後に動作します。たとえば、経費を保留したり、レポートを却下したりします。内容を確認して保存してください。', + 'これは標準ルールではないため、エージェントルールとして下書きしました。エージェントルールは、経費やレポートが変更された後に動作します。たとえば、経費を保留したり、レポートを却下したりします。', }, tabs: { general: '一般', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 3355afb905e4..939a79954d45 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8668,7 +8668,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, gotIt: 'Begrepen', createRule: 'Regel maken', fallbackBanner: - 'Dit is geen standaardregel, dus we hebben er een agentregel van gemaakt. Agentregels treden in werking nadat een uitgave of rapport verandert, bijvoorbeeld door de uitgave vast te houden of het rapport af te wijzen. Controleer de regel en sla hem op.', + 'Dit is geen standaardregel, dus we hebben er een agentregel van gemaakt. Agentregels treden in werking nadat een uitgave of rapport verandert, bijvoorbeeld door de uitgave vast te houden of het rapport af te wijzen.', }, tabs: { general: 'Algemeen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index dbf7969a449d..4124b636e96f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8693,7 +8693,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, gotIt: 'Jasne', createRule: 'Utwórz regułę', fallbackBanner: - 'To nie jest standardowa reguła, więc przygotowaliśmy ją jako regułę agenta. Reguły agenta działają po zmianie wydatku lub raportu, na przykład wstrzymując wydatek lub odrzucając raport. Sprawdź ją i zapisz.', + 'To nie jest standardowa reguła, więc przygotowaliśmy ją jako regułę agenta. Reguły agenta działają po zmianie wydatku lub raportu, na przykład wstrzymując wydatek lub odrzucając raport.', }, tabs: { general: 'Ogólne', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9ff885e54e73..a449ff8923d2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8675,7 +8675,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, gotIt: 'Entendi', createRule: 'Criar regra', fallbackBanner: - 'Esta não é uma regra padrão, então a redigimos como uma regra de agente. As regras de agente agem depois que uma despesa ou um relatório muda, por exemplo, retendo a despesa ou rejeitando o relatório. Revise e salve.', + 'Esta não é uma regra padrão, então a redigimos como uma regra de agente. As regras de agente agem depois que uma despesa ou um relatório muda, por exemplo, retendo a despesa ou rejeitando o relatório.', }, tabs: { general: 'Geral', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ce1845e3e5cb..9a128337f6fd 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8371,7 +8371,7 @@ ${reportName}`, describeRuleForConcierge: '描述你的规则,Concierge 会帮你创建', gotIt: '知道了', createRule: '创建规则', - fallbackBanner: '这不是标准规则,因此我们将其起草为代理规则。代理规则会在报销或报表发生变化后执行操作,例如暂挂该报销或拒绝该报表。请检查并保存。', + fallbackBanner: '这不是标准规则,因此我们将其起草为代理规则。代理规则会在报销或报表发生变化后执行操作,例如暂挂该报销或拒绝该报表。', }, tabs: {general: '常规', cardRestrictions: '卡片限制', expenseDefaults: '报销默认设置', requireFields: '字段要求', flagForReview: '标记以供审核', agents: '代理人'}, bulkActions: { From 11e7b637d4ae44a9e405b161b7f1f8cf1e1c8dd2 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Tue, 13 Oct 2026 23:46:56 -0700 Subject: [PATCH 07/20] feat(rules): describe a rule inline on New rule instead of in a modal --- src/CONST/index.ts | 2 +- src/languages/de.ts | 8 +- src/languages/el.ts | 8 +- src/languages/en.ts | 8 +- src/languages/es.ts | 8 +- src/languages/fr.ts | 8 +- src/languages/it.ts | 8 +- src/languages/ja.ts | 8 +- src/languages/nl.ts | 8 +- src/languages/pl.ts | 8 +- src/languages/pt-BR.ts | 8 +- src/languages/zh-hans.ts | 8 +- .../workspace/rules/DescribeRuleForm.tsx | 228 ++++++++++++++++++ .../workspace/rules/DescribeRuleModal.tsx | 197 +++------------ src/pages/workspace/rules/RulesNewPage.tsx | 103 ++++---- src/styles/index.ts | 4 + src/styles/variables.ts | 1 + tests/ui/RulesNewPageDescribeRuleTest.tsx | 81 +++---- 18 files changed, 432 insertions(+), 272 deletions(-) create mode 100644 src/pages/workspace/rules/DescribeRuleForm.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2e35a1afa21a..875e26e7bb2e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -10285,7 +10285,7 @@ const CONST = { NEW_RULE_MENU_ITEM_FLAG_FOR_REVIEW: 'WorkspaceRules-NewRuleMenuItem-FlagForReview', NEW_RULE_MENU_ITEM_REQUIRE_FIELDS: 'WorkspaceRules-NewRuleMenuItem-RequireFields', NEW_RULE_MENU_ITEM_APPLY_EXPENSE_DEFAULTS: 'WorkspaceRules-NewRuleMenuItem-ApplyExpenseDefaults', - NEW_RULE_MENU_ITEM_DESCRIBE_RULE: 'WorkspaceRules-NewRuleMenuItem-DescribeRule', + NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE: 'WorkspaceRules-NewRuleMenuItem-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 38a89aa92fa0..7d6c7046218e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8814,8 +8814,12 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc flagForReviewDescription: 'Genehmigende benachrichtigen, wenn Ausgaben Kategorienlimits überschreiten', requireFields: 'Feldanforderungen festlegen', requireFieldsDescription: 'Bestimmte Spesenfelder vorschreiben oder auf ihre Pflicht verzichten.', - describeYourRule: 'Beschreibe deine Regel', - describeYourRuleDescription: 'Sag Concierge, was du brauchst, und Concierge erstellt die Regel', + createAgentRule: 'Agentenregel beschreiben', + createAgentRuleDescription: 'Erstellen Sie flexible Regeln, die ausgeführt werden, wenn Sie sie benötigen.', + buildUsingAI: 'Mit KI erstellen', + startFromScratch: 'Oder von Grund auf neu beginnen', + buildRule: 'Regel erstellen', + buildingRule: 'Regel wird erstellt...', describe: 'Beschreiben', describeRule: 'Beschreibe deine Regel in einfachen Worten und Concierge erstellt sie für dich', describeRuleInputLabel: 'Beschreibe die Regel', diff --git a/src/languages/el.ts b/src/languages/el.ts index 7db5091c1a07..ab11baccb7d4 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8904,8 +8904,12 @@ ${reportName}`, requireFieldsDescription: 'Απαιτήστε συγκεκριμένα πεδία εξόδων ή παραλείψτε την απαίτησή τους.', applyExpenseDefaults: 'Εφαρμογή προεπιλογών δαπανών', applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', - describeYourRule: 'Περιγράψτε τον κανόνα σας', - describeYourRuleDescription: 'Πείτε στον Concierge τι χρειάζεστε και θα δημιουργήσει τον κανόνα', + createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', + createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', + buildUsingAI: 'Δημιουργία με AI', + startFromScratch: 'Ή ξεκινήστε από την αρχή', + buildRule: 'Δημιουργία κανόνα', + buildingRule: 'Δημιουργία κανόνα...', describe: 'Περιγραφή', describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', describeRuleInputLabel: 'Περιγράψτε τον κανόνα', diff --git a/src/languages/en.ts b/src/languages/en.ts index a4592fb5c87f..c69380267820 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9089,8 +9089,12 @@ const translations = { requireFieldsDescription: 'Require specific expense fields or waive requiring them.', applyExpenseDefaults: 'Apply expense defaults', applyExpenseDefaultsDescription: 'Update fields without submitter doing anything', - describeYourRule: 'Describe your rule', - describeYourRuleDescription: 'Tell Concierge what you need and it will build the rule', + createAgentRule: 'Describe agent rule', + createAgentRuleDescription: 'Create flexible rules that run when you need.', + buildUsingAI: 'Build using AI', + startFromScratch: 'Or start from scratch', + buildRule: 'Build rule', + buildingRule: 'Building rule...', describe: 'Describe', describeRule: 'Describe your rule in plain English and have Concierge build the rule for you', describeRuleInputLabel: 'Describe the rule', diff --git a/src/languages/es.ts b/src/languages/es.ts index 0b480a3fff8a..17c11adc2e94 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8819,8 +8819,12 @@ ${reportName}`, flagForReviewDescription: 'Notificar a los aprobadores cuando los gastos superen los límites de categoría', requireFields: 'Configurar requisitos de campos', requireFieldsDescription: 'Exige campos específicos de gasto o renuncia a exigirlos.', - describeYourRule: 'Describe tu regla', - describeYourRuleDescription: 'Dile a Concierge lo que necesitas y creará la regla', + createAgentRule: 'Describir regla de agente', + createAgentRuleDescription: 'Crea reglas flexibles que se ejecuten cuando las necesites.', + buildUsingAI: 'Crear con IA', + startFromScratch: 'O empieza desde cero', + buildRule: 'Crear regla', + buildingRule: 'Creando regla...', describe: 'Describir', describeRule: 'Describe tu regla con tus propias palabras y Concierge la creará por ti', describeRuleInputLabel: 'Describe la regla', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 41de57e627a7..494f6f7793e7 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8837,8 +8837,12 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e flagForReviewDescription: 'Avertir les approbateurs lorsque les dépenses dépassent les limites de catégorie', requireFields: 'Définir les exigences des champs', requireFieldsDescription: 'Rendre certains champs de dépense obligatoires ou renoncer à les exiger.', - describeYourRule: 'Décrivez votre règle', - describeYourRuleDescription: 'Dites à Concierge ce dont vous avez besoin et il créera la règle', + 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.', + buildUsingAI: 'Créer avec l’IA', + startFromScratch: 'Ou partir de zéro', + buildRule: 'Créer la règle', + buildingRule: 'Création de la règle...', 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', diff --git a/src/languages/it.ts b/src/languages/it.ts index 63c183e7b396..00c01cb023b8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8765,8 +8765,12 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, flagForReviewDescription: 'Avvisa gli approvatori quando le spese superano i limiti di categoria', requireFields: 'Imposta i requisiti del campo', requireFieldsDescription: 'Richiedi campi specifici per le spese oppure rinuncia a renderli obbligatori.', - describeYourRule: 'Descrivi la tua regola', - describeYourRuleDescription: 'Di’ a Concierge di cosa hai bisogno e creerà la regola', + createAgentRule: 'Descrivi regola agente', + createAgentRuleDescription: 'Crea regole flessibili che vengono eseguite quando ne hai bisogno.', + buildUsingAI: 'Crea con l’IA', + startFromScratch: 'Oppure inizia da zero', + buildRule: 'Crea regola', + buildingRule: 'Creazione della regola...', describe: 'Descrivi', describeRule: 'Descrivi la tua regola con parole semplici e Concierge la creerà per te', describeRuleInputLabel: 'Descrivi la regola', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1f9d7f44bfe9..7309224350f4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8654,8 +8654,12 @@ ${reportName}`, flagForReviewDescription: '条件が満たされたときに通知します。', requireFields: 'フィールドの必須条件を設定', requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', - describeYourRule: 'ルールを説明する', - describeYourRuleDescription: '必要なことを伝えると、Concierge がルールを作成します', + createAgentRule: 'エージェントルールの説明', + createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', + buildUsingAI: 'AI で作成', + startFromScratch: 'または最初から作成', + buildRule: 'ルールを作成', + buildingRule: 'ルールを作成中...', describe: '説明する', describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', describeRuleInputLabel: 'ルールを説明', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 939a79954d45..99cf3fb0a0a2 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8739,8 +8739,12 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, flagForReviewDescription: 'Goedkeurders informeren wanneer onkosten de categorielimieten overschrijden', requireFields: 'Veldvereisten instellen', requireFieldsDescription: 'Specifieke onkostenvakken verplicht stellen of afzien van het verplicht stellen ervan.', - describeYourRule: 'Beschrijf je regel', - describeYourRuleDescription: 'Vertel Concierge wat je nodig hebt en Concierge maakt de regel', + createAgentRule: 'Agentregel beschrijven', + createAgentRuleDescription: 'Maak flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', + buildUsingAI: 'Maken met AI', + startFromScratch: 'Of begin helemaal opnieuw', + buildRule: 'Regel maken', + buildingRule: 'Regel maken...', describe: 'Beschrijven', describeRule: 'Beschrijf je regel in gewone taal en Concierge maakt de regel voor je', describeRuleInputLabel: 'Beschrijf de regel', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 4124b636e96f..48c0209b14d0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8764,8 +8764,12 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, flagForReviewDescription: 'Powiadamiaj zatwierdzających, gdy wydatki przekraczają limity kategorii', requireFields: 'Ustaw wymagania pola', requireFieldsDescription: 'Wymagaj konkretnych pól wydatku lub zrezygnuj z ich wymagania.', - describeYourRule: 'Opisz swoją regułę', - describeYourRuleDescription: 'Powiedz Concierge, czego potrzebujesz, a utworzy regułę', + createAgentRule: 'Opisz regułę agenta', + createAgentRuleDescription: 'Twórz elastyczne reguły, które uruchamiają się wtedy, gdy ich potrzebujesz.', + buildUsingAI: 'Utwórz za pomocą AI', + startFromScratch: 'Lub zacznij od zera', + buildRule: 'Utwórz regułę', + buildingRule: 'Tworzenie reguły...', describe: 'Opisz', describeRule: 'Opisz swoją regułę własnymi słowami, a Concierge utworzy ją za Ciebie', describeRuleInputLabel: 'Opisz regułę', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index a449ff8923d2..4c81d63313b5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8746,8 +8746,12 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, flagForReviewDescription: 'Notificar aprovadores quando as despesas excederem os limites da categoria', requireFields: 'Definir requisitos de campo', requireFieldsDescription: 'Exigir campos específicos de despesa ou dispensar a exigência deles.', - describeYourRule: 'Descreva sua regra', - describeYourRuleDescription: 'Diga ao Concierge do que você precisa e ele criará a regra', + createAgentRule: 'Descrever regra do agente', + createAgentRuleDescription: 'Crie regras flexíveis que sejam executadas quando você precisar.', + buildUsingAI: 'Criar com IA', + startFromScratch: 'Ou comece do zero', + buildRule: 'Criar regra', + buildingRule: 'Criando regra...', describe: 'Descrever', describeRule: 'Descreva sua regra em linguagem simples e o Concierge criará a regra para você', describeRuleInputLabel: 'Descreva a regra', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 9a128337f6fd..2768bf937109 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8435,8 +8435,12 @@ ${reportName}`, flagForReviewDescription: '当费用超出类别限额时通知审批人', requireFields: '设置字段要求', requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', - describeYourRule: '描述你的规则', - describeYourRuleDescription: '告诉 Concierge 你的需求,它会为你创建规则', + createAgentRule: '描述代理规则', + createAgentRuleDescription: '创建可按需运行的灵活规则。', + buildUsingAI: '使用 AI 创建', + startFromScratch: '或从头开始', + buildRule: '创建规则', + buildingRule: '正在创建规则...', describe: '描述', describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', describeRuleInputLabel: '描述规则', diff --git a/src/pages/workspace/rules/DescribeRuleForm.tsx b/src/pages/workspace/rules/DescribeRuleForm.tsx new file mode 100644 index 000000000000..63103c030b84 --- /dev/null +++ b/src/pages/workspace/rules/DescribeRuleForm.tsx @@ -0,0 +1,228 @@ +/** + * Form where an admin describes a rule in plain English and Concierge builds the rule. + */ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getDecodedCategoryName} from '@libs/CategoryUtils'; + +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 {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; + +import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {useFocusEffect} from '@react-navigation/native'; +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; + +type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; + +type DescribeRuleFormProps = { + policyID: string; + + /** The rule type of the editor that asks for the rule. Without it, the form accepts an answer of any known rule type. */ + ruleType?: GeneratedRuleType; + + /** Whether the form takes answers. A form that is hidden or behind another page ignores them. */ + isActive: boolean; + + /** Whether the input gets focus when the form becomes active */ + shouldFocusInput?: boolean; + + submitButtonText: string; + + /** The style of the view around the input, which sets the height of the input */ + inputContainerStyle: StyleProp; + + style?: StyleProp; + + addBottomSafeAreaPadding?: boolean; + + /** Called with the generated form values and their rule type after Concierge builds the rule */ + onRuleGenerated: (values: GeneratedRuleValues, generatedRuleType: GeneratedRuleType) => void; + + /** Called in place of the inline error when the description is not one standard rule */ + onAgentRuleFallback?: (prompt: string, generationID: string, state: GeneratedRuleState) => void; + + /** Called after the form gives an answer to the caller and clears itself */ + onFinish?: () => void; +}; + +type PendingGeneration = { + generationID: string; + prompt: string; +}; + +const GENERATED_RULE_TYPES = new Set(Object.values(CONST.GENERATED_RULE.RULE_TYPE)); + +/** Returns whether a description with this answer can become an agent rule */ +function isAgentRuleFallbackState(state: GeneratedRuleState): boolean { + return state === CONST.GENERATED_RULE.STATE.UNSUPPORTED || state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES; +} + +/** 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 DescribeRuleForm({ + policyID, + ruleType, + isActive, + shouldFocusInput = false, + submitButtonText, + inputContainerStyle, + style, + addBottomSafeAreaPadding = false, + onRuleGenerated, + onAgentRuleFallback, + onFinish, +}: DescribeRuleFormProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const formRef = useRef(null); + const inputRef = useRef(null); + const pendingGenerationRef = 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; + const generatedRuleType = generatedRule?.ruleType; + const generatedRuleValues = generatedRule?.rule; + const inputLabel = translate('workspace.rules.newRule.describeRuleInputLabel'); + + const finish = () => { + pendingGenerationRef.current = undefined; + clearNewRulePrompt(); + onFinish?.(); + }; + + useEffect(() => { + const pendingGeneration = pendingGenerationRef.current; + if (!isActive || !generatedRuleID || !generatedRuleState || generatedRuleID !== pendingGeneration?.generationID) { + return; + } + if (onAgentRuleFallback && isAgentRuleFallbackState(generatedRuleState)) { + // The effect can run again before Onyx clears the answer. Clear the pending generation first, because the + // caller must get the answer one time only. + pendingGenerationRef.current = undefined; + clearGeneratedRule(); + onAgentRuleFallback(pendingGeneration.prompt, generatedRuleID, generatedRuleState); + finish(); + return; + } + if (generatedRuleState !== CONST.GENERATED_RULE.STATE.RULE) { + setNewRulePromptError(generatedRuleState); + clearGeneratedRule(); + return; + } + // Concierge picks the rule type itself. It can answer with a rule type other than `ruleType`, or with a rule type + // that this app version does not know. + const canApplyRuleType = ruleType ? generatedRuleType === ruleType : GENERATED_RULE_TYPES.has(generatedRuleType ?? ''); + if (!generatedRuleType || !canApplyRuleType) { + setNewRulePromptError(CONST.GENERATED_RULE.STATE.UNSUPPORTED); + clearGeneratedRule(); + return; + } + onRuleGenerated(withPolicyCategory(generatedRuleValues ?? {}, policyCategories), generatedRuleType); + finish(); + }, [finish, generatedRuleID, generatedRuleState, generatedRuleType, generatedRuleValues, isActive, onAgentRuleFallback, onRuleGenerated, policyCategories, ruleType]); + + useEffect(() => { + if (!isActive || !newRulePromptForm?.isLoading || pendingGenerationRef.current) { + return; + } + clearNewRulePrompt(); + }, [isActive, newRulePromptForm?.isLoading]); + + // Focus only after the open animation, because focusing mid-animation blurs the input on mWeb and native + useFocusEffect(() => { + if (!isActive || !shouldFocusInput) { + return; + } + const focusTimeout = setTimeout(() => inputRef.current?.focus(), CONST.ANIMATED_TRANSITION); + return () => clearTimeout(focusTimeout); + }); + + 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) => { + const prompt = values[INPUT_IDS.PROMPT].trim(); + pendingGenerationRef.current = {generationID: generateRule(policyID, prompt, ruleType), prompt}; + }; + + return ( + + + + + + ); +} + +DescribeRuleForm.displayName = 'DescribeRuleForm'; + +export default DescribeRuleForm; +export type {DescribeRuleFormProps}; diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx index 70035fe27ed0..e6fef749b7cd 100644 --- a/src/pages/workspace/rules/DescribeRuleModal.tsx +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -1,37 +1,22 @@ /** * Modal where an admin describes a rule in plain English and Concierge fills in the open rule editor's form. */ -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 type {AnimatedTextInputRef} from '@components/RNTextInput'; 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 {clearGeneratedRule, clearNewRulePrompt, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; +import {clearNewRulePrompt} 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 {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; - -import type {TextInputKeyPressEvent} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; -import {useFocusEffect} from '@react-navigation/native'; -import React, {useEffect, useRef} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; -type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; +import DescribeRuleForm from './DescribeRuleForm'; type DescribeRuleModalProps = { isVisible: boolean; @@ -40,177 +25,61 @@ type DescribeRuleModalProps = { policyID: string; - /** The rule type of the editor that opens the modal. Without it, the modal accepts an answer of any known rule type. */ - ruleType?: GeneratedRuleType; + /** The rule type of the editor that opens the modal */ + ruleType: GeneratedRuleType; /** Called with the generated form values and their rule type after Concierge builds the rule */ onRuleGenerated: (values: GeneratedRuleValues, generatedRuleType: GeneratedRuleType) => void; - - /** - * Called in place of the inline error when the description is not one standard rule. The modal stays open. The - * caller closes it or shows an error. - */ - onAgentRuleFallback?: (prompt: string, generationID: string, state: GeneratedRuleState) => void; -}; - -type PendingGeneration = { - generationID: string; - prompt: string; }; -const GENERATED_RULE_TYPES = new Set(Object.values(CONST.GENERATED_RULE.RULE_TYPE)); - -/** Returns whether a description with this answer can become an agent rule */ -function isAgentRuleFallbackState(state: GeneratedRuleState): boolean { - return state === CONST.GENERATED_RULE.STATE.UNSUPPORTED || state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES; -} - -/** 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, onAgentRuleFallback}: DescribeRuleModalProps) { +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 inputRef = useRef(null); - const pendingGenerationRef = 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; - const generatedRuleType = generatedRule?.ruleType; - const generatedRuleValues = generatedRule?.rule; - const inputLabel = translate('workspace.rules.newRule.describeRuleInputLabel'); - - const close = () => { - pendingGenerationRef.current = undefined; - clearNewRulePrompt(); - onClose(); - }; - useEffect(() => { - const pendingGeneration = pendingGenerationRef.current; - if (!generatedRuleID || !generatedRuleState || generatedRuleID !== pendingGeneration?.generationID) { - return; - } - if (onAgentRuleFallback && isAgentRuleFallbackState(generatedRuleState)) { - // The effect can run again before Onyx clears the answer. Clear the pending generation first, because the - // caller must get the answer one time only. - pendingGenerationRef.current = undefined; - clearGeneratedRule(); - onAgentRuleFallback(pendingGeneration.prompt, generatedRuleID, generatedRuleState); - return; + // Each opening gets a new form, so a description that is still building when the modal closes cannot fill in the + // editor after the modal opens again. + const [formKey, setFormKey] = useState(0); + const [wasVisible, setWasVisible] = useState(isVisible); + if (isVisible !== wasVisible) { + setWasVisible(isVisible); + if (isVisible) { + setFormKey((key) => key + 1); } - if (generatedRuleState !== CONST.GENERATED_RULE.STATE.RULE) { - setNewRulePromptError(generatedRuleState); - clearGeneratedRule(); - return; - } - // Concierge picks the rule type itself. It can answer with a rule type other than `ruleType`, or with a rule type - // that this app version does not know. - const canApplyRuleType = ruleType ? generatedRuleType === ruleType : GENERATED_RULE_TYPES.has(generatedRuleType ?? ''); - if (!generatedRuleType || !canApplyRuleType) { - setNewRulePromptError(CONST.GENERATED_RULE.STATE.UNSUPPORTED); - clearGeneratedRule(); - return; - } - onRuleGenerated(withPolicyCategory(generatedRuleValues ?? {}, policyCategories), generatedRuleType); - close(); - }, [close, generatedRuleID, generatedRuleState, generatedRuleType, generatedRuleValues, onAgentRuleFallback, onRuleGenerated, policyCategories, ruleType]); + } - useEffect(() => { - if (!isVisible || !newRulePromptForm?.isLoading || pendingGenerationRef.current) { - return; - } + const dismiss = () => { clearNewRulePrompt(); - }, [isVisible, newRulePromptForm?.isLoading]); - - // Focus only after the open animation, because focusing mid-animation blurs the input on mWeb and native - useFocusEffect(() => { - if (!isVisible) { - return; - } - const focusTimeout = setTimeout(() => inputRef.current?.focus(), CONST.ANIMATED_TRANSITION); - return () => clearTimeout(focusTimeout); - }); - - 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) => { - const prompt = values[INPUT_IDS.PROMPT].trim(); - pendingGenerationRef.current = {generationID: generateRule(policyID, prompt, ruleType), prompt}; + onClose(); }; return ( - + {translate('workspace.rules.newRule.describeRule')} - - - - + + ); } diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index c9d0c802d06e..03d7f4cc9656 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -1,3 +1,4 @@ +import ActivityIndicator from '@components/ActivityIndicator'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -28,7 +29,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; -import {close as closeModal} from '@userActions/Modal'; +import {clearNewRulePrompt} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -38,11 +39,11 @@ import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; import type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type IconAsset from '@src/types/utils/IconAsset'; -import {useFocusEffect} from '@react-navigation/native'; -import React, {useState} from 'react'; +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import React from 'react'; import {View} from 'react-native'; -import DescribeRuleModal from './DescribeRuleModal'; +import DescribeRuleForm from './DescribeRuleForm'; type RulesNewPageProps = | PlatformStackScreenProps @@ -92,8 +93,9 @@ function RulesNewPage({route}: RulesNewPageProps) { const isCategoryScopedCreate = isCategorySettingsFlow || !!categoryName; const policy = usePolicy(policyID); const {showConfirmModal} = useConfirmModal(); - const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); + const isFocused = useIsFocused(); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [isBuildingRule = false] = useOnyx(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, {selector: (form) => form?.isLoading}); // A generated category stays in the draft only when the policy categories contain it. Therefore this page loads // the categories before the answer arrives. @@ -108,12 +110,15 @@ function RulesNewPage({route}: RulesNewPageProps) { useFocusEffect(() => { fetchPolicyCategories(); + // The Describe modal of the rule editors uses the same form. Clear it when this page loses focus, so the modal + // does not open with the description or the error of this page. + return () => clearNewRulePrompt(); }); const offerAgentRule = (prompt: string, generationID: string, state: GeneratedRuleState) => { Log.info('[GenerateRule] Offered agent rule fallback', false, {policyID, generationID, state}); setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[AGENT_RULE_INPUT_IDS.PROMPT]: prompt}); - closeModal(() => Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID, generationID))); + Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID, generationID)); }; // From category settings the only expense default is the category's tax rate, so the type chooser is skipped. It @@ -134,15 +139,6 @@ function RulesNewPage({route}: RulesNewPageProps) { }; const newRuleOptions: NewRuleOption[] = [ - { - key: 'describeRule', - icon: illustrations.AiBot, - title: translate('workspace.rules.newRule.describeYourRule'), - description: translate('workspace.rules.newRule.describeYourRuleDescription'), - onPress: () => setIsDescribeRuleModalVisible(true), - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_DESCRIBE_RULE, - isWorkspaceOnly: true, - }, { key: 'restrictCardSpend', icon: illustrations.CardReaderAlt, @@ -188,6 +184,15 @@ function RulesNewPage({route}: RulesNewPageProps) { // Only category settings can scope the editor to its category, so a category passed any other way drops it. isWorkspaceOnly: !isCategorySettingsFlow, }, + { + key: 'createAgentRule', + icon: illustrations.AiBot, + title: translate('workspace.rules.newRule.createAgentRule'), + description: translate('workspace.rules.newRule.createAgentRuleDescription'), + onPress: () => Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)), + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE, + isWorkspaceOnly: true, + }, ]; const visibleNewRuleOptions = isCategoryScopedCreate ? newRuleOptions.filter((option) => !option.isWorkspaceOnly) : newRuleOptions; @@ -209,34 +214,50 @@ function RulesNewPage({route}: RulesNewPageProps) { style={[styles.flexGrow1]} addBottomSafeAreaPadding > - {translate('workspace.rules.newRule.subtitle')} - - {visibleNewRuleOptions.map((option) => ( - {translate('workspace.rules.newRule.subtitle')} + ) : ( + <> + {translate('workspace.rules.newRule.buildUsingAI')} + seedDraftAndNavigate(values, generatedRuleType, policyID)} + onAgentRuleFallback={offerAgentRule} /> - ))} - + + )} + {isBuildingRule && !isCategoryScopedCreate ? ( + + + {translate('workspace.rules.newRule.buildingRule')} + + ) : ( + + {!isCategoryScopedCreate && ( + {translate('workspace.rules.newRule.startFromScratch')} + )} + {visibleNewRuleOptions.map((option) => ( + + ))} + + )} - {!isCategoryScopedCreate && ( - setIsDescribeRuleModalVisible(false)} - policyID={policyID} - onRuleGenerated={(values, generatedRuleType) => seedDraftAndNavigate(values, generatedRuleType, policyID)} - onAgentRuleFallback={offerAgentRule} - /> - )} ); diff --git a/src/styles/index.ts b/src/styles/index.ts index 447f31c0a549..e016d4d4fee3 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6248,6 +6248,10 @@ const staticStyles = (theme: ThemeColors) => height: variables.describeRulePromptInputHeight, }, + describeRuleInlinePromptInput: { + height: variables.describeRuleInlinePromptInputHeight, + }, + emptyStateSamlIllustration: { width: 183, height: 160, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 72376ce47d4f..185cb8d4e354 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -385,6 +385,7 @@ export default { rulesNewMenuItemMinHeight: 84, agentRulePromptInputHeight: 340, describeRulePromptInputHeight: 312, + describeRuleInlinePromptInputHeight: 115, cardPreviewWidth: 235, cardDetailsActionButtonMinWidth: 140, cardScarfOverlayWidth: 264, diff --git a/tests/ui/RulesNewPageDescribeRuleTest.tsx b/tests/ui/RulesNewPageDescribeRuleTest.tsx index 7a80147ebd9c..151898f17f45 100644 --- a/tests/ui/RulesNewPageDescribeRuleTest.tsx +++ b/tests/ui/RulesNewPageDescribeRuleTest.tsx @@ -13,9 +13,6 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import RulesNewPage from '@pages/workspace/rules/RulesNewPage'; -import type * as ModalActions from '@userActions/Modal'; -import {onModalDidClose} from '@userActions/Modal'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -42,6 +39,7 @@ jest.mock('@react-navigation/native', () => { useFocusEffect: (callback: () => void) => { callback(); }, + useIsFocused: () => true, }; }); jest.mock('@hooks/useLazyAsset', () => ({ @@ -107,16 +105,6 @@ jest.mock('@components/MenuItem', () => { return ({title, onPress}: {title: string; onPress: () => void}) => ReactModule.createElement(Pressable, {accessibilityLabel: title, accessibilityRole: 'button', onPress}, ReactModule.createElement(Text, null, title)); }); -jest.mock('@components/Modal', () => { - const ReactModule = jest.requireActual('react'); - const {setCloseModal} = jest.requireActual('@userActions/Modal'); - // Register the close callback the same as BaseModal does, because the `close` action in `@userActions/Modal` must close this modal. - function MockModal({isVisible, onClose, children}: {isVisible: boolean; onClose: () => void; children: React.ReactNode}) { - ReactModule.useEffect(() => (isVisible ? setCloseModal(onClose) : undefined), [isVisible, onClose]); - return isVisible ? children : null; - } - return MockModal; -}); jest.mock('@components/Form/InputWrapper', () => () => null); jest.mock('@components/Form/FormProvider', () => { const ReactModule = jest.requireActual('react'); @@ -159,23 +147,17 @@ async function renderRulesNewPage() { await waitForBatchedUpdatesWithAct(); } -async function openDescribeModal() { - await renderRulesNewPage(); - fireEvent.press(screen.getByLabelText('workspace.rules.newRule.describeYourRule')); - await waitForBatchedUpdatesWithAct(); -} - function isGenerateRuleParams(parameters: unknown): parameters is GenerateRuleParams { return typeof parameters === 'object' && parameters !== null && 'generationID' in parameters && 'prompt' in parameters; } -/** Submits the prompt through the describe modal and returns the parameters of the GenerateRule request */ +/** Submits the prompt through the describe form and returns the parameters of the GenerateRule request */ function describeRule(writeSpy: WriteSpy, prompt: string): GenerateRuleParams { mockPrompt = prompt; - fireEvent.press(screen.getByLabelText('common.next')); + fireEvent.press(screen.getByLabelText('workspace.rules.newRule.buildRule')); const parameters = writeSpy.mock.calls.find(([command]) => command === WRITE_COMMANDS.GENERATE_RULE)?.at(1); if (!isGenerateRuleParams(parameters)) { - throw new Error('The describe modal did not request GenerateRule'); + throw new Error('The describe form did not request GenerateRule'); } return parameters; } @@ -213,20 +195,23 @@ describe('RulesNewPage describe flow', () => { }); }); - it('lists Describe your rule first, before the four rule types', async () => { + it('shows the describe form above the rule types', async () => { // Given an admin on the New rule page // When the page renders await renderRulesNewPage(); - // Then describing comes first, so the admin can describe the rule before picking a rule type + // Then the admin can describe the rule, or start from one of the rule types below the form + expect(screen.getByText('workspace.rules.newRule.buildUsingAI')).toBeOnTheScreen(); + expect(screen.getByLabelText('workspace.rules.newRule.buildRule')).toBeOnTheScreen(); + expect(screen.getByText('workspace.rules.newRule.startFromScratch')).toBeOnTheScreen(); const options = screen.getAllByRole('button'); const expectedOptionNames = [ - 'workspace.rules.newRule.describeYourRule', 'workspace.rules.newRule.restrictCardSpend', 'workspace.rules.newRule.flagForReview', 'workspace.rules.newRule.requireFields', 'workspace.rules.newRule.applyExpenseDefaults', + 'workspace.rules.newRule.createAgentRule', ]; expect(options).toHaveLength(expectedOptionNames.length); for (const [index, name] of expectedOptionNames.entries()) { @@ -236,7 +221,7 @@ describe('RulesNewPage describe flow', () => { it('seeds the draft of the generated rule type and opens its editor', async () => { // Given the admin described a flag for review rule from New rule, so the request lets Concierge pick the rule type - await openDescribeModal(); + await renderRulesNewPage(); const {generationID, ruleType} = describeRule(writeSpy, 'Flag expenses over $500'); expect(ruleType).toBeUndefined(); @@ -255,7 +240,7 @@ describe('RulesNewPage describe flow', () => { it('scopes an expense defaults draft to a merchant rule', async () => { // Given the admin described an expense default for a merchant - await openDescribeModal(); + await renderRulesNewPage(); const {generationID} = describeRule(writeSpy, 'Set Uber expenses to not billable'); // When Concierge answers with an expense defaults rule @@ -277,24 +262,15 @@ describe('RulesNewPage describe flow', () => { // Given an admin described something no standard rule can do const logSpy = jest.spyOn(Log, 'info'); const prompt = 'Block gift cards and require receipts over $50'; - await openDescribeModal(); + await renderRulesNewPage(); const {generationID} = describeRule(writeSpy, prompt); // When Concierge answers that it is not one standard rule await answerGeneration({generationID, state}); - // Then the modal closes with the prompt as an agent rule draft, and nothing opens while the modal hides - expect(screen.queryByLabelText('common.next')).toBeNull(); + // Then the prompt becomes the agent rule draft, and the agent rule page opens, joined to the description by its generationID expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toEqual({prompt}); expect(logSpy).toHaveBeenCalledWith('[GenerateRule] Offered agent rule fallback', false, {policyID: POLICY_ID, generationID, state}); - expect(Navigation.navigate).not.toHaveBeenCalled(); - - // When the modal finishes its hide transition - act(() => { - onModalDidClose(); - }); - - // Then the agent rule page opens, joined to the description by its generationID expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_AGENT_NEW.getRoute(POLICY_ID, generationID)); // And nothing is saved, because the admin must still review the draft and press Create @@ -305,7 +281,7 @@ describe('RulesNewPage describe flow', () => { it.each([undefined, 'futureRuleType'])('shows the unsupported error when a rule answer has the rule type %s', async (generatedRuleType) => { // Given an admin described a rule from New rule - await openDescribeModal(); + await renderRulesNewPage(); const {generationID} = describeRule(writeSpy, 'Flag expenses over $500'); // When Concierge answers with a rule but with no rule type, or with a rule type that this app version does not know @@ -317,12 +293,29 @@ describe('RulesNewPage describe flow', () => { rule: {maxExpenseAmount: '500'}, }); - // Then the modal stays open with the unsupported error, because no editor can show these values + // Then the form shows the unsupported error, because no editor can show these values expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); - expect(screen.getByLabelText('common.next')).toBeOnTheScreen(); + expect(screen.getByLabelText('workspace.rules.newRule.buildRule')).toBeOnTheScreen(); expect(Navigation.navigate).not.toHaveBeenCalled(); }); + it('shows that the rule is building in place of the rule types', async () => { + // Given the admin described a rule from New rule + await renderRulesNewPage(); + describeRule(writeSpy, 'Flag expenses over $500'); + + // When the form waits for the answer + await act(async () => { + await Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, {isLoading: true}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the page says the rule is building and hides the rule types, so the admin does not start a second rule + expect(screen.getByText('workspace.rules.newRule.buildingRule')).toBeOnTheScreen(); + expect(screen.queryByText('workspace.rules.newRule.startFromScratch')).toBeNull(); + expect(screen.queryByLabelText('workspace.rules.newRule.flagForReview')).toBeNull(); + }); + it('loads the policy categories when the workspace uses categories and none are loaded', async () => { // Given a workspace that uses categories, with no categories loaded setAdminPolicy(true); @@ -330,19 +323,19 @@ describe('RulesNewPage describe flow', () => { // When the New rule page opens await renderRulesNewPage(); - // Then the page requests the categories, because the describe modal keeps only a category that the workspace has + // Then the page requests the categories, because the describe form keeps only a category that the workspace has expect(openPolicyCategoriesPage).toHaveBeenCalledWith(POLICY_ID); }); it('keeps the inline error for an unintelligible description', async () => { // Given an admin typed something Concierge cannot read - await openDescribeModal(); + await renderRulesNewPage(); const {generationID} = describeRule(writeSpy, 'asdf qwerty zxcv'); // When Concierge answers that the description is unintelligible await answerGeneration({generationID, state: CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE}); - // Then the modal asks for a clearer description, because an agent rule needs a prompt that makes sense + // Then the form asks for a clearer description, because an agent rule needs a prompt that makes sense expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unintelligible')]); expect(Navigation.navigate).not.toHaveBeenCalled(); expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toBeUndefined(); From 7f84468d4395fa6b4ddad206ddbe75c6913a4a15 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Tue, 13 Oct 2026 23:51:25 -0700 Subject: [PATCH 08/20] fix(rules): drop the unused DescribeRuleFormProps export --- src/pages/workspace/rules/DescribeRuleForm.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/workspace/rules/DescribeRuleForm.tsx b/src/pages/workspace/rules/DescribeRuleForm.tsx index 63103c030b84..dbb00979bae6 100644 --- a/src/pages/workspace/rules/DescribeRuleForm.tsx +++ b/src/pages/workspace/rules/DescribeRuleForm.tsx @@ -225,4 +225,3 @@ function DescribeRuleForm({ DescribeRuleForm.displayName = 'DescribeRuleForm'; export default DescribeRuleForm; -export type {DescribeRuleFormProps}; From 07fd07223c38ed46c724b85e666054d200e2337f Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Wed, 14 Oct 2026 00:02:50 -0700 Subject: [PATCH 09/20] feat(rules): open Describe your rule as a page in the RHP --- src/CONST/index.ts | 2 +- src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + 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 +- .../ModalStackNavigators/index.tsx | 1 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 3 + src/libs/Navigation/types.ts | 3 + .../workspace/rules/DescribeRuleForm.tsx | 14 +- .../workspace/rules/DescribeRuleModal.tsx | 29 ++-- .../workspace/rules/DescribeRulePage.tsx | 134 ++++++++++++++++ src/pages/workspace/rules/RulesNewPage.tsx | 145 ++++-------------- src/styles/index.ts | 4 - src/styles/variables.ts | 1 - ...eRuleTest.tsx => DescribeRulePageTest.tsx} | 66 ++++---- 25 files changed, 253 insertions(+), 232 deletions(-) create mode 100644 src/pages/workspace/rules/DescribeRulePage.tsx rename tests/ui/{RulesNewPageDescribeRuleTest.tsx => DescribeRulePageTest.tsx} (88%) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 875e26e7bb2e..2e35a1afa21a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -10285,7 +10285,7 @@ const CONST = { NEW_RULE_MENU_ITEM_FLAG_FOR_REVIEW: 'WorkspaceRules-NewRuleMenuItem-FlagForReview', 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_MENU_ITEM_DESCRIBE_RULE: 'WorkspaceRules-NewRuleMenuItem-DescribeRule', REQUIRE_RECEIPTS_SAVE: 'WorkspaceRules-RequireReceiptsSave', REQUIRE_FIELDS_SAVE: 'WorkspaceRules-RequireFieldsSave', FLAG_RECEIPT_LINE_ITEMS_SAVE: 'WorkspaceRules-FlagReceiptLineItemsSave', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7f618d29c396..f6d74cb731dd 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4387,6 +4387,10 @@ const ROUTES = { route: 'workspaces/:policyID/rules/new', getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, + RULES_DESCRIBE: { + route: 'workspaces/:policyID/rules/describe', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/describe` as const, + }, RULES_EXPENSE_DEFAULT_TYPE: { route: 'workspaces/:policyID/rules/expense-defaults/new', getRoute: (policyID: string) => `workspaces/${policyID}/rules/expense-defaults/new` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 9905d3ad4829..f6b184fd436b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -1016,6 +1016,7 @@ const SCREENS = { RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', RULES_NEW: 'Rules_New', + RULES_DESCRIBE: 'Rules_Describe', RULES_EXPENSE_DEFAULT_TYPE: 'Rules_Expense_Default_Type', RULES_MERCHANT_NEW: 'Rules_Merchant_New', RULES_MERCHANT_IMPORT: 'Rules_Merchant_Import', diff --git a/src/languages/de.ts b/src/languages/de.ts index 7d6c7046218e..4995435b9182 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8814,12 +8814,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc flagForReviewDescription: 'Genehmigende benachrichtigen, wenn Ausgaben Kategorienlimits überschreiten', requireFields: 'Feldanforderungen festlegen', 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.', - buildUsingAI: 'Mit KI erstellen', - startFromScratch: 'Oder von Grund auf neu beginnen', + describeYourRule: 'Beschreibe deine Regel', + describeYourRuleDescription: 'Sag Concierge, was du brauchst, und Concierge erstellt die Regel', buildRule: 'Regel erstellen', - buildingRule: 'Regel wird erstellt...', describe: 'Beschreiben', describeRule: 'Beschreibe deine Regel in einfachen Worten und Concierge erstellt sie für dich', describeRuleInputLabel: 'Beschreibe die Regel', diff --git a/src/languages/el.ts b/src/languages/el.ts index ab11baccb7d4..eb83ce19e672 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8904,12 +8904,9 @@ ${reportName}`, requireFieldsDescription: 'Απαιτήστε συγκεκριμένα πεδία εξόδων ή παραλείψτε την απαίτησή τους.', applyExpenseDefaults: 'Εφαρμογή προεπιλογών δαπανών', applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', - createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', - createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', - buildUsingAI: 'Δημιουργία με AI', - startFromScratch: 'Ή ξεκινήστε από την αρχή', + describeYourRule: 'Περιγράψτε τον κανόνα σας', + describeYourRuleDescription: 'Πείτε στον Concierge τι χρειάζεστε και θα δημιουργήσει τον κανόνα', buildRule: 'Δημιουργία κανόνα', - buildingRule: 'Δημιουργία κανόνα...', describe: 'Περιγραφή', describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', describeRuleInputLabel: 'Περιγράψτε τον κανόνα', diff --git a/src/languages/en.ts b/src/languages/en.ts index c69380267820..172e94ad0db5 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9089,12 +9089,9 @@ const translations = { requireFieldsDescription: 'Require specific expense fields or waive requiring them.', applyExpenseDefaults: 'Apply expense defaults', applyExpenseDefaultsDescription: 'Update fields without submitter doing anything', - createAgentRule: 'Describe agent rule', - createAgentRuleDescription: 'Create flexible rules that run when you need.', - buildUsingAI: 'Build using AI', - startFromScratch: 'Or start from scratch', + describeYourRule: 'Describe your rule', + describeYourRuleDescription: 'Tell Concierge what you need and it will build the rule', buildRule: 'Build rule', - buildingRule: 'Building rule...', describe: 'Describe', describeRule: 'Describe your rule in plain English and have Concierge build the rule for you', describeRuleInputLabel: 'Describe the rule', diff --git a/src/languages/es.ts b/src/languages/es.ts index 17c11adc2e94..af80797fa9b4 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8819,12 +8819,9 @@ ${reportName}`, flagForReviewDescription: 'Notificar a los aprobadores cuando los gastos superen los límites de categoría', requireFields: 'Configurar requisitos de campos', 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.', - buildUsingAI: 'Crear con IA', - startFromScratch: 'O empieza desde cero', + describeYourRule: 'Describe tu regla', + describeYourRuleDescription: 'Dile a Concierge lo que necesitas y creará la regla', buildRule: 'Crear regla', - buildingRule: 'Creando regla...', describe: 'Describir', describeRule: 'Describe tu regla con tus propias palabras y Concierge la creará por ti', describeRuleInputLabel: 'Describe la regla', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 494f6f7793e7..72ce01a7af3e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8837,12 +8837,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e flagForReviewDescription: 'Avertir les approbateurs lorsque les dépenses dépassent les limites de catégorie', requireFields: 'Définir les exigences des champs', 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.', - buildUsingAI: 'Créer avec l’IA', - startFromScratch: 'Ou partir de zéro', + describeYourRule: 'Décrivez votre règle', + describeYourRuleDescription: 'Dites à Concierge ce dont vous avez besoin et il créera la règle', buildRule: 'Créer la règle', - buildingRule: 'Création de la règle...', 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', diff --git a/src/languages/it.ts b/src/languages/it.ts index 00c01cb023b8..6f60141096f9 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8765,12 +8765,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, flagForReviewDescription: 'Avvisa gli approvatori quando le spese superano i limiti di categoria', requireFields: 'Imposta i requisiti del campo', 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.', - buildUsingAI: 'Crea con l’IA', - startFromScratch: 'Oppure inizia da zero', + describeYourRule: 'Descrivi la tua regola', + describeYourRuleDescription: 'Di’ a Concierge di cosa hai bisogno e creerà la regola', buildRule: 'Crea regola', - buildingRule: 'Creazione della regola...', describe: 'Descrivi', describeRule: 'Descrivi la tua regola con parole semplici e Concierge la creerà per te', describeRuleInputLabel: 'Descrivi la regola', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7309224350f4..86d3bcef2801 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8654,12 +8654,9 @@ ${reportName}`, flagForReviewDescription: '条件が満たされたときに通知します。', requireFields: 'フィールドの必須条件を設定', requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', - createAgentRule: 'エージェントルールの説明', - createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', - buildUsingAI: 'AI で作成', - startFromScratch: 'または最初から作成', + describeYourRule: 'ルールを説明する', + describeYourRuleDescription: '必要なことを伝えると、Concierge がルールを作成します', buildRule: 'ルールを作成', - buildingRule: 'ルールを作成中...', describe: '説明する', describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', describeRuleInputLabel: 'ルールを説明', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 99cf3fb0a0a2..99a92bc8b86c 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8739,12 +8739,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, flagForReviewDescription: 'Goedkeurders informeren wanneer onkosten de categorielimieten overschrijden', requireFields: 'Veldvereisten instellen', 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.', - buildUsingAI: 'Maken met AI', - startFromScratch: 'Of begin helemaal opnieuw', + describeYourRule: 'Beschrijf je regel', + describeYourRuleDescription: 'Vertel Concierge wat je nodig hebt en Concierge maakt de regel', buildRule: 'Regel maken', - buildingRule: 'Regel maken...', describe: 'Beschrijven', describeRule: 'Beschrijf je regel in gewone taal en Concierge maakt de regel voor je', describeRuleInputLabel: 'Beschrijf de regel', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 48c0209b14d0..253066050fcb 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8764,12 +8764,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, flagForReviewDescription: 'Powiadamiaj zatwierdzających, gdy wydatki przekraczają limity kategorii', requireFields: 'Ustaw wymagania pola', 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.', - buildUsingAI: 'Utwórz za pomocą AI', - startFromScratch: 'Lub zacznij od zera', + describeYourRule: 'Opisz swoją regułę', + describeYourRuleDescription: 'Powiedz Concierge, czego potrzebujesz, a utworzy regułę', buildRule: 'Utwórz regułę', - buildingRule: 'Tworzenie reguły...', describe: 'Opisz', describeRule: 'Opisz swoją regułę własnymi słowami, a Concierge utworzy ją za Ciebie', describeRuleInputLabel: 'Opisz regułę', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4c81d63313b5..f60dccf34d2e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8746,12 +8746,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, flagForReviewDescription: 'Notificar aprovadores quando as despesas excederem os limites da categoria', requireFields: 'Definir requisitos de campo', 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.', - buildUsingAI: 'Criar com IA', - startFromScratch: 'Ou comece do zero', + describeYourRule: 'Descreva sua regra', + describeYourRuleDescription: 'Diga ao Concierge do que você precisa e ele criará a regra', buildRule: 'Criar regra', - buildingRule: 'Criando regra...', describe: 'Descrever', describeRule: 'Descreva sua regra em linguagem simples e o Concierge criará a regra para você', describeRuleInputLabel: 'Descreva a regra', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2768bf937109..87b377013e86 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8435,12 +8435,9 @@ ${reportName}`, flagForReviewDescription: '当费用超出类别限额时通知审批人', requireFields: '设置字段要求', requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', - createAgentRule: '描述代理规则', - createAgentRuleDescription: '创建可按需运行的灵活规则。', - buildUsingAI: '使用 AI 创建', - startFromScratch: '或从头开始', + describeYourRule: '描述你的规则', + describeYourRuleDescription: '告诉 Concierge 你的需求,它会为你创建规则', buildRule: '创建规则', - buildingRule: '正在创建规则...', describe: '描述', describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', describeRuleInputLabel: '描述规则', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 488a2f3c4fbd..d65d874c9bc6 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1234,6 +1234,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesCustomPage').default, [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, [SCREENS.WORKSPACE.RULES_NEW]: () => require('../../../../pages/workspace/rules/RulesNewPage').default, + [SCREENS.WORKSPACE.RULES_DESCRIBE]: () => require('../../../../pages/workspace/rules/DescribeRulePage').default, [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: () => require('../../../../pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: () => require('../../../../pages/workspace/rules/MerchantRules/ImportMerchantRulesPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 4e9826d10229..68a265c83d45 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -483,6 +483,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_NEW]: { path: ROUTES.RULES_NEW.route, }, + [SCREENS.WORKSPACE.RULES_DESCRIBE]: { + path: ROUTES.RULES_DESCRIBE.route, + }, [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { path: ROUTES.RULES_EXPENSE_DEFAULT_TYPE.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 5388a6700053..b785941f5f35 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1921,6 +1921,9 @@ type SettingsNavigatorParamList = { policyID: string; categoryName?: string; }; + [SCREENS.WORKSPACE.RULES_DESCRIBE]: { + policyID: string; + }; [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { policyID: string; }; diff --git a/src/pages/workspace/rules/DescribeRuleForm.tsx b/src/pages/workspace/rules/DescribeRuleForm.tsx index dbb00979bae6..64434557acd5 100644 --- a/src/pages/workspace/rules/DescribeRuleForm.tsx +++ b/src/pages/workspace/rules/DescribeRuleForm.tsx @@ -21,6 +21,7 @@ import INPUT_IDS from '@src/types/form/NewRulePromptForm'; import type {PolicyCategories} from '@src/types/onyx'; import type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; +import type {ReactNode} from 'react'; import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -51,6 +52,12 @@ type DescribeRuleFormProps = { addBottomSafeAreaPadding?: boolean; + /** Whether the form scrolls and keeps the submit button at the bottom, as on a full page */ + isFullPage?: boolean; + + /** Content above the input */ + children?: ReactNode; + /** Called with the generated form values and their rule type after Concierge builds the rule */ onRuleGenerated: (values: GeneratedRuleValues, generatedRuleType: GeneratedRuleType) => void; @@ -92,6 +99,8 @@ function DescribeRuleForm({ inputContainerStyle, style, addBottomSafeAreaPadding = false, + isFullPage = false, + children, onRuleGenerated, onAgentRuleFallback, onFinish, @@ -195,11 +204,12 @@ function DescribeRuleForm({ submitButtonText={submitButtonText} style={style} addBottomSafeAreaPadding={addBottomSafeAreaPadding} - shouldUseScrollView={false} - submitFlexEnabled={false} + shouldUseScrollView={isFullPage} + submitFlexEnabled={isFullPage} shouldHideFixErrorsAlert keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} > + {children} - + {translate('workspace.rules.newRule.describeRule')} - - + ); } diff --git a/src/pages/workspace/rules/DescribeRulePage.tsx b/src/pages/workspace/rules/DescribeRulePage.tsx new file mode 100644 index 000000000000..081a2b324951 --- /dev/null +++ b/src/pages/workspace/rules/DescribeRulePage.tsx @@ -0,0 +1,134 @@ +/** + * Page where an admin describes a new rule in plain English. Concierge builds a standard rule when it can, and the page + * drafts an agent rule when it can't. + */ +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; + +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 {setDraftValues} from '@libs/actions/FormActions'; +import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; +import {setDraftFlagForReviewRule, setDraftMerchantRule, setDraftRequireFieldsRule, setDraftSpendRule} from '@libs/actions/User'; +import {getMerchantRuleDraftFromGeneratedValues} from '@libs/ExpenseDefaultRuleUtils'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getSpendRuleDraftFromGeneratedValues} from '@libs/SpendRulesUtils'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import {clearNewRulePrompt} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; +import type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; + +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import React from 'react'; + +import DescribeRuleForm from './DescribeRuleForm'; + +type DescribeRulePageProps = PlatformStackScreenProps; + +/** Seeds the draft of the editor for the generated rule type and opens that editor */ +function seedDraftAndNavigate(values: GeneratedRuleValues, ruleType: GeneratedRuleType, policyID: string) { + switch (ruleType) { + case CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS: + setDraftRequireFieldsRule(values); + Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID)); + break; + case CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW: + setDraftFlagForReviewRule(values); + Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID)); + break; + case CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND: + setDraftSpendRule(getSpendRuleDraftFromGeneratedValues(values)); + Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + break; + case CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS: + setDraftMerchantRule(getMerchantRuleDraftFromGeneratedValues(values)); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + break; + default: + break; + } +} + +function DescribeRulePage({route}: DescribeRulePageProps) { + const {policyID} = route.params; + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const isFocused = useIsFocused(); + const policy = usePolicy(policyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + + // A generated category stays in the draft only when the policy categories contain it. Therefore this page loads + // the categories before the answer arrives. + const fetchPolicyCategories = () => { + if (!policy?.areCategoriesEnabled || policyCategories) { + return; + } + openPolicyCategoriesPage(policyID); + }; + + useNetwork({onReconnect: fetchPolicyCategories}); + + useFocusEffect(() => { + fetchPolicyCategories(); + // The Describe modal of the rule editors uses the same form. Clear it when this page loses focus, so the modal + // does not open with the description or the error of this page. + return () => clearNewRulePrompt(); + }); + + const offerAgentRule = (prompt: string, generationID: string, state: GeneratedRuleState) => { + Log.info('[GenerateRule] Offered agent rule fallback', false, {policyID, generationID, state}); + setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[AGENT_RULE_INPUT_IDS.PROMPT]: prompt}); + Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID, generationID)); + }; + + return ( + + + + seedDraftAndNavigate(values, generatedRuleType, policyID)} + onAgentRuleFallback={offerAgentRule} + > + {translate('workspace.rules.newRule.describeRule')} + + + + ); +} + +DescribeRulePage.displayName = 'DescribeRulePage'; + +export default DescribeRulePage; diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 03d7f4cc9656..2650296514a5 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -1,4 +1,3 @@ -import ActivityIndicator from '@components/ActivityIndicator'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -8,43 +7,27 @@ import Text from '@components/Text'; 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 {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 {getMerchantRuleDraftFromGeneratedValues} from '@libs/ExpenseDefaultRuleUtils'; -import Log from '@libs/Log'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getSpendRuleDraftFromGeneratedValues} from '@libs/SpendRulesUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; -import {clearNewRulePrompt} 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 {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type IconAsset from '@src/types/utils/IconAsset'; -import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import React from 'react'; import {View} from 'react-native'; -import DescribeRuleForm from './DescribeRuleForm'; - type RulesNewPageProps = | PlatformStackScreenProps | PlatformStackScreenProps; @@ -60,30 +43,6 @@ type NewRuleOption = { isWorkspaceOnly?: boolean; }; -/** Seeds the draft of the editor for the generated rule type and opens that editor */ -function seedDraftAndNavigate(values: GeneratedRuleValues, ruleType: GeneratedRuleType, policyID: string) { - switch (ruleType) { - case CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS: - setDraftRequireFieldsRule(values); - Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID)); - break; - case CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW: - setDraftFlagForReviewRule(values); - Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID)); - break; - case CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND: - setDraftSpendRule(getSpendRuleDraftFromGeneratedValues(values)); - Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); - break; - case CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS: - setDraftMerchantRule(getMerchantRuleDraftFromGeneratedValues(values)); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - break; - default: - break; - } -} - function RulesNewPage({route}: RulesNewPageProps) { const {policyID, categoryName} = route.params; const {translate} = useLocalize(); @@ -93,33 +52,6 @@ function RulesNewPage({route}: RulesNewPageProps) { const isCategoryScopedCreate = isCategorySettingsFlow || !!categoryName; const policy = usePolicy(policyID); const {showConfirmModal} = useConfirmModal(); - const isFocused = useIsFocused(); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - const [isBuildingRule = false] = useOnyx(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, {selector: (form) => form?.isLoading}); - - // A generated category stays in the draft only when the policy categories contain it. Therefore this page loads - // the categories before the answer arrives. - const fetchPolicyCategories = () => { - if (!policy?.areCategoriesEnabled || policyCategories) { - return; - } - openPolicyCategoriesPage(policyID); - }; - - useNetwork({onReconnect: fetchPolicyCategories}); - - useFocusEffect(() => { - fetchPolicyCategories(); - // The Describe modal of the rule editors uses the same form. Clear it when this page loses focus, so the modal - // does not open with the description or the error of this page. - return () => clearNewRulePrompt(); - }); - - const offerAgentRule = (prompt: string, generationID: string, state: GeneratedRuleState) => { - Log.info('[GenerateRule] Offered agent rule fallback', false, {policyID, generationID, state}); - setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[AGENT_RULE_INPUT_IDS.PROMPT]: prompt}); - Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID, generationID)); - }; // From category settings the only expense default is the category's tax rate, so the type chooser is skipped. It // can't be set without a rate to pick, so that case gets the chooser's explainer instead of an empty picker. @@ -139,6 +71,15 @@ function RulesNewPage({route}: RulesNewPageProps) { }; const newRuleOptions: NewRuleOption[] = [ + { + key: 'describeRule', + icon: illustrations.AiBot, + title: translate('workspace.rules.newRule.describeYourRule'), + description: translate('workspace.rules.newRule.describeYourRuleDescription'), + onPress: () => Navigation.navigate(ROUTES.RULES_DESCRIBE.getRoute(policyID)), + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_DESCRIBE_RULE, + isWorkspaceOnly: true, + }, { key: 'restrictCardSpend', icon: illustrations.CardReaderAlt, @@ -184,15 +125,6 @@ function RulesNewPage({route}: RulesNewPageProps) { // Only category settings can scope the editor to its category, so a category passed any other way drops it. isWorkspaceOnly: !isCategorySettingsFlow, }, - { - key: 'createAgentRule', - icon: illustrations.AiBot, - title: translate('workspace.rules.newRule.createAgentRule'), - description: translate('workspace.rules.newRule.createAgentRuleDescription'), - onPress: () => Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)), - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE, - isWorkspaceOnly: true, - }, ]; const visibleNewRuleOptions = isCategoryScopedCreate ? newRuleOptions.filter((option) => !option.isWorkspaceOnly) : newRuleOptions; @@ -214,49 +146,24 @@ function RulesNewPage({route}: RulesNewPageProps) { style={[styles.flexGrow1]} addBottomSafeAreaPadding > - {isCategoryScopedCreate ? ( - {translate('workspace.rules.newRule.subtitle')} - ) : ( - <> - {translate('workspace.rules.newRule.buildUsingAI')} - seedDraftAndNavigate(values, generatedRuleType, policyID)} - onAgentRuleFallback={offerAgentRule} + {translate('workspace.rules.newRule.subtitle')} + + {visibleNewRuleOptions.map((option) => ( + - - )} - {isBuildingRule && !isCategoryScopedCreate ? ( - - - {translate('workspace.rules.newRule.buildingRule')} - - ) : ( - - {!isCategoryScopedCreate && ( - {translate('workspace.rules.newRule.startFromScratch')} - )} - {visibleNewRuleOptions.map((option) => ( - - ))} - - )} + ))} + diff --git a/src/styles/index.ts b/src/styles/index.ts index e016d4d4fee3..447f31c0a549 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6248,10 +6248,6 @@ const staticStyles = (theme: ThemeColors) => height: variables.describeRulePromptInputHeight, }, - describeRuleInlinePromptInput: { - height: variables.describeRuleInlinePromptInputHeight, - }, - emptyStateSamlIllustration: { width: 183, height: 160, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 185cb8d4e354..72376ce47d4f 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -385,7 +385,6 @@ export default { rulesNewMenuItemMinHeight: 84, agentRulePromptInputHeight: 340, describeRulePromptInputHeight: 312, - describeRuleInlinePromptInputHeight: 115, cardPreviewWidth: 235, cardDetailsActionButtonMinWidth: 140, cardScarfOverlayWidth: 264, diff --git a/tests/ui/RulesNewPageDescribeRuleTest.tsx b/tests/ui/DescribeRulePageTest.tsx similarity index 88% rename from tests/ui/RulesNewPageDescribeRuleTest.tsx rename to tests/ui/DescribeRulePageTest.tsx index 151898f17f45..02928820ba18 100644 --- a/tests/ui/RulesNewPageDescribeRuleTest.tsx +++ b/tests/ui/DescribeRulePageTest.tsx @@ -11,6 +11,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import DescribeRulePage from '@pages/workspace/rules/DescribeRulePage'; import RulesNewPage from '@pages/workspace/rules/RulesNewPage'; import CONST from '@src/CONST'; @@ -126,6 +127,7 @@ jest.mock('@components/Form/FormProvider', () => { }); type RulesNewPageProps = PlatformStackScreenProps; +type DescribeRulePageProps = PlatformStackScreenProps; const POLICY_ID = 'policy1'; @@ -147,6 +149,16 @@ async function renderRulesNewPage() { await waitForBatchedUpdatesWithAct(); } +async function renderDescribeRulePage() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only screen props. The page reads only route.params. + const props = { + route: {key: 'Rules_Describe', name: SCREENS.WORKSPACE.RULES_DESCRIBE, params: {policyID: POLICY_ID}}, + navigation: {}, + } as unknown as DescribeRulePageProps; + render(); + await waitForBatchedUpdatesWithAct(); +} + function isGenerateRuleParams(parameters: unknown): parameters is GenerateRuleParams { return typeof parameters === 'object' && parameters !== null && 'generationID' in parameters && 'prompt' in parameters; } @@ -178,7 +190,7 @@ function getAddPolicyAgentRuleCalls(writeSpy: WriteSpy) { return writeSpy.mock.calls.filter(([command]) => command === WRITE_COMMANDS.ADD_POLICY_AGENT_RULE); } -describe('RulesNewPage describe flow', () => { +describe('Describe your rule', () => { let writeSpy: WriteSpy; beforeEach(() => { @@ -195,33 +207,34 @@ describe('RulesNewPage describe flow', () => { }); }); - it('shows the describe form above the rule types', async () => { + it('lists Describe your rule first, and it opens its own page', async () => { // Given an admin on the New rule page - - // When the page renders await renderRulesNewPage(); - // Then the admin can describe the rule, or start from one of the rule types below the form - expect(screen.getByText('workspace.rules.newRule.buildUsingAI')).toBeOnTheScreen(); - expect(screen.getByLabelText('workspace.rules.newRule.buildRule')).toBeOnTheScreen(); - expect(screen.getByText('workspace.rules.newRule.startFromScratch')).toBeOnTheScreen(); + // When the admin looks at the options const options = screen.getAllByRole('button'); + + // Then describing comes first, before the four rule types, so the admin can describe the rule before picking a rule type const expectedOptionNames = [ + 'workspace.rules.newRule.describeYourRule', 'workspace.rules.newRule.restrictCardSpend', 'workspace.rules.newRule.flagForReview', 'workspace.rules.newRule.requireFields', 'workspace.rules.newRule.applyExpenseDefaults', - 'workspace.rules.newRule.createAgentRule', ]; expect(options).toHaveLength(expectedOptionNames.length); for (const [index, name] of expectedOptionNames.entries()) { expect(options.at(index)).toHaveAccessibleName(name); } + + // And it opens the describe page in the RHP + fireEvent.press(screen.getByLabelText('workspace.rules.newRule.describeYourRule')); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_DESCRIBE.getRoute(POLICY_ID)); }); it('seeds the draft of the generated rule type and opens its editor', async () => { - // Given the admin described a flag for review rule from New rule, so the request lets Concierge pick the rule type - await renderRulesNewPage(); + // Given the admin described a flag for review rule on the describe page, so the request lets Concierge pick the rule type + await renderDescribeRulePage(); const {generationID, ruleType} = describeRule(writeSpy, 'Flag expenses over $500'); expect(ruleType).toBeUndefined(); @@ -240,7 +253,7 @@ describe('RulesNewPage describe flow', () => { it('scopes an expense defaults draft to a merchant rule', async () => { // Given the admin described an expense default for a merchant - await renderRulesNewPage(); + await renderDescribeRulePage(); const {generationID} = describeRule(writeSpy, 'Set Uber expenses to not billable'); // When Concierge answers with an expense defaults rule @@ -262,7 +275,7 @@ describe('RulesNewPage describe flow', () => { // Given an admin described something no standard rule can do const logSpy = jest.spyOn(Log, 'info'); const prompt = 'Block gift cards and require receipts over $50'; - await renderRulesNewPage(); + await renderDescribeRulePage(); const {generationID} = describeRule(writeSpy, prompt); // When Concierge answers that it is not one standard rule @@ -280,8 +293,8 @@ describe('RulesNewPage describe flow', () => { ); it.each([undefined, 'futureRuleType'])('shows the unsupported error when a rule answer has the rule type %s', async (generatedRuleType) => { - // Given an admin described a rule from New rule - await renderRulesNewPage(); + // Given an admin described a rule on the describe page + await renderDescribeRulePage(); const {generationID} = describeRule(writeSpy, 'Flag expenses over $500'); // When Concierge answers with a rule but with no rule type, or with a rule type that this app version does not know @@ -299,29 +312,12 @@ describe('RulesNewPage describe flow', () => { expect(Navigation.navigate).not.toHaveBeenCalled(); }); - it('shows that the rule is building in place of the rule types', async () => { - // Given the admin described a rule from New rule - await renderRulesNewPage(); - describeRule(writeSpy, 'Flag expenses over $500'); - - // When the form waits for the answer - await act(async () => { - await Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, {isLoading: true}); - }); - await waitForBatchedUpdatesWithAct(); - - // Then the page says the rule is building and hides the rule types, so the admin does not start a second rule - expect(screen.getByText('workspace.rules.newRule.buildingRule')).toBeOnTheScreen(); - expect(screen.queryByText('workspace.rules.newRule.startFromScratch')).toBeNull(); - expect(screen.queryByLabelText('workspace.rules.newRule.flagForReview')).toBeNull(); - }); - it('loads the policy categories when the workspace uses categories and none are loaded', async () => { // Given a workspace that uses categories, with no categories loaded setAdminPolicy(true); - // When the New rule page opens - await renderRulesNewPage(); + // When the describe page opens + await renderDescribeRulePage(); // Then the page requests the categories, because the describe form keeps only a category that the workspace has expect(openPolicyCategoriesPage).toHaveBeenCalledWith(POLICY_ID); @@ -329,7 +325,7 @@ describe('RulesNewPage describe flow', () => { it('keeps the inline error for an unintelligible description', async () => { // Given an admin typed something Concierge cannot read - await renderRulesNewPage(); + await renderDescribeRulePage(); const {generationID} = describeRule(writeSpy, 'asdf qwerty zxcv'); // When Concierge answers that the description is unintelligible From b4004e4887518d22d83b1d85c87d120d9a2d9868 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Wed, 14 Oct 2026 00:09:16 -0700 Subject: [PATCH 10/20] fix(rules): apply Polyglot Parrot translation refinements --- src/languages/de.ts | 6 +++--- src/languages/el.ts | 4 ++-- src/languages/es.ts | 2 +- src/languages/fr.ts | 4 ++-- src/languages/it.ts | 2 +- src/languages/ja.ts | 6 +++--- src/languages/nl.ts | 4 ++-- src/languages/pl.ts | 6 +++--- src/languages/pt-BR.ts | 4 ++-- src/languages/zh-hans.ts | 4 ++-- 10 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 4995435b9182..c06a2f6dd6bc 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8743,7 +8743,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc gotIt: 'Verstanden', createRule: 'Regel erstellen', fallbackBanner: - 'Das ist keine Standardregel, daher haben wir sie als Agentenregel entworfen. Agentenregeln greifen, nachdem sich eine Ausgabe oder ein Bericht ändert, zum Beispiel indem sie die Ausgabe zurückhalten oder den Bericht ablehnen.', + 'Dies ist keine Standardregel, daher haben wir sie als Agentenregel erstellt. Agentenregeln greifen, nachdem sich eine Ausgabe oder ein Bericht geändert hat, zum Beispiel indem sie die Ausgabe zurückhalten oder den Bericht ablehnen.', }, tabs: { general: 'Allgemein', @@ -8814,8 +8814,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc flagForReviewDescription: 'Genehmigende benachrichtigen, wenn Ausgaben Kategorienlimits überschreiten', requireFields: 'Feldanforderungen festlegen', requireFieldsDescription: 'Bestimmte Spesenfelder vorschreiben oder auf ihre Pflicht verzichten.', - describeYourRule: 'Beschreibe deine Regel', - describeYourRuleDescription: 'Sag Concierge, was du brauchst, und Concierge erstellt die Regel', + describeYourRule: 'Beschreiben Sie Ihre Regel', + describeYourRuleDescription: 'Sagen Sie Concierge, was Sie brauchen, und es erstellt die Regel', buildRule: 'Regel erstellen', describe: 'Beschreiben', describeRule: 'Beschreibe deine Regel in einfachen Worten und Concierge erstellt sie für dich', diff --git a/src/languages/el.ts b/src/languages/el.ts index eb83ce19e672..fd371ffd28fe 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8905,7 +8905,7 @@ ${reportName}`, applyExpenseDefaults: 'Εφαρμογή προεπιλογών δαπανών', applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', describeYourRule: 'Περιγράψτε τον κανόνα σας', - describeYourRuleDescription: 'Πείτε στον Concierge τι χρειάζεστε και θα δημιουργήσει τον κανόνα', + describeYourRuleDescription: 'Πείτε στο Concierge τι χρειάζεστε και αυτό θα δημιουργήσει τον κανόνα', buildRule: 'Δημιουργία κανόνα', describe: 'Περιγραφή', describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', @@ -9198,7 +9198,7 @@ ${reportName}`, describeRuleForConcierge: 'Περιγράψτε τον κανόνα σας και το Concierge θα τον δημιουργήσει', createRule: 'Δημιουργία κανόνα', fallbackBanner: - 'Αυτός δεν είναι τυπικός κανόνας, οπότε τον συντάξαμε ως κανόνα πράκτορα. Οι κανόνες πράκτορα ενεργούν αφού αλλάξει μια δαπάνη ή μια αναφορά, για παράδειγμα θέτοντας τη δαπάνη σε αναστολή ή απορρίπτοντας την αναφορά.', + 'Αυτός δεν είναι ένας τυπικός κανόνας, οπότε τον διαμορφώσαμε ως κανόνα πράκτορα. Οι κανόνες πράκτορα εφαρμόζονται αφού αλλάξει μια δαπάνη ή μια αναφορά, για παράδειγμα κρατώντας τη δαπάνη ή απορρίπτοντας την αναφορά.', suggestionsTab: 'Προτεινόμενο', writeTab: 'Επεξεργασία', findSuggestion: 'Βρείτε έναν κανόνα', diff --git a/src/languages/es.ts b/src/languages/es.ts index af80797fa9b4..f5996dec6153 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8748,7 +8748,7 @@ ${reportName}`, gotIt: 'Entendido', createRule: 'Crear regla', fallbackBanner: - 'Esta no es una regla estándar, así que la redactamos como regla de agente. Las reglas de agente actúan después de que cambia un gasto o un informe, por ejemplo, reteniendo el gasto o rechazando el informe.', + 'Esta no es una regla estándar, así que la redactamos como una regla de agente. Las reglas de agente actúan después de que un gasto o informe cambia, por ejemplo reteniendo el gasto o rechazando el informe.', }, tabs: { general: 'General', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 72ce01a7af3e..2b669397ea87 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8766,7 +8766,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e gotIt: 'Compris', createRule: 'Créer une règle', fallbackBanner: - 'Ce n’est pas une règle standard, nous l’avons donc rédigée comme une règle d’agent. Les règles d’agent agissent après la modification d’une dépense ou d’une note de frais, par exemple en mettant la dépense en attente ou en rejetant la note de frais.', + 'Ce n’est pas une règle standard, nous l’avons donc rédigée comme une règle d’agent. Les règles d’agent agissent après la modification d’une dépense ou d’une note de frais, par exemple en bloquant la dépense ou en rejetant la note de frais.', }, tabs: { general: 'Général', @@ -8839,7 +8839,7 @@ 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.', describeYourRule: 'Décrivez votre règle', describeYourRuleDescription: 'Dites à Concierge ce dont vous avez besoin et il créera la règle', - buildRule: 'Créer la règle', + buildRule: 'Créer une règle', 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', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6f60141096f9..d16268b79536 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8694,7 +8694,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, gotIt: 'Capito', createRule: 'Crea regola', fallbackBanner: - 'Questa non è una regola standard, quindi l’abbiamo preparata come regola agente. Le regole agente intervengono dopo che una spesa o un rapporto cambia, ad esempio mettendo in sospeso la spesa o rifiutando il rapporto.', + 'Questa non è una regola standard, quindi l’abbiamo creata come regola agente. Le regole agente agiscono dopo che una spesa o un report vengono modificati, ad esempio bloccando la spesa o rifiutando il report.', }, tabs: { general: 'Generale', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 86d3bcef2801..217f6588e131 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8583,7 +8583,7 @@ ${reportName}`, gotIt: '了解しました', createRule: 'ルールを作成', fallbackBanner: - 'これは標準ルールではないため、エージェントルールとして下書きしました。エージェントルールは、経費やレポートが変更された後に動作します。たとえば、経費を保留したり、レポートを却下したりします。', + 'これは標準ルールではないため、エージェントルールとして作成しました。エージェントルールは、経費やレポートに変更があった後に動作し、例えば経費を保留にしたり、レポートを却下したりします。', }, tabs: { general: '一般', @@ -8654,8 +8654,8 @@ ${reportName}`, flagForReviewDescription: '条件が満たされたときに通知します。', requireFields: 'フィールドの必須条件を設定', requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', - describeYourRule: 'ルールを説明する', - describeYourRuleDescription: '必要なことを伝えると、Concierge がルールを作成します', + describeYourRule: 'ルールの内容を入力してください', + describeYourRuleDescription: '必要な内容をConciergeに伝えると、自動でルールを作成します', buildRule: 'ルールを作成', describe: '説明する', describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 99a92bc8b86c..83c9f9ee46d3 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8668,7 +8668,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, gotIt: 'Begrepen', createRule: 'Regel maken', fallbackBanner: - 'Dit is geen standaardregel, dus we hebben er een agentregel van gemaakt. Agentregels treden in werking nadat een uitgave of rapport verandert, bijvoorbeeld door de uitgave vast te houden of het rapport af te wijzen.', + 'Dit is geen standaardregel, dus hebben we hem opgesteld als een agentregel. Agentregels treden in werking nadat een uitgave of rapport verandert, bijvoorbeeld door de uitgave vast te houden of het rapport af te wijzen.', }, tabs: { general: 'Algemeen', @@ -8740,7 +8740,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, requireFields: 'Veldvereisten instellen', requireFieldsDescription: 'Specifieke onkostenvakken verplicht stellen of afzien van het verplicht stellen ervan.', describeYourRule: 'Beschrijf je regel', - describeYourRuleDescription: 'Vertel Concierge wat je nodig hebt en Concierge maakt de regel', + describeYourRuleDescription: 'Vertel Concierge wat je nodig hebt en die maakt de regel voor je', buildRule: 'Regel maken', describe: 'Beschrijven', describeRule: 'Beschrijf je regel in gewone taal en Concierge maakt de regel voor je', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 253066050fcb..4044739fb220 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8693,7 +8693,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, gotIt: 'Jasne', createRule: 'Utwórz regułę', fallbackBanner: - 'To nie jest standardowa reguła, więc przygotowaliśmy ją jako regułę agenta. Reguły agenta działają po zmianie wydatku lub raportu, na przykład wstrzymując wydatek lub odrzucając raport.', + 'To nie jest standardowa reguła, więc przygotowaliśmy ją jako regułę agenta. Reguły agenta działają po zmianie wydatku lub raportu, na przykład wstrzymując wydatek albo odrzucając raport.', }, tabs: { general: 'Ogólne', @@ -8765,8 +8765,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, requireFields: 'Ustaw wymagania pola', requireFieldsDescription: 'Wymagaj konkretnych pól wydatku lub zrezygnuj z ich wymagania.', describeYourRule: 'Opisz swoją regułę', - describeYourRuleDescription: 'Powiedz Concierge, czego potrzebujesz, a utworzy regułę', - buildRule: 'Utwórz regułę', + describeYourRuleDescription: 'Powiedz Concierge, czego potrzebujesz, a on utworzy regułę', + buildRule: 'Zbuduj regułę', describe: 'Opisz', describeRule: 'Opisz swoją regułę własnymi słowami, a Concierge utworzy ją za Ciebie', describeRuleInputLabel: 'Opisz regułę', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index f60dccf34d2e..4d97e6440f58 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8675,7 +8675,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, gotIt: 'Entendi', createRule: 'Criar regra', fallbackBanner: - 'Esta não é uma regra padrão, então a redigimos como uma regra de agente. As regras de agente agem depois que uma despesa ou um relatório muda, por exemplo, retendo a despesa ou rejeitando o relatório.', + 'Esta não é uma regra padrão, então a criamos como uma regra de agente. As regras de agente entram em ação depois que uma despesa ou um relatório é alterado, por exemplo, ao reter a despesa ou rejeitar o relatório.', }, tabs: { general: 'Geral', @@ -8747,7 +8747,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, requireFields: 'Definir requisitos de campo', requireFieldsDescription: 'Exigir campos específicos de despesa ou dispensar a exigência deles.', describeYourRule: 'Descreva sua regra', - describeYourRuleDescription: 'Diga ao Concierge do que você precisa e ele criará a regra', + describeYourRuleDescription: 'Diga ao Concierge do que você precisa e ele vai criar a regra', buildRule: 'Criar regra', describe: 'Descrever', describeRule: 'Descreva sua regra em linguagem simples e o Concierge criará a regra para você', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 87b377013e86..f03cc9198a6b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8371,7 +8371,7 @@ ${reportName}`, describeRuleForConcierge: '描述你的规则,Concierge 会帮你创建', gotIt: '知道了', createRule: '创建规则', - fallbackBanner: '这不是标准规则,因此我们将其起草为代理规则。代理规则会在报销或报表发生变化后执行操作,例如暂挂该报销或拒绝该报表。', + fallbackBanner: '这不是一条标准规则,所以我们将其制定为代理规则。代理规则会在报销或报表发生变更后生效,例如暂扣该报销或驳回该报表。', }, tabs: {general: '常规', cardRestrictions: '卡片限制', expenseDefaults: '报销默认设置', requireFields: '字段要求', flagForReview: '标记以供审核', agents: '代理人'}, bulkActions: { @@ -8437,7 +8437,7 @@ ${reportName}`, requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', describeYourRule: '描述你的规则', describeYourRuleDescription: '告诉 Concierge 你的需求,它会为你创建规则', - buildRule: '创建规则', + buildRule: '构建规则', describe: '描述', describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', describeRuleInputLabel: '描述规则', From 8e4e66e4268293dee908233b6e1ec00ea5dad56b Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Wed, 14 Oct 2026 00:56:27 -0700 Subject: [PATCH 11/20] fix(rules): add an Add agent rule row under the Agents tab list --- .../workspace/rules/tabs/RulesAgentsTab.tsx | 18 +++ tests/ui/RulesAgentsTabTest.tsx | 131 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 tests/ui/RulesAgentsTabTest.tsx diff --git a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx index f626758620bd..1cbb0b4566a9 100644 --- a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx @@ -1,6 +1,8 @@ import AgentRulesList from '@components/AgentRules/AgentRulesList'; import useAgentRulesSectionHeader from '@components/AgentRules/useAgentRulesSectionHeader'; import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; +import MenuItem from '@components/MenuItem'; +import MenuItemSectionRoot from '@components/MenuItem/presets/MenuItemSectionRoot'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; @@ -111,6 +113,22 @@ function RulesAgentsTab({policyID, canWriteRules, showReadOnlyModal, headerCompo listContainerStyle={[styles.mt6, styles.gap2]} menuItemWrapperStyle={styles.justifyContentCenter} /> + {/* The row stays pressable when the user cannot write rules. Pressing it explains why, so it only looks disabled. */} + + + + + + + + {translate('workspace.rules.agentRules.addRule')} + + + + diff --git a/tests/ui/RulesAgentsTabTest.tsx b/tests/ui/RulesAgentsTabTest.tsx new file mode 100644 index 000000000000..083f7035376e --- /dev/null +++ b/tests/ui/RulesAgentsTabTest.tsx @@ -0,0 +1,131 @@ +import {render} from '@testing-library/react-native'; + +import MenuItemSectionRoot from '@components/MenuItem/presets/MenuItemSectionRoot'; + +import usePolicy from '@hooks/usePolicy'; + +import Navigation from '@libs/Navigation/Navigation'; + +import RulesAgentsTab from '@pages/workspace/rules/tabs/RulesAgentsTab'; + +import ROUTES from '@src/ROUTES'; + +import React from 'react'; + +jest.mock('@components/AgentRules/AgentRulesList', () => jest.fn(() => null)); +jest.mock('@components/AgentRules/useAgentRulesSectionHeader', () => jest.fn(() => ({renderTitle: jest.fn(), renderSubtitle: jest.fn()}))); +jest.mock('@components/EmptyStateComponent/GenericEmptyStateComponent', () => jest.fn(() => null)); +jest.mock('@components/MenuItem', () => + Object.assign( + jest.fn(() => null), + { + Row: jest.fn(() => null), + Leading: jest.fn(() => null), + Content: jest.fn(() => null), + Icon: jest.fn(() => null), + Title: jest.fn(() => null), + }, + ), +); +jest.mock('@components/MenuItem/presets/MenuItemSectionRoot', () => jest.fn(() => null)); +jest.mock( + '@components/ScrollView', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/Section', + () => + ({children}: {children: React.ReactNode}) => + children, +); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({Plus: null})), + useMemoizedLazyIllustrations: jest.fn(() => ({AgentsIceCream: null})), +})); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); +jest.mock('@hooks/useStyleUtils', () => jest.fn(() => ({getMaximumWidth: jest.fn(() => ({}))}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), +})); + +const mockedUsePolicy = jest.mocked(usePolicy); +const mockedMenuItemSectionRoot = jest.mocked(MenuItemSectionRoot); +const mockedNavigate = jest.mocked(Navigation.navigate); + +const POLICY_ID = 'POLICY_ID_1'; + +const mockKeyboardEvent = new KeyboardEvent('keydown'); + +function pressAddAgentRule() { + const onPress = mockedMenuItemSectionRoot.mock.calls.at(0)?.at(0)?.onPress; + onPress?.(mockKeyboardEvent); +} + +describe('RulesAgentsTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Given a workspace that already has an agent rule, so the tab shows the list and not the empty state + (mockedUsePolicy as jest.Mock).mockReturnValue({ + id: POLICY_ID, + rules: {agentRules: {r1: {ruleID: 'r1', prompt: 'Reject expenses over $75', created: '2026-01-01 00:00:00'}}}, + }); + }); + + it('opens a new agent rule from the row under the list', () => { + // Given an admin who can write rules + render( + , + ); + + // When the admin presses Add agent rule + pressAddAgentRule(); + + // Then the agent rule page opens, because New rule has no agent rule option of its own + expect(mockedNavigate).toHaveBeenCalledWith(ROUTES.RULES_AGENT_NEW.getRoute(POLICY_ID)); + }); + + it('explains why a user who cannot write rules cannot add one', () => { + // Given a user who cannot write rules + const showReadOnlyModal = jest.fn(); + render( + , + ); + + // When the user presses Add agent rule + pressAddAgentRule(); + + // Then the read-only explanation shows and no page opens + expect(showReadOnlyModal).toHaveBeenCalled(); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); +}); From 950f1297e0d1c004866e6a44acd4791f7e4c1935 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Wed, 14 Oct 2026 00:57:50 -0700 Subject: [PATCH 12/20] style(rules): match the add agent rule row's bottom spacing --- src/pages/workspace/rules/tabs/RulesAgentsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx index 1cbb0b4566a9..41d20ec2212a 100644 --- a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx @@ -114,7 +114,7 @@ function RulesAgentsTab({policyID, canWriteRules, showReadOnlyModal, headerCompo menuItemWrapperStyle={styles.justifyContentCenter} /> {/* The row stays pressable when the user cannot write rules. Pressing it explains why, so it only looks disabled. */} - + Date: Wed, 14 Oct 2026 13:48:01 -0700 Subject: [PATCH 13/20] feat(rules): show Describe your rule only with the describeRule beta --- src/CONST/index.ts | 1 + 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 + .../workspace/rules/DescribeRulePage.tsx | 3 ++ src/pages/workspace/rules/RulesNewPage.tsx | 39 ++++++++++++++----- tests/ui/DescribeRulePageTest.tsx | 35 ++++++++++++++++- 15 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2e35a1afa21a..b092698dd718 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -10285,6 +10285,7 @@ const CONST = { NEW_RULE_MENU_ITEM_FLAG_FOR_REVIEW: 'WorkspaceRules-NewRuleMenuItem-FlagForReview', 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_MENU_ITEM_DESCRIBE_RULE: 'WorkspaceRules-NewRuleMenuItem-DescribeRule', REQUIRE_RECEIPTS_SAVE: 'WorkspaceRules-RequireReceiptsSave', REQUIRE_FIELDS_SAVE: 'WorkspaceRules-RequireFieldsSave', diff --git a/src/languages/de.ts b/src/languages/de.ts index c06a2f6dd6bc..5572ab35b5c2 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8814,6 +8814,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc flagForReviewDescription: 'Genehmigende benachrichtigen, wenn Ausgaben Kategorienlimits überschreiten', requireFields: 'Feldanforderungen festlegen', 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.', describeYourRule: 'Beschreiben Sie Ihre Regel', describeYourRuleDescription: 'Sagen Sie Concierge, was Sie brauchen, und es erstellt die Regel', buildRule: 'Regel erstellen', diff --git a/src/languages/el.ts b/src/languages/el.ts index fd371ffd28fe..eeaa84760e62 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8904,6 +8904,8 @@ ${reportName}`, requireFieldsDescription: 'Απαιτήστε συγκεκριμένα πεδία εξόδων ή παραλείψτε την απαίτησή τους.', applyExpenseDefaults: 'Εφαρμογή προεπιλογών δαπανών', applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', + createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', + createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', describeYourRule: 'Περιγράψτε τον κανόνα σας', describeYourRuleDescription: 'Πείτε στο Concierge τι χρειάζεστε και αυτό θα δημιουργήσει τον κανόνα', buildRule: 'Δημιουργία κανόνα', diff --git a/src/languages/en.ts b/src/languages/en.ts index 172e94ad0db5..60389f465282 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9089,6 +9089,8 @@ const translations = { requireFieldsDescription: 'Require specific expense fields or waive requiring them.', applyExpenseDefaults: 'Apply expense defaults', applyExpenseDefaultsDescription: 'Update fields without submitter doing anything', + createAgentRule: 'Describe agent rule', + createAgentRuleDescription: 'Create flexible rules that run when you need.', describeYourRule: 'Describe your rule', describeYourRuleDescription: 'Tell Concierge what you need and it will build the rule', buildRule: 'Build rule', diff --git a/src/languages/es.ts b/src/languages/es.ts index f5996dec6153..eee7d5dcadf0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8819,6 +8819,8 @@ ${reportName}`, flagForReviewDescription: 'Notificar a los aprobadores cuando los gastos superen los límites de categoría', requireFields: 'Configurar requisitos de campos', 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.', describeYourRule: 'Describe tu regla', describeYourRuleDescription: 'Dile a Concierge lo que necesitas y creará la regla', buildRule: 'Crear regla', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2b669397ea87..ae623f076135 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8837,6 +8837,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e flagForReviewDescription: 'Avertir les approbateurs lorsque les dépenses dépassent les limites de catégorie', requireFields: 'Définir les exigences des champs', 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.', describeYourRule: 'Décrivez votre règle', describeYourRuleDescription: 'Dites à Concierge ce dont vous avez besoin et il créera la règle', buildRule: 'Créer une règle', diff --git a/src/languages/it.ts b/src/languages/it.ts index d16268b79536..43bc906a6796 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8765,6 +8765,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, flagForReviewDescription: 'Avvisa gli approvatori quando le spese superano i limiti di categoria', requireFields: 'Imposta i requisiti del campo', 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.', describeYourRule: 'Descrivi la tua regola', describeYourRuleDescription: 'Di’ a Concierge di cosa hai bisogno e creerà la regola', buildRule: 'Crea regola', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 217f6588e131..70ed6117c6b4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8654,6 +8654,8 @@ ${reportName}`, flagForReviewDescription: '条件が満たされたときに通知します。', requireFields: 'フィールドの必須条件を設定', requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', + createAgentRule: 'エージェントルールの説明', + createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', describeYourRule: 'ルールの内容を入力してください', describeYourRuleDescription: '必要な内容をConciergeに伝えると、自動でルールを作成します', buildRule: 'ルールを作成', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 83c9f9ee46d3..ae2a72a16231 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8739,6 +8739,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, flagForReviewDescription: 'Goedkeurders informeren wanneer onkosten de categorielimieten overschrijden', requireFields: 'Veldvereisten instellen', 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.', describeYourRule: 'Beschrijf je regel', describeYourRuleDescription: 'Vertel Concierge wat je nodig hebt en die maakt de regel voor je', buildRule: 'Regel maken', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 4044739fb220..afed68a92d4c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8764,6 +8764,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, flagForReviewDescription: 'Powiadamiaj zatwierdzających, gdy wydatki przekraczają limity kategorii', requireFields: 'Ustaw wymagania pola', 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.', describeYourRule: 'Opisz swoją regułę', describeYourRuleDescription: 'Powiedz Concierge, czego potrzebujesz, a on utworzy regułę', buildRule: 'Zbuduj regułę', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4d97e6440f58..56de887d07ce 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8746,6 +8746,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, flagForReviewDescription: 'Notificar aprovadores quando as despesas excederem os limites da categoria', requireFields: 'Definir requisitos de campo', 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.', describeYourRule: 'Descreva sua regra', describeYourRuleDescription: 'Diga ao Concierge do que você precisa e ele vai criar a regra', buildRule: 'Criar regra', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index f03cc9198a6b..cc2e0dd2a225 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8435,6 +8435,8 @@ ${reportName}`, flagForReviewDescription: '当费用超出类别限额时通知审批人', requireFields: '设置字段要求', requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', + createAgentRule: '描述代理规则', + createAgentRuleDescription: '创建可按需运行的灵活规则。', describeYourRule: '描述你的规则', describeYourRuleDescription: '告诉 Concierge 你的需求,它会为你创建规则', buildRule: '构建规则', diff --git a/src/pages/workspace/rules/DescribeRulePage.tsx b/src/pages/workspace/rules/DescribeRulePage.tsx index 081a2b324951..97f7f0c169b3 100644 --- a/src/pages/workspace/rules/DescribeRulePage.tsx +++ b/src/pages/workspace/rules/DescribeRulePage.tsx @@ -9,6 +9,7 @@ import Text from '@components/Text'; 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'; @@ -69,6 +70,7 @@ function DescribeRulePage({route}: DescribeRulePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const isFocused = useIsFocused(); + const {isBetaEnabled} = usePermissions(); const policy = usePolicy(policyID); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -103,6 +105,7 @@ function DescribeRulePage({route}: DescribeRulePageProps) { accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} policyFeature={CONST.POLICY.POLICY_FEATURE.RULES} policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} + shouldBeBlocked={!isBetaEnabled(CONST.BETAS.DESCRIBE_RULE)} > Navigation.navigate(ROUTES.RULES_DESCRIBE.getRoute(policyID)), - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_DESCRIBE_RULE, - isWorkspaceOnly: true, - }, + ...(isDescribeRuleBetaEnabled + ? [ + { + key: 'describeRule', + icon: illustrations.AiBot, + title: translate('workspace.rules.newRule.describeYourRule'), + description: translate('workspace.rules.newRule.describeYourRuleDescription'), + onPress: () => Navigation.navigate(ROUTES.RULES_DESCRIBE.getRoute(policyID)), + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_DESCRIBE_RULE, + isWorkspaceOnly: true, + }, + ] + : []), { key: 'restrictCardSpend', icon: illustrations.CardReaderAlt, @@ -125,6 +132,20 @@ function RulesNewPage({route}: RulesNewPageProps) { // Only category settings can scope the editor to its category, so a category passed any other way drops it. isWorkspaceOnly: !isCategorySettingsFlow, }, + // Describe your rule drafts an agent rule when it cannot build a standard rule. Therefore it replaces this option. + ...(isDescribeRuleBetaEnabled + ? [] + : [ + { + key: 'createAgentRule', + icon: illustrations.AiBot, + title: translate('workspace.rules.newRule.createAgentRule'), + description: translate('workspace.rules.newRule.createAgentRuleDescription'), + onPress: () => Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)), + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE, + isWorkspaceOnly: true, + }, + ]), ]; const visibleNewRuleOptions = isCategoryScopedCreate ? newRuleOptions.filter((option) => !option.isWorkspaceOnly) : newRuleOptions; diff --git a/tests/ui/DescribeRulePageTest.tsx b/tests/ui/DescribeRulePageTest.tsx index 02928820ba18..78ba676295ee 100644 --- a/tests/ui/DescribeRulePageTest.tsx +++ b/tests/ui/DescribeRulePageTest.tsx @@ -32,6 +32,7 @@ import {translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; let mockPrompt = ''; +let mockIsDescribeRuleBetaEnabled = true; jest.mock('@react-navigation/native', () => { const actualNavigation = jest.requireActual('@react-navigation/native'); @@ -51,6 +52,11 @@ jest.mock('@hooks/useLocalize', () => translate: (key: string) => key, })), ); +jest.mock('@hooks/usePermissions', () => + jest.fn(() => ({ + isBetaEnabled: (beta: string) => beta === jest.requireActual<{default: typeof CONST}>('@src/CONST').default.BETAS.DESCRIBE_RULE && mockIsDescribeRuleBetaEnabled, + })), +); jest.mock('@hooks/usePolicy', () => jest.fn()); jest.mock('@libs/actions/Policy/Category', () => ({ ...jest.requireActual>('@libs/actions/Policy/Category'), @@ -196,6 +202,7 @@ describe('Describe your rule', () => { beforeEach(() => { jest.clearAllMocks(); mockPrompt = ''; + mockIsDescribeRuleBetaEnabled = true; writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); setAdminPolicy(); }); @@ -208,7 +215,7 @@ describe('Describe your rule', () => { }); it('lists Describe your rule first, and it opens its own page', async () => { - // Given an admin on the New rule page + // Given an admin with the describeRule beta on the New rule page await renderRulesNewPage(); // When the admin looks at the options @@ -232,6 +239,32 @@ describe('Describe your rule', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_DESCRIBE.getRoute(POLICY_ID)); }); + it('keeps Describe agent rule in place of Describe your rule without the beta', async () => { + // Given an admin without the describeRule beta, who must see the New rule options that exist without the feature + mockIsDescribeRuleBetaEnabled = false; + await renderRulesNewPage(); + + // When the admin looks at the options + const options = screen.getAllByRole('button'); + + // Then the four rule types come first and Describe agent rule comes last, with no Describe your rule + const expectedOptionNames = [ + 'workspace.rules.newRule.restrictCardSpend', + 'workspace.rules.newRule.flagForReview', + 'workspace.rules.newRule.requireFields', + 'workspace.rules.newRule.applyExpenseDefaults', + 'workspace.rules.newRule.createAgentRule', + ]; + expect(options).toHaveLength(expectedOptionNames.length); + for (const [index, name] of expectedOptionNames.entries()) { + expect(options.at(index)).toHaveAccessibleName(name); + } + + // And Describe agent rule opens the agent rule page + fireEvent.press(screen.getByLabelText('workspace.rules.newRule.createAgentRule')); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_AGENT_NEW.getRoute(POLICY_ID)); + }); + it('seeds the draft of the generated rule type and opens its editor', async () => { // Given the admin described a flag for review rule on the describe page, so the request lets Concierge pick the rule type await renderDescribeRulePage(); From c2624468e4c6dab93b255be51f0fb5f95b1c675e Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Wed, 14 Oct 2026 15:05:24 -0700 Subject: [PATCH 14/20] fix(rules): align agent rule Back with the fallback state Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rules/AgentRules/AddAgentRulePage.tsx | 2 +- tests/ui/AddAgentRulePageTest.tsx | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index a0917f767c07..7b1a9f9fa86a 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -141,7 +141,7 @@ function AddAgentRulePage({ }; const handleBackButtonPress = () => { - if (!generationID && activeTab === CONST.TAB.AGENT_RULE.WRITE) { + if (!isDraftFromFallback && activeTab === CONST.TAB.AGENT_RULE.WRITE) { setActiveTab(CONST.TAB.AGENT_RULE.SUGGESTIONS); return; } diff --git a/tests/ui/AddAgentRulePageTest.tsx b/tests/ui/AddAgentRulePageTest.tsx index 603a2056f86d..a324d693ef38 100644 --- a/tests/ui/AddAgentRulePageTest.tsx +++ b/tests/ui/AddAgentRulePageTest.tsx @@ -270,6 +270,24 @@ describe('AddAgentRulePage', () => { logSpy.mockRestore(); }); + it('switches from the Write tab to Suggestions on Back after the admin picks a suggestion in the fallback flow', async () => { + // Given the describe flow opened the page with a generationID and the admin picked a suggestion + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByLabelText('workspace.rules.agentRules.suggestionsTab')); + fireEvent.press(screen.getByLabelText('PickSuggestion')); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + + // When the admin presses Back + fireEvent.press(screen.getByLabelText('Back')); + + // Then the page returns to Suggestions and stays open, because picking a suggestion leaves the fallback flow + expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(screen.queryByText('WriteTab')).toBeNull(); + }); + it('logs the generationID with the saved agent rule when the page opens with a generationID', async () => { // Given the describe flow opened the page with a generationID const logSpy = jest.spyOn(Log, 'info'); From cff27290528b713b42ed23d3291df31dd255843d Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Wed, 14 Oct 2026 15:20:34 -0700 Subject: [PATCH 15/20] refactor(rules): pass the describe form's layout props through --- src/pages/workspace/rules/DescribeRuleForm.tsx | 12 +++++++----- src/pages/workspace/rules/DescribeRulePage.tsx | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/rules/DescribeRuleForm.tsx b/src/pages/workspace/rules/DescribeRuleForm.tsx index 64434557acd5..0953283f8652 100644 --- a/src/pages/workspace/rules/DescribeRuleForm.tsx +++ b/src/pages/workspace/rules/DescribeRuleForm.tsx @@ -52,8 +52,9 @@ type DescribeRuleFormProps = { addBottomSafeAreaPadding?: boolean; - /** Whether the form scrolls and keeps the submit button at the bottom, as on a full page */ - isFullPage?: boolean; + shouldUseScrollView?: boolean; + + submitFlexEnabled?: boolean; /** Content above the input */ children?: ReactNode; @@ -99,7 +100,8 @@ function DescribeRuleForm({ inputContainerStyle, style, addBottomSafeAreaPadding = false, - isFullPage = false, + shouldUseScrollView = false, + submitFlexEnabled = false, children, onRuleGenerated, onAgentRuleFallback, @@ -204,8 +206,8 @@ function DescribeRuleForm({ submitButtonText={submitButtonText} style={style} addBottomSafeAreaPadding={addBottomSafeAreaPadding} - shouldUseScrollView={isFullPage} - submitFlexEnabled={isFullPage} + shouldUseScrollView={shouldUseScrollView} + submitFlexEnabled={submitFlexEnabled} shouldHideFixErrorsAlert keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} > diff --git a/src/pages/workspace/rules/DescribeRulePage.tsx b/src/pages/workspace/rules/DescribeRulePage.tsx index 97f7f0c169b3..d7edc0a5e626 100644 --- a/src/pages/workspace/rules/DescribeRulePage.tsx +++ b/src/pages/workspace/rules/DescribeRulePage.tsx @@ -121,7 +121,8 @@ function DescribeRulePage({route}: DescribeRulePageProps) { inputContainerStyle={styles.describeRulePromptInput} style={[styles.flexGrow1, styles.mh5]} addBottomSafeAreaPadding - isFullPage + shouldUseScrollView + submitFlexEnabled onRuleGenerated={(values, generatedRuleType) => seedDraftAndNavigate(values, generatedRuleType, policyID)} onAgentRuleFallback={offerAgentRule} > From 90bc5dc0197b0a42e4f7d1a670a060636c22ef2c Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Thu, 15 Oct 2026 11:43:16 -0700 Subject: [PATCH 16/20] feat(rules): describe a rule in a New rule section instead of a page --- src/CONST/index.ts | 1 - src/ROUTES.ts | 4 - src/SCREENS.ts | 1 - src/languages/de.ts | 13 +-- src/languages/el.ts | 13 +-- src/languages/en.ts | 13 +-- src/languages/es.ts | 13 +-- src/languages/fr.ts | 13 +-- src/languages/it.ts | 13 +-- src/languages/ja.ts | 13 +-- src/languages/nl.ts | 13 +-- src/languages/pl.ts | 13 +-- src/languages/pt-BR.ts | 13 +-- src/languages/zh-hans.ts | 13 +-- .../ModalStackNavigators/index.tsx | 1 - .../RELATIONS/WORKSPACE_TO_RHP.ts | 1 - src/libs/Navigation/linkingConfig/config.ts | 3 - src/libs/Navigation/types.ts | 3 - .../rules/AgentRules/AddAgentRulePage.tsx | 4 +- ...beRulePage.tsx => DescribeNewRuleForm.tsx} | 92 +++++++++---------- .../workspace/rules/DescribeRuleForm.tsx | 7 +- src/pages/workspace/rules/RulesNewPage.tsx | 75 ++++++++------- src/styles/index.ts | 4 + src/styles/variables.ts | 1 + ...t.tsx => RulesNewPageDescribeRuleTest.tsx} | 75 +++++++-------- 25 files changed, 211 insertions(+), 204 deletions(-) rename src/pages/workspace/rules/{DescribeRulePage.tsx => DescribeNewRuleForm.tsx} (56%) rename tests/ui/{DescribeRulePageTest.tsx => RulesNewPageDescribeRuleTest.tsx} (86%) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b092698dd718..875e26e7bb2e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -10286,7 +10286,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_MENU_ITEM_DESCRIBE_RULE: 'WorkspaceRules-NewRuleMenuItem-DescribeRule', REQUIRE_RECEIPTS_SAVE: 'WorkspaceRules-RequireReceiptsSave', REQUIRE_FIELDS_SAVE: 'WorkspaceRules-RequireFieldsSave', FLAG_RECEIPT_LINE_ITEMS_SAVE: 'WorkspaceRules-FlagReceiptLineItemsSave', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f6d74cb731dd..7f618d29c396 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4387,10 +4387,6 @@ const ROUTES = { route: 'workspaces/:policyID/rules/new', getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, - RULES_DESCRIBE: { - route: 'workspaces/:policyID/rules/describe', - getRoute: (policyID: string) => `workspaces/${policyID}/rules/describe` as const, - }, RULES_EXPENSE_DEFAULT_TYPE: { route: 'workspaces/:policyID/rules/expense-defaults/new', getRoute: (policyID: string) => `workspaces/${policyID}/rules/expense-defaults/new` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index f6b184fd436b..9905d3ad4829 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -1016,7 +1016,6 @@ const SCREENS = { RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', RULES_NEW: 'Rules_New', - RULES_DESCRIBE: 'Rules_Describe', RULES_EXPENSE_DEFAULT_TYPE: 'Rules_Expense_Default_Type', RULES_MERCHANT_NEW: 'Rules_Merchant_New', RULES_MERCHANT_IMPORT: 'Rules_Merchant_Import', diff --git a/src/languages/de.ts b/src/languages/de.ts index 5572ab35b5c2..b950c2edc02e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8806,19 +8806,20 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc newRule: { title: 'Neue Regel', subtitle: 'Was möchten Sie tun?', + startWithTemplate: 'Mit einer Vorlage beginnen', restrictCardSpend: 'Kartenausgaben beschränken', restrictCardSpendDescription: 'Ausgaben am Verkaufsort blockieren oder begrenzen', applyExpenseDefaults: 'Standardspesen übernehmen', - applyExpenseDefaultsDescription: 'Felder aktualisieren, ohne dass die einreichende Person etwas tun muss', + applyExpenseDefaultsDescription: 'Felder automatisch aktualisieren', flagForReview: 'Zur Überprüfung markieren', - flagForReviewDescription: 'Genehmigende benachrichtigen, wenn Ausgaben Kategorienlimits überschreiten', + flagForReviewDescription: 'Benachrichtigen, wenn Ihre Bedingungen erfüllt sind', requireFields: 'Feldanforderungen festlegen', - requireFieldsDescription: 'Bestimmte Spesenfelder vorschreiben oder auf ihre Pflicht verzichten.', + requireFieldsShort: 'Felder vorschreiben', + requireFieldsDescription: 'Belege, Kategorien usw.', createAgentRule: 'Agentenregel beschreiben', createAgentRuleDescription: 'Erstellen Sie flexible Regeln, die ausgeführt werden, wenn Sie sie benötigen.', - describeYourRule: 'Beschreiben Sie Ihre Regel', - describeYourRuleDescription: 'Sagen Sie Concierge, was Sie brauchen, und es erstellt die Regel', - buildRule: 'Regel erstellen', + describeItBelow: 'Oder beschreiben Sie sie einfach unten', + describeRulePlaceholder: 'Alle Ausgaben über 100 $ blockieren', describe: 'Beschreiben', describeRule: 'Beschreibe deine Regel in einfachen Worten und Concierge erstellt sie für dich', describeRuleInputLabel: 'Beschreibe die Regel', diff --git a/src/languages/el.ts b/src/languages/el.ts index eeaa84760e62..27cbaeade53f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8896,19 +8896,20 @@ ${reportName}`, newRule: { title: 'Νέος κανόνας', subtitle: 'Τι θέλετε να κάνετε;', + startWithTemplate: 'Ξεκινήστε με ένα πρότυπο', restrictCardSpend: 'Περιορισμός δαπανών κάρτας', restrictCardSpendDescription: 'Μπλοκάρετε ή περιορίστε τις δαπάνες στο σημείο πώλησης', flagForReview: 'Σημείωση για έλεγχο', - flagForReviewDescription: 'Ειδοποίηση εγκριτών όταν τα έξοδα υπερβαίνουν τα όρια κατηγορίας', + flagForReviewDescription: 'Ειδοποίηση όταν πληρούνται οι προϋποθέσεις σας', requireFields: 'Ορίστε απαιτήσεις πεδίου', - requireFieldsDescription: 'Απαιτήστε συγκεκριμένα πεδία εξόδων ή παραλείψτε την απαίτησή τους.', + requireFieldsShort: 'Απαίτηση πεδίων', + requireFieldsDescription: 'Αποδείξεις, κατηγορίες κ.λπ.', applyExpenseDefaults: 'Εφαρμογή προεπιλογών δαπανών', - applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', + applyExpenseDefaultsDescription: 'Αυτόματη ενημέρωση πεδίων', createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', - describeYourRule: 'Περιγράψτε τον κανόνα σας', - describeYourRuleDescription: 'Πείτε στο Concierge τι χρειάζεστε και αυτό θα δημιουργήσει τον κανόνα', - buildRule: 'Δημιουργία κανόνα', + describeItBelow: 'Ή απλώς περιγράψτε τον παρακάτω', + describeRulePlaceholder: 'Αποκλεισμός όλων των δαπανών άνω των $100', describe: 'Περιγραφή', describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', describeRuleInputLabel: 'Περιγράψτε τον κανόνα', diff --git a/src/languages/en.ts b/src/languages/en.ts index 60389f465282..14cf31858973 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9081,19 +9081,20 @@ const translations = { newRule: { title: 'New rule', subtitle: 'What do you want to do?', + startWithTemplate: 'Start with a template', restrictCardSpend: 'Restrict card spend', restrictCardSpendDescription: 'Block or limit spend at the point of sale', flagForReview: 'Flag for review', - flagForReviewDescription: 'Notify approvers when expenses exceed category limits', + flagForReviewDescription: 'Notify when your conditions are met', requireFields: 'Set field requirements', - requireFieldsDescription: 'Require specific expense fields or waive requiring them.', + requireFieldsShort: 'Require fields', + requireFieldsDescription: 'Receipts, categories, etc.', applyExpenseDefaults: 'Apply expense defaults', - applyExpenseDefaultsDescription: 'Update fields without submitter doing anything', + applyExpenseDefaultsDescription: 'Update fields automatically', createAgentRule: 'Describe agent rule', createAgentRuleDescription: 'Create flexible rules that run when you need.', - describeYourRule: 'Describe your rule', - describeYourRuleDescription: 'Tell Concierge what you need and it will build the rule', - buildRule: 'Build rule', + describeItBelow: 'Or just describe it below', + describeRulePlaceholder: 'Block all spend above $100', describe: 'Describe', describeRule: 'Describe your rule in plain English and have Concierge build the rule for you', describeRuleInputLabel: 'Describe the rule', diff --git a/src/languages/es.ts b/src/languages/es.ts index eee7d5dcadf0..e5c77eb3c138 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8811,19 +8811,20 @@ ${reportName}`, newRule: { title: 'Nueva regla', subtitle: '¿Qué quieres hacer?', + startWithTemplate: 'Empieza con una plantilla', restrictCardSpend: 'Restringir gasto con la tarjeta', restrictCardSpendDescription: 'Bloquea o limita el gasto en el punto de venta', applyExpenseDefaults: 'Aplicar valores predeterminados de gastos', - applyExpenseDefaultsDescription: 'Actualizar campos sin que quien los envía haga nada', + applyExpenseDefaultsDescription: 'Actualizar campos automáticamente', flagForReview: 'Marcar para revisión', - flagForReviewDescription: 'Notificar a los aprobadores cuando los gastos superen los límites de categoría', + flagForReviewDescription: 'Notificar cuando se cumplan tus condiciones', requireFields: 'Configurar requisitos de campos', - requireFieldsDescription: 'Exige campos específicos de gasto o renuncia a exigirlos.', + requireFieldsShort: 'Exigir campos', + requireFieldsDescription: 'Recibos, categorías, etc.', createAgentRule: 'Describir regla de agente', createAgentRuleDescription: 'Crea reglas flexibles que se ejecuten cuando las necesites.', - describeYourRule: 'Describe tu regla', - describeYourRuleDescription: 'Dile a Concierge lo que necesitas y creará la regla', - buildRule: 'Crear regla', + describeItBelow: 'O simplemente descríbela abajo', + describeRulePlaceholder: 'Bloquear todos los gastos superiores a $100', describe: 'Describir', describeRule: 'Describe tu regla con tus propias palabras y Concierge la creará por ti', describeRuleInputLabel: 'Describe la regla', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ae623f076135..b82d7045193d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8829,19 +8829,20 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e newRule: { title: 'Nouvelle règle', subtitle: 'Que voulez-vous faire ?', + startWithTemplate: 'Commencer avec un modèle', restrictCardSpend: 'Limiter les dépenses de la carte', restrictCardSpendDescription: 'Bloquer ou limiter les dépenses au point de vente', applyExpenseDefaults: 'Appliquer les valeurs de dépense par défaut', - applyExpenseDefaultsDescription: 'Mettre à jour les champs sans que le déclarant ne fasse quoi que ce soit', + applyExpenseDefaultsDescription: 'Mettre à jour les champs automatiquement', flagForReview: 'Marquer pour examen', - flagForReviewDescription: 'Avertir les approbateurs lorsque les dépenses dépassent les limites de catégorie', + flagForReviewDescription: 'Avertir lorsque vos conditions sont remplies', requireFields: 'Définir les exigences des champs', - requireFieldsDescription: 'Rendre certains champs de dépense obligatoires ou renoncer à les exiger.', + requireFieldsShort: 'Exiger des champs', + requireFieldsDescription: 'Reçus, catégories, etc.', 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.', - describeYourRule: 'Décrivez votre règle', - describeYourRuleDescription: 'Dites à Concierge ce dont vous avez besoin et il créera la règle', - buildRule: 'Créer une règle', + describeItBelow: 'Ou décrivez-la simplement ci-dessous', + describeRulePlaceholder: 'Bloquer toutes les dépenses supérieures à 100 $', 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', diff --git a/src/languages/it.ts b/src/languages/it.ts index 43bc906a6796..514845378df8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8757,19 +8757,20 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, newRule: { title: 'Nuova regola', subtitle: 'Che cosa vuoi fare?', + startWithTemplate: 'Inizia con un modello', restrictCardSpend: 'Limita spesa carta', restrictCardSpendDescription: 'Blocca o limita la spesa al punto vendita', applyExpenseDefaults: 'Applica impostazioni predefinite spese', - applyExpenseDefaultsDescription: 'Aggiorna i campi senza che chi invia debba fare nulla', + applyExpenseDefaultsDescription: 'Aggiorna i campi automaticamente', flagForReview: 'Contrassegna per revisione', - flagForReviewDescription: 'Avvisa gli approvatori quando le spese superano i limiti di categoria', + flagForReviewDescription: 'Avvisa quando le tue condizioni sono soddisfatte', requireFields: 'Imposta i requisiti del campo', - requireFieldsDescription: 'Richiedi campi specifici per le spese oppure rinuncia a renderli obbligatori.', + requireFieldsShort: 'Richiedi campi', + requireFieldsDescription: 'Ricevute, categorie, ecc.', createAgentRule: 'Descrivi regola agente', createAgentRuleDescription: 'Crea regole flessibili che vengono eseguite quando ne hai bisogno.', - describeYourRule: 'Descrivi la tua regola', - describeYourRuleDescription: 'Di’ a Concierge di cosa hai bisogno e creerà la regola', - buildRule: 'Crea regola', + describeItBelow: 'Oppure descrivila qui sotto', + describeRulePlaceholder: 'Blocca tutte le spese superiori a 100 $', describe: 'Descrivi', describeRule: 'Descrivi la tua regola con parole semplici e Concierge la creerà per te', describeRuleInputLabel: 'Descrivi la regola', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 70ed6117c6b4..358dc30d3d1a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8646,19 +8646,20 @@ ${reportName}`, newRule: { title: '新しいルール', subtitle: '何をしたいですか?', + startWithTemplate: 'テンプレートから始める', restrictCardSpend: 'カード利用を制限', restrictCardSpendDescription: '販売時点で支出をブロックまたは制限する', applyExpenseDefaults: '経費のデフォルトを適用', - applyExpenseDefaultsDescription: '申請者が何も操作しなくてもフィールドを更新する', + applyExpenseDefaultsDescription: 'フィールドを自動で更新する', flagForReview: '確認のためにフラグを付ける', - flagForReviewDescription: '条件が満たされたときに通知します。', + flagForReviewDescription: '条件が満たされたときに通知する', requireFields: 'フィールドの必須条件を設定', - requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', + requireFieldsShort: '必須フィールド', + requireFieldsDescription: '領収書、カテゴリなど', createAgentRule: 'エージェントルールの説明', createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', - describeYourRule: 'ルールの内容を入力してください', - describeYourRuleDescription: '必要な内容をConciergeに伝えると、自動でルールを作成します', - buildRule: 'ルールを作成', + describeItBelow: 'または、下で説明してください', + describeRulePlaceholder: '100ドルを超えるすべての支出をブロック', describe: '説明する', describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', describeRuleInputLabel: 'ルールを説明', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ae2a72a16231..3c26acc03f4c 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8731,19 +8731,20 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, newRule: { title: 'Nieuwe regel', subtitle: 'Wat wil je doen?', + startWithTemplate: 'Begin met een sjabloon', restrictCardSpend: 'Kaartuitgaven beperken', restrictCardSpendDescription: 'Blokkeer of beperk uitgaven bij het verkooppunt', applyExpenseDefaults: 'Standaardinstellingen voor uitgaven toepassen', - applyExpenseDefaultsDescription: 'Velden bijwerken zonder dat de indiener iets hoeft te doen', + applyExpenseDefaultsDescription: 'Velden automatisch bijwerken', flagForReview: 'Markeren voor controle', - flagForReviewDescription: 'Goedkeurders informeren wanneer onkosten de categorielimieten overschrijden', + flagForReviewDescription: 'Melding wanneer aan je voorwaarden is voldaan', requireFields: 'Veldvereisten instellen', - requireFieldsDescription: 'Specifieke onkostenvakken verplicht stellen of afzien van het verplicht stellen ervan.', + requireFieldsShort: 'Velden verplichten', + requireFieldsDescription: 'Bonnen, categorieën, enz.', createAgentRule: 'Agentregel beschrijven', createAgentRuleDescription: 'Maak flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', - describeYourRule: 'Beschrijf je regel', - describeYourRuleDescription: 'Vertel Concierge wat je nodig hebt en die maakt de regel voor je', - buildRule: 'Regel maken', + describeItBelow: 'Of beschrijf hem hieronder', + describeRulePlaceholder: 'Blokkeer alle uitgaven boven $100', describe: 'Beschrijven', describeRule: 'Beschrijf je regel in gewone taal en Concierge maakt de regel voor je', describeRuleInputLabel: 'Beschrijf de regel', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index afed68a92d4c..20b97bb0335e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8756,19 +8756,20 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, newRule: { title: 'Nowa reguła', subtitle: 'Co chcesz zrobić?', + startWithTemplate: 'Zacznij od szablonu', restrictCardSpend: 'Ogranicz wydatki kartą', restrictCardSpendDescription: 'Blokuj lub ograniczaj wydatki w punkcie sprzedaży', applyExpenseDefaults: 'Zastosuj domyślne ustawienia wydatków', - applyExpenseDefaultsDescription: 'Aktualizuj pola bez wymagania działania od osoby zgłaszającej', + applyExpenseDefaultsDescription: 'Aktualizuj pola automatycznie', flagForReview: 'Oznacz do przejrzenia', - flagForReviewDescription: 'Powiadamiaj zatwierdzających, gdy wydatki przekraczają limity kategorii', + flagForReviewDescription: 'Powiadamiaj, gdy Twoje warunki zostaną spełnione', requireFields: 'Ustaw wymagania pola', - requireFieldsDescription: 'Wymagaj konkretnych pól wydatku lub zrezygnuj z ich wymagania.', + requireFieldsShort: 'Wymagaj pól', + requireFieldsDescription: 'Paragony, kategorie itp.', createAgentRule: 'Opisz regułę agenta', createAgentRuleDescription: 'Twórz elastyczne reguły, które uruchamiają się wtedy, gdy ich potrzebujesz.', - describeYourRule: 'Opisz swoją regułę', - describeYourRuleDescription: 'Powiedz Concierge, czego potrzebujesz, a on utworzy regułę', - buildRule: 'Zbuduj regułę', + describeItBelow: 'Lub po prostu opisz ją poniżej', + describeRulePlaceholder: 'Blokuj wszystkie wydatki powyżej 100 USD', describe: 'Opisz', describeRule: 'Opisz swoją regułę własnymi słowami, a Concierge utworzy ją za Ciebie', describeRuleInputLabel: 'Opisz regułę', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 56de887d07ce..8842e7a0ea83 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8738,19 +8738,20 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, newRule: { title: 'Nova regra', subtitle: 'O que você quer fazer?', + startWithTemplate: 'Comece com um modelo', restrictCardSpend: 'Restringir gastos do cartão', restrictCardSpendDescription: 'Bloqueie ou limite gastos no ponto de venda', applyExpenseDefaults: 'Aplicar padrões de despesa', - applyExpenseDefaultsDescription: 'Atualizar campos sem o responsável pelo envio fazer nada', + applyExpenseDefaultsDescription: 'Atualizar campos automaticamente', flagForReview: 'Marcar para revisão', - flagForReviewDescription: 'Notificar aprovadores quando as despesas excederem os limites da categoria', + flagForReviewDescription: 'Notificar quando suas condições forem atendidas', requireFields: 'Definir requisitos de campo', - requireFieldsDescription: 'Exigir campos específicos de despesa ou dispensar a exigência deles.', + requireFieldsShort: 'Exigir campos', + requireFieldsDescription: 'Recibos, categorias etc.', createAgentRule: 'Descrever regra do agente', createAgentRuleDescription: 'Crie regras flexíveis que sejam executadas quando você precisar.', - describeYourRule: 'Descreva sua regra', - describeYourRuleDescription: 'Diga ao Concierge do que você precisa e ele vai criar a regra', - buildRule: 'Criar regra', + describeItBelow: 'Ou simplesmente descreva abaixo', + describeRulePlaceholder: 'Bloquear todos os gastos acima de US$ 100', describe: 'Descrever', describeRule: 'Descreva sua regra em linguagem simples e o Concierge criará a regra para você', describeRuleInputLabel: 'Descreva a regra', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index cc2e0dd2a225..ce215a5a5da3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8427,19 +8427,20 @@ ${reportName}`, newRule: { title: '新规则', subtitle: '你想要做什么?', + startWithTemplate: '从模板开始', restrictCardSpend: '限制卡片支出', restrictCardSpendDescription: '在销售点阻止或限制支出', applyExpenseDefaults: '应用报销默认设置', - applyExpenseDefaultsDescription: '在提交人无须执行任何操作的情况下更新字段', + applyExpenseDefaultsDescription: '自动更新字段', flagForReview: '标记以供审核', - flagForReviewDescription: '当费用超出类别限额时通知审批人', + flagForReviewDescription: '满足你的条件时发送通知', requireFields: '设置字段要求', - requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', + requireFieldsShort: '必填字段', + requireFieldsDescription: '收据、类别等', createAgentRule: '描述代理规则', createAgentRuleDescription: '创建可按需运行的灵活规则。', - describeYourRule: '描述你的规则', - describeYourRuleDescription: '告诉 Concierge 你的需求,它会为你创建规则', - buildRule: '构建规则', + describeItBelow: '或者直接在下方描述', + describeRulePlaceholder: '阻止所有超过 100 美元的支出', describe: '描述', describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', describeRuleInputLabel: '描述规则', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index d65d874c9bc6..488a2f3c4fbd 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1234,7 +1234,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesCustomPage').default, [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, [SCREENS.WORKSPACE.RULES_NEW]: () => require('../../../../pages/workspace/rules/RulesNewPage').default, - [SCREENS.WORKSPACE.RULES_DESCRIBE]: () => require('../../../../pages/workspace/rules/DescribeRulePage').default, [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: () => require('../../../../pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: () => require('../../../../pages/workspace/rules/MerchantRules/ImportMerchantRulesPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 68a265c83d45..4e9826d10229 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -483,7 +483,6 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_NEW]: { path: ROUTES.RULES_NEW.route, }, - [SCREENS.WORKSPACE.RULES_DESCRIBE]: { - path: ROUTES.RULES_DESCRIBE.route, - }, [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { path: ROUTES.RULES_EXPENSE_DEFAULT_TYPE.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index b785941f5f35..5388a6700053 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1921,9 +1921,6 @@ type SettingsNavigatorParamList = { policyID: string; categoryName?: string; }; - [SCREENS.WORKSPACE.RULES_DESCRIBE]: { - policyID: string; - }; [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { policyID: string; }; diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index 7b1a9f9fa86a..c9a1caacdce4 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -257,8 +257,8 @@ function AddAgentRulePage({ text={translate('workspace.rules.agentRules.fallbackBanner')} icon={icons.Info} shouldShowIcon - containerStyles={[styles.mh5, styles.mb3]} - textStyles={styles.breakWord} + containerStyles={[styles.mh5, styles.mb3, styles.p4, styles.br3]} + textStyles={[styles.breakWord, styles.textSupporting]} /> )} diff --git a/src/pages/workspace/rules/DescribeRulePage.tsx b/src/pages/workspace/rules/DescribeNewRuleForm.tsx similarity index 56% rename from src/pages/workspace/rules/DescribeRulePage.tsx rename to src/pages/workspace/rules/DescribeNewRuleForm.tsx index d7edc0a5e626..683fbda85f57 100644 --- a/src/pages/workspace/rules/DescribeRulePage.tsx +++ b/src/pages/workspace/rules/DescribeNewRuleForm.tsx @@ -1,16 +1,16 @@ /** - * Page where an admin describes a new rule in plain English. Concierge builds a standard rule when it can, and the page - * drafts an agent rule when it can't. + * The section of New rule where an admin describes a rule in plain English. Concierge builds a standard rule when it + * can, and the section drafts an agent rule when it can't. */ -import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; -import ScreenWrapper from '@components/ScreenWrapper'; +import Icon from '@components/Icon'; import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} 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 useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setDraftValues} from '@libs/actions/FormActions'; @@ -19,27 +19,32 @@ import {setDraftFlagForReviewRule, setDraftMerchantRule, setDraftRequireFieldsRu import {getMerchantRuleDraftFromGeneratedValues} from '@libs/ExpenseDefaultRuleUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getSpendRuleDraftFromGeneratedValues} from '@libs/SpendRulesUtils'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import variables from '@styles/variables'; import {clearNewRulePrompt} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; import type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; +import type {ReactNode} from 'react'; + import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import React from 'react'; +import {View} from 'react-native'; import DescribeRuleForm from './DescribeRuleForm'; -type DescribeRulePageProps = PlatformStackScreenProps; +type DescribeNewRuleFormProps = { + policyID: string; + + /** Content above the describe section */ + children: ReactNode; +}; /** Seeds the draft of the editor for the generated rule type and opens that editor */ function seedDraftAndNavigate(values: GeneratedRuleValues, ruleType: GeneratedRuleType, policyID: string) { @@ -65,16 +70,16 @@ function seedDraftAndNavigate(values: GeneratedRuleValues, ruleType: GeneratedRu } } -function DescribeRulePage({route}: DescribeRulePageProps) { - const {policyID} = route.params; +function DescribeNewRuleForm({policyID, children}: DescribeNewRuleFormProps) { const {translate} = useLocalize(); + const theme = useTheme(); const styles = useThemeStyles(); const isFocused = useIsFocused(); - const {isBetaEnabled} = usePermissions(); const policy = usePolicy(policyID); + const icons = useMemoizedLazyExpensifyIcons(['Sparkles']); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - // A generated category stays in the draft only when the policy categories contain it. Therefore this page loads + // A generated category stays in the draft only when the policy categories contain it. Therefore this form loads // the categories before the answer arrives. const fetchPolicyCategories = () => { if (!policy?.areCategoriesEnabled || policyCategories) { @@ -87,8 +92,8 @@ function DescribeRulePage({route}: DescribeRulePageProps) { useFocusEffect(() => { fetchPolicyCategories(); - // The Describe modal of the rule editors uses the same form. Clear it when this page loses focus, so the modal - // does not open with the description or the error of this page. + // The Describe modal of the rule editors uses the same form. Clear it when New rule loses focus, so the modal + // does not open with the description or the error of New rule. return () => clearNewRulePrompt(); }); @@ -99,40 +104,33 @@ function DescribeRulePage({route}: DescribeRulePageProps) { }; return ( - seedDraftAndNavigate(values, generatedRuleType, policyID)} + onAgentRuleFallback={offerAgentRule} > - - - seedDraftAndNavigate(values, generatedRuleType, policyID)} - onAgentRuleFallback={offerAgentRule} - > - {translate('workspace.rules.newRule.describeRule')} - - - + {children} + + {translate('workspace.rules.newRule.describeItBelow')} + + + ); } -DescribeRulePage.displayName = 'DescribeRulePage'; +DescribeNewRuleForm.displayName = 'DescribeNewRuleForm'; -export default DescribeRulePage; +export default DescribeNewRuleForm; diff --git a/src/pages/workspace/rules/DescribeRuleForm.tsx b/src/pages/workspace/rules/DescribeRuleForm.tsx index 0953283f8652..ff03ef3e790b 100644 --- a/src/pages/workspace/rules/DescribeRuleForm.tsx +++ b/src/pages/workspace/rules/DescribeRuleForm.tsx @@ -56,6 +56,9 @@ type DescribeRuleFormProps = { submitFlexEnabled?: boolean; + /** An example description. The input shows it in place of its label, because the input hides a placeholder behind a label until it gets focus. */ + placeholder?: string; + /** Content above the input */ children?: ReactNode; @@ -102,6 +105,7 @@ function DescribeRuleForm({ addBottomSafeAreaPadding = false, shouldUseScrollView = false, submitFlexEnabled = false, + placeholder, children, onRuleGenerated, onAgentRuleFallback, @@ -217,7 +221,8 @@ function DescribeRuleForm({ InputComponent={TextInput} ref={inputRef} inputID={INPUT_IDS.PROMPT} - label={inputLabel} + label={placeholder ? undefined : inputLabel} + placeholder={placeholder} accessibilityLabel={inputLabel} role={CONST.ROLE.PRESENTATION} onKeyPress={submitFormOnModEnter} diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 5574bc52c9aa..0123104667b0 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -29,6 +29,8 @@ import type IconAsset from '@src/types/utils/IconAsset'; import React from 'react'; import {View} from 'react-native'; +import DescribeNewRuleForm from './DescribeNewRuleForm'; + type RulesNewPageProps = | PlatformStackScreenProps | PlatformStackScreenProps; @@ -74,19 +76,6 @@ function RulesNewPage({route}: RulesNewPageProps) { }; const newRuleOptions: NewRuleOption[] = [ - ...(isDescribeRuleBetaEnabled - ? [ - { - key: 'describeRule', - icon: illustrations.AiBot, - title: translate('workspace.rules.newRule.describeYourRule'), - description: translate('workspace.rules.newRule.describeYourRuleDescription'), - onPress: () => Navigation.navigate(ROUTES.RULES_DESCRIBE.getRoute(policyID)), - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_DESCRIBE_RULE, - isWorkspaceOnly: true, - }, - ] - : []), { key: 'restrictCardSpend', icon: illustrations.CardReaderAlt, @@ -112,7 +101,7 @@ function RulesNewPage({route}: RulesNewPageProps) { { key: 'requireFields', icon: illustrations.CheckboxText, - title: translate('workspace.rules.newRule.requireFields'), + title: translate(isDescribeRuleBetaEnabled ? 'workspace.rules.newRule.requireFieldsShort' : 'workspace.rules.newRule.requireFields'), description: translate('workspace.rules.newRule.requireFieldsDescription'), onPress: () => Navigation.navigate( @@ -132,7 +121,7 @@ function RulesNewPage({route}: RulesNewPageProps) { // Only category settings can scope the editor to its category, so a category passed any other way drops it. isWorkspaceOnly: !isCategorySettingsFlow, }, - // Describe your rule drafts an agent rule when it cannot build a standard rule. Therefore it replaces this option. + // The describe section drafts an agent rule when it cannot build a standard rule. Therefore it replaces this option. ...(isDescribeRuleBetaEnabled ? [] : [ @@ -149,6 +138,24 @@ function RulesNewPage({route}: RulesNewPageProps) { ]; const visibleNewRuleOptions = isCategoryScopedCreate ? newRuleOptions.filter((option) => !option.isWorkspaceOnly) : newRuleOptions; + // The describe section cannot scope a rule to a category. Therefore it shows only when New rule opens from the workspace Rules page. + const shouldShowDescribeSection = isDescribeRuleBetaEnabled && !isCategoryScopedCreate; + + const newRuleMenuItems = visibleNewRuleOptions.map((option) => ( + + )); return ( - - {translate('workspace.rules.newRule.subtitle')} - - {visibleNewRuleOptions.map((option) => ( - - ))} - - + {shouldShowDescribeSection ? ( + + {translate('workspace.rules.newRule.startWithTemplate')} + {newRuleMenuItems} + + ) : ( + + {translate('workspace.rules.newRule.subtitle')} + {newRuleMenuItems} + + )} ); diff --git a/src/styles/index.ts b/src/styles/index.ts index 447f31c0a549..3668de5d90b2 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6248,6 +6248,10 @@ const staticStyles = (theme: ThemeColors) => height: variables.describeRulePromptInputHeight, }, + describeNewRulePromptInput: { + height: variables.describeNewRulePromptInputHeight, + }, + emptyStateSamlIllustration: { width: 183, height: 160, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 72376ce47d4f..913a7a67508c 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -385,6 +385,7 @@ export default { rulesNewMenuItemMinHeight: 84, agentRulePromptInputHeight: 340, describeRulePromptInputHeight: 312, + describeNewRulePromptInputHeight: 96, cardPreviewWidth: 235, cardDetailsActionButtonMinWidth: 140, cardScarfOverlayWidth: 264, diff --git a/tests/ui/DescribeRulePageTest.tsx b/tests/ui/RulesNewPageDescribeRuleTest.tsx similarity index 86% rename from tests/ui/DescribeRulePageTest.tsx rename to tests/ui/RulesNewPageDescribeRuleTest.tsx index 78ba676295ee..4394a8eb797c 100644 --- a/tests/ui/DescribeRulePageTest.tsx +++ b/tests/ui/RulesNewPageDescribeRuleTest.tsx @@ -11,7 +11,6 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import DescribeRulePage from '@pages/workspace/rules/DescribeRulePage'; import RulesNewPage from '@pages/workspace/rules/RulesNewPage'; import CONST from '@src/CONST'; @@ -45,6 +44,7 @@ jest.mock('@react-navigation/native', () => { }; }); jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({Sparkles: 'Sparkles'})), useMemoizedLazyIllustrations: jest.fn(() => ({CardReaderAlt: 'CardReaderAlt', Flag: 'Flag', CheckboxText: 'CheckboxText', ReportReceipt: 'ReportReceipt', AiBot: 'AiBot'})), })); jest.mock('@hooks/useLocalize', () => @@ -58,6 +58,8 @@ jest.mock('@hooks/usePermissions', () => })), ); jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@hooks/useTheme', () => jest.fn(() => ({icon: '#000'}))); +jest.mock('@components/Icon', () => () => null); jest.mock('@libs/actions/Policy/Category', () => ({ ...jest.requireActual>('@libs/actions/Policy/Category'), openPolicyCategoriesPage: jest.fn(), @@ -133,7 +135,6 @@ jest.mock('@components/Form/FormProvider', () => { }); type RulesNewPageProps = PlatformStackScreenProps; -type DescribeRulePageProps = PlatformStackScreenProps; const POLICY_ID = 'policy1'; @@ -145,26 +146,16 @@ function setAdminPolicy(areCategoriesEnabled = false) { mockedUsePolicy.mockReturnValue({...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID, role: CONST.POLICY.ROLE.ADMIN, areCategoriesEnabled}); } -async function renderRulesNewPage() { +async function renderRulesNewPage(categoryName?: string) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only screen props. The page reads only route.name and route.params. const props = { - route: {key: 'Rules_New', name: SCREENS.WORKSPACE.RULES_NEW, params: {policyID: POLICY_ID}}, + route: {key: 'Rules_New', name: SCREENS.WORKSPACE.RULES_NEW, params: {policyID: POLICY_ID, categoryName}}, navigation: {}, } as unknown as RulesNewPageProps; render(); await waitForBatchedUpdatesWithAct(); } -async function renderDescribeRulePage() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only screen props. The page reads only route.params. - const props = { - route: {key: 'Rules_Describe', name: SCREENS.WORKSPACE.RULES_DESCRIBE, params: {policyID: POLICY_ID}}, - navigation: {}, - } as unknown as DescribeRulePageProps; - render(); - await waitForBatchedUpdatesWithAct(); -} - function isGenerateRuleParams(parameters: unknown): parameters is GenerateRuleParams { return typeof parameters === 'object' && parameters !== null && 'generationID' in parameters && 'prompt' in parameters; } @@ -172,7 +163,7 @@ function isGenerateRuleParams(parameters: unknown): parameters is GenerateRulePa /** Submits the prompt through the describe form and returns the parameters of the GenerateRule request */ function describeRule(writeSpy: WriteSpy, prompt: string): GenerateRuleParams { mockPrompt = prompt; - fireEvent.press(screen.getByLabelText('workspace.rules.newRule.buildRule')); + fireEvent.press(screen.getByLabelText('common.next')); const parameters = writeSpy.mock.calls.find(([command]) => command === WRITE_COMMANDS.GENERATE_RULE)?.at(1); if (!isGenerateRuleParams(parameters)) { throw new Error('The describe form did not request GenerateRule'); @@ -196,7 +187,7 @@ function getAddPolicyAgentRuleCalls(writeSpy: WriteSpy) { return writeSpy.mock.calls.filter(([command]) => command === WRITE_COMMANDS.ADD_POLICY_AGENT_RULE); } -describe('Describe your rule', () => { +describe('New rule describe section', () => { let writeSpy: WriteSpy; beforeEach(() => { @@ -214,19 +205,18 @@ describe('Describe your rule', () => { }); }); - it('lists Describe your rule first, and it opens its own page', async () => { + it('shows the four templates and then the describe section', async () => { // Given an admin with the describeRule beta on the New rule page await renderRulesNewPage(); // When the admin looks at the options const options = screen.getAllByRole('button'); - // Then describing comes first, before the four rule types, so the admin can describe the rule before picking a rule type + // Then the four rule types come first, because the admin can describe the rule in the section below them const expectedOptionNames = [ - 'workspace.rules.newRule.describeYourRule', 'workspace.rules.newRule.restrictCardSpend', 'workspace.rules.newRule.flagForReview', - 'workspace.rules.newRule.requireFields', + 'workspace.rules.newRule.requireFieldsShort', 'workspace.rules.newRule.applyExpenseDefaults', ]; expect(options).toHaveLength(expectedOptionNames.length); @@ -234,12 +224,23 @@ describe('Describe your rule', () => { expect(options.at(index)).toHaveAccessibleName(name); } - // And it opens the describe page in the RHP - fireEvent.press(screen.getByLabelText('workspace.rules.newRule.describeYourRule')); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_DESCRIBE.getRoute(POLICY_ID)); + // And the describe section follows, with its own submit button + expect(screen.getByText('workspace.rules.newRule.startWithTemplate')).toBeOnTheScreen(); + expect(screen.getByText('workspace.rules.newRule.describeItBelow')).toBeOnTheScreen(); + expect(screen.getByLabelText('common.next')).toBeOnTheScreen(); }); - it('keeps Describe agent rule in place of Describe your rule without the beta', async () => { + it('hides the describe section when New rule opens for a category', async () => { + // Given an admin with the describeRule beta who opens New rule for a category, which the describe section cannot scope a rule to + await renderRulesNewPage('Meals'); + + // Then New rule lists only the rule types, without the describe section + expect(screen.queryByText('workspace.rules.newRule.describeItBelow')).toBeNull(); + expect(screen.queryByLabelText('common.next')).toBeNull(); + expect(screen.getByText('workspace.rules.newRule.subtitle')).toBeOnTheScreen(); + }); + + it('keeps Describe agent rule in place of the describe section without the beta', async () => { // Given an admin without the describeRule beta, who must see the New rule options that exist without the feature mockIsDescribeRuleBetaEnabled = false; await renderRulesNewPage(); @@ -247,7 +248,7 @@ describe('Describe your rule', () => { // When the admin looks at the options const options = screen.getAllByRole('button'); - // Then the four rule types come first and Describe agent rule comes last, with no Describe your rule + // Then the four rule types come first and Describe agent rule comes last, with no describe section const expectedOptionNames = [ 'workspace.rules.newRule.restrictCardSpend', 'workspace.rules.newRule.flagForReview', @@ -260,14 +261,16 @@ describe('Describe your rule', () => { expect(options.at(index)).toHaveAccessibleName(name); } + expect(screen.queryByText('workspace.rules.newRule.describeItBelow')).toBeNull(); + // And Describe agent rule opens the agent rule page fireEvent.press(screen.getByLabelText('workspace.rules.newRule.createAgentRule')); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_AGENT_NEW.getRoute(POLICY_ID)); }); it('seeds the draft of the generated rule type and opens its editor', async () => { - // Given the admin described a flag for review rule on the describe page, so the request lets Concierge pick the rule type - await renderDescribeRulePage(); + // Given the admin described a flag for review rule in the describe section, so the request lets Concierge pick the rule type + await renderRulesNewPage(); const {generationID, ruleType} = describeRule(writeSpy, 'Flag expenses over $500'); expect(ruleType).toBeUndefined(); @@ -286,7 +289,7 @@ describe('Describe your rule', () => { it('scopes an expense defaults draft to a merchant rule', async () => { // Given the admin described an expense default for a merchant - await renderDescribeRulePage(); + await renderRulesNewPage(); const {generationID} = describeRule(writeSpy, 'Set Uber expenses to not billable'); // When Concierge answers with an expense defaults rule @@ -308,7 +311,7 @@ describe('Describe your rule', () => { // Given an admin described something no standard rule can do const logSpy = jest.spyOn(Log, 'info'); const prompt = 'Block gift cards and require receipts over $50'; - await renderDescribeRulePage(); + await renderRulesNewPage(); const {generationID} = describeRule(writeSpy, prompt); // When Concierge answers that it is not one standard rule @@ -326,8 +329,8 @@ describe('Describe your rule', () => { ); it.each([undefined, 'futureRuleType'])('shows the unsupported error when a rule answer has the rule type %s', async (generatedRuleType) => { - // Given an admin described a rule on the describe page - await renderDescribeRulePage(); + // Given an admin described a rule in the describe section + await renderRulesNewPage(); const {generationID} = describeRule(writeSpy, 'Flag expenses over $500'); // When Concierge answers with a rule but with no rule type, or with a rule type that this app version does not know @@ -341,7 +344,7 @@ describe('Describe your rule', () => { // Then the form shows the unsupported error, because no editor can show these values expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); - expect(screen.getByLabelText('workspace.rules.newRule.buildRule')).toBeOnTheScreen(); + expect(screen.getByLabelText('common.next')).toBeOnTheScreen(); expect(Navigation.navigate).not.toHaveBeenCalled(); }); @@ -349,16 +352,16 @@ describe('Describe your rule', () => { // Given a workspace that uses categories, with no categories loaded setAdminPolicy(true); - // When the describe page opens - await renderDescribeRulePage(); + // When New rule opens with the describe section + await renderRulesNewPage(); - // Then the page requests the categories, because the describe form keeps only a category that the workspace has + // Then New rule requests the categories, because the describe form keeps only a category that the workspace has expect(openPolicyCategoriesPage).toHaveBeenCalledWith(POLICY_ID); }); it('keeps the inline error for an unintelligible description', async () => { // Given an admin typed something Concierge cannot read - await renderDescribeRulePage(); + await renderRulesNewPage(); const {generationID} = describeRule(writeSpy, 'asdf qwerty zxcv'); // When Concierge answers that the description is unintelligible From b42819a3a36e574e1cc6a5e5129568de30f62dab Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Thu, 15 Oct 2026 12:20:10 -0700 Subject: [PATCH 17/20] style(rules): pad the top of the New rule describe box --- src/pages/workspace/rules/DescribeRuleForm.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/workspace/rules/DescribeRuleForm.tsx b/src/pages/workspace/rules/DescribeRuleForm.tsx index ff03ef3e790b..54d5f98a7569 100644 --- a/src/pages/workspace/rules/DescribeRuleForm.tsx +++ b/src/pages/workspace/rules/DescribeRuleForm.tsx @@ -223,6 +223,8 @@ function DescribeRuleForm({ inputID={INPUT_IDS.PROMPT} label={placeholder ? undefined : inputLabel} placeholder={placeholder} + // Without a label, the input has no top padding. Match the padding at its sides. + textInputContainerStyles={placeholder ? styles.pt2 : undefined} accessibilityLabel={inputLabel} role={CONST.ROLE.PRESENTATION} onKeyPress={submitFormOnModEnter} From 29f09a9671e66c6f6755e27bf38115e6653b7a44 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Thu, 15 Oct 2026 12:20:55 -0700 Subject: [PATCH 18/20] fix(rules): use $2,000 in the describe box 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 b950c2edc02e..b148b95dbc89 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8819,7 +8819,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc createAgentRule: 'Agentenregel beschreiben', createAgentRuleDescription: 'Erstellen Sie flexible Regeln, die ausgeführt werden, wenn Sie sie benötigen.', describeItBelow: 'Oder beschreiben Sie sie einfach unten', - describeRulePlaceholder: 'Alle Ausgaben über 100 $ blockieren', + describeRulePlaceholder: 'Alle Ausgaben über 2.000 $ blockieren', describe: 'Beschreiben', describeRule: 'Beschreibe deine Regel in einfachen Worten und Concierge erstellt sie für dich', describeRuleInputLabel: 'Beschreibe die Regel', diff --git a/src/languages/el.ts b/src/languages/el.ts index 27cbaeade53f..eab31545f036 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8909,7 +8909,7 @@ ${reportName}`, createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', describeItBelow: 'Ή απλώς περιγράψτε τον παρακάτω', - describeRulePlaceholder: 'Αποκλεισμός όλων των δαπανών άνω των $100', + describeRulePlaceholder: 'Αποκλεισμός όλων των δαπανών άνω των 2.000 $', describe: 'Περιγραφή', describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', describeRuleInputLabel: 'Περιγράψτε τον κανόνα', diff --git a/src/languages/en.ts b/src/languages/en.ts index 14cf31858973..97397142f6f3 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9094,7 +9094,7 @@ const translations = { createAgentRule: 'Describe agent rule', createAgentRuleDescription: 'Create flexible rules that run when you need.', describeItBelow: 'Or just describe it below', - describeRulePlaceholder: 'Block all spend above $100', + describeRulePlaceholder: 'Block all spend above $2,000', describe: 'Describe', describeRule: 'Describe your rule in plain English and have Concierge build the rule for you', describeRuleInputLabel: 'Describe the rule', diff --git a/src/languages/es.ts b/src/languages/es.ts index e5c77eb3c138..a8d0ed5a03fc 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8824,7 +8824,7 @@ ${reportName}`, createAgentRule: 'Describir regla de agente', createAgentRuleDescription: 'Crea reglas flexibles que se ejecuten cuando las necesites.', describeItBelow: 'O simplemente descríbela abajo', - describeRulePlaceholder: 'Bloquear todos los gastos superiores a $100', + describeRulePlaceholder: 'Bloquear todos los gastos superiores a $2,000', describe: 'Describir', describeRule: 'Describe tu regla con tus propias palabras y Concierge la creará por ti', describeRuleInputLabel: 'Describe la regla', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b82d7045193d..bfb28ebdc275 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8842,7 +8842,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e 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.', describeItBelow: 'Ou décrivez-la simplement ci-dessous', - describeRulePlaceholder: 'Bloquer toutes les dépenses supérieures à 100 $', + describeRulePlaceholder: 'Bloquer toutes les dépenses supérieures à 2 000 $', 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', diff --git a/src/languages/it.ts b/src/languages/it.ts index 514845378df8..8f03232f2703 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8770,7 +8770,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, createAgentRule: 'Descrivi regola agente', createAgentRuleDescription: 'Crea regole flessibili che vengono eseguite quando ne hai bisogno.', describeItBelow: 'Oppure descrivila qui sotto', - describeRulePlaceholder: 'Blocca tutte le spese superiori a 100 $', + describeRulePlaceholder: 'Blocca tutte le spese superiori a 2.000 $', describe: 'Descrivi', describeRule: 'Descrivi la tua regola con parole semplici e Concierge la creerà per te', describeRuleInputLabel: 'Descrivi la regola', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 358dc30d3d1a..4df58837380e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8659,7 +8659,7 @@ ${reportName}`, createAgentRule: 'エージェントルールの説明', createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', describeItBelow: 'または、下で説明してください', - describeRulePlaceholder: '100ドルを超えるすべての支出をブロック', + describeRulePlaceholder: '2,000ドルを超えるすべての支出をブロック', describe: '説明する', describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', describeRuleInputLabel: 'ルールを説明', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 3c26acc03f4c..32bf490c2818 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8744,7 +8744,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, createAgentRule: 'Agentregel beschrijven', createAgentRuleDescription: 'Maak flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', describeItBelow: 'Of beschrijf hem hieronder', - describeRulePlaceholder: 'Blokkeer alle uitgaven boven $100', + describeRulePlaceholder: 'Blokkeer alle uitgaven boven $2.000', describe: 'Beschrijven', describeRule: 'Beschrijf je regel in gewone taal en Concierge maakt de regel voor je', describeRuleInputLabel: 'Beschrijf de regel', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 20b97bb0335e..df32d1488530 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8769,7 +8769,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, createAgentRule: 'Opisz regułę agenta', createAgentRuleDescription: 'Twórz elastyczne reguły, które uruchamiają się wtedy, gdy ich potrzebujesz.', describeItBelow: 'Lub po prostu opisz ją poniżej', - describeRulePlaceholder: 'Blokuj wszystkie wydatki powyżej 100 USD', + describeRulePlaceholder: 'Blokuj wszystkie wydatki powyżej 2000 $', describe: 'Opisz', describeRule: 'Opisz swoją regułę własnymi słowami, a Concierge utworzy ją za Ciebie', describeRuleInputLabel: 'Opisz regułę', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 8842e7a0ea83..d36f1f24fc1f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8751,7 +8751,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, createAgentRule: 'Descrever regra do agente', createAgentRuleDescription: 'Crie regras flexíveis que sejam executadas quando você precisar.', describeItBelow: 'Ou simplesmente descreva abaixo', - describeRulePlaceholder: 'Bloquear todos os gastos acima de US$ 100', + describeRulePlaceholder: 'Bloquear todos os gastos acima de $2.000', describe: 'Descrever', describeRule: 'Descreva sua regra em linguagem simples e o Concierge criará a regra para você', describeRuleInputLabel: 'Descreva a regra', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ce215a5a5da3..03dd1262f802 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8440,7 +8440,7 @@ ${reportName}`, createAgentRule: '描述代理规则', createAgentRuleDescription: '创建可按需运行的灵活规则。', describeItBelow: '或者直接在下方描述', - describeRulePlaceholder: '阻止所有超过 100 美元的支出', + describeRulePlaceholder: '阻止所有超过 2,000 美元的支出', describe: '描述', describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', describeRuleInputLabel: '描述规则', From 38ca573967edcaaeb233b9dce5e9df883c26adc7 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Thu, 15 Oct 2026 13:57:09 -0700 Subject: [PATCH 19/20] fix(rules): keep the navigation types file under the line limit --- src/libs/Navigation/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 5388a6700053..57dfed229393 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2079,8 +2079,6 @@ type SettingsNavigatorParamList = { }; [SCREENS.WORKSPACE.RULES_AGENT_NEW]: { policyID: string; - - /** The generation that produced this draft, when Concierge could not build the description as a standard rule */ generationID?: string; }; [SCREENS.WORKSPACE.RULES_AGENT_EDIT]: { From 1a45ef209104a6d0aa1461b981bf3efa0e0c200c Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Fri, 16 Oct 2026 11:32:06 -0700 Subject: [PATCH 20/20] fix(rules): let New rule's describe box stay empty without an error --- src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/actions/Policy/Rules.ts | 8 ++++++++ .../workspace/rules/DescribeNewRuleForm.tsx | 1 + .../workspace/rules/DescribeRuleForm.tsx | 19 ++++++++++++++++--- tests/ui/RulesNewPageDescribeRuleTest.tsx | 14 ++++++++++++++ 15 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index b148b95dbc89..199abc841a7d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8820,6 +8820,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc createAgentRuleDescription: 'Erstellen Sie flexible Regeln, die ausgeführt werden, wenn Sie sie benötigen.', describeItBelow: 'Oder beschreiben Sie sie einfach unten', describeRulePlaceholder: 'Alle Ausgaben über 2.000 $ blockieren', + selectTemplateOrDescribe: 'Wählen Sie eine Vorlage aus oder beschreiben Sie eine Regel, um fortzufahren', describe: 'Beschreiben', describeRule: 'Beschreibe deine Regel in einfachen Worten und Concierge erstellt sie für dich', describeRuleInputLabel: 'Beschreibe die Regel', diff --git a/src/languages/el.ts b/src/languages/el.ts index eab31545f036..bbce16c6acfc 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8910,6 +8910,7 @@ ${reportName}`, createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', describeItBelow: 'Ή απλώς περιγράψτε τον παρακάτω', describeRulePlaceholder: 'Αποκλεισμός όλων των δαπανών άνω των 2.000 $', + selectTemplateOrDescribe: 'Επιλέξτε ένα πρότυπο ή περιγράψτε έναν κανόνα για να συνεχίσετε', describe: 'Περιγραφή', describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', describeRuleInputLabel: 'Περιγράψτε τον κανόνα', diff --git a/src/languages/en.ts b/src/languages/en.ts index 97397142f6f3..14c193653897 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9095,6 +9095,7 @@ const translations = { createAgentRuleDescription: 'Create flexible rules that run when you need.', describeItBelow: 'Or just describe it below', describeRulePlaceholder: 'Block all spend above $2,000', + selectTemplateOrDescribe: 'Select a template or describe a rule to continue', describe: 'Describe', describeRule: 'Describe your rule in plain English and have Concierge build the rule for you', describeRuleInputLabel: 'Describe the rule', diff --git a/src/languages/es.ts b/src/languages/es.ts index a8d0ed5a03fc..8b8e2e2f5787 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8825,6 +8825,7 @@ ${reportName}`, createAgentRuleDescription: 'Crea reglas flexibles que se ejecuten cuando las necesites.', describeItBelow: 'O simplemente descríbela abajo', describeRulePlaceholder: 'Bloquear todos los gastos superiores a $2,000', + selectTemplateOrDescribe: 'Selecciona una plantilla o describe una regla para continuar', describe: 'Describir', describeRule: 'Describe tu regla con tus propias palabras y Concierge la creará por ti', describeRuleInputLabel: 'Describe la regla', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index bfb28ebdc275..fcd066931e40 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8843,6 +8843,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e createAgentRuleDescription: 'Créez des règles flexibles qui s’exécutent quand vous en avez besoin.', describeItBelow: 'Ou décrivez-la simplement ci-dessous', describeRulePlaceholder: 'Bloquer toutes les dépenses supérieures à 2 000 $', + selectTemplateOrDescribe: 'Sélectionnez un modèle ou décrivez une règle pour continuer', 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', diff --git a/src/languages/it.ts b/src/languages/it.ts index 8f03232f2703..09984bba3ba2 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8771,6 +8771,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, createAgentRuleDescription: 'Crea regole flessibili che vengono eseguite quando ne hai bisogno.', describeItBelow: 'Oppure descrivila qui sotto', describeRulePlaceholder: 'Blocca tutte le spese superiori a 2.000 $', + selectTemplateOrDescribe: 'Seleziona un modello o descrivi una regola per continuare', describe: 'Descrivi', describeRule: 'Descrivi la tua regola con parole semplici e Concierge la creerà per te', describeRuleInputLabel: 'Descrivi la regola', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 4df58837380e..fc3ad95b3da4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8660,6 +8660,7 @@ ${reportName}`, createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', describeItBelow: 'または、下で説明してください', describeRulePlaceholder: '2,000ドルを超えるすべての支出をブロック', + selectTemplateOrDescribe: '続行するには、テンプレートを選択するかルールを説明してください', describe: '説明する', describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', describeRuleInputLabel: 'ルールを説明', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 32bf490c2818..dbeff4275de4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8745,6 +8745,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, createAgentRuleDescription: 'Maak flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', describeItBelow: 'Of beschrijf hem hieronder', describeRulePlaceholder: 'Blokkeer alle uitgaven boven $2.000', + selectTemplateOrDescribe: 'Selecteer een sjabloon of beschrijf een regel om door te gaan', describe: 'Beschrijven', describeRule: 'Beschrijf je regel in gewone taal en Concierge maakt de regel voor je', describeRuleInputLabel: 'Beschrijf de regel', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index df32d1488530..9bc35d80aed7 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8770,6 +8770,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, createAgentRuleDescription: 'Twórz elastyczne reguły, które uruchamiają się wtedy, gdy ich potrzebujesz.', describeItBelow: 'Lub po prostu opisz ją poniżej', describeRulePlaceholder: 'Blokuj wszystkie wydatki powyżej 2000 $', + selectTemplateOrDescribe: 'Wybierz szablon lub opisz regułę, aby kontynuować', describe: 'Opisz', describeRule: 'Opisz swoją regułę własnymi słowami, a Concierge utworzy ją za Ciebie', describeRuleInputLabel: 'Opisz regułę', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d36f1f24fc1f..e42949ac12ff 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8752,6 +8752,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, createAgentRuleDescription: 'Crie regras flexíveis que sejam executadas quando você precisar.', describeItBelow: 'Ou simplesmente descreva abaixo', describeRulePlaceholder: 'Bloquear todos os gastos acima de $2.000', + selectTemplateOrDescribe: 'Selecione um modelo ou descreva uma regra para continuar', describe: 'Descrever', describeRule: 'Descreva sua regra em linguagem simples e o Concierge criará a regra para você', describeRuleInputLabel: 'Descreva a regra', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 03dd1262f802..25e2ea4ea4e9 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8441,6 +8441,7 @@ ${reportName}`, createAgentRuleDescription: '创建可按需运行的灵活规则。', describeItBelow: '或者直接在下方描述', describeRulePlaceholder: '阻止所有超过 2,000 美元的支出', + selectTemplateOrDescribe: '请选择模板或描述规则以继续', describe: '描述', describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', describeRuleInputLabel: '描述规则', diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 99f1418bcf4a..a691664e8cc5 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -454,6 +454,13 @@ function setNewRulePromptError(state: GeneratedRuleState) { }); } +/** Shows the empty description error above the submit button of the new rule prompt form */ +function setNewRulePromptEmptyError() { + Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, { + errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.rules.newRule.selectTemplateOrDescribe'), + }); +} + function clearGeneratedRule() { Onyx.set(ONYXKEYS.GENERATED_RULE, null); } @@ -728,6 +735,7 @@ export { getTransactionsMatchingCodingRule, addPolicyAgentRule, generateRule, + setNewRulePromptEmptyError, setNewRulePromptError, clearGeneratedRule, clearNewRulePrompt, diff --git a/src/pages/workspace/rules/DescribeNewRuleForm.tsx b/src/pages/workspace/rules/DescribeNewRuleForm.tsx index 683fbda85f57..84c8b2512b06 100644 --- a/src/pages/workspace/rules/DescribeNewRuleForm.tsx +++ b/src/pages/workspace/rules/DescribeNewRuleForm.tsx @@ -110,6 +110,7 @@ function DescribeNewRuleForm({policyID, children}: DescribeNewRuleFormProps) { submitButtonText={translate('common.next')} inputContainerStyle={styles.describeNewRulePromptInput} placeholder={translate('workspace.rules.newRule.describeRulePlaceholder')} + canSkipDescription style={[styles.flexGrow1, styles.mh5]} addBottomSafeAreaPadding shouldUseScrollView diff --git a/src/pages/workspace/rules/DescribeRuleForm.tsx b/src/pages/workspace/rules/DescribeRuleForm.tsx index 54d5f98a7569..abbd8f95fd0a 100644 --- a/src/pages/workspace/rules/DescribeRuleForm.tsx +++ b/src/pages/workspace/rules/DescribeRuleForm.tsx @@ -13,7 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import {clearGeneratedRule, clearNewRulePrompt, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; +import {clearGeneratedRule, clearNewRulePrompt, generateRule, setNewRulePromptEmptyError, setNewRulePromptError} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -56,6 +56,12 @@ type DescribeRuleFormProps = { submitFlexEnabled?: boolean; + /** + * Whether the admin can leave the description empty, because the screen offers another way to make a rule. The input + * then shows no error for an empty description. Submit shows the error above the submit button instead. + */ + canSkipDescription?: boolean; + /** An example description. The input shows it in place of its label, because the input hides a placeholder behind a label until it gets focus. */ placeholder?: string; @@ -105,6 +111,7 @@ function DescribeRuleForm({ addBottomSafeAreaPadding = false, shouldUseScrollView = false, submitFlexEnabled = false, + canSkipDescription = false, placeholder, children, onRuleGenerated, @@ -190,14 +197,18 @@ function DescribeRuleForm({ const validate = (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; - if (!values[INPUT_IDS.PROMPT]?.trim()) { + if (!canSkipDescription && !values[INPUT_IDS.PROMPT]?.trim()) { errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); } return errors; }; const submit = (values: FormOnyxValues) => { - const prompt = values[INPUT_IDS.PROMPT].trim(); + const prompt = values[INPUT_IDS.PROMPT]?.trim() ?? ''; + if (!prompt) { + setNewRulePromptEmptyError(); + return; + } pendingGenerationRef.current = {generationID: generateRule(policyID, prompt, ruleType), prompt}; }; @@ -213,6 +224,8 @@ function DescribeRuleForm({ shouldUseScrollView={shouldUseScrollView} submitFlexEnabled={submitFlexEnabled} shouldHideFixErrorsAlert + // An empty description sends no request, so nothing would stop a loading state that starts on the press. + shouldShowLoadingImmediatelyOnPress={!canSkipDescription} keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} > {children} diff --git a/tests/ui/RulesNewPageDescribeRuleTest.tsx b/tests/ui/RulesNewPageDescribeRuleTest.tsx index 4394a8eb797c..6bdf2082626a 100644 --- a/tests/ui/RulesNewPageDescribeRuleTest.tsx +++ b/tests/ui/RulesNewPageDescribeRuleTest.tsx @@ -230,6 +230,20 @@ describe('New rule describe section', () => { expect(screen.getByLabelText('common.next')).toBeOnTheScreen(); }); + it('asks for a template or a description when Next is pressed with an empty box', async () => { + // Given an admin on New rule who neither picks a template nor types a description + await renderRulesNewPage(); + mockPrompt = ''; + + // When the admin presses Next + fireEvent.press(screen.getByLabelText('common.next')); + await waitForBatchedUpdatesWithAct(); + + // Then the error above Next explains both ways forward, and nothing is sent, because the box is optional on New rule + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.selectTemplateOrDescribe')]); + expect(writeSpy.mock.calls.filter(([command]) => command === WRITE_COMMANDS.GENERATE_RULE)).toHaveLength(0); + }); + it('hides the describe section when New rule opens for a category', async () => { // Given an admin with the describeRule beta who opens New rule for a category, which the describe section cannot scope a rule to await renderRulesNewPage('Meals');