Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions contributingGuides/FORMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,33 @@ import KEYBOARD_SUBMIT_BEHAVIOR from './keyboardSubmitBehavior';
> [!NOTE]
> Only override `keyboardSubmitBehavior` on screens where `onSubmit` triggers navigation. For forms that stay on-screen after submission, keep the default (`'dismiss-then-submit'`) to avoid layout jumps.

### Reacting to form value changes (`FormValueWatcher`)

Sometimes a parent screen needs to react when a form value changes from outside the form — for example when an RHP picker writes to the Onyx draft via `setDraftValues` and the parent has to re-sync `FormProvider`'s internal state (clear `touched` flags, wipe local errors via `resetForm`, etc.).

Instead of adding a `useEffect` inside the parent (which can't see `FormProvider`'s `inputValues` directly), use the `FormValueWatcher` primitive together with `FormProvider`'s children render prop. It's a render-null component that fires `onValuesChange(current, previous)` whenever the `values` reference changes, and short-circuits when the reference is identical (no spurious fires).

```jsx
<FormProvider formID={ONYXKEYS.FORMS.MY_FORM} ref={formRef} /* ... */>
{({inputValues}) => (
<>
<FormValueWatcher
values={inputValues}
onValuesChange={(current, previous) => {
if (current.type === previous.type) {
return;
}
formRef.current?.resetForm(current);
}}
/>
{/* ...InputWrappers... */}
</>
)}
</FormProvider>
```

Use it only when you genuinely need a cross-component reaction; for in-form logic, prefer `onValueChange` / `onInputChange` on the specific input.

### Safe Area Padding

Any `FormProvider.tsx` that has a button at the bottom. If the `<FormProvider>` is inside a `<ScreenWrapper>`, the bottom safe area inset is handled automatically (`includeSafeAreaPaddingBottom` needs to be set to `true`, but its the default).
Expand Down
6 changes: 5 additions & 1 deletion src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {getUrlWithParams} from './libs/Url';
import SCREENS from './SCREENS';
import type {Screen} from './SCREENS';
import type {CompanyCardFeedWithDomainID, PersonalCardFeed} from './types/onyx';
import type {ConnectionName, SageIntacctMappingName} from './types/onyx/Policy';
import type {ConnectionName, PolicyReportFieldType, SageIntacctMappingName} from './types/onyx/Policy';
import type {CustomFieldType} from './types/onyx/PolicyEmployee';

