Skip to content

Comments

Country blacklist project setting#427

Merged
Blaumaus merged 10 commits intomainfrom
feature/country-block
Nov 30, 2025
Merged

Country blacklist project setting#427
Blaumaus merged 10 commits intomainfrom
feature/country-block

Conversation

@Blaumaus
Copy link
Member

@Blaumaus Blaumaus commented Nov 30, 2025

#424

Community Edition support

  • Your feature is implemented for the Swetrix Community Edition
  • This PR only updates the Cloud (Enterprise) Edition code (e.g. Paddle webhooks, blog, payouts, etc.)

Database migrations

  • Clickhouse / MySQL migrations added for this PR
  • No table schemas changed in this PR

Documentation

  • You have updated the documentation according to your PR
  • This PR did not change any publicly documented endpoints

Summary by CodeRabbit

  • New Features
    • Added country-level traffic blocking for projects.
    • Added searchable country selector with flags in Project settings and an improved multi-select UI.
  • Behavior
    • Server now enforces country blacklist and will block requests from blacklisted countries.
  • Chores
    • Database/schema updated to persist country blacklists.
  • Localization
    • Added UI strings for country blacklist labels, hints and search.

✏️ Tip: You can customize this high-level summary in your review settings.

@Blaumaus Blaumaus self-assigned this Nov 30, 2025
@coderabbitai
Copy link

coderabbitai bot commented Nov 30, 2025

Warning

Rate limit exceeded

@Blaumaus has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 3 minutes and 58 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between e8623d1 and 3a6239a.

📒 Files selected for processing (17)
  • backend/apps/cloud/src/project/dto/create-project.dto.ts (1 hunks)
  • backend/apps/cloud/src/project/dto/project.dto.ts (2 hunks)
  • backend/apps/cloud/src/project/project.service.ts (3 hunks)
  • backend/apps/community/src/common/utils.ts (4 hunks)
  • backend/apps/community/src/project/dto/project.dto.ts (2 hunks)
  • backend/apps/community/src/project/project.controller.ts (2 hunks)
  • backend/apps/community/src/project/project.service.ts (4 hunks)
  • backend/apps/community/src/user/user.controller.ts (1 hunks)
  • backend/migrations/clickhouse/selfhosted_2025_11_30.js (1 hunks)
  • web/app/lib/constants/index.ts (1 hunks)
  • web/app/pages/Project/Settings/ProjectSettings.tsx (6 hunks)
  • web/app/pages/Project/Settings/tabs/Shields.tsx (3 hunks)
  • web/app/ui/MultiSelect.tsx (2 hunks)
  • web/public/locales/de.json (1 hunks)
  • web/public/locales/fr.json (1 hunks)
  • web/public/locales/pl.json (2 hunks)
  • web/public/locales/uk.json (1 hunks)

Walkthrough

Adds country-level blacklist support: new countryBlacklist field in DB/entities/DTOs, UI multi-select to manage it, analytics service method to enforce country bans, and controllers updated to validate project and block requests from blacklisted countries before processing.

Changes

