Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
09d3885
feat: add selectors to policy/derived hooks to prevent unnecessary re…
adhorodyski Mar 16, 2026
3aeeabb
feat: export getAllTransactionDrafts and getAllReportNameValuePairs g…
adhorodyski Mar 16, 2026
1165b11
feat: add scan variant components (ScanEditReceipt, ScanFromReport, S…
adhorodyski Mar 16, 2026
36c22ec
feat: rewrite web IOURequestStepScan as thin router
adhorodyski Mar 16, 2026
5ba0e03
feat: rewrite native IOURequestStepScan as thin router
adhorodyski Mar 16, 2026
d0c3515
chore: delete handleMoneyRequestStepScanParticipants + useReceiptScan…
adhorodyski Mar 16, 2026
f2ac982
feat: add unified Camera component with platform variants
adhorodyski Mar 16, 2026
ae35066
refactor: update variants to use unified Camera component
adhorodyski Mar 16, 2026
b3cb2b0
refactor: use selector in useReportIsArchived, adopt in scan routers
adhorodyski Mar 16, 2026
10e56ff
chore: remove unused withCurrentUserPersonalDetails HOC from scan rou…
adhorodyski Mar 16, 2026
d123e91
chore: remove dead location permission code from 3 scan variants
adhorodyski Mar 17, 2026
bdb6a87
refactor: extract GpsLocationGate from ScanSkipConfirmation
adhorodyski Mar 17, 2026
a601708
refactor: rename GpsLocationGate → GpsPermissionGate, remove file cou…
adhorodyski Mar 17, 2026
b07ce39
refactor: extract shared scan utilities, fix onLayout wiring
adhorodyski Mar 17, 2026
00f19cd
chore: delete duplicate index.native.tsx router
adhorodyski Mar 17, 2026
a0769f5
refactor: remove dead params from navigateToConfirmationStep
adhorodyski Mar 17, 2026
d19f381
refactor: clean onLayout type signature, remove leaked test concern
adhorodyski Mar 17, 2026
9278ff0
refactor: move test receipt flow to parent, eliminate onLayout bridging
adhorodyski Mar 17, 2026
7aaa65e
refactor: flatten capture handler chain, improve naming
adhorodyski Mar 17, 2026
b6286ae
refactor: flatten nested if in ScanGlobalCreate onFilesAccepted
adhorodyski Mar 17, 2026
6fc258c
Merge remote-tracking branch 'exfy/main' into decompose-scan-screens-…
adhorodyski Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 35 additions & 17 deletions src/hooks/useDefaultExpensePolicy.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,50 @@
import type {OnyxCollection} from 'react-native-onyx';
import {isPaidGroupPolicy, isPolicyAccessible} from '@libs/PolicyUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy} from '@src/types/onyx';
import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails';
import useOnyx from './useOnyx';
import usePreferredPolicy from './usePreferredPolicy';

/**
* Selector that finds the single qualifying group policy ID from the collection.
* Returns only an ID (stable) — prevents re-renders when unrelated policies change.
*/
function getSingleGroupPolicyID(policies: OnyxCollection<Policy>, login: string): string | undefined {
if (!policies) {
return undefined;
}

let singlePolicyID: string | undefined;
for (const policy of Object.values(policies)) {
if (!policy || !isPaidGroupPolicy(policy) || !isPolicyAccessible(policy, login)) {
continue;
}
if (!singlePolicyID) {
singlePolicyID = policy.id;
} else {
return undefined; // More than one — no single default
}
}

return singlePolicyID;
}

export default function useDefaultExpensePolicy() {
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`);
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy();
const {login = ''} = useCurrentUserPersonalDetails();
const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${preferredPolicyID}`);

// Selector returns only the qualifying policy ID — stable value, prevents re-renders
const [singleGroupPolicyID] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {
selector: (policies) => getSingleGroupPolicyID(policies, login),
});

