diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8b6c6c0e4b65..225f46935a60 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6057,6 +6057,10 @@ const CONST = { FLAG_FOR_REVIEW: 'flagForReview', AGENTS: 'agents', }, + AGENT_RULE: { + SUGGESTIONS: 'suggestions', + WRITE: 'write', + }, SPLIT: { AMOUNT: 'amount', PERCENTAGE: 'percentage', @@ -8846,6 +8850,7 @@ const CONST = { SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', AGENT_RULE_ITEM: 'WorkspaceRules-AgentRuleItem', ADD_AGENT_RULE: 'WorkspaceRules-AddAgentRule', + SUGGESTED_AGENT_RULE: 'WorkspaceRules-SuggestedAgentRule', AGENT_RULE_DELETE: 'WorkspaceRules-AgentRuleDelete', NEW_RULE_MENU_ITEM: 'WorkspaceRules-NewRuleMenuItem', NEW_RULE_MENU_ITEM_RESTRICT_CARD_SPEND: 'WorkspaceRules-NewRuleMenuItem-RestrictCardSpend', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 532d700759fd..e879f02731cc 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -157,6 +157,9 @@ const ONYXKEYS = { BETAS: 'betas', BETA_CONFIGURATION: 'betaConfiguration', + /** Agent rule suggestions for the add-rule Suggestions tab */ + AGENT_RULE_SUGGESTIONS: 'agentRuleSuggestions', + /** Whether the user is a member of a policy other than their personal */ HAS_NON_PERSONAL_POLICY: 'hasNonPersonalPolicy', @@ -433,6 +436,9 @@ const ONYXKEYS = { /** Is loading policy rules preview? */ IS_LOADING_POLICY_CODING_RULES_PREVIEW: 'isLoadingPolicyCodingRulesPreview', + /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ + IS_LOADING_AGENT_RULE_SUGGESTIONS: 'isLoadingAgentRuleSuggestions', + /** Set when we are loading fresh subscription/billing data from the server */ IS_LOADING_SUBSCRIPTION_DATA: 'isLoadingSubscriptionData', @@ -1527,6 +1533,7 @@ type OnyxValuesMapping = { [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; + [ONYXKEYS.AGENT_RULE_SUGGESTIONS]: OnyxTypes.SuggestedAgentRule[]; [ONYXKEYS.BETA_CONFIGURATION]: OnyxTypes.BetaConfiguration; [ONYXKEYS.NVP_MUTED_PLATFORMS]: Partial>; [ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf; @@ -1596,6 +1603,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_SHARE_BANK_ACCOUNTS]: boolean; [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; + [ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS]: boolean; [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean; [ONYXKEYS.IS_LOADING_SUBSCRIPTION_DATA]: boolean; diff --git a/src/hooks/useSuggestedAgentRules.ts b/src/hooks/useSuggestedAgentRules.ts new file mode 100644 index 000000000000..7c874a09aaf3 --- /dev/null +++ b/src/hooks/useSuggestedAgentRules.ts @@ -0,0 +1,29 @@ +/** + * Reads backend-served suggested agent rules from Onyx for the add-agent-rule Suggestions tab. + */ +import ONYXKEYS from '@src/ONYXKEYS'; +import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import useOnyx from './useOnyx'; + +type UseSuggestedAgentRulesResult = { + /** Suggested rules from Onyx, or an empty array while loading or when no suggestions exist */ + data: SuggestedAgentRule[]; + + /** Whether Onyx is hydrating or the suggestions request is fetching data */ + isLoading: boolean; +}; + +function useSuggestedAgentRules(): UseSuggestedAgentRulesResult { + const [suggestions, metadata] = useOnyx(ONYXKEYS.AGENT_RULE_SUGGESTIONS); + const [isFetchingSuggestions] = useOnyx(ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS); + const isLoading = isLoadingOnyxValue(metadata) || !!isFetchingSuggestions; + + return { + data: suggestions ?? [], + isLoading, + }; +} + +export default useSuggestedAgentRules; diff --git a/src/languages/de.ts b/src/languages/de.ts index 95a23c719dfd..85820d42e08d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7749,16 +7749,22 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc addRule: 'Agentenregel hinzufügen', findRule: 'Agentenregel finden', addRuleTitle: 'Regel hinzufügen', + newRuleTitle: 'Neue Regel', editRuleTitle: 'Regel bearbeiten', deleteRule: 'Regel löschen', deleteRuleConfirmation: 'Sind Sie sicher, dass Sie diese Regel löschen möchten?', describeRuleTitle: 'Beschreiben Sie die Regel, der Ihre KI-Agentin/Ihr KI-Agent folgen soll', + describeRuleHeadline: 'Beschreibe deine Regel', disclaimer: 'KI-Agenten können Fehler machen.', agentCreatedTitle: 'RuleBot wurde zu Ihrem Arbeitsbereich hinzugefügt!', agentCreatedDescription: (agentsRoute: string) => `Um Ihre Agent-Regeln durchzusetzen, haben wir einen Agenten für Sie erstellt und ihn als Administrator zu Ihrem Arbeitsbereich hinzugefügt.

Bearbeiten Sie die Details Ihres Agenten unter Konto > Agenten.
`, + suggestionsTab: 'Vorschläge', + writeTab: 'Bearbeiten', + findSuggestion: 'Regel finden', + emptySuggestionsTitle: 'Keine Vorschläge verfügbar', + emptySuggestionsSubtitle: 'Versuchen Sie, Ihre eigene Regel zu schreiben.', revampSubtitle: 'Beschreiben Sie flexible Regeln, die bei Bedarf ausgeführt werden.', - newRuleTitle: 'Neue Regel', describeRuleForConcierge: 'Beschreiben Sie Ihre Regel und Concierge erstellt sie', gotIt: 'Verstanden', createRule: 'Regel erstellen', diff --git a/src/languages/en.ts b/src/languages/en.ts index 057d87f4e356..4480df485bf1 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8057,12 +8057,18 @@ const translations = { deleteRule: 'Delete rule', deleteRuleConfirmation: 'Are you sure you want to delete this rule?', describeRuleTitle: 'Describe the rule for your AI agent to follow', + describeRuleHeadline: 'Describe your rule', describeRuleForConcierge: 'Describe your rule and Concierge will build it', disclaimer: 'AI agents can make mistakes.', createRule: 'Create rule', 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.
`, + suggestionsTab: 'Suggested', + writeTab: 'Edit', + findSuggestion: 'Find a rule', + emptySuggestionsTitle: 'No suggestions available', + emptySuggestionsSubtitle: 'Try writing your own rule.', gotIt: 'Got it', }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 9750a85de185..2374a3bfb114 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7641,7 +7641,9 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, addRule: 'Añadir regla de agente', findRule: 'Encontrar regla de agente', addRuleTitle: 'Añadir regla', + newRuleTitle: 'Nueva regla', describeRuleTitle: 'Describe la regla que debe seguir tu agente de IA', + describeRuleHeadline: 'Describe tu regla', editRuleTitle: 'Editar regla', deleteRule: 'Eliminar regla', deleteRuleConfirmation: '¿Seguro que quieres eliminar esta regla?', @@ -7649,8 +7651,12 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, agentCreatedTitle: '¡RuleBot se ha añadido a tu espacio de trabajo!', agentCreatedDescription: (agentsRoute: string) => `Para aplicar tus reglas de agente, hemos creado un agente para ti y lo hemos añadido como administrador de tu espacio de trabajo.

Edita los datos de tu agente en Cuenta > Agentes.
`, + suggestionsTab: 'Sugerencias', + writeTab: 'Editar', + findSuggestion: 'Buscar una regla', + emptySuggestionsTitle: 'No hay sugerencias disponibles', + emptySuggestionsSubtitle: 'Prueba a escribir tu propia regla.', revampSubtitle: 'Describe reglas flexibles que se ejecutan cuando las necesitas.', - newRuleTitle: 'Nueva regla', describeRuleForConcierge: 'Describe tu regla y Concierge la creará', gotIt: 'Entendido', createRule: 'Crear regla', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1b75c445f283..7dedf35b8ff1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7779,16 +7779,22 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e addRule: 'Ajouter une règle d’agent', findRule: 'Rechercher une règle d’agent', addRuleTitle: 'Ajouter une règle', + newRuleTitle: 'Nouvelle règle', editRuleTitle: 'Modifier la règle', deleteRule: 'Supprimer la règle', deleteRuleConfirmation: 'Voulez-vous vraiment supprimer cette règle ?', describeRuleTitle: 'Décrivez la règle que votre agent IA doit suivre', + describeRuleHeadline: 'Décrivez votre règle', disclaimer: 'Les agents IA peuvent faire des erreurs.', agentCreatedTitle: 'RuleBot a été ajouté à votre espace de travail !', agentCreatedDescription: (agentsRoute: string) => `Pour appliquer vos règles d’agent, nous avons créé un agent pour vous et l’avons ajouté comme administrateur de votre espace de travail.

Modifiez les détails de votre agent dans Compte > Agents.
`, + suggestionsTab: 'Suggestions', + writeTab: 'Modifier', + findSuggestion: 'Trouver une règle', + emptySuggestionsTitle: 'Aucune suggestion disponible', + emptySuggestionsSubtitle: 'Essayez d’écrire votre propre règle.', revampSubtitle: 'Décrivez des règles flexibles qui s’exécutent quand vous en avez besoin.', - newRuleTitle: 'Nouvelle règle', describeRuleForConcierge: 'Décrivez votre règle et Concierge la créera', gotIt: 'Compris', createRule: 'Créer une règle', diff --git a/src/languages/it.ts b/src/languages/it.ts index dfcdf2ee5181..61494d2cb119 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7732,16 +7732,22 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, addRule: 'Aggiungi regola agente', findRule: 'Trova regola agente', addRuleTitle: 'Aggiungi regola', + newRuleTitle: 'Nuova regola', editRuleTitle: 'Modifica regola', deleteRule: 'Elimina regola', deleteRuleConfirmation: 'Sei sicuro di voler eliminare questa regola?', describeRuleTitle: 'Descrivi la regola che il tuo agente AI deve seguire', + describeRuleHeadline: 'Descrivi la tua regola', disclaimer: 'Gli agenti IA possono commettere errori.', agentCreatedTitle: 'RuleBot è stato aggiunto al tuo spazio di lavoro!', agentCreatedDescription: (agentsRoute: string) => `Per applicare le tue regole dell’agente, abbiamo creato un agente per te e lo abbiamo aggiunto come amministratore del tuo spazio di lavoro.

Modifica i dettagli del tuo agente in Account > Agenti.
`, + suggestionsTab: 'Suggerimenti', + writeTab: 'Modifica', + findSuggestion: 'Trova una regola', + emptySuggestionsTitle: 'Nessun suggerimento disponibile', + emptySuggestionsSubtitle: 'Prova a scrivere la tua regola.', revampSubtitle: 'Descrivi regole flessibili che vengono eseguite quando ne hai bisogno.', - newRuleTitle: 'Nuova regola', describeRuleForConcierge: 'Descrivi la tua regola e Concierge la creerà', gotIt: 'Capito', createRule: 'Crea regola', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0ffc19ec41b5..82b24e1cb319 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7636,16 +7636,22 @@ ${reportName}`, addRule: 'エージェントルールを追加', findRule: 'エージェントルールを検索', addRuleTitle: 'ルールを追加', + newRuleTitle: '新しいルール', editRuleTitle: 'ルールを編集', deleteRule: 'ルールを削除', deleteRuleConfirmation: 'このルールを削除してもよろしいですか?', describeRuleTitle: 'AI エージェントに従わせるルールを記述してください', + describeRuleHeadline: 'ルールを説明する', disclaimer: 'AI エージェントは間違える場合があります。', agentCreatedTitle: 'RuleBot がワークスペースに追加されました!', agentCreatedDescription: (agentsRoute: string) => `エージェント ルールを適用するために、エージェントを作成し、ワークスペースの管理者として追加しました。

エージェントの詳細は 「アカウント」>「エージェント」 で編集できます。
`, + suggestionsTab: 'おすすめ', + writeTab: '編集', + findSuggestion: 'ルールを探す', + emptySuggestionsTitle: '利用可能な提案はありません', + emptySuggestionsSubtitle: '独自のルールを作成してみてください。', revampSubtitle: '必要なときに実行できる柔軟なルールを設定します。', - newRuleTitle: '新しいルール', describeRuleForConcierge: 'ルールの内容を入力すると、Concierge が自動作成します', gotIt: '了解しました', createRule: 'ルールを作成', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d34f8495295a..e8d1a8d88be4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7710,16 +7710,22 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, addRule: 'Agentregel toevoegen', findRule: 'Agentregel zoeken', addRuleTitle: 'Regel toevoegen', + newRuleTitle: 'Nieuwe regel', editRuleTitle: 'Regel bewerken', deleteRule: 'Regel verwijderen', deleteRuleConfirmation: 'Weet je zeker dat je deze regel wilt verwijderen?', describeRuleTitle: 'Beschrijf de regel die je AI-agent moet volgen', + describeRuleHeadline: 'Beschrijf je regel', disclaimer: 'AI-agents kunnen fouten maken.', agentCreatedTitle: 'RuleBot is toegevoegd aan je werkruimte!', agentCreatedDescription: (agentsRoute: string) => `Om je agentregels af te dwingen, hebben we een agent voor je gemaakt en deze als beheerder aan je werkruimte toegevoegd.

Bewerk de gegevens van je agent in Account > Agents.
`, + suggestionsTab: 'Suggesties', + writeTab: 'Bewerken', + findSuggestion: 'Zoek een regel', + emptySuggestionsTitle: 'Geen suggesties beschikbaar', + emptySuggestionsSubtitle: 'Probeer je eigen regel te schrijven.', revampSubtitle: 'Beschrijf flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', - newRuleTitle: 'Nieuwe regel', describeRuleForConcierge: 'Beschrijf je regel en Concierge bouwt hem voor je', gotIt: 'Begrepen', createRule: 'Regel maken', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 5171d9d5e5d5..1fec55c92ab0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7690,16 +7690,22 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, addRule: 'Dodaj regułę agenta', findRule: 'Znajdź regułę agenta', addRuleTitle: 'Dodaj regułę', + newRuleTitle: 'Nowa reguła', editRuleTitle: 'Edytuj regułę', deleteRule: 'Usuń regułę', deleteRuleConfirmation: 'Na pewno chcesz usunąć tę regułę?', describeRuleTitle: 'Opisz zasadę, której ma przestrzegać twój agent AI', + describeRuleHeadline: 'Opisz swoją regułę', disclaimer: 'Agenci AI mogą popełniać błędy.', agentCreatedTitle: 'RuleBot został dodany do Twojego obszaru roboczego!', agentCreatedDescription: (agentsRoute: string) => `Aby egzekwować Twoje reguły agenta, utworzyliśmy dla Ciebie agenta i dodaliśmy go jako administratora do Twojego obszaru roboczego.

Edytuj dane swojego agenta w sekcji Konto > Agenci.
`, + suggestionsTab: 'Propozycje', + writeTab: 'Edytuj', + findSuggestion: 'Znajdź regułę', + emptySuggestionsTitle: 'Brak dostępnych propozycji', + emptySuggestionsSubtitle: 'Spróbuj napisać własną regułę.', revampSubtitle: 'Opisuj elastyczne reguły, które uruchamiają się wtedy, kiedy tego potrzebujesz.', - newRuleTitle: 'Nowa reguła', describeRuleForConcierge: 'Opisz swoją regułę, a Concierge ją utworzy', gotIt: 'Jasne', createRule: 'Utwórz regułę', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d4c1672a2cb5..f560ea228c52 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7702,16 +7702,22 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, addRule: 'Adicionar regra de agente', findRule: 'Encontrar regra de agente', addRuleTitle: 'Adicionar regra', + newRuleTitle: 'Nova regra', editRuleTitle: 'Editar regra', deleteRule: 'Excluir regra', deleteRuleConfirmation: 'Tem certeza de que quer excluir esta regra?', describeRuleTitle: 'Descreva a regra que seu agente de IA deve seguir', + describeRuleHeadline: 'Descreva sua regra', disclaimer: 'Os agentes de IA podem cometer erros.', agentCreatedTitle: 'O RuleBot foi adicionado ao seu espaço de trabalho!', agentCreatedDescription: (agentsRoute: string) => `Para aplicar suas regras de agente, criamos um agente para você e o adicionamos como administrador do seu espaço de trabalho.

Edite os detalhes do seu agente em Conta > Agentes.
`, + suggestionsTab: 'Sugestões', + writeTab: 'Editar', + findSuggestion: 'Encontrar uma regra', + emptySuggestionsTitle: 'Nenhuma sugestão disponível', + emptySuggestionsSubtitle: 'Tente escrever sua própria regra.', revampSubtitle: 'Descreva regras flexíveis que são executadas quando você precisar.', - newRuleTitle: 'Nova regra', describeRuleForConcierge: 'Descreva sua regra e o Concierge vai criá-la', gotIt: 'Entendi', createRule: 'Criar regra', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5547a6a118a9..4c6d8bfd3647 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7484,16 +7484,22 @@ ${reportName}`, addRule: '添加代理规则', findRule: '查找代理规则', addRuleTitle: '添加规则', + newRuleTitle: '新规则', editRuleTitle: '编辑规则', deleteRule: '删除规则', deleteRuleConfirmation: '确定要删除此规则吗?', describeRuleTitle: '描述你的 AI 代理需要遵循的规则', + describeRuleHeadline: '描述您的规则', disclaimer: 'AI 智能体可能会犯错。', agentCreatedTitle: 'RuleBot 已添加到你的工作区!', agentCreatedDescription: (agentsRoute: string) => `为了执行你的代理规则,我们为你创建了一个代理,并将其添加为你工作区的管理员。

你可以在 “账户”>“代理” 中编辑代理的详细信息。
`, + suggestionsTab: '建议', + writeTab: '编辑', + findSuggestion: '查找规则', + emptySuggestionsTitle: '暂无可用建议', + emptySuggestionsSubtitle: '尝试编写您自己的规则。', revampSubtitle: '按需运行的灵活规则描述', - newRuleTitle: '新规则', describeRuleForConcierge: '描述你的规则,Concierge 会帮你创建', gotIt: '知道了', createRule: '创建规则', diff --git a/src/libs/API/parameters/GetAgentRuleSuggestionsParams.ts b/src/libs/API/parameters/GetAgentRuleSuggestionsParams.ts new file mode 100644 index 000000000000..042aa6accb41 --- /dev/null +++ b/src/libs/API/parameters/GetAgentRuleSuggestionsParams.ts @@ -0,0 +1,5 @@ +type GetAgentRuleSuggestionsParams = { + policyID: string; +}; + +export default GetAgentRuleSuggestionsParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index b2aad40dd9d9..fe0e245ed5ba 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -72,6 +72,7 @@ export type {default as GetMissingOnyxMessagesParams} from './GetMissingOnyxMess export type {default as GetNewerActionsParams} from './GetNewerActionsParams'; export type {default as GetOlderActionsParams} from './GetOlderActionsParams'; export type {default as GetPolicyCategoriesParams} from './GetPolicyCategories'; +export type {default as GetAgentRuleSuggestionsParams} from './GetAgentRuleSuggestionsParams'; export type {default as GetReportPrivateNoteParams} from './GetReportPrivateNoteParams'; export type {default as GetRouteParams} from './GetRouteParams'; export type {default as CreateAppReportParams} from './CreateAppReportParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 0fbe33af72e7..c8214e32663a 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1433,6 +1433,7 @@ const READ_COMMANDS = { GET_SAML_SETTINGS: 'GetSAMLSettings', GET_DUPLICATE_TRANSACTION_DETAILS: 'GetDuplicateTransactionDetails', GET_TRANSACTIONS_MATCHING_CODING_RULE: 'GetTransactionsMatchingCodingRule', + GET_AGENT_RULE_SUGGESTIONS: 'GetAgentRuleSuggestions', GET_ASSIGNED_SUPPORT_DATA: 'GetAssignedSupportData', OPEN_AGENTS_PAGE: 'OpenAgentsPage', OPEN_PROFILE_PAGE: 'OpenProfilePage', @@ -1538,6 +1539,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_DOMAIN_PAGE]: Parameters.OpenDomainPageParams; [READ_COMMANDS.GET_DUPLICATE_TRANSACTION_DETAILS]: Parameters.GetDuplicateTransactionDetailsParams; [READ_COMMANDS.GET_TRANSACTIONS_MATCHING_CODING_RULE]: Parameters.GetTransactionsMatchingCodingRuleParams; + [READ_COMMANDS.GET_AGENT_RULE_SUGGESTIONS]: Parameters.GetAgentRuleSuggestionsParams; [READ_COMMANDS.GET_ASSIGNED_SUPPORT_DATA]: null; [READ_COMMANDS.OPEN_AGENTS_PAGE]: null; [READ_COMMANDS.OPEN_PROFILE_PAGE]: null; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 5e63da804d0b..a3372e6c2cc7 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -129,6 +129,7 @@ const ONYX_KEY_EXPORT_RULES: Record = { const safeOnyxKeys = new Set([ ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID, ONYXKEYS.ACTIVE_CLIENTS, + ONYXKEYS.AGENT_RULE_SUGGESTIONS, ONYXKEYS.ARE_AGENTS_LOADED, ONYXKEYS.ARE_POLICY_ROOMS_LOADED, ONYXKEYS.BETAS, @@ -182,6 +183,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.IS_COMING_FROM_GLOBAL_REIMBURSEMENTS_FLOW, ONYXKEYS.IS_DEBUG_MODE_ENABLED, ONYXKEYS.IS_GPS_IN_PROGRESS_MODAL_OPEN, + ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS, ONYXKEYS.IS_LOADING_APP, ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE, diff --git a/src/libs/PolicyRulesUtils.ts b/src/libs/PolicyRulesUtils.ts index 440ea0dbdffe..1252cc710764 100644 --- a/src/libs/PolicyRulesUtils.ts +++ b/src/libs/PolicyRulesUtils.ts @@ -1,5 +1,49 @@ import CONST from '@src/CONST'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; + +import StringUtils from './StringUtils'; + +type SuggestedAgentRuleIcon = 'ThumbsUp' | 'CircleSlash' | 'Flag' | 'Coins'; + +type SuggestedAgentRuleIconRule = { + /** Icon to show when any keyword matches */ + icon: Exclude; + + /** Higher priority wins when multiple rules match the same suggestion. */ + priority: number; + + /** Keywords matched against suggestion id + title */ + keywords: readonly string[]; +}; + +/** Fallback when no keyword rule matches */ +const DEFAULT_SUGGESTED_AGENT_RULE_ICON: SuggestedAgentRuleIcon = 'ThumbsUp'; + +/** + * Backend suggestions are {id, title, prompt} only. Icons are derived from id + title. + * Keywords are matched as whole tokens so substrings like "age"/"cap" do not match "manage"/"capital". + */ + +const SUGGESTED_AGENT_RULE_ICON_RULES = [ + { + icon: 'Flag', + priority: 1, + keywords: ['flag', 'flagged', 'mismatch', 'empty', 'mixed', 'age', 'weekend', 'suspected', 'unusually', 'within', 'window', 'deadline'], + }, + { + icon: 'Coins', + priority: 2, + keywords: ['amount', 'total', 'cap', 'tip', 'currency', 'limit'], + }, + { + icon: 'CircleSlash', + priority: 3, + keywords: ['block', 'banned', 'blocked', 'alcohol', 'gift-card', 'personal', 'ai-generated', 'handwritten', 'incorrect-receipt'], + }, +] as const satisfies readonly SuggestedAgentRuleIconRule[]; + +const SUGGESTED_AGENT_RULE_ICON_NAMES = [DEFAULT_SUGGESTED_AGENT_RULE_ICON, ...SUGGESTED_AGENT_RULE_ICON_RULES.map((rule) => rule.icon)] as const; function isPendingDeleteOrUpdate(pendingAction: PendingAction | undefined): boolean { return pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; @@ -9,4 +53,28 @@ function getMccGroupDisplayName(groupID: string): string { return groupID.charAt(0).toUpperCase() + groupID.slice(1); } -export {getMccGroupDisplayName, isPendingDeleteOrUpdate}; +/** + * Returns true when keyword appears as a whole token in text. + */ +function textIncludesKeywordToken(text: string, keyword: string): boolean { + return new RegExp(`(?:^|[^a-z0-9])${StringUtils.escapeRegExp(keyword)}(?:$|[^a-z0-9])`).test(text); +} + +function getSuggestedAgentRuleIcon(suggestion: SuggestedAgentRule): SuggestedAgentRuleIcon { + const text = `${suggestion.id} ${suggestion.title}`.toLowerCase(); + let bestMatchingRule: SuggestedAgentRuleIconRule | undefined; + + for (const rule of SUGGESTED_AGENT_RULE_ICON_RULES) { + const hasKeywordMatch = rule.keywords.some((keyword) => textIncludesKeywordToken(text, keyword)); + if (!hasKeywordMatch) { + continue; + } + if (!bestMatchingRule || rule.priority > bestMatchingRule.priority) { + bestMatchingRule = rule; + } + } + + return bestMatchingRule?.icon ?? DEFAULT_SUGGESTED_AGENT_RULE_ICON; +} + +export {getMccGroupDisplayName, getSuggestedAgentRuleIcon, isPendingDeleteOrUpdate, SUGGESTED_AGENT_RULE_ICON_NAMES}; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 7dde67ddfb7f..06c0ea5114e4 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,6 +1,12 @@ import {getImportFailedFinalModal} from '@libs/actions/ImportSpreadsheet'; import * as API from '@libs/API'; -import type {AddPolicyAgentRuleParams, DeletePolicyAgentRuleParams, ImportMerchantRulesSpreadsheetParams, UpdatePolicyAgentRuleParams} from '@libs/API/parameters'; +import type { + AddPolicyAgentRuleParams, + DeletePolicyAgentRuleParams, + GetAgentRuleSuggestionsParams, + ImportMerchantRulesSpreadsheetParams, + UpdatePolicyAgentRuleParams, +} from '@libs/API/parameters'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; import type SetPolicyCodingRuleParams from '@libs/API/parameters/SetPolicyCodingRuleParams'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; @@ -122,6 +128,41 @@ function openPolicyRulesPage(policyID: string | undefined) { API.read(READ_COMMANDS.OPEN_POLICY_RULES_PAGE, params); } +/** + * Fetches ready-made agent rule suggestions for the add-agent-rule Suggestions tab. + */ +function getAgentRuleSuggestions(policyID: string | undefined) { + if (!policyID) { + Log.warn('Invalid params for getAgentRuleSuggestions', {policyID}); + return; + } + + const params: GetAgentRuleSuggestionsParams = {policyID}; + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS, + value: true, + }, + ]; + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS, + value: false, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS, + value: false, + }, + ]; + + API.read(READ_COMMANDS.GET_AGENT_RULE_SUGGESTIONS, params, {optimisticData, successData, failureData}); +} + /** * Creates or updates a coding rule for the given policy * @param policyID - The ID of the policy to create/update the rule for @@ -631,6 +672,7 @@ function clearPolicyAgentRuleErrors(policyID: string, agentRuleID: string, agent export { openPolicyRulesPage, + getAgentRuleSuggestions, setPolicyCodingRule, importMerchantRulesSpreadsheet, deletePolicyCodingRule, diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index 45f24dd25f4b..f2d69c09adf4 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -1,20 +1,23 @@ -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; +import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {BotAvatarBlue} from '@components/Icon/DefaultBotAvatars'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; +import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; +import TabSelectorContextProvider from '@components/TabSelector/TabSelectorContext'; +import type {TabSelectorBaseItem} from '@components/TabSelector/types'; import useConfirmModal from '@hooks/useConfirmModal'; -import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import {clearDraftValues, setDraftValues} from '@libs/actions/FormActions'; import Tab from '@libs/actions/Tab'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -25,22 +28,34 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; -import {addPolicyAgentRule} from '@userActions/Policy/Rules'; +import {addPolicyAgentRule, getAgentRuleSuggestions} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/AddAgentRuleForm'; +import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; -import React, {useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +import type {AddAgentRuleFormID} from './AddAgentRuleWriteTab'; + +import AddAgentRuleSuggestionsTab from './AddAgentRuleSuggestionsTab'; +import AddAgentRuleWriteTab from './AddAgentRuleWriteTab'; + type AddAgentRulePageProps = PlatformStackScreenProps; -type AddAgentRuleFormID = typeof ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM; +type AgentRuleTab = ValueOf; + +const AGENT_RULE_TAB_VALUES = new Set(Object.values(CONST.TAB.AGENT_RULE)); + +function isAgentRuleTab(key: string): key is AgentRuleTab { + return AGENT_RULE_TAB_VALUES.has(key); +} function AddAgentRulePage({ route: { @@ -49,31 +64,77 @@ function AddAgentRulePage({ }: AddAgentRulePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const shouldUseScrollableLayout = useIsInLandscapeMode(); + const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); const isCustomAgentEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); - const shouldUseExpandedRevampFormLayout = isRulesRevampEnabled && !shouldUseScrollableLayout; const policy = usePolicy(policyID); - const formRef = useRef(null); const linkPressedRef = useRef(false); const {showConfirmModal, closeModal} = useConfirmModal(); - - const handleKeyPress = (e: TextInputKeyPressEvent | KeyboardEvent) => { - if (!('key' in e)) { + const [activeTab, setActiveTab] = useState(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 hasDraftPrompt = !!draftValues?.[INPUT_IDS.PROMPT]?.trim(); + + // Reset the active tab to Suggestions when the workspace changes. + if (activeTabPolicyID !== policyID) { + setActiveTabPolicyID(policyID); + setActiveTab(CONST.TAB.AGENT_RULE.SUGGESTIONS); + } + + // Clear the draft on unmount only. + useEffect(() => { + return () => clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); + }, []); + + useEffect(() => { + if (isOffline) { + return; + } + getAgentRuleSuggestions(policyID); + }, [policyID, isOffline]); + + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => hasDraftPrompt, + onConfirm: () => clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM), + }); + + const tabs: TabSelectorBaseItem[] = [ + { + key: CONST.TAB.AGENT_RULE.SUGGESTIONS, + title: translate('workspace.rules.agentRules.suggestionsTab'), + icon: tabIcons.Feed, + }, + { + key: CONST.TAB.AGENT_RULE.WRITE, + title: translate('workspace.rules.agentRules.writeTab'), + icon: tabIcons.Pencil, + }, + ]; + + const selectSuggestion = (suggestion: SuggestedAgentRule) => { + if (!suggestion.prompt?.trim()) { return; } - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - formRef.current?.submit(); + setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[INPUT_IDS.PROMPT]: suggestion.prompt}); + setActiveTab(CONST.TAB.AGENT_RULE.WRITE); + }; + + const selectTab = (key: string) => { + if (!isAgentRuleTab(key)) { + return; } + setActiveTab(key); }; - const validate = (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - if (!values[INPUT_IDS.PROMPT].trim()) { - errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); + const handleBackButtonPress = () => { + if (activeTab === CONST.TAB.AGENT_RULE.WRITE) { + setActiveTab(CONST.TAB.AGENT_RULE.SUGGESTIONS); + return; } - return errors; + Navigation.goBack(); }; const navigateBackToAgentsTab = () => { @@ -90,7 +151,9 @@ 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); + suppressDiscardPrompt(); addPolicyAgentRule(policyID, rand64(), values[INPUT_IDS.PROMPT]); + clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); if (!isFirstRule) { navigateBackToAgentsTab(); return; @@ -141,10 +204,6 @@ function AddAgentRulePage({ }); }; - const inputWrapperStyles: StyleProp = shouldUseExpandedRevampFormLayout - ? [styles.flex1, styles.mnh0, styles.agentRulePromptInput] - : [styles.flex1, shouldUseScrollableLayout && styles.minHeight42]; - return ( - - - - - - - {translate('workspace.rules.agentRules.disclaimer')} + + + + + + + + + - + {activeTab === CONST.TAB.AGENT_RULE.WRITE && } + ); diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx new file mode 100644 index 000000000000..24f5a133573b --- /dev/null +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx @@ -0,0 +1,167 @@ +/** + * Suggestions tab for the add-agent-rule flow. Lists backend-served ready-made rules as + * selectable cards; Next prefills the Edit tab prompt via the parent callback. + */ +import ActivityIndicator from '@components/ActivityIndicator'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import Button from '@components/ButtonComposed'; +import FixedFooter from '@components/FixedFooter'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useSuggestedAgentRules from '@hooks/useSuggestedAgentRules'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getSuggestedAgentRuleIcon, SUGGESTED_AGENT_RULE_ICON_NAMES} from '@libs/PolicyRulesUtils'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +type AddAgentRuleSuggestionsTabProps = { + /** Called with the chosen suggestion when the user presses Next */ + onSelectSuggestion: (suggestion: SuggestedAgentRule) => void; +}; + +function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestionsTabProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {isOffline} = useNetwork(); + const {data, isLoading} = useSuggestedAgentRules(); + const illustrations = useMemoizedLazyIllustrations(['Lightbulb']); + const icons = useMemoizedLazyExpensifyIcons([...SUGGESTED_AGENT_RULE_ICON_NAMES]); + const [searchValue, setSearchValue] = useState(''); + const [selectedSuggestionID, setSelectedSuggestionID] = useState(); + + const trimmedSearch = searchValue.trim().toLowerCase(); + const filteredSuggestions = !trimmedSearch + ? data + : data.filter((suggestion) => suggestion.title?.toLowerCase().includes(trimmedSearch) || suggestion.prompt?.toLowerCase().includes(trimmedSearch)); + + const selectedSuggestion = filteredSuggestions.find((suggestion) => suggestion.id === selectedSuggestionID); + const hasNoSuggestions = data.length === 0; + const shouldShowLoadingIndicator = isLoading && hasNoSuggestions && !isOffline; + const shouldShowEmptyState = hasNoSuggestions && (!isLoading || isOffline); + + const goToEditWithSelection = () => { + if (!selectedSuggestion) { + return; + } + onSelectSuggestion(selectedSuggestion); + }; + + if (shouldShowLoadingIndicator) { + return ( + + + + ); + } + + if (shouldShowEmptyState) { + return ( + + + + ); + } + + const hasNoFilteredSuggestions = filteredSuggestions.length === 0; + + return ( + + + + + + {hasNoFilteredSuggestions ? ( + + {translate('common.noResultsFound')} + + ) : ( + filteredSuggestions.map((suggestion) => { + const iconName = getSuggestedAgentRuleIcon(suggestion); + const isSelected = suggestion.id === selectedSuggestionID; + const suggestionLabel = suggestion.prompt ?? suggestion.title ?? ''; + return ( + setSelectedSuggestionID(suggestion.id)} + wrapperStyle={[styles.mh5]} + style={[ + styles.flexRow, + styles.alignItemsCenter, + styles.ph5, + styles.pv5, + styles.highlightBG, + styles.borderRadiusComponentNormal, + isSelected && styles.activeComponentBG, + ]} + hoverStyle={!isSelected ? styles.hoveredComponentBG : undefined} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SUGGESTED_AGENT_RULE} + > + + {suggestionLabel} + + ); + }) + )} + + + + + + ); +} + +AddAgentRuleSuggestionsTab.displayName = 'AddAgentRuleSuggestionsTab'; + +export default AddAgentRuleSuggestionsTab; diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx new file mode 100644 index 000000000000..320493b10800 --- /dev/null +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx @@ -0,0 +1,111 @@ +/** + * Write (Edit) tab for the add-agent-rule flow. Owns the free-text prompt form and save path. + */ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/AddAgentRuleForm'; + +import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native'; + +import React, {useRef} from 'react'; +import {View} from 'react-native'; + +type AddAgentRuleFormID = typeof ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM; + +type AddAgentRuleWriteTabProps = { + /** Called with the form values when the user saves the rule */ + onSave: (values: FormOnyxValues) => void; +}; + +function AddAgentRuleWriteTab({onSave}: AddAgentRuleWriteTabProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const shouldUseScrollableLayout = useIsInLandscapeMode(); + const {isBetaEnabled} = usePermissions(); + const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); + const shouldUseExpandedRevampFormLayout = isRulesRevampEnabled && !shouldUseScrollableLayout; + const formRef = useRef(null); + const describeRuleLabel = isRulesRevampEnabled ? translate('workspace.rules.agentRules.describeRuleForConcierge') : translate('workspace.rules.agentRules.describeRuleTitle'); + + const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in event)) { + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + formRef.current?.submit(); + } + }; + + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!values[INPUT_IDS.PROMPT]?.trim()) { + errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); + } + return errors; + }; + + const inputWrapperStyles: StyleProp = shouldUseExpandedRevampFormLayout + ? [styles.flex1, styles.mnh0, styles.agentRulePromptInput] + : [styles.flex1, shouldUseScrollableLayout && styles.minHeight42]; + + return ( + + + {!isRulesRevampEnabled && ( + <> + {translate('workspace.rules.agentRules.describeRuleHeadline')} + {translate('workspace.rules.agentRules.describeRuleForConcierge')} + + )} + + + + {translate('workspace.rules.agentRules.disclaimer')} + + + ); +} + +AddAgentRuleWriteTab.displayName = 'AddAgentRuleWriteTab'; + +export default AddAgentRuleWriteTab; +export type {AddAgentRuleFormID}; diff --git a/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx index 7c93b7b39662..60b0c65de244 100644 --- a/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx @@ -55,11 +55,11 @@ function EditAgentRulePage({ const formRef = useRef(null); const describeRuleLabel = isRulesRevampEnabled ? translate('workspace.rules.agentRules.describeRuleForConcierge') : translate('workspace.rules.agentRules.describeRuleTitle'); - const handleKeyPress = (e: TextInputKeyPressEvent | KeyboardEvent) => { - if (!('key' in e)) { + const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in event)) { return; } - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { formRef.current?.submit(); } }; @@ -157,7 +157,7 @@ function EditAgentRulePage({ label={describeRuleLabel} accessibilityLabel={describeRuleLabel} role={CONST.ROLE.PRESENTATION} - onKeyPress={handleKeyPress} + onKeyPress={submitFormOnModEnter} defaultValue={agentRule.prompt} multiline shouldLabelStayOnSingleLine diff --git a/src/types/onyx/SuggestedAgentRule.ts b/src/types/onyx/SuggestedAgentRule.ts new file mode 100644 index 000000000000..353111490928 --- /dev/null +++ b/src/types/onyx/SuggestedAgentRule.ts @@ -0,0 +1,16 @@ +/** + * A ready-made agent rule suggestion delivered from the backend via Onyx. + * Selecting one prefills the add-rule prompt the admin can still edit before saving. + */ +type SuggestedAgentRule = { + /** ID for the suggestion */ + id: string; + + /** Display title shown in the Suggestions tab */ + title: string; + + /** Prompt text written into the add-rule form when selected */ + prompt: string; +}; + +export default SuggestedAgentRule; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index d8eebfcc9732..2210cb5c7772 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -176,6 +176,7 @@ import type ShareTempFile from './ShareTempFile'; import type SidePanel from './SidePanel'; import type {SidePanelContext} from './SidePanel'; import type StripeCustomerID from './StripeCustomerID'; +import type SuggestedAgentRule from './SuggestedAgentRule'; import type SupportalPermissionDenied from './SupportalPermissionDenied'; import type Task from './Task'; import type Transaction from './Transaction'; @@ -429,4 +430,5 @@ export type { LocallyProcessed3DSChallengeReviews, InitiatingBankAccountUnlock, AgentPrompt, + SuggestedAgentRule, }; diff --git a/tests/ui/AddAgentRuleSuggestionsTabTest.tsx b/tests/ui/AddAgentRuleSuggestionsTabTest.tsx new file mode 100644 index 000000000000..78eebb880327 --- /dev/null +++ b/tests/ui/AddAgentRuleSuggestionsTabTest.tsx @@ -0,0 +1,237 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import useNetwork from '@hooks/useNetwork'; +import useSuggestedAgentRules from '@hooks/useSuggestedAgentRules'; + +import AddAgentRuleSuggestionsTab from '@pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab'; + +import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; + +import React from 'react'; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + ThumbsUp: 'ThumbsUp', + CircleSlash: 'CircleSlash', + Flag: 'Flag', + Coins: 'Coins', + })), + useMemoizedLazyIllustrations: jest.fn(() => ({Lightbulb: 'Lightbulb'})), +})); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/useNetwork'); +jest.mock('@hooks/useSuggestedAgentRules'); +jest.mock('@hooks/useTheme', () => jest.fn(() => ({icon: '#000'}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@components/ActivityIndicator', () => { + const ReactModule = jest.requireActual('react'); + const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string}>}>('react-native'); + return jest.fn(() => ReactModule.createElement(View, {testID: 'suggestions-loading-indicator'})); +}); +jest.mock('@components/Icon', () => jest.fn(() => null)); +jest.mock('@components/TextInput', () => { + const ReactModule = jest.requireActual('react'); + const {TextInput} = jest.requireActual<{ + TextInput: React.ComponentType<{value?: string; onChangeText?: (text: string) => void; accessibilityLabel?: string}>; + }>('react-native'); + return ({value, onChangeText, label}: {value?: string; onChangeText?: (text: string) => void; label?: string}) => + ReactModule.createElement(TextInput, { + value, + onChangeText, + accessibilityLabel: label, + }); +}); +jest.mock('@components/BlockingViews/BlockingView', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({title, subtitle}: {title: string; subtitle?: string}) => + ReactModule.createElement(ReactModule.Fragment, null, ReactModule.createElement(Text, null, title), subtitle ? ReactModule.createElement(Text, null, subtitle) : null); +}); +jest.mock('@components/ButtonComposed', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{ + accessibilityRole?: string; + accessibilityState?: {disabled?: boolean}; + disabled?: boolean; + onPress?: () => void; + children?: React.ReactNode; + }>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + function MockButton({children, onPress, isDisabled}: {children: React.ReactNode; onPress?: () => void; isDisabled?: boolean}) { + return ReactModule.createElement( + Pressable, + { + accessibilityRole: 'button', + accessibilityState: {disabled: !!isDisabled}, + disabled: isDisabled, + onPress, + }, + children, + ); + } + MockButton.Text = ({children}: {children: React.ReactNode}) => ReactModule.createElement(Text, null, children); + return MockButton; +}); +jest.mock('@components/FixedFooter', () => { + const ReactModule = jest.requireActual('react'); + const {View} = jest.requireActual<{View: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({children}: {children: React.ReactNode}) => ReactModule.createElement(View, null, children); +}); +jest.mock('@components/Pressable', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable} = jest.requireActual<{ + Pressable: React.ComponentType<{ + onPress?: () => void; + accessibilityLabel?: string; + accessibilityRole?: string; + children?: React.ReactNode; + }>; + }>('react-native'); + return { + PressableWithFeedback: ({children, onPress, accessibilityLabel}: {children: React.ReactNode; onPress?: () => void; accessibilityLabel?: string}) => + ReactModule.createElement( + Pressable, + { + onPress, + accessibilityLabel, + accessibilityRole: 'button', + }, + children, + ), + }; +}); +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); +}); + +const mockedUseSuggestedAgentRules = jest.mocked(useSuggestedAgentRules); +const mockedUseNetwork = jest.mocked(useNetwork); + +const SUGGESTIONS: SuggestedAgentRule[] = [ + { + id: 'approveUnder75', + title: 'Approve under $75 title', + prompt: 'Approve any report that consists of expenses under $75', + }, + { + id: 'blockGambling', + title: 'Block gambling title', + prompt: 'Block all spend from gambling or shady websites', + }, +]; + +describe('AddAgentRuleSuggestionsTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedUseNetwork.mockReturnValue({isOffline: false}); + mockedUseSuggestedAgentRules.mockReturnValue({data: SUGGESTIONS, isLoading: false}); + }); + + it('renders suggestion prompts from hook data', () => { + render(); + + expect(screen.getByText(SUGGESTIONS.at(0)?.prompt ?? '')).toBeOnTheScreen(); + expect(screen.getByText(SUGGESTIONS.at(1)?.prompt ?? '')).toBeOnTheScreen(); + expect(screen.queryByText(SUGGESTIONS.at(0)?.title ?? '')).toBeNull(); + expect(screen.queryByText(SUGGESTIONS.at(1)?.title ?? '')).toBeNull(); + }); + + it('calls onSelectSuggestion only after a card is chosen and Next is pressed', () => { + const onSelectSuggestion = jest.fn(); + render(); + + fireEvent.press(screen.getByText('common.next')); + expect(onSelectSuggestion).not.toHaveBeenCalled(); + + fireEvent.press(screen.getByLabelText(SUGGESTIONS.at(0)?.prompt ?? '')); + fireEvent.press(screen.getByText('common.next')); + + expect(onSelectSuggestion).toHaveBeenCalledTimes(1); + expect(onSelectSuggestion).toHaveBeenCalledWith(SUGGESTIONS.at(0)); + }); + + it('filters the list by search text against prompts', () => { + render(); + + fireEvent.changeText(screen.getByLabelText('workspace.rules.agentRules.findSuggestion'), 'gambling'); + + expect(screen.queryByText(SUGGESTIONS.at(0)?.prompt ?? '')).toBeNull(); + expect(screen.getByText(SUGGESTIONS.at(1)?.prompt ?? '')).toBeOnTheScreen(); + }); + + it('shows a simple no-results message when search matches nothing', () => { + render(); + + fireEvent.changeText(screen.getByLabelText('workspace.rules.agentRules.findSuggestion'), 'zzz-no-match'); + + expect(screen.getByText('common.noResultsFound')).toBeOnTheScreen(); + expect(screen.queryByText('common.noResultsFoundSubtitle')).toBeNull(); + expect(screen.queryByText(SUGGESTIONS.at(0)?.prompt ?? '')).toBeNull(); + expect(screen.queryByText(SUGGESTIONS.at(1)?.prompt ?? '')).toBeNull(); + }); + + it('disables Next when the selected suggestion is filtered out', () => { + const onSelectSuggestion = jest.fn(); + render(); + + fireEvent.press(screen.getByLabelText(SUGGESTIONS.at(0)?.prompt ?? '')); + fireEvent.changeText(screen.getByLabelText('workspace.rules.agentRules.findSuggestion'), 'gambling'); + fireEvent.press(screen.getByText('common.next')); + + expect(onSelectSuggestion).not.toHaveBeenCalled(); + }); + + it('shows a loading indicator while suggestions are loading', () => { + mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: true}); + render(); + + expect(screen.getByTestId('suggestions-loading-indicator')).toBeOnTheScreen(); + expect(screen.queryByText('workspace.rules.agentRules.emptySuggestionsTitle')).toBeNull(); + }); + + it('shows the empty state when there are no suggestions', () => { + mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: false}); + render(); + + expect(screen.getByText('workspace.rules.agentRules.emptySuggestionsTitle')).toBeOnTheScreen(); + expect(screen.getByText('workspace.rules.agentRules.emptySuggestionsSubtitle')).toBeOnTheScreen(); + }); + + it('shows the offline empty state instead of a spinner when offline and still loading', () => { + mockedUseNetwork.mockReturnValue({isOffline: true}); + mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: true}); + render(); + + expect(screen.queryByTestId('suggestions-loading-indicator')).toBeNull(); + expect(screen.getByText('workspace.rules.agentRules.emptySuggestionsTitle')).toBeOnTheScreen(); + expect(screen.getByText('common.youAppearToBeOffline')).toBeOnTheScreen(); + }); + + it('shows the offline empty subtitle when offline with no suggestions', () => { + mockedUseNetwork.mockReturnValue({isOffline: true}); + mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: false}); + render(); + + expect(screen.getByText('workspace.rules.agentRules.emptySuggestionsTitle')).toBeOnTheScreen(); + expect(screen.getByText('common.youAppearToBeOffline')).toBeOnTheScreen(); + expect(screen.queryByText('workspace.rules.agentRules.emptySuggestionsSubtitle')).toBeNull(); + }); +}); diff --git a/tests/unit/getSuggestedAgentRuleIconTest.ts b/tests/unit/getSuggestedAgentRuleIconTest.ts new file mode 100644 index 000000000000..12230606fdcb --- /dev/null +++ b/tests/unit/getSuggestedAgentRuleIconTest.ts @@ -0,0 +1,111 @@ +import {getSuggestedAgentRuleIcon} from '@libs/PolicyRulesUtils'; + +describe('getSuggestedAgentRuleIcon', () => { + it('maps amount/cap titles to Coins even when the prompt says Reject', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'expense-amount-over', + title: 'Per-expense amount over a cap', + prompt: 'Reject any single expense of $75 or more.', + }), + ).toBe('Coins'); + }); + + it('prefers Coins over Flag when amount and within both match', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'expense-amount-between', + title: 'Per-expense amount within a flagged band', + prompt: 'Reject expenses with an amount between $500 and $1000 inclusive.', + }), + ).toBe('Coins'); + }); + + it('prefers CircleSlash over Coins when block and amount both match', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'blocked-amount', + title: 'Block expenses over an amount cap', + prompt: 'Reject blocked high-amount spend.', + }), + ).toBe('CircleSlash'); + }); + + it('maps flagged titles without money keywords to Flag', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'receipt-mismatch', + title: 'Receipt mismatch on weekend', + prompt: 'Flag expenses with a receipt mismatch.', + }), + ).toBe('Flag'); + }); + + it('maps banned/block titles to CircleSlash', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'merchant-contains', + title: 'Merchant name contains a banned word', + prompt: 'Reject any expense whose merchant name contains the word "casino".', + }), + ).toBe('CircleSlash'); + }); + + it('defaults allow-list / category rules to ThumbsUp', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'category-is', + title: 'Category is a specific value', + prompt: 'Only approve expenses categorized as "Travel". Reject expenses in any other category.', + }), + ).toBe('ThumbsUp'); + }); + + it('defaults to ThumbsUp when no keyword matches', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'custom', + title: 'Something custom', + prompt: 'Do a custom thing', + }), + ).toBe('ThumbsUp'); + }); + + it('does not match age inside agent or manage', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'agent-rules', + title: 'Agent rules for workspace', + prompt: 'Describe agent rules.', + }), + ).toBe('ThumbsUp'); + + expect( + getSuggestedAgentRuleIcon({ + id: 'manage-approvals', + title: 'Manage approval workflow', + prompt: 'Manage approvals carefully.', + }), + ).toBe('ThumbsUp'); + }); + + it('does not match cap inside capital', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'capital-expense', + title: 'Capital expense approval', + prompt: 'Approve capital expenses.', + }), + ).toBe('ThumbsUp'); + }); + + it('does not match age inside page', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'page-count', + title: 'Page count limits', + prompt: 'Limit page count.', + }), + ).toBe('ThumbsUp'); + }); +});