Cohort / File(s) Change Summary
Backend: Analytics controllers
backend/apps/cloud/src/analytics/analytics.controller.ts, backend/apps/community/src/analytics/analytics.controller.ts
Call validate(...) to obtain Project, resolve IP country, then call checkCountryBlacklist(project, country) before continuing request processing across endpoints (logCustom, log, noscript, error paths, etc.).
Backend: Analytics services
backend/apps/cloud/src/analytics/analytics.service.ts, backend/apps/community/src/analytics/analytics.service.ts
validate(...) and validateHeartbeat(...) now return Promise<Project> (returning the project on success); added checkCountryBlacklist(project, country) which throws when the country is blacklisted.
Backend: Project model & DTOs
backend/apps/cloud/src/project/entity/project.entity.ts, backend/apps/cloud/src/project/dto/project.dto.ts, backend/apps/community/src/project/entity/project.entity.ts, backend/apps/community/src/project/dto/project.dto.ts
Added countryBlacklist property to Project entities/DTOs (stored as simple-array / `string[]
Backend: Project controllers
backend/apps/cloud/src/project/project.controller.ts, backend/apps/community/src/project/project.controller.ts
Normalize/map projectDTO.countryBlacklist on update (trim/map to array or set null/empty array) and persist to project.countryBlacklist.
Backend: Migrations
backend/migrations/clickhouse/initialise_selfhosted.js, backend/migrations/clickhouse/selfhosted_2025_11_30.js, backend/migrations/mysql/2025_11_30_country_blacklist.sql
Added countryBlacklist column to project table (ClickHouse and MySQL migrations).
Backend: Misc. defaults & allowed keys
backend/apps/community/src/common/utils.ts
Added countryBlacklist to ALLOWED_KEYS and initialized default value in project creation defaults.
Frontend: Project model & settings
web/app/lib/models/Project.ts, web/app/pages/Project/Settings/ProjectSettings.tsx
Added countryBlacklist to Project model and form state; load server value into form and include in update payload (serialize as null when empty).
Frontend: Shields UI (Country selector)
web/app/pages/Project/Settings/tabs/Shields.tsx
Added country blacklist UI: new props countryBlacklist / setCountryBlacklist, searchable MultiSelect with flags, select/remove handlers and localized country names.
Frontend: UI component update
web/app/ui/MultiSelect.tsx
Overhauled MultiSelect: searchable dropdown with controlled open/search state, selection/removal handlers, and changed props (removed searchPlaseholder).
Frontend: Localization
web/public/locales/en.json
Added locale keys: countryBlacklist, countryBlacklistHint, searchCountries, selectCountry.
Other / Minor
backend/migrations/mysql/2025_11_30.sql
Removed trailing blank line (no functional change).

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller
    participant AnalyticsService
    participant GeoIP as Geo Service
    participant Database

    Client->>Controller: POST /analytics (payload, origin, IP)
    Controller->>AnalyticsService: validate(logDTO, origin, ip)
    AnalyticsService->>Database: fetch Project by origin
    Database-->>AnalyticsService: Project
    AnalyticsService-->>Controller: Project

    Controller->>GeoIP: resolveCountry(ip)
    GeoIP-->>Controller: countryCode or null

    Controller->>AnalyticsService: checkCountryBlacklist(project, countryCode)
    alt country is blacklisted
        AnalyticsService-->>Controller: throws BadRequestException
        Controller-->>Client: 400 Bad Request
    else allowed
        Controller->>Controller: collect device/browser/region info
        Controller->>Database: insert analytics record
        Database-->>Controller: OK
        Controller-->>Client: 201 Created
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Verify consistency of validate / validateHeartbeat signature changes across cloud/community modules.
  • Check that checkCountryBlacklist is invoked in all analytics entry points and that thrown exceptions are handled to produce expected HTTP responses.
  • Validate DB migration semantics (nullable vs default) align with entity DTO definitions.
  • Frontend: ensure Shields MultiSelect correctly binds countryBlacklist and that empty arrays serialize as null per API expectation.

Possibly related issues

Possibly related PRs

  • Rework filters experience #423 — Modifies analytics controllers/services and may overlap with changed controller/service signatures and logic; potential for conflicts or coordination.

Poem

🐇 I hopped through rows of country codes,
Picked flags and trimmed the loads,
I guard the logs with careful sight,
Block the hops that aren't polite,
A tiny rabbit keeps data light.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning PR description is incomplete. It references the template but leaves critical sections blank, lacks implementation details, and missing the 'Changes' section entirely. Add a 'Changes' section describing what was implemented. Clarify which checklist items are actually completed (appears mixed/contradictory). Provide details about the country blacklist feature implementation.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the primary feature: adding a country blacklist setting to projects.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Blaumaus Blaumaus marked this pull request as ready for review November 30, 2025 21:13
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
backend/apps/community/src/analytics/analytics.controller.ts (1)

885-910: Country blacklist is enforced after creating/updating sessions

In logCustom, log, noscript, and logError you call generateAndStoreSessionId and/or processInteractionSD before checkCountryBlacklist(project, country). This means requests from blacklisted countries won’t write analytics rows, but they will still create/refresh sessions and contribute to heartbeat/“online visitors” style metrics.

Consider moving getGeoDetails(...) and checkCountryBlacklist(...) earlier (right after validate(...) and before any session or heartbeat side effects) so that blocked countries are excluded consistently across all derived stats.

Also applies to: 1007-1030, 1141-1155, 1441-1456

backend/apps/cloud/src/analytics/analytics.controller.ts (1)

1151-1164: Country blacklist check runs after session creation, causing partial tracking of blocked users

In logError, logCustom, log, and noscript you now:

  1. Call validate(...) to get project.
  2. Create/refresh sessions via generateAndStoreSessionId and processInteractionSD.
  3. Only then call checkCountryBlacklist(project, country).

For requests from blacklisted countries this prevents ClickHouse writes, but sessions and session duration keys are still created/updated, so:

  • “Live visitors” / heartbeat counts can include users from blocked countries.
  • Session duration stats may include interactions from those users.

Consider reordering to:

  • Derive country via getGeoDetails(...).
  • Run checkCountryBlacklist(project, country).
  • Only if it passes, proceed with generateAndStoreSessionId, processInteractionSD, and downstream inserts.

This keeps country blocking semantics consistent across all analytics-derived metrics.

Also applies to: 1254-1276, 1377-1397, 1512-1525

backend/apps/cloud/src/analytics/analytics.service.ts (1)

462-503: validateHeartbeat callers should capture the returned Project for consistency and potential downstream checks

All validate() call sites are correctly updated to capture the Project return value (e.g., const project = await this.analyticsService.validate(...)), and the returned object is used downstream. However, both validateHeartbeat() call sites ignore the return value with bare await statements, despite the method now returning Promise<Project>. This is inconsistent with the validate() pattern and may miss opportunities for the same downstream validation checks (like country blacklisting) that validate() enables. Consider capturing the return value: const project = await this.analyticsService.validateHeartbeat(...) in both controllers.

🧹 Nitpick comments (6)
backend/apps/community/src/project/entity/project.entity.ts (1)

11-11: Consider nullable type for consistency with cloud entity and frontend.

The cloud entity declares countryBlacklist with { nullable: true, default: null }, and the frontend uses string[] | null. This community model uses string[] which doesn't account for null values, potentially causing runtime issues when the field is null.

-  countryBlacklist: string[]
+  countryBlacklist: string[] | null
backend/apps/cloud/src/analytics/analytics.service.ts (1)

433-451: Normalize blacklist entries to avoid case-sensitivity surprises

checkCountryBlacklist uppercases the country argument but not the entries in project.countryBlacklist. If any codes are ever stored in lowercase/mixed case (e.g. via future APIs or migrations), they would silently bypass the check.

You can make this more robust by normalizing the list as well:

-    const countryBlacklist = _filter(
-      project.countryBlacklist,
-      Boolean,
-    ) as string[]
+    const countryBlacklist = _filter(
+      project.countryBlacklist,
+      Boolean,
+    ).map(cc => _toUpper(cc as string)) as string[]

The rest of the logic can then continue to use _includes(countryBlacklist, _toUpper(country)).

web/app/pages/Project/Settings/tabs/Shields.tsx (1)

1-90: Country blacklist UI wiring looks solid; consider minor state-sync tweaks

The new country blacklist UI is well integrated: codes are sourced from isoCountries, sorted per language, and wired into MultiSelect with search and toggle handlers. A couple of small refinements to consider:

  • Keep searchedCountries in sync with allCountries when the language changes, so the visible list updates automatically:
-  const [searchedCountries, setSearchedCountries] = useState<string[]>(allCountries)
+  const [searchedCountries, setSearchedCountries] = useState<string[]>(allCountries)
+
+  useEffect(() => {
+    setSearchedCountries(allCountries)
+  }, [allCountries])
  • To be extra defensive, you could also normalize codes to uppercase in setCountryBlacklist (though you already get uppercased alpha‑2 codes from isoCountries).

Overall, the UX and data flow here look good.

Also applies to: 113-131

web/app/pages/Project/Settings/ProjectSettings.tsx (1)

269-273: Country blacklist state wiring is correct; possible simplification

The countryBlacklist flow is coherent end‑to‑end:

  • Form state always holds a string[] ([] by default), populated from result.countryBlacklist || [].
  • onSubmit correctly serializes to null when empty, matching typical backend patterns for optional arrays.
  • Shields receives countryBlacklist={form.countryBlacklist || []} and a setter that updates form.countryBlacklist, so edits propagate back into the main form.

If you want to simplify later, you could drop the separate countryBlacklist prop and let Shields read/write form.countryBlacklist directly (via a setForm callback), but what you have now is perfectly valid.

Also applies to: 286-296, 419-423, 465-485, 786-806

backend/apps/cloud/src/project/project.controller.ts (1)

1763-1770: Country blacklist mapping is consistent; consider adding validation / max-size guard

This block correctly mirrors the existing ipBlacklist handling and keeps countryBlacklist either string[] or null, which fits the new entity field. The only thing missing is any bound on how many country codes a client can send, which is also what CodeQL is complaining about here (loop over user-controlled list). To harden this and quiet the scanner, consider enforcing a reasonable maximum and element-shape at the DTO/validation layer (e.g. array-of-ISO codes with a small upper bound) rather than in the controller.

backend/apps/cloud/src/project/dto/project.dto.ts (1)

46-52: Clarify countryBlacklist shape in docs and optionally add validation

The new countryBlacklist field and its description look good, but two small improvements would help: (1) make the Swagger example match the actual JSON shape (["UA","RU","BY"] rather than a comma-separated string) to avoid ambiguity, and (2) optionally add class-validator decorators so this is treated as an optional array of ISO country codes with a sane max size, similar in spirit to how you intend ipBlacklist to behave.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8682384 and 3facfe5.

📒 Files selected for processing (12)
  • backend/apps/cloud/src/analytics/analytics.controller.ts (8 hunks)
  • backend/apps/cloud/src/analytics/analytics.service.ts (4 hunks)
  • backend/apps/cloud/src/project/dto/project.dto.ts (1 hunks)
  • backend/apps/cloud/src/project/entity/project.entity.ts (1 hunks)
  • backend/apps/cloud/src/project/project.controller.ts (1 hunks)
  • backend/apps/community/src/analytics/analytics.controller.ts (8 hunks)
  • backend/apps/community/src/analytics/analytics.service.ts (3 hunks)
  • backend/apps/community/src/project/entity/project.entity.ts (1 hunks)
  • web/app/lib/models/Project.ts (1 hunks)
  • web/app/pages/Project/Settings/ProjectSettings.tsx (5 hunks)
  • web/app/pages/Project/Settings/tabs/Shields.tsx (3 hunks)
  • web/public/locales/en.json (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
backend/apps/community/src/analytics/analytics.controller.ts (1)
backend/apps/cloud/src/common/utils.ts (1)
  • getGeoDetails (254-283)
🪛 GitHub Check: CodeQL
backend/apps/cloud/src/project/project.controller.ts

[failure] 1765-1765: Loop bound injection
Iteration over a user-controlled object with a potentially unbounded .length property from a user-provided value.

🔇 Additional comments (5)
web/app/lib/models/Project.ts (1)

136-136: LGTM!

The countryBlacklist field is correctly typed as string[] | null to match the backend entity, and is logically placed alongside the existing ipBlacklist field.

web/public/locales/en.json (1)

1250-1253: LGTM!

The locale strings are clear, follow existing naming conventions, and provide appropriate labels and hints for the new country blacklist feature.

backend/apps/community/src/analytics/analytics.service.ts (3)

398-416: LGTM!

The implementation correctly mirrors the existing checkIpBlacklist pattern with appropriate null handling, case-insensitive country code matching via _toUpper, and filtering of empty values. The early return for null country prevents unnecessary processing.


418-461: LGTM!

Returning the validated Project instead of void is a good design improvement. It enables callers to perform additional project-level checks (like country blacklist) without redundant Redis lookups, and maintains backward compatibility.


463-487: LGTM!

The return type change is consistent with the validate method modification, enabling country blacklist checks for heartbeat requests without redundant project lookups.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
backend/migrations/clickhouse/initialise_selfhosted.js (1)

21-35: New countryBlacklist column shape looks consistent with existing schema

Defining countryBlacklist as Nullable(String) alongside ipBlacklist Nullable(String) keeps the ClickHouse schema internally consistent and closer to the MySQL TEXT DEFAULT NULL column. Just make sure the dedicated ClickHouse ALTER migration uses the same Nullable(String) type so freshly initialised DBs and migrated DBs have identical schemas.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3facfe5 and 7b6d021.

📒 Files selected for processing (8)
  • backend/apps/cloud/src/project/dto/project.dto.ts (1 hunks)
  • backend/apps/community/src/common/utils.ts (2 hunks)
  • backend/apps/community/src/project/dto/project.dto.ts (1 hunks)
  • backend/apps/community/src/project/project.controller.ts (1 hunks)
  • backend/migrations/clickhouse/initialise_selfhosted.js (1 hunks)
  • backend/migrations/clickhouse/selfhosted_2025_11_30.js (1 hunks)
  • backend/migrations/mysql/2025_11_30.sql (0 hunks)
  • backend/migrations/mysql/2025_11_30_country_blacklist.sql (1 hunks)
💤 Files with no reviewable changes (1)
  • backend/migrations/mysql/2025_11_30.sql
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/apps/cloud/src/project/dto/project.dto.ts
🧰 Additional context used
🧬 Code graph analysis (1)
backend/migrations/clickhouse/selfhosted_2025_11_30.js (1)
backend/migrations/clickhouse/setup.js (2)
  • dbName (107-107)
  • queriesRunner (76-105)
🪛 GitHub Check: CodeQL
backend/apps/community/src/project/project.controller.ts

[failure] 947-947: Loop bound injection
Iteration over a user-controlled object with a potentially unbounded .length property from a user-provided value.

🔇 Additional comments (5)
backend/apps/community/src/common/utils.ts (2)

114-124: Adding countryBlacklist to ALLOWED_KEYS is consistent with existing update flow

Including countryBlacklist alongside ipBlacklist ensures project updates can safely propagate this new field via updateProjectClickhouse, reusing the existing mass-assignment guard.

Please just double‑check that:

  • The project.countryBlacklist property exists on the Project entity/DTOs in the community app, and
  • The ClickHouse project table defines countryBlacklist as a String (or compatible) column,

so that the String binding used in updateProjectClickhouse matches the schema.


441-454: Default countryBlacklist initialization matches existing ipBlacklist behavior

Setting countryBlacklist: '' for new projects keeps defaults consistent with ipBlacklist and avoids null-handling edge cases in downstream logic.

To keep behavior uniform for existing data, ensure the ClickHouse migration:

  • Adds countryBlacklist with a default of '', and
  • (If needed) backfills any pre‑existing rows to '' rather than NULL.

That will align stored values with what this insert path produces.

backend/migrations/mysql/2025_11_30_country_blacklist.sql (1)

1-1: MySQL countryBlacklist migration looks correct and consistent

Adding countryBlacklist TEXT DEFAULT NULL AFTER ipBlacklist matches the existing blacklist pattern and nullable semantics. No issues from a schema standpoint.

backend/apps/community/src/project/dto/project.dto.ts (1)

46-52: DTO countryBlacklist property is well-documented and consistent

The new countryBlacklist: string[] | null field, with the Swagger metadata you added, cleanly mirrors ipBlacklist/origins and clearly explains expected ISO 3166‑1 alpha‑2 values and behavior. Looks good.

backend/apps/community/src/project/project.controller.ts (1)

945-952: Update logic for countryBlacklist matches existing patterns

The new block mirrors origins/ipBlacklist handling: trimming each entry and defaulting to an empty array when the field isn’t provided. That’s consistent with current update semantics and should integrate cleanly with the new DB column and DTO.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/app/ui/MultiSelect.tsx (2)

17-17: Fix the typo in the prop name.

The prop is misspelled as placholder instead of placeholder in the interface definition (line 17), destructuring (line 31), and usage (line 95). This affects the component's public API.

Apply this diff to fix the typo:

 interface MultiSelectProps {
   className?: string
   onRemove: (item: any) => void
   onSelect: (item: any) => void
   items: any[]
   labelExtractor?: (item: any) => string
   keyExtractor?: (item: any) => string
   label: any[]
   hint?: string
-  placholder?: string
+  placeholder?: string
   searchPlaseholder?: string
   onSearch?: (search: string) => void
   itemExtractor?: (item: any) => string
 }

 const MultiSelect = ({
   onRemove,
   onSelect,
   items,
   labelExtractor,
   keyExtractor,
   label,
   hint,
-  placholder = 'Select...',
+  placeholder = 'Select...',
   className,
   itemExtractor,
   onSearch,
 }: MultiSelectProps) => {
   // ...
   <input
     ref={inputRef}
     type='text'
     className='w-full cursor-text rounded-md border border-gray-300 bg-white py-2 pr-8 pl-3 text-sm transition-colors placeholder:text-gray-400 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-hidden dark:border-gray-700 dark:bg-slate-800 dark:text-gray-50 dark:placeholder:text-gray-500'
-    placeholder={placholder}
+    placeholder={placeholder}
     value={searchValue}
     onChange={handleInputChange}
     onFocus={handleFocus}
     onKeyDown={handleKeyDown}
   />

Also applies to: 31-31, 95-95


18-18: Remove the unused prop.

The searchPlaseholder prop is defined in the interface but never destructured or used in the component. This is dead code left over from the refactor.

Apply this diff:

 interface MultiSelectProps {
   className?: string
   onRemove: (item: any) => void
   onSelect: (item: any) => void
   items: any[]
   labelExtractor?: (item: any) => string
   keyExtractor?: (item: any) => string
   label: any[]
   hint?: string
   placholder?: string
-  searchPlaseholder?: string
   onSearch?: (search: string) => void
   itemExtractor?: (item: any) => string
 }
🧹 Nitpick comments (2)
web/app/ui/MultiSelect.tsx (2)

41-54: Consider memoizing the onSearch callback in parent components.

The useEffect includes onSearch in its dependency array. If the parent component doesn't memoize this callback (e.g., with useCallback), the effect will re-run on every parent render, repeatedly adding and removing the event listener. While not critical, this could impact performance in frequently re-rendering parent components.


91-109: Consider adding accessibility attributes.

The search input and dropdown could benefit from ARIA attributes to improve screen reader support:

  • Add aria-label or aria-labelledby to the input
  • Add aria-expanded={isOpen} to indicate dropdown state
  • Add role="combobox" to the input
  • Add aria-controls to link input to the dropdown list

Example:

 <input
   ref={inputRef}
   type='text'
+  role='combobox'
+  aria-expanded={isOpen}
+  aria-controls='multiselect-listbox'
+  aria-label='Search options'
   className='w-full cursor-text rounded-md border border-gray-300 bg-white py-2 pr-8 pl-3 text-sm transition-colors placeholder:text-gray-400 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-hidden dark:border-gray-700 dark:bg-slate-800 dark:text-gray-50 dark:placeholder:text-gray-500'
   placeholder={placholder}
   value={searchValue}
   onChange={handleInputChange}
   onFocus={handleFocus}
   onKeyDown={handleKeyDown}
 />

Additionally, the dropdown list should have role="listbox" and id="multiselect-listbox", and each option should have role="option".

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7b6d021 and e8623d1.

📒 Files selected for processing (1)
  • web/app/ui/MultiSelect.tsx (1 hunks)
🔇 Additional comments (3)
web/app/ui/MultiSelect.tsx (3)

36-85: LGTM! State management and event handlers are well-implemented.

The state management (isOpen, searchValue) and all event handlers (input change, focus, keydown, select) are correctly implemented. The flow is logical:

  • Opening dropdown on focus provides good UX
  • Escape key properly closes and resets state
  • Search value is propagated to parent via onSearch
  • Item selection correctly invokes onSelect and resets search

111-159: LGTM! Dropdown rendering is correct.

The dropdown logic properly handles:

  • Conditional rendering based on isOpen state
  • Empty state with a user-friendly message
  • Item mapping with correct key extraction
  • Selection indicators (checkmarks) for selected items
  • Proper event handling via handleSelectItem
  • Appropriate styling for selected vs unselected states

163-188: LGTM! Selected items display is well-implemented.

The selected items chip display correctly:

  • Shows only when items are selected (conditional on !_isEmpty(label))
  • Uses proper key extraction for each chip
  • Displays items using labelExtractor
  • Remove button properly prevents event propagation
  • Includes screen reader text for accessibility
  • Styling supports both light and dark modes

@Blaumaus Blaumaus merged commit 6c7978e into main Nov 30, 2025
5 of 6 checks passed
This was referenced Dec 14, 2025
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.

1 participant