type WorkspaceCompanyCardsAssignCardParams = {
Expand Down Expand Up @@ -2613,6 +2613,10 @@ const ROUTES = {
route: 'workspaces/:policyID/reports/listValues/:reportFieldID?',
getRoute: (policyID: string, reportFieldID?: string) => `workspaces/${policyID}/reports/listValues/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const,
},
WORKSPACE_REPORT_FIELDS_TYPE_SELECTOR: {
route: 'workspaces/:policyID/reports/typeSelector/:currentType?',
getRoute: (policyID: string, currentType?: PolicyReportFieldType) => `workspaces/${policyID}/reports/typeSelector/${currentType ? encodeURIComponent(currentType) : ''}` as const,
},
WORKSPACE_REPORT_FIELDS_ADD_VALUE: {
route: 'workspaces/:policyID/reports/addValue/:reportFieldID?',
getRoute: (policyID: string, reportFieldID?: string) => `workspaces/${policyID}/reports/addValue/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const,
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,7 @@ const SCREENS = {
REPORT_FIELDS_VALUE_SETTINGS: 'Workspace_ReportFields_ValueSettings',
REPORT_FIELDS_EDIT_VALUE: 'Workspace_ReportFields_EditValue',
REPORT_FIELDS_EDIT_INITIAL_VALUE: 'Workspace_ReportFields_EditInitialValue',
REPORT_FIELDS_TYPE_SELECTOR: 'Workspace_ReportFields_TypeSelector',
TAX_EDIT: 'Workspace_Tax_Edit',
TAX_NAME: 'Workspace_Tax_Name',
TAX_VALUE: 'Workspace_Tax_Value',
Expand Down
38 changes: 38 additions & 0 deletions src/components/Form/FormValueWatcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {useEffect, useRef} from 'react';
import type {OnyxFormKey} from '@src/ONYXKEYS';
import type {FormOnyxValues} from './types';

type FormValueWatcherProps<TFormID extends OnyxFormKey> = {
/** Form values to watch - the `inputValues` exposed by FormProvider's render prop. */
values: FormOnyxValues<TFormID>;

/**
* Fires when the `values` prop reference changes.
* Receives the new values and the previous values for direct comparison.
*/
onValuesChange: (current: FormOnyxValues<TFormID>, previous: FormOnyxValues<TFormID>) => void;
};

/**
* Render-null primitive that observes a `values` object and fires `onValuesChange` with `(current, previous)`
* whenever the reference changes. Opt-in: only consumers who render it pay the cost.
*
* Designed to live inside `FormProvider`'s render prop so consumers can react to draft-driven updates
* (e.g. from an RHP picker page) without having to wire up their own previous-value ref + effect.
*/
function FormValueWatcher<TFormID extends OnyxFormKey>({values, onValuesChange}: FormValueWatcherProps<TFormID>) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add this logic inside FormProvider?

Copy link
Copy Markdown
Contributor Author

@sharabai sharabai May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ZhenjaHorbach I don't think so. I separated it out into a component because FormProvider has more than 500 references across the codebase. And I was the only consumer. I wanted to be more modular and have something I could use, but also something that could be used by others if there's a need for it.

Copy link
Copy Markdown
Contributor Author

@sharabai sharabai May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the useEffect would run on every change of values for those 100+ consumers that don't use it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay then
I'm just a little confused by render-null component and how it is used
But on the other hand, there seems to be no better solution in the current situation
So let's leave it as is 😁

const previousValuesRef = useRef(values);
useEffect(() => {
if (previousValuesRef.current === values) {
return;
}
const previous = previousValuesRef.current;
previousValuesRef.current = values;
onValuesChange(values, previous);
}, [values, onValuesChange]);
return null;
}

FormValueWatcher.displayName = 'FormValueWatcher';

export default FormValueWatcher;
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,7 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP
[SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: () => require<ReactComponentModule>('../../../../pages/workspace/reports/ReportFieldsAddListValuePage').default,
[SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: () => require<ReactComponentModule>('../../../../pages/workspace/reports/ReportFieldsValueSettingsPage').default,
[SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: () => require<ReactComponentModule>('../../../../pages/workspace/reports/ReportFieldsInitialValuePage').default,
[SCREENS.WORKSPACE.REPORT_FIELDS_TYPE_SELECTOR]: () => require<ReactComponentModule>('../../../../pages/workspace/reports/TypeSelector/TypeSelectorPage').default,
[SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require<ReactComponentModule>('../../../../pages/workspace/reports/ReportFieldsEditValuePage').default,
[SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_IMPORT]: () => require<ReactComponentModule>('../../../../pages/workspace/accounting/intacct/import/SageIntacctImportPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_TOGGLE_MAPPING]: () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ const WORKSPACE_TO_RHP: Partial<Record<keyof WorkspaceSplitNavigatorParamList, s
SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS,
SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE,
SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE,
SCREENS.WORKSPACE.REPORT_FIELDS_TYPE_SELECTOR,
],
[SCREENS.WORKSPACE.INVOICES]: [SCREENS.WORKSPACE.INVOICES_COMPANY_NAME, SCREENS.WORKSPACE.INVOICES_COMPANY_WEBSITE, SCREENS.WORKSPACE.INVOICES_VERIFY_ACCOUNT],
[SCREENS.WORKSPACE.COMPANY_CARDS]: [
Expand Down
3 changes: 3 additions & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,9 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
[SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: {
path: ROUTES.WORKSPACE_EDIT_REPORT_FIELDS_INITIAL_VALUE.route,
},
[SCREENS.WORKSPACE.REPORT_FIELDS_TYPE_SELECTOR]: {
path: ROUTES.WORKSPACE_REPORT_FIELDS_TYPE_SELECTOR.route,
},
[SCREENS.CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT_ROOT]: {
path: ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.route,
exact: true,
Expand Down
6 changes: 5 additions & 1 deletion src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type NAVIGATORS from '@src/NAVIGATORS';
import type {Route as ExpensifyRoute, Route as Routes} from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type {CompanyCardFeedWithDomainID, PersonalCardFeed} from '@src/types/onyx';
import type {ConnectionName, SageIntacctMappingName} from '@src/types/onyx/Policy';
import type {ConnectionName, PolicyReportFieldType, SageIntacctMappingName} from '@src/types/onyx/Policy';
import type {CustomFieldType} from '@src/types/onyx/PolicyEmployee';
import type {FileObject} from '@src/types/utils/Attachment';
import type {SIDEBAR_TO_SPLIT} from './linkingConfig/RELATIONS';
Expand Down Expand Up @@ -663,6 +663,10 @@ type SettingsNavigatorParamList = {
policyID: string;
reportFieldID: string;
};
[SCREENS.WORKSPACE.REPORT_FIELDS_TYPE_SELECTOR]: {
policyID: string;
currentType?: PolicyReportFieldType;
};
[SCREENS.WORKSPACE.MEMBER_DETAILS]: {
policyID: string;
accountID: string;
Expand Down
34 changes: 14 additions & 20 deletions src/pages/workspace/reports/CreateReportFieldsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useRef} from 'react';
import {View} from 'react-native';
import type {OnyxCollection} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
import FormValueWatcher from '@components/Form/FormValueWatcher';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
Expand All @@ -11,7 +12,6 @@ import TextPicker from '@components/TextPicker';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
import DateUtils from '@libs/DateUtils';
import {addErrorMessage} from '@libs/ErrorUtils';
import {hasCircularReferences} from '@libs/Formula';
import Navigation from '@libs/Navigation/Navigation';
Expand All @@ -35,8 +35,6 @@ import TypeSelector from './TypeSelector';

type CreateReportFieldsPageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.REPORT_FIELDS_CREATE>;

const defaultDate = DateUtils.extractDate(new Date().toString());

function WorkspaceCreateReportFieldsPage({
policy,
route: {
Expand Down Expand Up @@ -195,6 +193,18 @@ function WorkspaceCreateReportFieldsPage({
>
{({inputValues}) => (
<View style={styles.mhn5}>
<FormValueWatcher
values={inputValues}
onValuesChange={(current, previous) => {
if (previous[INPUT_IDS.TYPE] === undefined) {
return;
}
if (current[INPUT_IDS.TYPE] === previous[INPUT_IDS.TYPE]) {
return;
}
formRef.current?.resetForm(current);
}}
/>
<InputWrapper
InputComponent={TextPicker}
inputID={INPUT_IDS.NAME}
Expand All @@ -214,24 +224,8 @@ function WorkspaceCreateReportFieldsPage({
InputComponent={TypeSelector}
inputID={INPUT_IDS.TYPE}
label={translate('common.type')}
subtitle={translate('workspace.reportFields.typeInputSubtitle')}
rightLabel={translate('common.required')}
onTypeSelected={(type) => {
let initialValue;
if (type === CONST.REPORT_FIELD_TYPES.DATE) {
initialValue = defaultDate;
} else if (type === CONST.REPORT_FIELD_TYPES.FORMULA) {
initialValue = '{report:id}';
} else {
initialValue = '';
}

formRef.current?.resetForm({
...inputValues,
type,
initialValue,
});
}}
policyID={policyID}
/>

{inputValues[INPUT_IDS.TYPE] === CONST.REPORT_FIELD_TYPES.LIST && (
Expand Down
67 changes: 0 additions & 67 deletions src/pages/workspace/reports/TypeSelector/TypeSelectorModal.tsx

This file was deleted.

80 changes: 80 additions & 0 deletions src/pages/workspace/reports/TypeSelector/TypeSelectorPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from 'react';
import {View} from 'react-native';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
import {setDraftValues} from '@libs/actions/FormActions';
import DateUtils from '@libs/DateUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import {hasAccountingConnections} from '@libs/PolicyUtils';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import type {ReportFieldItemType} from '@pages/workspace/reports/ReportFieldTypePicker';
import ReportFieldTypePicker from '@pages/workspace/reports/ReportFieldTypePicker';
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/WorkspaceReportFieldForm';
import type {PolicyReportFieldType} from '@src/types/onyx/Policy';

type TypeSelectorPageProps = PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.REPORT_FIELDS_TYPE_SELECTOR>;

function getDefaultInitialValueForReportFieldType(type: PolicyReportFieldType): string {
if (type === CONST.REPORT_FIELD_TYPES.DATE) {
return DateUtils.extractDate(new Date().toString());
}
if (type === CONST.REPORT_FIELD_TYPES.FORMULA) {
return '{report:id}';
}
return '';
}

function TypeSelectorPage({
route: {
params: {policyID, currentType},
},
}: TypeSelectorPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);

const onTypeSelected = (item: ReportFieldItemType) => {
setDraftValues(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM, {
[INPUT_IDS.TYPE]: item.value,
[INPUT_IDS.INITIAL_VALUE]: getDefaultInitialValueForReportFieldType(item.value),
});
Navigation.goBack(ROUTES.WORKSPACE_CREATE_REPORT_FIELD.getRoute(policyID));
};

return (
<AccessOrNotFoundWrapper
accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]}
policyID={policyID}
featureName={CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED}
shouldBeBlocked={hasAccountingConnections(policy)}
>
<ScreenWrapper
style={styles.pb0}
includePaddingTop={false}
enableEdgeToEdgeBottomSafeAreaPadding
testID="TypeSelectorPage"
>
<HeaderWithBackButton title={translate('common.type')} />
<View style={[styles.ph5, styles.pb4]}>
<Text style={[styles.sidebarLinkText, styles.optionAlternateText]}>{translate('workspace.reportFields.typeInputSubtitle')}</Text>
</View>
<ReportFieldTypePicker
defaultValue={currentType ?? CONST.REPORT_FIELD_TYPES.TEXT}
onOptionSelected={onTypeSelected}
/>
</ScreenWrapper>
</AccessOrNotFoundWrapper>
);
}

export default TypeSelectorPage;
Loading
Loading