Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e6b23f4
Move multi-level tag Required from the Tags table to Rules
Krishna2323 Aug 12, 2026
3b046d9
add lock to tags toggle and load tags.
Krishna2323 Aug 12, 2026
62ceef3
Name required tag lists in the Require fields row description
Krishna2323 Aug 12, 2026
c00ff16
remove comment.
Krishna2323 Aug 12, 2026
4476c30
Update tag toggle tests for per-level Required
Krishna2323 Aug 12, 2026
da3048f
add margin bottom to custom tag name row.
Krishna2323 Aug 12, 2026
bedf44f
Track only edited tag levels and guard the last required one
Krishna2323 Aug 12, 2026
1e013bd
Narrow the hasChanges dependency and drop redundant memos in the rule…
Krishna2323 Aug 12, 2026
9a807b9
fetch tags on general tab.
Krishna2323 Aug 12, 2026
507cc6d
Stop an accounting connection from locking tag Required
Krishna2323 Aug 12, 2026
fd3eb4b
remove comment.
Krishna2323 Aug 12, 2026
8ca53c1
Merge branch 'Expensify:main' into krishna2323/issue-95764-multilevel…
Krishna2323 Aug 13, 2026
369f2de
Merge branch 'Expensify:main' into krishna2323/issue-95764-multilevel…
Krishna2323 Aug 13, 2026
a6c755e
Merge branch 'Expensify:main' into krishna2323/issue-95764-multilevel…
Krishna2323 Aug 14, 2026
8a564b1
Fix per-level tag Required toggles to write one list at a time
Krishna2323 Aug 14, 2026
fa0ec85
Fix per-level tag Required toggles to write one list at a time
Krishna2323 Aug 14, 2026
ba96c54
Recompute tag violations once for a mixed require/unrequire save
Krishna2323 Aug 14, 2026
7b17f18
Extract hasPerTagListRequired and getTagListLabel helpers
Krishna2323 Aug 14, 2026
0355e5f
Merge branch 'Expensify:main' into krishna2323/issue-95764-multilevel…
Krishna2323 Aug 16, 2026
fac7f35
align rules page toggles to flex start.
Krishna2323 Aug 16, 2026
ccfea93
Fix optimistic tag and category violations on the Require fields page
Krishna2323 Aug 17, 2026
1b7ef47
Clear Required on tag levels with no enabled tags
Krishna2323 Aug 17, 2026
a4d317a
remove useCallback and useMemo
Krishna2323 Aug 18, 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
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ function FieldRequirementSettingRow({
const isDisabled = !canWriteRules || isCouplingDisabled;

const rowContent = (
<View style={[styles.ph5, styles.pv3, styles.flexRow, styles.alignItemsCenter, styles.justifyContentBetween, styles.gap3, styles.flexWrap]}>
<View style={[styles.ph5, styles.pv3, styles.flexRow, styles.alignItemsCenter, styles.justifyContentBetween, styles.gap3]}>
<Text style={[styles.flexShrink1, styles.pr3, styles.alignSelfCenter]}>{label}</Text>
{hasWaiveState ? (
<FieldRequirementsDirectionToggle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function FieldRequirementsDirectionToggle({direction, disabled = false, onSelect
};

return (
<View style={[styles.flexRow, styles.border, styles.borderRadiusNormal]}>
<View style={[styles.flexRow, styles.border, styles.borderRadiusNormal, styles.alignSelfStart]}>
<Button
onPress={() => selectDirection(isRequireSelected, CONST.FIELD_REQUIREMENTS_DIRECTION.REQUIRE)}
isDisabled={disabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function SpendRuleRestrictionTypeToggleRevamp({restrictionAction, onSelect, icon
/>
)}
<Text style={[styles.flex1, styles.pr3, styles.alignSelfCenter]}>{translate('workspace.rules.spendRules.restrictMerchants')}</Text>
<View style={[styles.flexRow, styles.border, styles.borderRadiusNormal]}>
<View style={[styles.flexRow, styles.border, styles.borderRadiusNormal, styles.alignSelfStart]}>
<Button
onPress={() => onSelect(null)}
size={CONST.BUTTON_SIZE.SMALL}
Expand Down
6 changes: 5 additions & 1 deletion src/components/Tables/WorkspaceTagsTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Table from '@components/Table';
import type {TableEmptyStateProps} from '@components/Table/TableEmptyStates/TableEmptyState';

import useLocalize from '@hooks/useLocalize';
import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';

Expand All @@ -11,6 +12,7 @@ import type {AvatarSource} from '@libs/UserAvatarUtils';

import variables from '@styles/variables';

import CONST from '@src/CONST';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';

import type {ListRenderItemInfo} from '@shopify/flash-list';
Expand Down Expand Up @@ -71,11 +73,13 @@ export default function WorkspaceTagsTable({
const styles = useThemeStyles();
const {translate, localeCompare} = useLocalize();
const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout();
const {isBetaEnabled} = usePermissions();

const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth;
const shouldShowTagCountColumn = isMultiLevelTags && !shouldUseNarrowTableLayout;
const shouldShowEnabledColumn = !isMultiLevelTags;
const shouldShowRequiredColumn = isMultiLevelTags && !hasDependentTags;
// Required is configured from Rules once the revamp is on, so the rows have no switch to head.
const shouldShowRequiredColumn = isMultiLevelTags && !hasDependentTags && !isBetaEnabled(CONST.BETAS.RULES_REVAMP);

const tagTableColumns: Array<TableColumn<WorkspaceTagTableColumnKey>> = [
{
Expand Down
22 changes: 20 additions & 2 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2012,14 +2012,30 @@ function hasDependentTags(policy: OnyxEntry<Policy>, policyTagList: OnyxEntry<Po
if (!policy?.hasMultipleTagLists) {
return false;
}
return Object.values(policyTagList ?? {}).some((tagList) => Object.values(tagList.tags).some((tag) => !!tag.rules?.parentTagsFilter || !!tag.parentTagsFilter));
// An empty tag list arrives without the `tags` key, despite the type.
return Object.values(policyTagList ?? {}).some((tagList) => Object.values(tagList.tags ?? {}).some((tag) => !!tag.rules?.parentTagsFilter || !!tag.parentTagsFilter));
}

function hasIndependentTags(policy: OnyxEntry<Policy>, policyTagList: OnyxEntry<PolicyTagLists>) {
if (!policy?.hasMultipleTagLists || hasDependentTags(policy, policyTagList)) {
return false;
}
return Object.values(policyTagList ?? {}).some((tagList) => Object.values(tagList.tags).length > 0);
return Object.values(policyTagList ?? {}).some((tagList) => Object.values(tagList.tags ?? {}).length > 0);
}

/**
* Whether Required lives on each tag list rather than on the policy-wide requiresTag flag.
*
* Deliberately not hasIndependentTags: this gates on the tag list count instead of the hasMultipleTagLists flag, and it
* must stay true for a multi-level workspace whose lists are still empty, otherwise the per-level rows would disappear.
*/
function hasPerTagListRequired(policy: OnyxEntry<Policy>, policyTagList: OnyxEntry<PolicyTagLists>) {
return isMultiLevelTags(policyTagList) && !hasDependentTags(policy, policyTagList);
}

/** Admins name their tag lists, so prefer that name and fall back to the caller's generic label. */
function getTagListLabel(tagListName: string | undefined, fallbackLabel: string) {
return (tagListName ? getCleanedTagName(tagListName) : '') || fallbackLabel;
}

/** Get the Xero organizations connected to the policy */
Expand Down Expand Up @@ -3034,6 +3050,7 @@ export {
getActivePoliciesWithExpenseChat,
getAdminEmployees,
getCleanedTagName,
getTagListLabel,
getCommaSeparatedTagNameWithSanitizedColons,
getConnectedIntegration,
getConnectionExporters,
Expand Down Expand Up @@ -3212,6 +3229,7 @@ export {
getActiveEmployeeWorkspaces,
getPolicyRole,
hasIndependentTags,
hasPerTagListRequired,
getLengthOfTag,
getTagGLCode,
getGLCodeFromPolicyTag,
Expand Down
7 changes: 5 additions & 2 deletions src/libs/actions/Policy/Category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1209,7 +1209,8 @@ function setPolicyCategoryGLCode(policyID: string, categoryName: string, glCode:
API.write(WRITE_COMMANDS.UPDATE_POLICY_CATEGORY_GL_CODE, parameters, onyxData);
}

function setWorkspaceRequiresCategory(policyData: PolicyData, requiresCategory: boolean) {
/** Pass shouldRecomputeViolations = false when tag Required changes in the same save: each recompute SETs violations from the pre-save snapshot, so two overwrite each other. */
function setWorkspaceRequiresCategory(policyData: PolicyData, requiresCategory: boolean, shouldRecomputeViolations = true) {
const policyID = policyData.policy?.id;
const policyOptimisticData: Partial<Policy> = {
requiresCategory,
Expand Down Expand Up @@ -1258,7 +1259,9 @@ function setWorkspaceRequiresCategory(policyData: PolicyData, requiresCategory:
],
};

pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData);
if (shouldRecomputeViolations) {
pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData);
}

const parameters = {
policyID,
Expand Down
95 changes: 94 additions & 1 deletion src/libs/actions/Policy/Tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1058,10 +1058,12 @@ function renamePolicyTagList(policyID: string, policyTagListName: {oldName: stri
API.write(WRITE_COMMANDS.RENAME_POLICY_TAG_LIST, parameters, onyxData);
}

function setPolicyRequiresTag(policyData: PolicyData, requiresTag: boolean) {
/** extraPolicyUpdate folds a caller's same-save requiresCategory change into this action's single violation recompute. */
function setPolicyRequiresTag(policyData: PolicyData, requiresTag: boolean, extraPolicyUpdate: Partial<Policy> = {}) {
const policyID = policyData.policy?.id;

const policyOptimisticData: Partial<Policy> = {
...extraPolicyUpdate,
requiresTag,
// A manual toggle is explicit, so any pending switch-level restore intent is no longer needed.
pendingRequiresTagRestore: null,
Expand Down Expand Up @@ -1258,6 +1260,96 @@ function setPolicyTagsRequired(policyData: PolicyData, requiresTag: boolean, tag
API.write(WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED, parameters, onyxData);
}

/**
* Applies a mix of required and optional across independent tag levels in one save, keyed by orderWeight.
*
* Violations are recomputed once from the combined end state. Calling setPolicyTagsRequired per level instead would hand
* each request only its own level's change against the same pre-save snapshot, and since the recompute SETs the violations
* key, the last request would overwrite the others — e.g. requiring Region while clearing Department would drop the
* Missing Region violation the first half just added.
*/
function setPolicyTagLevelsRequired(policyData: PolicyData, requiredByOrderWeight: Record<number, boolean>, extraPolicyUpdate: Partial<Policy> = {}) {
const policyID = policyData.policy?.id;
if (!policyID || !policyData.tags) {
return;
}

const changedTagLists = Object.values(policyData.tags).filter((tagList) => !!tagList.name && requiredByOrderWeight[tagList.orderWeight] !== undefined);
if (changedTagLists.length === 0) {
return;
}

const combinedTagsUpdate: Record<string, Partial<PolicyTagList>> = {};
for (const tagList of changedTagLists) {
combinedTagsUpdate[tagList.name] = {required: requiredByOrderWeight[tagList.orderWeight]};
}

// ViolationsUtils gates every tag violation behind policy.requiresTag, so mirror what the backend derives from the
// levels instead of waiting for the next policy refresh.
const requiresTag = Object.values(policyData.tags).some((tagList) => requiredByOrderWeight[tagList.orderWeight] ?? !!tagList.required);
const policyUpdate: Partial<Policy> = {...extraPolicyUpdate, requiresTag};

const buildOnyxData = (tagLists: Array<PolicyTagLists[string]>, isRequired: boolean, shouldRecomputeViolations: boolean) => {
const optimisticValue: Record<string, Partial<PolicyTagLists[string]>> = {};
const successValue: Record<string, Partial<PolicyTagLists[string]>> = {};
const failureValue: Record<string, Partial<PolicyTagLists[string]>> = {};

for (const tagList of tagLists) {
optimisticValue[tagList.name] = {
required: isRequired,
pendingFields: {required: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
errorFields: {required: null},
};
successValue[tagList.name] = {pendingFields: {required: null}};
failureValue[tagList.name] = {
required: tagList.required,
pendingFields: {required: null},
errorFields: {required: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.genericFailureMessage')},
};
}

const onyxData: OnyxData<typeof ONYXKEYS.COLLECTION.POLICY | typeof ONYXKEYS.COLLECTION.POLICY_TAGS> = {
optimisticData: [{onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, value: optimisticValue}],
successData: [{onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, value: successValue}],
failureData: [{onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, value: failureValue}],
};

// Only the request that owns the recompute writes the derived policy flags, so the other can't revert them.
if (shouldRecomputeViolations) {
onyxData.optimisticData?.push({onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: policyUpdate});
onyxData.failureData?.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {requiresTag: policyData.policy?.requiresTag ?? false},
});
pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, {}, combinedTagsUpdate);
}

return onyxData;
};

// The command carries a single isRequired, so mixed directions still need one request each. Only the first one carries
// the violation recompute, so the second can't overwrite it.
let hasRecomputedViolations = false;
for (const isRequired of [true, false]) {
const tagListsForDirection = changedTagLists.filter((tagList) => requiredByOrderWeight[tagList.orderWeight] === isRequired);
if (tagListsForDirection.length === 0) {
continue;
}

const onyxData = buildOnyxData(tagListsForDirection, isRequired, !hasRecomputedViolations);
hasRecomputedViolations = true;
Comment thread
Krishna2323 marked this conversation as resolved.

const parameters: SetPolicyTagListsRequired = {
policyID,
tagListIndexes: tagListsForDirection.map((tagList) => tagList.orderWeight),
isRequired,
};

API.write(WRITE_COMMANDS.SET_POLICY_TAG_LISTS_REQUIRED, parameters, onyxData);
}
}

type SetPolicyTagGLCodeProps = {
policyID: string;
tagName: string;
Expand Down Expand Up @@ -1447,6 +1539,7 @@ export {
buildOptimisticPolicyRecentlyUsedTags,
setPolicyRequiresTag,
setPolicyShowTagGLCodes,
setPolicyTagLevelsRequired,
setPolicyTagsRequired,
createPolicyTag,
clearPolicyTagErrors,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,28 @@ import Section from '@components/Section';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
import useThemeStyles from '@hooks/useThemeStyles';

import {getBillableExpensesPendingAction, getCashExpenseReimbursableMode, setPolicyAttendeeTrackingEnabled, setWorkspaceEReceiptsEnabled} from '@libs/actions/Policy/Policy';
import {openPolicyTagsPage} from '@libs/actions/Policy/Tag';
import Navigation from '@libs/Navigation/Navigation';
import {isAttendeeTrackingEnabled, isCollectPolicy, tryNavigateToControlPolicyUpgrade} from '@libs/PolicyUtils';
import {getTagListLabel, getTagLists, hasPerTagListRequired, isAttendeeTrackingEnabled, isCollectPolicy, tryNavigateToControlPolicyUpgrade} from '@libs/PolicyUtils';

import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow';

import variables from '@styles/variables';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import type {PendingAction} from '@src/types/onyx/OnyxCommon';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';

import React, {useMemo} from 'react';
import React, {useEffect, useMemo} from 'react';
import {View} from 'react-native';

import PublicReceiptVisibilityToggle from './PublicReceiptVisibilityToggle';
Expand Down Expand Up @@ -59,6 +62,7 @@ function IndividualExpenseRulesSectionRevamp({policyID, canWriteRules}: Individu
const {translate} = useLocalize();
const styles = useThemeStyles();
const policy = usePolicy(policyID);
const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`);
const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'Coins', 'Receipt', 'ReceiptCheck', 'Task', 'Cash', 'Users', 'Eye']);

const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD;
Expand Down Expand Up @@ -116,7 +120,31 @@ function IndividualExpenseRulesSectionRevamp({policyID, canWriteRules}: Individu
const areEReceiptsEnabled = policy?.eReceipts ?? false;
const isAttendeeTrackingEnabledForPolicy = isAttendeeTrackingEnabled(policy);

const requiredFieldsList = [policy?.requiresCategory && translate('common.category'), policy?.requiresTag && translate('common.tag')].filter(Boolean).join(', ');
useEffect(() => {
// The subtitle names the required tag lists, and only the Tags pages fetch them, so it would otherwise read
// stale until Tags is opened. This section mounts only while the General tab is active.
openPolicyTagsPage(policyID);
Comment thread
JS00001 marked this conversation as resolved.
}, [policyID]);

const tagLists = getTagLists(policyTags);

// Name the tag lists the way the Require fields page rows do, instead of a generic "Tag".
const requiredTagLabels = (() => {
const genericTagLabel = translate('common.tag');

if (hasPerTagListRequired(policy, policyTags)) {
return tagLists.filter((tagList) => tagList.required).map((tagList) => getTagListLabel(tagList.name, genericTagLabel));
}

if (!policy?.requiresTag) {
Comment thread
JS00001 marked this conversation as resolved.
return [];
}

// One flag covers every level, so a list name only fits when there is exactly one list.
return [tagLists.length === 1 ? getTagListLabel(tagLists.at(0)?.name, genericTagLabel) : genericTagLabel];
Comment thread
JS00001 marked this conversation as resolved.
})();

const requiredFieldsList = [policy?.requiresCategory && translate('common.category'), ...requiredTagLabels].filter(Boolean).join(', ');

const prohibitedExpensesText = useMemo(() => {
const prohibitedExpensesList = Object.values(CONST.POLICY.PROHIBITED_EXPENSES)
Expand Down
Loading
Loading