// Per-key lookup for the single group policy (only fires when that specific policy changes)
const [singleGroupPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${singleGroupPolicyID}`);

if (isRestrictedToPreferredPolicy && isPaidGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login)) {
return preferredPolicy;
}
Expand All @@ -20,20 +53,5 @@ export default function useDefaultExpensePolicy() {
return activePolicy;
}

// If there is exactly one group policy, use that as the default expense policy
let singlePolicy;
for (const policy of Object.values(allPolicies ?? {})) {
if (!policy || !isPaidGroupPolicy(policy) || !isPolicyAccessible(policy, login)) {
continue;
}

if (!singlePolicy) {
singlePolicy = policy;
} else {
singlePolicy = undefined;
break;
}
}

return singlePolicy;
return singleGroupPolicy;
}
83 changes: 60 additions & 23 deletions src/hooks/usePolicyForMovingExpenses.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {activePolicySelector} from '@selectors/Policy';
import type {OnyxEntry} from 'react-native-onyx';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {useSession} from '@components/OnyxListItemProvider';
import {canSubmitPerDiemExpenseFromWorkspace, isPaidGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -31,48 +31,85 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry<Policy>, login: string
);
}

function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: boolean, expensePolicyID?: string) {
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, {
selector: activePolicySelector,
});
type PolicyQualificationResult = {
singlePolicyID: string | undefined;
isMemberOfMoreThanOnePolicy: boolean;
validExpensePolicyID: string | undefined;
};

const session = useSession();
const login = session?.email ?? '';
/**
* Selector that computes which policies qualify for moving expenses.
* Returns only IDs and flags — stable output that prevents re-renders when unrelated policies change.
*/
function getPolicyQualificationResult(
policies: OnyxCollection<Policy>,
login: string,
isPerDiemRequest?: boolean,
isTimeRequest?: boolean,
expensePolicyID?: string,
): PolicyQualificationResult {
if (!policies) {
return {singlePolicyID: undefined, isMemberOfMoreThanOnePolicy: false, validExpensePolicyID: undefined};
}

// Early exit optimization: only need to check if we have 0, 1, or >1 policies
let singleUserPolicy;
let singlePolicyID: string | undefined;
let isMemberOfMoreThanOnePolicy = false;
for (const policy of Object.values(allPolicies ?? {})) {
for (const policy of Object.values(policies)) {
if (!isPolicyValidForMovingExpenses(policy, login, isPerDiemRequest, isTimeRequest)) {
continue;
}

if (!singleUserPolicy) {
singleUserPolicy = policy;
if (!singlePolicyID) {
singlePolicyID = policy?.id;
} else {
isMemberOfMoreThanOnePolicy = true;
break; // Found 2, no need to continue
break;
}
}

// If an expense policy ID is provided and valid, prefer it over the active policy
// This ensures that when viewing/editing an expense from workspace B, we show workspace B
// even if the user's default workspace is A
let validExpensePolicyID: string | undefined;
if (expensePolicyID) {
const expensePolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${expensePolicyID}`];
const expensePolicy = policies[`${ONYXKEYS.COLLECTION.POLICY}${expensePolicyID}`];
if (expensePolicy && isPolicyValidForMovingExpenses(expensePolicy, login, isPerDiemRequest, isTimeRequest)) {
return {policyForMovingExpensesID: expensePolicyID, policyForMovingExpenses: expensePolicy, shouldSelectPolicy: false};
validExpensePolicyID = expensePolicyID;
}
}

return {singlePolicyID, isMemberOfMoreThanOnePolicy, validExpensePolicyID};
}

function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: boolean, expensePolicyID?: string) {
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, {
selector: activePolicySelector,
});

const session = useSession();
const login = session?.email ?? '';

// Contextual selector — captures login/flags from closure.
// Returns only IDs + flags (stable output) to prevent re-renders when unrelated policies change.
const policyQualificationSelector = (policies: OnyxCollection<Policy>) => getPolicyQualificationResult(policies, login, isPerDiemRequest, isTimeRequest, expensePolicyID);
const [qualificationResult] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {
selector: policyQualificationSelector,
});

const {singlePolicyID, isMemberOfMoreThanOnePolicy, validExpensePolicyID} = qualificationResult ?? {};

// Per-key lookup for the resolved policy (only fires when that specific policy changes)
const resolvedPolicyID = validExpensePolicyID ?? singlePolicyID;
const [resolvedPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${resolvedPolicyID}`);

// If an expense policy ID is provided and valid, prefer it over the active policy
if (validExpensePolicyID) {
return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false};
}

if (activePolicy && (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && (!isTimeRequest || isTimeTrackingEnabled(activePolicy))) {
return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false};
}

if (singleUserPolicy && !isMemberOfMoreThanOnePolicy) {
return {policyForMovingExpensesID: singleUserPolicy.id, policyForMovingExpenses: singleUserPolicy, shouldSelectPolicy: false};
if (singlePolicyID && !isMemberOfMoreThanOnePolicy) {
return {policyForMovingExpensesID: singlePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false};
}

if (isMemberOfMoreThanOnePolicy) {
Expand Down
14 changes: 14 additions & 0 deletions src/hooks/useReportAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,18 @@ function useReportAttributes() {
return reportAttributes?.reports;
}

/**
* Returns a single report's attributes using a selector.
* Deep comparison is cheap (single small object), so re-renders only occur
* when that specific report's attributes change — not on every global report change.
*/
function useReportAttributesByID(reportID: string | undefined) {
const reportAttributesByIDSelector = (value: {reports?: Record<string, unknown>} | undefined) => (reportID ? value?.reports?.[reportID] : undefined);
const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {
selector: reportAttributesByIDSelector,
});
return reportAttributes;
}

export default useReportAttributes;
export {useReportAttributesByID};
9 changes: 6 additions & 3 deletions src/hooks/useReportIsArchived.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import {isArchivedReport} from '@libs/ReportUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import useOnyx from './useOnyx';

const isArchivedSelector = isArchivedReport;

function useReportIsArchived(reportID?: string): boolean {
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`);
const isReportArchived = isArchivedReport(reportNameValuePairs);
return isReportArchived;
const [isArchived] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {
selector: isArchivedSelector,
});
return !!isArchived;
}

export default useReportIsArchived;
Loading
Loading