diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 4b5290df046e..8afc6a92722c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4517,7 +4517,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 2a7cbd67d4a5..5f8a2b4cd3c9 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8804,6 +8804,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: + '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', @@ -8866,16 +8868,21 @@ 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.', + 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 1e4ccc6f58ee..05f8a99c394b 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8960,16 +8960,21 @@ ${reportName}`, newRule: { title: 'Νέος κανόνας', subtitle: 'Τι θέλετε να κάνετε;', + startWithTemplate: 'Ξεκινήστε με ένα πρότυπο', restrictCardSpend: 'Περιορισμός δαπανών κάρτας', restrictCardSpendDescription: 'Μπλοκάρετε ή περιορίστε τις δαπάνες στο σημείο πώλησης', flagForReview: 'Σημείωση για έλεγχο', - flagForReviewDescription: 'Ειδοποίηση εγκριτών όταν τα έξοδα υπερβαίνουν τα όρια κατηγορίας', + flagForReviewDescription: 'Ειδοποίηση όταν πληρούνται οι προϋποθέσεις σας', requireFields: 'Ορίστε απαιτήσεις πεδίου', - requireFieldsDescription: 'Απαιτήστε συγκεκριμένα πεδία εξόδων ή παραλείψτε την απαίτησή τους.', + requireFieldsShort: 'Απαίτηση πεδίων', + requireFieldsDescription: 'Αποδείξεις, κατηγορίες κ.λπ.', applyExpenseDefaults: 'Εφαρμογή προεπιλογών δαπανών', - applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', + applyExpenseDefaultsDescription: 'Αυτόματη ενημέρωση πεδίων', createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', + describeItBelow: 'Ή απλώς περιγράψτε τον παρακάτω', + describeRulePlaceholder: 'Αποκλεισμός όλων των δαπανών άνω των 2.000 $', + selectTemplateOrDescribe: 'Επιλέξτε ένα πρότυπο ή περιγράψτε έναν κανόνα για να συνεχίσετε', describe: 'Περιγραφή', describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', describeRuleInputLabel: 'Περιγράψτε τον κανόνα', @@ -9261,6 +9266,8 @@ ${reportName}`, describeRuleHeadline: 'Περιγράψτε τον κανόνα σας', describeRuleForConcierge: 'Περιγράψτε τον κανόνα σας και το Concierge θα τον δημιουργήσει', createRule: 'Δημιουργία κανόνα', + fallbackBanner: + 'Αυτός δεν είναι ένας τυπικός κανόνας, οπότε τον διαμορφώσαμε ως κανόνα πράκτορα. Οι κανόνες πράκτορα εφαρμόζονται αφού αλλάξει μια δαπάνη ή μια αναφορά, για παράδειγμα κρατώντας τη δαπάνη ή απορρίπτοντας την αναφορά.', suggestionsTab: 'Προτεινόμενο', writeTab: 'Επεξεργασία', findSuggestion: 'Βρείτε έναν κανόνα', diff --git a/src/languages/en.ts b/src/languages/en.ts index 76b5678919c9..55a23bbfbd3d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9192,16 +9192,21 @@ 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.', + 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', @@ -9508,6 +9513,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.", 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 23ef54fdd178..e491088e0ee7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8804,6 +8804,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 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', @@ -8866,16 +8868,21 @@ ${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.', + 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 9351bae37b96..b48b6cab62bc 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8827,6 +8827,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 bloquant la dépense ou en rejetant la note de frais.', }, tabs: { general: 'Général', @@ -8889,16 +8891,21 @@ 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.', + 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 32b67c9eb7a5..8669dd1065af 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8755,6 +8755,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 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', @@ -8817,16 +8819,21 @@ 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.', + 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 b2ab211bf99b..ed4b43097ac6 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8644,6 +8644,8 @@ ${reportName}`, describeRuleForConcierge: 'ルールの内容を入力すると、Concierge が自動作成します', gotIt: '了解しました', createRule: 'ルールを作成', + fallbackBanner: + 'これは標準ルールではないため、エージェントルールとして作成しました。エージェントルールは、経費やレポートに変更があった後に動作し、例えば経費を保留にしたり、レポートを却下したりします。', }, tabs: { general: '一般', @@ -8706,16 +8708,21 @@ ${reportName}`, newRule: { title: '新しいルール', subtitle: '何をしたいですか?', + startWithTemplate: 'テンプレートから始める', restrictCardSpend: 'カード利用を制限', restrictCardSpendDescription: '販売時点で支出をブロックまたは制限する', applyExpenseDefaults: '経費のデフォルトを適用', - applyExpenseDefaultsDescription: '申請者が何も操作しなくてもフィールドを更新する', + applyExpenseDefaultsDescription: 'フィールドを自動で更新する', flagForReview: '確認のためにフラグを付ける', - flagForReviewDescription: '条件が満たされたときに通知します。', + flagForReviewDescription: '条件が満たされたときに通知する', requireFields: 'フィールドの必須条件を設定', - requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', + requireFieldsShort: '必須フィールド', + requireFieldsDescription: '領収書、カテゴリなど', createAgentRule: 'エージェントルールの説明', createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', + describeItBelow: 'または、下で説明してください', + describeRulePlaceholder: '2,000ドルを超えるすべての支出をブロック', + selectTemplateOrDescribe: '続行するには、テンプレートを選択するかルールを説明してください', describe: '説明する', describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', describeRuleInputLabel: 'ルールを説明', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index addcafb231f9..0543602f1d02 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8729,6 +8729,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 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', @@ -8791,16 +8793,21 @@ 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.', + 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 efe62c626c04..a79b25c541ae 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8754,6 +8754,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 albo odrzucając raport.', }, tabs: { general: 'Ogólne', @@ -8816,16 +8818,21 @@ 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.', + 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 b95fbce65dc0..597f30dd1438 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8736,6 +8736,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 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', @@ -8798,16 +8800,21 @@ 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.', + 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 e204f9058786..bc038bbd09ab 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8428,6 +8428,7 @@ ${reportName}`, describeRuleForConcierge: '描述你的规则,Concierge 会帮你创建', gotIt: '知道了', createRule: '创建规则', + fallbackBanner: '这不是一条标准规则,所以我们将其制定为代理规则。代理规则会在报销或报表发生变更后生效,例如暂扣该报销或驳回该报表。', }, tabs: {general: '常规', cardRestrictions: '卡片限制', expenseDefaults: '报销默认设置', requireFields: '字段要求', flagForReview: '标记以供审核', agents: '代理人'}, bulkActions: { @@ -8483,16 +8484,21 @@ ${reportName}`, newRule: { title: '新规则', subtitle: '你想要做什么?', + startWithTemplate: '从模板开始', restrictCardSpend: '限制卡片支出', restrictCardSpendDescription: '在销售点阻止或限制支出', applyExpenseDefaults: '应用报销默认设置', - applyExpenseDefaultsDescription: '在提交人无须执行任何操作的情况下更新字段', + applyExpenseDefaultsDescription: '自动更新字段', flagForReview: '标记以供审核', - flagForReviewDescription: '当费用超出类别限额时通知审批人', + flagForReviewDescription: '满足你的条件时发送通知', requireFields: '设置字段要求', - requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', + requireFieldsShort: '必填字段', + requireFieldsDescription: '收据、类别等', createAgentRule: '描述代理规则', createAgentRuleDescription: '创建可按需运行的灵活规则。', + describeItBelow: '或者直接在下方描述', + describeRulePlaceholder: '阻止所有超过 2,000 美元的支出', + selectTemplateOrDescribe: '请选择模板或描述规则以继续', describe: '描述', describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', describeRuleInputLabel: '描述规则', 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/CategoryTaxRulesUtils.ts b/src/libs/CategoryTaxRulesUtils.ts index b274d681983c..d6a04f73f2b0 100644 --- a/src/libs/CategoryTaxRulesUtils.ts +++ b/src/libs/CategoryTaxRulesUtils.ts @@ -9,6 +9,7 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {MerchantRuleForm} from '@src/types/form'; import type {Policy, PolicyCategories, TaxRate} from '@src/types/onyx'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ExpenseRule} from '@src/types/onyx/Policy'; @@ -45,6 +46,13 @@ function isSelectableTaxRate(policy: Policy | undefined, taxID: string, taxRate: return !taxRate.isDisabled && taxRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !(isCategoryRule && taxID === policy?.taxRates?.defaultExternalID); } +/** Returns the category tax rule draft for generated values. It keeps the generated tax rate only when a category rule can use it. */ +function getCategoryTaxRuleDraftFromGeneratedValues({tax, ...values}: GeneratedRuleValues, policy: Policy | undefined): Partial { + const taxRate = tax ? policy?.taxRates?.taxes?.[tax] : undefined; + const selectableTax = tax && taxRate && isSelectableTaxRate(policy, tax, taxRate, true) ? tax : undefined; + return {...values, tax: selectableTax, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY}; +} + /** * Whether a new category tax default has any rate to choose. Holding rates is not enough: they can all be disabled, or * the workspace default can be the only one left, which a category rule can't use. @@ -194,6 +202,7 @@ function getCategoryNameFromTaxRuleKey(key: string): string { export { categoryHasTaxRule, + getCategoryTaxRuleDraftFromGeneratedValues, getCategoryNameFromTaxRuleKey, getCategoryTaxRulesTableData, getCategoryTaxRuleTaxID, diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 2f211e7014ce..981963f838d5 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 {MerchantRuleFormValues}; export { buildCopiedExpenseDefaultRules, @@ -458,6 +465,7 @@ export { canEditMerchantRule, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, + getMerchantRuleDraftFromGeneratedValues, getMerchantRuleFormValues, getPolicyExpenseDefaultRules, getRuleMerchantMatchSummary, diff --git a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts index da59db2d8830..feb4bd3572ca 100644 --- a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts +++ b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts @@ -1678,6 +1678,7 @@ type WorkspaceSettingsParamList = { }; [SCREENS.WORKSPACE.RULES_AGENT_NEW]: { policyID: string; + generationID?: string; }; [SCREENS.WORKSPACE.RULES_AGENT_EDIT]: { policyID: string; diff --git a/src/libs/RequireFieldsRulesUtils.ts b/src/libs/RequireFieldsRulesUtils.ts index fae4f1488e31..47e9db705549 100644 --- a/src/libs/RequireFieldsRulesUtils.ts +++ b/src/libs/RequireFieldsRulesUtils.ts @@ -10,6 +10,7 @@ import type {Route} from '@src/ROUTES'; import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src/types/form/RequireFieldsRuleForm'; import INPUT_IDS from '@src/types/form/RequireFieldsRuleForm'; import type {Policy, PolicyCategories, PolicyCategory} from '@src/types/onyx'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -28,6 +29,8 @@ import {setDraftRequireFieldsRule} from './actions/User'; import {getDecodedCategoryName} from './CategoryUtils'; import {isPendingDeleteOrUpdate} from './PolicyRulesUtils'; +const REQUIRE_FIELDS_SETTING_FIELD_KEYS = [INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const; + type RequireFieldsRuleType = DeepValueOf; type FieldRequirementsDirection = DeepValueOf; @@ -797,6 +800,24 @@ function getRequireFieldsFieldSettingUpdate( }; } +/** Returns the require fields draft for generated values, and the setting fields that the generated settings change */ +function getRequireFieldsDraftFromGeneratedValues(values: GeneratedRuleValues): {draft: GeneratedRuleValues; touchedFieldKeys: Set} { + let draft = values; + const touchedFieldKeys = new Set(); + for (const fieldKey of REQUIRE_FIELDS_SETTING_FIELD_KEYS) { + const setting = values[fieldKey]; + if (setting === undefined) { + continue; + } + const update = getRequireFieldsFieldSettingUpdate(fieldKey, setting); + draft = {...draft, ...update.formUpdate}; + for (const touchedFieldKey of update.touchedFieldKeys) { + touchedFieldKeys.add(touchedFieldKey); + } + } + return {draft, touchedFieldKeys}; +} + /** * Direction currently applied on the category for this field, or undefined when there is no override. * Unlike getRequireFieldsFormFromCategory, unset fields stay undefined instead of defaulting to Don't require. @@ -973,6 +994,7 @@ function getRequireFieldsFieldCouplingTooltipKey( } export { + REQUIRE_FIELDS_SETTING_FIELD_KEYS, canClearRequireFieldsField, categoryHasAnyRequireFieldsRule, deleteRequireFieldsRule, @@ -983,6 +1005,7 @@ export { getRequireFieldsDisplayedSetting, getRequireFieldsFieldClearKeys, getRequireFieldsFieldCouplingTooltipKey, + getRequireFieldsDraftFromGeneratedValues, getRequireFieldsFieldSettingUpdate, getRequireFieldsFormFromCategory, getRequireFieldsPendingActionForCategory, diff --git a/src/libs/SpendRulesUtils.ts b/src/libs/SpendRulesUtils.ts index 37bc6d62faab..7bbe66831c1d 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 {Card, ExpensifyCardSettings, WorkspaceCardsList} from '@src/types/onyx'; import type {ExpensifyCardRule, ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -121,6 +122,21 @@ function buildSpendRuleAST(spendRuleValues: SpendRuleValues, existingCreated?: s }; } +/** + * Returns the spend rule draft for generated values, without the categories that a spend rule cannot hold. A rule for all + * cards covers the eligible cards, and any other rule keeps the cards of the current draft. + */ +function getSpendRuleDraftFromGeneratedValues({isAllCards, ...values}: GeneratedRuleValues, eligibleCardIDs: string[] | undefined, currentCardIDs?: string[]): Partial { + const categories = values.categories?.filter(isSpendRuleCategory); + const cardIDs = isAllCards && eligibleCardIDs ? eligibleCardIDs : currentCardIDs; + return {...values, cardIDs, categories}; +} + +/** 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; @@ -389,8 +405,10 @@ export { buildSpendRuleAST, getEligibleSpendRuleCards, getSpendRuleByCardID, + getSpendRuleDraftFromGeneratedValues, getSpendRuleFormValuesFromCardRule, getSpendRuleSummaryParts, getSpendRuleSummaryText, getTruncatedSpendRuleSummary, + hasSpendRuleMerchantRestrictions, }; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 6fa691caf191..68e4879701a2 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -332,11 +332,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 (!isDraftFromFallback && 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,20 @@ function AddAgentRulePage({ onSelectSuggestion={selectSuggestion} /> - {activeTab === CONST.TAB.AGENT_RULE.WRITE && } + {activeTab === CONST.TAB.AGENT_RULE.WRITE && ( + <> + {isDraftFromFallback && ( + + )} + + + )} diff --git a/src/pages/workspace/rules/DescribeNewRuleForm.tsx b/src/pages/workspace/rules/DescribeNewRuleForm.tsx new file mode 100644 index 000000000000..77ca88639147 --- /dev/null +++ b/src/pages/workspace/rules/DescribeNewRuleForm.tsx @@ -0,0 +1,176 @@ +/** + * 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 Icon from '@components/Icon'; +import Text from '@components/Text'; + +import useDefaultFundID from '@hooks/useDefaultFundID'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useTheme from '@hooks/useTheme'; +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 {filterInactiveCards} from '@libs/CardUtils'; +import {categoryHasTaxRule, getCategoryTaxRuleDraftFromGeneratedValues} from '@libs/CategoryTaxRulesUtils'; +import {getMerchantRuleDraftFromGeneratedValues} from '@libs/ExpenseDefaultRuleUtils'; +import {hasExplicitFlagAmount} from '@libs/FlagForReviewRulesUtils'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import {categoryHasAnyRequireFieldsRule, getRequireFieldsDraftFromGeneratedValues} from '@libs/RequireFieldsRulesUtils'; +import {getEligibleSpendRuleCards, getSpendRuleDraftFromGeneratedValues} from '@libs/SpendRulesUtils'; + +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 AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; +import type {Policy, PolicyCategory} from '@src/types/onyx'; +import type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; + +import type {ReactNode} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import React from 'react'; +import {View} from 'react-native'; + +import DescribeRuleForm from './DescribeRuleForm'; + +type DescribeNewRuleFormProps = { + policyID: string; + + /** Content above the describe section */ + children: ReactNode; +}; + +type SeedDraftContext = { + policyID: string; + policy: OnyxEntry; + + /** The cards that a new spend rule can cover, or undefined while the card settings load */ + eligibleCardIDs: string[] | undefined; +}; + +/** Seeds the draft of the editor for the generated rule type and opens that editor */ +function seedDraftAndNavigate(values: GeneratedRuleValues, ruleType: GeneratedRuleType, {policyID, policy, eligibleCardIDs}: SeedDraftContext) { + switch (ruleType) { + case CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS: + setDraftRequireFieldsRule(getRequireFieldsDraftFromGeneratedValues(values).draft); + 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, eligibleCardIDs)); + 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; + // The expense defaults editor takes its rule type from the draft. Therefore a category draft opens the category tax rate editor. + case CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE: + setDraftMerchantRule(getCategoryTaxRuleDraftFromGeneratedValues(values, policy)); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + break; + default: + break; + } +} + +function DescribeNewRuleForm({policyID, children}: DescribeNewRuleFormProps) { + const {translate} = useLocalize(); + const theme = useTheme(); + const styles = useThemeStyles(); + const isFocused = useIsFocused(); + const policy = usePolicy(policyID); + const icons = useMemoizedLazyExpensifyIcons(['Sparkles']); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const domainAccountID = useDefaultFundID(policyID); + const [expensifyCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${domainAccountID}`); + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); + const eligibleCardIDs = expensifyCardSettings ? getEligibleSpendRuleCards(cardsList, expensifyCardSettings).map((card) => String(card.cardID)) : undefined; + + // 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) { + return; + } + openPolicyCategoriesPage(policyID); + }; + + useNetwork({onReconnect: fetchPolicyCategories}); + + useFocusEffect(() => { + fetchPolicyCategories(); + // 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(); + }); + + /** Returns whether a new rule of the generated rule type can use the category */ + const isCategoryEligible = (category: PolicyCategory, generatedRuleType: GeneratedRuleType) => { + switch (generatedRuleType) { + case CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS: + return !categoryHasAnyRequireFieldsRule(category); + case CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW: + return !hasExplicitFlagAmount(category.maxExpenseAmount); + case CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE: + return !categoryHasTaxRule(policy?.rules?.expenseRules, category.name); + default: + return true; + } + }; + + 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, policy, eligibleCardIDs})} + isCategoryEligible={isCategoryEligible} + onAgentRuleFallback={offerAgentRule} + > + {children} + + {translate('workspace.rules.newRule.describeItBelow')} + + + + ); +} + +DescribeNewRuleForm.displayName = 'DescribeNewRuleForm'; + +export default DescribeNewRuleForm; diff --git a/src/pages/workspace/rules/DescribeRuleForm.tsx b/src/pages/workspace/rules/DescribeRuleForm.tsx new file mode 100644 index 000000000000..681a729d8fbe --- /dev/null +++ b/src/pages/workspace/rules/DescribeRuleForm.tsx @@ -0,0 +1,276 @@ +/** + * 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, setNewRulePromptEmptyError, setNewRulePromptError, setNewRulePromptErrorMessage} 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, PolicyCategory} 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'; + +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; + + shouldUseScrollView?: boolean; + + 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; + + /** 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; + + /** Whether a new rule of the generated rule type can use the category, so a generated category that already has this kind of rule shows an error */ + isCategoryEligible?: (category: PolicyCategory, generatedRuleType: GeneratedRuleType) => boolean; + + /** 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 categories with the workspace's own spelling of them, and drops any the workspace has no enabled category for */ +function withPolicyCategories(values: GeneratedRuleValues, policyCategories: OnyxEntry): GeneratedRuleValues { + const {category, categoriesToMatch, ...rest} = values; + const findPolicyCategoryName = (categoryName: string) => + Object.values(policyCategories ?? {}).find((candidate) => candidate.enabled && getDecodedCategoryName(candidate.name) === getDecodedCategoryName(categoryName))?.name; + const policyCategoryName = category ? findPolicyCategoryName(category) : undefined; + return { + ...rest, + ...(policyCategoryName ? {category: policyCategoryName} : {}), + ...(categoriesToMatch ? {categoriesToMatch: categoriesToMatch.flatMap((categoryName) => findPolicyCategoryName(categoryName) ?? [])} : {}), + }; +} + +function DescribeRuleForm({ + policyID, + ruleType, + isActive, + shouldFocusInput = false, + submitButtonText, + inputContainerStyle, + style, + addBottomSafeAreaPadding = false, + shouldUseScrollView = false, + submitFlexEnabled = false, + canSkipDescription = false, + placeholder, + children, + onRuleGenerated, + isCategoryEligible, + 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; + } + const values = withPolicyCategories(generatedRuleValues ?? {}, policyCategories); + const hasCategoryWithRule = [values.category, ...(values.categoriesToMatch ?? [])].some((categoryName) => { + const policyCategory = categoryName ? policyCategories?.[categoryName] : undefined; + return !!policyCategory && !!isCategoryEligible && !isCategoryEligible(policyCategory, generatedRuleType); + }); + if (hasCategoryWithRule) { + setNewRulePromptErrorMessage('workspace.rules.newRule.promptErrors.categoryHasRule'); + clearGeneratedRule(); + return; + } + onRuleGenerated(values, generatedRuleType); + finish(); + }, [finish, generatedRuleID, generatedRuleState, generatedRuleType, generatedRuleValues, isActive, isCategoryEligible, 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 (!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() ?? ''; + if (!prompt) { + setNewRulePromptEmptyError(); + return; + } + pendingGenerationRef.current = {generationID: generateRule(policyID, prompt, ruleType), prompt}; + }; + + return ( + + {children} + + + + + ); +} + +DescribeRuleForm.displayName = 'DescribeRuleForm'; + +export default DescribeRuleForm; diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx index 276eb7314c41..920b625a2c71 100644 --- a/src/pages/workspace/rules/DescribeRuleModal.tsx +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -1,40 +1,25 @@ /** * 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 useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {getDecodedCategoryName} from '@libs/CategoryUtils'; - -import {clearGeneratedRule, clearNewRulePrompt, generateRule, setNewRulePromptError, setNewRulePromptErrorMessage} 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, PolicyCategory} from '@src/types/onyx'; +import type {PolicyCategory} from '@src/types/onyx'; import type {GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; -import type {TextInputKeyPressEvent} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; - -import {useFocusEffect} from '@react-navigation/native'; -import React, {useEffect, useRef} from 'react'; -import {View} from 'react-native'; +import React, {useState} from 'react'; -type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; +import DescribeRuleForm from './DescribeRuleForm'; type DescribeRuleModalProps = { isVisible: boolean; @@ -43,29 +28,16 @@ type DescribeRuleModalProps = { policyID: string; - /** The rule type of the editor the modal opens from, so Concierge fills in that form */ + /** The rule type of the editor that opens the modal */ 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; /** Whether a new rule can use the category, so a generated category that already has this kind of rule shows an error */ isCategoryEligible?: (category: PolicyCategory) => boolean; }; -/** Replaces the generated categories with the workspace's own spelling of them, and drops any the workspace has no enabled category for */ -function withPolicyCategories(values: GeneratedRuleValues, policyCategories: OnyxEntry): GeneratedRuleValues { - const {category, categoriesToMatch, ...rest} = values; - const findPolicyCategoryName = (categoryName: string) => - Object.values(policyCategories ?? {}).find((candidate) => candidate.enabled && getDecodedCategoryName(candidate.name) === getDecodedCategoryName(categoryName))?.name; - const policyCategoryName = category ? findPolicyCategoryName(category) : undefined; - return { - ...rest, - ...(policyCategoryName ? {category: policyCategoryName} : {}), - ...(categoriesToMatch ? {categoriesToMatch: categoriesToMatch.flatMap((categoryName) => findPolicyCategoryName(categoryName) ?? [])} : {}), - }; -} - function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenerated, isCategoryEligible}: DescribeRuleModalProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -75,87 +47,27 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera // 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 pendingGenerationIDRef = useRef(undefined); - const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); - 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 = () => { - pendingGenerationIDRef.current = undefined; - clearNewRulePrompt(); - onClose(); - }; - useEffect(() => { - if (!generatedRuleID || !generatedRuleState || generatedRuleID !== pendingGenerationIDRef.current) { - 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 the one this editor asked for. - if (generatedRuleType !== ruleType) { - setNewRulePromptError(CONST.GENERATED_RULE.STATE.UNSUPPORTED); - clearGeneratedRule(); - return; - } - const values = withPolicyCategories(generatedRuleValues ?? {}, policyCategories); - const hasCategoryWithRule = [values.category, ...(values.categoriesToMatch ?? [])].some((categoryName) => { - const policyCategory = categoryName ? policyCategories?.[categoryName] : undefined; - return !!policyCategory && !!isCategoryEligible && !isCategoryEligible(policyCategory); - }); - if (hasCategoryWithRule) { - setNewRulePromptErrorMessage('workspace.rules.newRule.promptErrors.categoryHasRule'); - clearGeneratedRule(); - 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); } - onRuleGenerated(values); - close(); - }, [close, generatedRuleID, generatedRuleState, generatedRuleType, generatedRuleValues, isCategoryEligible, onRuleGenerated, policyCategories, ruleType]); - - // 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) => { - pendingGenerationIDRef.current = generateRule(policyID, ruleType, values[INPUT_IDS.PROMPT].trim()); + const dismiss = () => { + clearNewRulePrompt(); + onClose(); }; return ( - {translate('workspace.rules.newRule.describeRule')} - - - - + ); } diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 236a1f1e49be..bf10f9b8c5f7 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 0225559a7c04..93a1f79347f7 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -31,9 +31,16 @@ import {deleteRule, setRule} from '@libs/actions/Policy/Rules'; import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; import Tab from '@libs/actions/Tab'; import {clearDraftMerchantRule, setDraftMerchantRule} from '@libs/actions/User'; -import {categoryHasTaxRule, getCategoryTaxRuleTaxID, getTaxRateDisplayName, hasUsableTaxRates, isCategoryRuleDraft, isSelectableTaxRate} from '@libs/CategoryTaxRulesUtils'; +import { + categoryHasTaxRule, + getCategoryTaxRuleDraftFromGeneratedValues, + getCategoryTaxRuleTaxID, + getTaxRateDisplayName, + hasUsableTaxRates, + isCategoryRuleDraft, +} from '@libs/CategoryTaxRulesUtils'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import {buildMerchantRule, canEditMerchantRule, getMerchantRuleFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; +import {buildMerchantRule, canEditMerchantRule, getMerchantRuleDraftFromGeneratedValues, getMerchantRuleFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; @@ -678,14 +685,8 @@ function MerchantRulePageBase({ } const shouldShowDescribeRule = !isEditingSavedRule && !isCategoryLocked; - const applyGeneratedRule = ({tax, ...values}: GeneratedRuleValues) => { - if (!isScopedToCategory) { - setDraftMerchantRule({...values, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); - return; - } - const taxRate = tax ? policy?.taxRates?.taxes?.[tax] : undefined; - const selectableTax = tax && taxRate && isSelectableTaxRate(policy, tax, taxRate, true) ? tax : undefined; - setDraftMerchantRule({...values, tax: selectableTax, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY}); + const applyGeneratedRule = (values: GeneratedRuleValues) => { + setDraftMerchantRule(isScopedToCategory ? getCategoryTaxRuleDraftFromGeneratedValues(values, policy) : getMerchantRuleDraftFromGeneratedValues(values)); }; const footer = canWriteRules ? ( diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 793f766eac85..36ab9ccda092 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -31,7 +31,9 @@ import { getEffectiveRequireFieldsRuleForm, getRequireFieldsDisplayedSetting, getRequireFieldsFieldClearKeys, + getRequireFieldsDraftFromGeneratedValues, getRequireFieldsFieldSettingUpdate, + REQUIRE_FIELDS_SETTING_FIELD_KEYS, getRequireFieldsFormFromCategory, getRequireFieldsPendingActionForCategory, getRequireFieldsRuleKey, @@ -54,12 +56,16 @@ 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'; 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 REQUIRE_FIELDS_SETTING_FIELD_KEYS.filter((fieldKey) => values?.[fieldKey] !== undefined); +} type RequireFieldsRulePageBaseProps = { policyID: string; @@ -129,7 +135,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Preserve whatever is currently shown (edit often displays category overrides // without those fields being in touchedFields yet). - for (const fieldKey of SETTING_FIELD_KEYS) { + for (const fieldKey of REQUIRE_FIELDS_SETTING_FIELD_KEYS) { const displayedSetting = getRequireFieldsDisplayedSetting({ fieldKey, category: previousCategory, @@ -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()); @@ -312,7 +322,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, const nextDraft: Partial = { [INPUT_IDS.CATEGORY]: form[INPUT_IDS.CATEGORY], }; - for (const settingFieldKey of SETTING_FIELD_KEYS) { + for (const settingFieldKey of REQUIRE_FIELDS_SETTING_FIELD_KEYS) { if (keysToClear.includes(settingFieldKey) || form[settingFieldKey] === undefined) { continue; } @@ -432,22 +442,10 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, } const applyGeneratedRule = (values: GeneratedRuleValues) => { - let generatedForm: GeneratedRuleValues = values; - const generatedFieldKeys = new Set(); - for (const fieldKey of SETTING_FIELD_KEYS) { - const setting = values[fieldKey]; - if (setting === undefined) { - continue; - } - const {formUpdate, touchedFieldKeys} = getRequireFieldsFieldSettingUpdate(fieldKey, setting); - generatedForm = {...generatedForm, ...formUpdate}; - for (const touchedFieldKey of touchedFieldKeys) { - generatedFieldKeys.add(touchedFieldKey); - } - } - setDraftRequireFieldsRule(generatedForm); + const {draft, touchedFieldKeys} = getRequireFieldsDraftFromGeneratedValues(values); + setDraftRequireFieldsRule(draft); setGeneratedCategoryName(values.category); - setTouchedFields(generatedFieldKeys); + setTouchedFields(touchedFieldKeys); }; const footer = canWriteRules ? ( diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 5cc21f7f416b..0123104667b0 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -7,6 +7,7 @@ import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -28,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; @@ -52,6 +55,8 @@ function RulesNewPage({route}: RulesNewPageProps) { const isCategoryScopedCreate = isCategorySettingsFlow || !!categoryName; const policy = usePolicy(policyID); const {showConfirmModal} = useConfirmModal(); + const {isBetaEnabled} = usePermissions(); + const isDescribeRuleBetaEnabled = isBetaEnabled(CONST.BETAS.DESCRIBE_RULE); // 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. @@ -96,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( @@ -116,18 +121,41 @@ 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, - }, + // The describe section 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; + // 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/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index b81380860d1e..1611f954b9ab 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -29,7 +29,13 @@ import {convertToBackendAmount} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import {getEligibleSpendRuleCards, getSpendRuleFormValuesFromCardRule, getTruncatedSpendRuleSummary} from '@libs/SpendRulesUtils'; +import { + getEligibleSpendRuleCards, + getSpendRuleDraftFromGeneratedValues, + getSpendRuleFormValuesFromCardRule, + getTruncatedSpendRuleSummary, + hasSpendRuleMerchantRestrictions, +} from '@libs/SpendRulesUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -44,7 +50,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,19 +105,15 @@ 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 = ({isAllCards, ...values}: GeneratedRuleValues) => { - const generatedCategories = values.categories?.filter(isSpendRuleCategory); - const generatedCardIDs = - isAllCards && expensifyCardSettings ? getEligibleSpendRuleCards(cardsList, expensifyCardSettings).map((card) => String(card.cardID)) : spendRuleForm?.cardIDs; - setDraftSpendRule({...values, cardIDs: generatedCardIDs, categories: generatedCategories}); - setIsRestrictMerchantsOff(!generatedCategories?.length && !values.merchantNames?.length); + const applyGeneratedRule = (values: GeneratedRuleValues) => { + const eligibleCardIDs = expensifyCardSettings ? getEligibleSpendRuleCards(cardsList, expensifyCardSettings).map((card) => String(card.cardID)) : undefined; + const draft = getSpendRuleDraftFromGeneratedValues(values, eligibleCardIDs, spendRuleForm?.cardIDs); + setDraftSpendRule(draft); + setIsRestrictMerchantsOff(!hasSpendRuleMerchantRestrictions(draft)); }; useEffect(() => { diff --git a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx index f626758620bd..41d20ec2212a 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/src/styles/index.ts b/src/styles/index.ts index 66ed1d62cfd4..077457e9713c 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6326,6 +6326,10 @@ const staticStyles = (theme: ThemeColors) => height: variables.describeRulePromptInputHeight, }, + describeNewRulePromptInput: { + height: variables.describeNewRulePromptInputHeight, + }, + describeRulePromptInputNarrow: { height: variables.textInputAutoGrowMaxHeight, }, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 4c4566d71bd1..c93926165e36 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -426,6 +426,7 @@ export default { rulesNewMenuItemMinHeight: 84, agentRulePromptInputHeight: 340, describeRulePromptInputHeight: 312, + describeNewRulePromptInputHeight: 96, cardPreviewWidth: 235, cardDetailsActionButtonMinWidth: 140, cardScarfOverlayWidth: 264, diff --git a/tests/ui/AddAgentRulePageTest.tsx b/tests/ui/AddAgentRulePageTest.tsx new file mode 100644 index 000000000000..a324d693ef38 --- /dev/null +++ b/tests/ui/AddAgentRulePageTest.tsx @@ -0,0 +1,307 @@ +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('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'); + 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/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 new file mode 100644 index 000000000000..e90c66ad8004 --- /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.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 () => { + // 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/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(); + }); +}); diff --git a/tests/ui/RulesNewPageDescribeRuleTest.tsx b/tests/ui/RulesNewPageDescribeRuleTest.tsx new file mode 100644 index 000000000000..8c352e4e9513 --- /dev/null +++ b/tests/ui/RulesNewPageDescribeRuleTest.tsx @@ -0,0 +1,451 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +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 RulesNewPage from '@pages/workspace/rules/RulesNewPage'; + +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 React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import getOnyxValue from '../utils/getOnyxValue'; +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'); + return { + ...actualNavigation, + useFocusEffect: (callback: () => void) => { + callback(); + }, + useIsFocused: () => true, + }; +}); +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', () => + jest.fn(() => ({ + 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('@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(), +})); +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/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 mockedUsePolicy = jest.mocked(usePolicy); + +type WriteSpy = jest.SpiedFunction; + +function setAdminPolicy(areCategoriesEnabled = false) { + mockedUsePolicy.mockReturnValue({...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID, role: CONST.POLICY.ROLE.ADMIN, areCategoriesEnabled}); +} + +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, categoryName}}, + navigation: {}, + } as unknown as RulesNewPageProps; + render(); + 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 form 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 form 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('New rule describe section', () => { + let writeSpy: WriteSpy; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrompt = ''; + mockIsDescribeRuleBetaEnabled = true; + writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + setAdminPolicy(); + }); + + afterEach(async () => { + writeSpy.mockRestore(); + await act(async () => { + await Onyx.clear(); + }); + }); + + 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 the four rule types come first, because the admin can describe the rule in the section below them + const expectedOptionNames = [ + 'workspace.rules.newRule.restrictCardSpend', + 'workspace.rules.newRule.flagForReview', + 'workspace.rules.newRule.requireFieldsShort', + 'workspace.rules.newRule.applyExpenseDefaults', + ]; + expect(options).toHaveLength(expectedOptionNames.length); + for (const [index, name] of expectedOptionNames.entries()) { + expect(options.at(index)).toHaveAccessibleName(name); + } + + // 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('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'); + + // 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(); + + // 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 section + 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); + } + + 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 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(); + + // 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 renderRulesNewPage(); + 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('opens the category tax rate editor for a categoryTaxRate answer', async () => { + // Given a workspace with a Meals category and a GST rate that a category rule can use, and an admin who describes a tax default + mockedUsePolicy.mockReturnValue({ + ...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), + id: POLICY_ID, + role: CONST.POLICY.ROLE.ADMIN, + taxRates: {name: 'Tax', defaultExternalID: 'VAT', defaultValue: '5%', foreignTaxDefault: 'VAT', taxes: {VAT: {name: 'VAT', value: '5%'}, GST: {name: 'GST', value: '10%'}}}, + }); + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, {Meals: {name: 'Meals', enabled: true}}); + }); + await renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, 'Use GST for meals'); + + // When Concierge answers with a category tax rate rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE, + rule: {categoriesToMatch: ['Meals'], tax: 'GST'}, + }); + + // Then the expense defaults editor opens scoped to categories, because it takes its rule type from the draft + expect(await getOnyxValue(ONYXKEYS.FORMS.MERCHANT_RULE_FORM)).toEqual({categoriesToMatch: ['Meals'], tax: 'GST', ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY}); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_MERCHANT_NEW.getRoute(POLICY_ID)); + }); + + it('shows an error when the generated category already has a tax rate rule', async () => { + // Given a Meals category that already has a tax rate rule + mockedUsePolicy.mockReturnValue({ + ...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), + id: POLICY_ID, + role: CONST.POLICY.ROLE.ADMIN, + rules: { + expenseRules: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention -- the expense rule stores the tax under the field name that the backend uses + tax: {field_id_TAX: {externalID: 'GST'}}, + applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.CATEGORY, value: 'Meals'}], + }, + ], + }, + }); + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, {Meals: {name: 'Meals', enabled: true}}); + }); + await renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, 'Use GST for meals'); + + // When Concierge answers with a tax rate rule for that category + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE, + rule: {categoriesToMatch: ['Meals'], tax: 'GST'}, + }); + + // Then New rule shows the error and opens nothing, because a category can have one tax rate rule + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.categoryHasRule')]); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + 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 renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, prompt); + + // When Concierge answers that it is not one standard rule + await answerGeneration({generationID, state}); + + // 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).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([undefined, 'futureRuleType'])('shows the unsupported error when a rule answer has the rule type %s', async (generatedRuleType) => { + // 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 + 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 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(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 + setAdminPolicy(true); + + // When New rule opens with the describe section + await renderRulesNewPage(); + + // 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 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 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(); + }); +});