Skip to content

Add filter negation feature#93909

Open
bernhardoj wants to merge 32 commits into
Expensify:mainfrom
bernhardoj:feat/93208-filter-negation
Open

Add filter negation feature#93909
bernhardoj wants to merge 32 commits into
Expensify:mainfrom
bernhardoj:feat/93208-filter-negation

Conversation

@bernhardoj

@bernhardoj bernhardoj commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Fixed Issues

$ #93208
PROPOSAL:

Tests

Same as QA Steps

  • Verify that no errors appear in the JS console

Offline tests

Same as QA Steps

QA Steps

  1. Go to Spend > Expenses
  2. Press the Filters button
  3. Open Status. Workspace, To, From, Currency, Purchase currency, Merchant, and "Exported to" filter
  4. Verify it has "Filter type" with "is" and "is not" options, and "is" is initially selected
  5. Open Has filter
  6. Verify it has "Filter type" with "has" and "doesn't have" options, and "has" is initially selected
  7. Apply any value and filter type to the filters
  8. If a negative filter type is applied (is not/doesn't have), verify the filter chip/bar label is prefixed with -
  9. Close and reopen the filter
  10. Verify the filter value and type are still selected with the correct value
  11. Open the save search page
  12. Verify it has the same label as in the filter chip/bar

For steps 3-7, if you want more test steps, follow this:
No filter is applied yet

  1. Open any filter from the step above
  2. Verify the positive filter type is selected
  3. Change the filter type to negative
  4. Verify the filter is not applied yet (because no value is selected)
  5. Apply any value
  6. Verify that a negation filter is applied
  7. Change the filter type back to positive
  8. Verify that a positive filter is applied
  9. Unapply and reapply the value
  10. Verify that a positive filter is applied

For filter chip/bar:

  1. Press any applied filter chip/bar
  2. Verify that the filter value and type are selected with the correct value
  3. Play around with the test steps above
  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
web.mp4

@bernhardoj bernhardoj requested review from a team as code owners June 18, 2026 05:17
@melvin-bot

melvin-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

Hey, I noticed you changed src/languages/en.ts in a PR from a fork. For security reasons, translations are not generated automatically for PRs from forks.

If you want to automatically generate translations for other locales, an Expensify employee will have to:

  1. Look at the code and make sure there are no malicious changes.
  2. Run the Generate static translations GitHub workflow. If you have write access and the K2 extension, you can simply click: [this button]

Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running:

npx ts-node ./scripts/generateTranslations.ts --help

Typically, you'd want to translate only what you changed by running npx ts-node ./scripts/generateTranslations.ts --compare-ref main

@melvin-bot melvin-bot Bot requested review from DylanDylann and JmillsExpensify and removed request for a team June 18, 2026 05:18
@melvin-bot

melvin-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

@DylanDylann Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot melvin-bot Bot removed the request for review from a team June 18, 2026 05:18
@bernhardoj

Copy link
Copy Markdown
Contributor Author

Can I run the translation script myself?

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d5746a1607

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@bernhardoj

Copy link
Copy Markdown
Contributor Author

Btw, when we apply a negated HAS filter with a Tag value, it will be applied as "Tag: No tag".

w.mp4

It's from this code. It causes weird buggy behavior with the Has filter component, as shown in the video above.

if (hasNegatedTagFilter) {
filtersForm[FILTER_KEYS.TAG] = [CONST.SEARCH.TAG_EMPTY_VALUE];
}

Is that expected?

@github-actions

Copy link
Copy Markdown
Contributor

🚧 @dubielzyk-expensify has triggered a test Expensify/App build. You can view the workflow run here.

@dubielzyk-expensify

Copy link
Copy Markdown
Contributor

This is looking great already @bernhardoj. Let's make this text label (13px) not body (15px)

CleanShot 2026-06-18 at 16 28 42@2x

@dubielzyk-expensify

dubielzyk-expensify commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

I'm not convinced we should use the is/is not in the chip itself:

CleanShot 2026-06-18 at 16 29 35@2x

I have to go and will have a noodle, but I think we might want an icon or do From: not Jon Dubby. But even that feels weird. I don't think it's crucial for it to be written fully out, but just be able to signal between is/isn't in some way, then when you open the filter you can see it more clearly there anyways

cc @Expensify/design @trjExpensify @JmillsExpensify

@bernhardoj

Copy link
Copy Markdown
Contributor Author

Let's make this text label (13px) not body (15px)

Done

@JS00001 JS00001 requested review from JS00001 and aimane-chnaif and removed request for DylanDylann June 18, 2026 12:12
@JmillsExpensify

Copy link
Copy Markdown
Contributor

I'm not convinced we should use the is/is not in the chip itself:

CleanShot 2026-06-18 at 16 29 35@2x I have to go and will have a noodle, but I think we might want an icon or do `From: not Jon Dubby`. But even that feels weird. I don't think it's crucial for it to be written fully out, but just be able to signal between is/isn't in some way, then when you open the filter you can see it more clearly there anyways

cc @Expensify/design @trjExpensify @JmillsExpensify

I'm fine with what I'm seeing in the adhoc.

@JmillsExpensify

Copy link
Copy Markdown
Contributor

@bernhardoj Looks like we're still missing support for Status and Workspace in the adhoc. Those are mentioned in the OP. Let me know if I'm missing something!

CleanShot 2026-06-18 at 16 47 06@2x

@JS00001

JS00001 commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Status and workspace are still having their BE PRs worked on: https://github.com/Expensify/Auth/pull/22241

@bernhardoj

bernhardoj commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Opened a seperate PR (because it's quite big) to allow workspace and status to be negatable.

cc: @aimane-chnaif

Copy link
Copy Markdown
Contributor

Works for me.

@stephanieelliott stephanieelliott removed their request for review July 10, 2026 20:21
@aimane-chnaif

Copy link
Copy Markdown
Contributor

Is this ready for review? Lots of tests failing

@bernhardoj

Copy link
Copy Markdown
Contributor Author

Not yet. I'll let you know when it's ready

@bernhardoj bernhardoj requested a review from aimane-chnaif July 14, 2026 16:56
@bernhardoj

Copy link
Copy Markdown
Contributor Author

@aimane-chnaif ready now

@JS00001

JS00001 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

lint is failing @bernhardoj

Comment thread src/libs/SearchUIUtils.ts
Comment on lines +5506 to +5509
if (isFilterNegatable(key)) {
const prefix = isFilterNegated(key) ? '-' : '';
return `${prefix}${translate(labelKey)}`;
}

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.

chip label is now a raw - prefix, contradicting the PR's own QA steps

So an applied negated filter renders as -Merchant: Uber / -Has: Receipt.
This conflicts with three things:

  • The PR's own QA steps (steps 8–9) still specify Merchant is not: test and Doesn't have: Receipt.
  • The picker UI itselfNegatableFilter still labels the toggle buttons "is / is not" and "has / doesn't have", and those strings are still translated across all 9 languages. A user picks "is not" and gets back a chip reading "-Merchant", which doesn't echo the wording they chose.
  • The - is hardcoded and unlocalized — it bypasses translate entirely, so the search.filters.filterType.*.negative strings are now only used by the toggle, never by the chip.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

chip label is now a raw - prefix, contradicting the PR's own QA steps

Looks like the AI still sees the old steps 😄

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.

yup.
Btw non-blocking nit: the - is hardcoded in getLabelValue rather than coming from a translation, so the chip prefix isn't localized the way the picker toggle ("is not" / "doesn't have") is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can - syntax be localized? 🤔

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.

No need localization but define in CONST?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah, that makes more sense. Updated.

Comment on lines 249 to +250
default:
return null;
break;

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.

default: case can return undefined.

The switch ends with default: break;, then unconditionally returns <NegatableFilter>{content}</NegatableFilter>.
For an unhandled key content is undefined, and NegatableFilter early-returns children for non-negatable keys — so the component returns undefined.
The switch is currently exhaustive so it's latent, but the old code returned null here.

Suggestion: have NegatableFilter return children ?? null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Undefined also renders nothing, same as null

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e483905be6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/libs/SearchQueryUtils.ts
policyID={getFilterNegatableValue(CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, values)}
ready={ready}
isNegated={isNegated}
onChange={(newValue, negated) => onChange(getFilterFormValues(baseFilterKey, newValue, negated))}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve negated Has Tag selections

Negating the Has filter with the Tag option does not round-trip through the existing query normalization. This path writes hasNot: ['tag'], which serializes to -has:tag, but buildFilterFormValuesFromQuery translates -has:tag into tag: [No tag] instead of hasNot; after applying/reopening, the chip becomes Tag: No tag and the Has filter no longer shows doesn't have Tag selected. Special-case Tag here or prevent it from being written as a negated Has value so the new negation UI preserves its own state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@JmillsExpensify @JS00001 currently, when we apply a negated Has with Tag value (-has:tag), it translates to {tag: ['none']}. Do we want to keep that behavior? (this is an existing behavior on prod)

no.tag.mp4

Or should we change it so it translates to {hasNot: tag}?

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Code review

Overall this is a clean, well-structured refactor — renaming filterKeybaseFilterKey, splitting the old FilterComponents into ListFilterContent + NegatableFilter, and threading an isNegated flag through the popups/pages. The negation logic itself checks out and is nicely covered by the new unit tests. One blocker (failing ESLint) and one minor robustness nit.

🔴 Blocker — ESLint is failing

The ESLint check is red. Two categories, both from this PR:

1. Parent-relative imports that must use the path alias (@dword-design/import-alias/prefer-alias):

2. New unsafe type assertions exceeding the eslint-seatbelt budget (@typescript-eslint/no-unsafe-type-assertion). The new as casts (e.g. removeNegation(filterKey) as SearchFilter['key'] in useSearchFiltersBar, as K in getFilterFormValues, and the ones flagged in SearchUIUtils.ts) tip each file over its grandfathered allowance ("N errors, only N-1 allowed"). Reduce the count back to the allowed number — prefer a type guard / narrowing over a raw assertion where possible, or confirm the seatbelt is intentionally being bumped.

🟡 Minor — removeNegation isn't anchored to the suffix

SearchQueryUtils.ts:2445 uses filterKey.replace(CONST.SEARCH.NOT_MODIFIER, ''), which strips the first Not anywhere in the string rather than the trailing modifier. It's safe for today's filter keys, but this PR makes it load-bearing (it now runs on every key in mapFiltersFormToLabelValueList). Consider anchoring it, e.g. filterKey.endsWith(NOT_MODIFIER) ? filterKey.slice(0, -NOT_MODIFIER.length) : filterKey, to match the already-correct isFilterNegated (which uses endsWith).

Things I verified look correct
  • getFilterFormValues correctly clears the opposite key (base vs. …Not) on every apply, so you never end up with both set from the UI.
  • getFilterNegatableValue keying off hasKey(values, negatedKey) preserves the is/is-not toggle state across reopen even when the value is empty — matches QA steps 10–11.
  • shouldShowFilter's new (isFilterNegatable(key) || !isFilterNegated(key)) clause is a sound guard: it prevents non-UI-negatable …Not keys (e.g. isNot, actionNot) from newly surfacing as chips now that labels resolve via removeNegation(key). No regression vs. old behavior (those keys weren't in FILTER_VIEW_MAP before either).
  • getDisplayValue …Not handling is complete for the negatable set (TO, FROM, HAS, CURRENCY, PURCHASE_CURRENCY, MERCHANT, EXPORTED_TO, POLICY_ID, STATUS) — the text/default fallthrough covers MERCHANT_NOT/EXPORTED_TO_NOT correctly, and IS isn't in NEGATABLE_FILTERS so the missing IS_NOT branch is intentional.
  • Changing HAS_NOT from string[]HasFilterValues to match HAS is the right call for getFilterFormValues to stay type-consistent.
  • The FilterComponents/index.tsxListFilterContent.tsx rename is clean — no dangling imports of the old module or FilterComponentsProps/CommonPopup/CommonFilterContentWrapper remain.
  • typecheck, Bun tests, and all test jobs are green.

@bernhardoj

Copy link
Copy Markdown
Contributor Author

All done, except #93909 (comment), waiting for confirmation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.