Skip to content

feat: enhance candidates table with filtering, sorting, and column visibility options#40

Merged
JoachimLK merged 1 commit intomainfrom
feat/filters-for-lists
Feb 24, 2026
Merged

feat: enhance candidates table with filtering, sorting, and column visibility options#40
JoachimLK merged 1 commit intomainfrom
feat/filters-for-lists

Conversation

@JoachimLK
Copy link
Copy Markdown
Contributor

@JoachimLK JoachimLK commented Feb 24, 2026

  • Implemented multi-status filtering for candidates using checkboxes.
  • Added score range filtering for candidate applications.
  • Introduced a column picker to toggle visibility of email, score, status, and applied date.
  • Enhanced sorting functionality for name, email, score, status, and applied date.
  • Updated UI to reflect active filters and provide clear filter options.
  • Improved empty state messaging for no candidates.

Summary

  • What does this PR change?
  • Why is this needed?

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Chore

Validation

  • I tested locally
  • I added/updated relevant documentation
  • I verified multi-tenant scoping and auth behavior for affected API paths

DCO

  • All commits in this PR are signed off (Signed-off-by) via git commit -s

Summary by CodeRabbit

  • New Features
    • Enhanced Applications dashboard with advanced multi-status filtering and score range controls
    • Added customizable column visibility toggles for both Applications and Candidates pages
    • Implemented interactive sorting across multiple fields with visual direction indicators
    • Upgraded Applications view to sortable table layout for improved data organization
    • Introduced persistent filter state with active filter pills for easy management

…sibility options

- Implemented multi-status filtering for candidates using checkboxes.
- Added score range filtering for candidate applications.
- Introduced a column picker to toggle visibility of email, score, status, and applied date.
- Enhanced sorting functionality for name, email, score, status, and applied date.
- Updated UI to reflect active filters and provide clear filter options.
- Improved empty state messaging for no candidates.
@railway-app railway-app Bot temporarily deployed to applirank / applirank-pr-40 February 24, 2026 08:08 Destroyed
@railway-app
Copy link
Copy Markdown

railway-app Bot commented Feb 24, 2026

🚅 Deployed to the applirank-pr-40 environment in applirank

Service Status Web Updated (UTC)
applirank ✅ Success (View Logs) Feb 24, 2026 at 8:11 am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 24, 2026

📝 Walkthrough

Walkthrough

Both dashboard pages received enhanced filtering and sorting capabilities. Single-status filters were replaced with multi-select status filtering, score range inputs, and a collapsible filter panel. Sorting was added across multiple columns with visual indicators. Column visibility toggles and persistent state management enable users to customize table displays and filtering behavior.

Changes

Cohort / File(s) Summary
Enhanced Dashboard Filtering & Sorting
app/pages/dashboard/applications/index.vue, app/pages/dashboard/jobs/[id]/candidates.vue
Multi-status filtering with togglable status selections, score range input validation (min/max), column visibility controls via dropdown panel. Sorting added across multiple columns (candidate, job, score, status, createdAt/applied) with asc/desc indicators. Filter state persisted using useState. Outside-click handling closes filter panel. Active filter count displayed. Computed sorted/filtered results applied to table rendering. Table columns conditionally rendered based on visibility preferences.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 Filtering, sorting, columns hide and show,
Multi-select statuses steal the glow,
With scores to range and tables to sort,
These dashboards now give users more sport!
Panel drops down, persistence stays true,
What wonderful UI we've built for you! 🎨✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The description covers key implementation details but lacks required template sections (Type of change, Validation, DCO) that remain unchecked. Complete the template by selecting a change type (Feature applies here), checking validation boxes with explanations, and confirming DCO sign-off status.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main changes: enhanced filtering, sorting, and column visibility for the candidates table.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/filters-for-lists

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.

Copy link
Copy Markdown

@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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/pages/dashboard/applications/index.vue`:
- Line 90: The status filter only runs when more than one status is selected,
causing stale results for single selections; update the filtering condition in
the component's filter logic (where selectedStatuses is used and app.status is
checked) to run whenever any status is selected — e.g., change the guard from
selectedStatuses.value.length > 1 to selectedStatuses.value.length > 0 (so: if
(selectedStatuses.value.length > 0 &&
!selectedStatuses.value.includes(app.status as Status)) return false) ensuring
single-selection filters are applied immediately.
- Around line 62-66: activeFilterCount and the score-based filter checks treat
empty-string values from v-model.number as non-null, causing sticky filters; fix
by introducing computed proxies for scoreMin and scoreMax that normalize '' and
null to undefined (getter returns undefined for '', null; setter writes
normalized value to the reactive state), replace direct uses of
scoreMin/scoreMax in activeFilterCount and the filter predicate (where checks
currently use scoreMin.value != null / scoreMax.value != null) with the
normalized computed proxies, and update the input bindings for the number fields
to use these computed proxies so the filter logic and the filter pill display
(which reads score values) see undefined for cleared inputs rather than ''.

In `@app/pages/dashboard/jobs/`[id]/candidates.vue:
- Around line 151-152: The status filter currently only applies when
selectedStatuses.value.length > 1, which skips filtering for a single selection;
change the condition in the .filter callback to apply whenever
selectedStatuses.value is non-empty (e.g., selectedStatuses.value.length > 0) so
that if selectedStatuses.value does not include app.status (app.status as
Status) the row is filtered out locally to keep the UI consistent until API
refetch completes.
- Around line 112-116: The computed activeFilterCount is using raw
scoreMin/scoreMax which can be '' (from v-model.number) or NaN and still pass
the != null checks, so create proxy computed properties (e.g.,
normalizedScoreMin and normalizedScoreMax) that return undefined when the source
is '' or Number.isNaN(value) and otherwise return the numeric value; then update
activeFilterCount and any other logic that reads scoreMin/scoreMax (including
the other places noted around the component) to use
normalizedScoreMin/normalizedScoreMax instead of scoreMin/scoreMax so cleared
inputs no longer act as a 0 filter.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between db56cd5 and 0ea270e.

📒 Files selected for processing (2)
  • app/pages/dashboard/applications/index.vue
  • app/pages/dashboard/jobs/[id]/candidates.vue

Comment on lines +62 to +66
const activeFilterCount = computed(() => {
let n = selectedStatuses.value.length
if (scoreMin.value != null) n++
if (scoreMax.value != null) n++
return n
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Vue 3 v-model.number empty input number field behavior

💡 Result:

In Vue 3, v-model.number does not guarantee your model is always a number:

  • Vue casts using parseFloat(). If parsing fails, it keeps the original string. (vuejs.org)
  • When the user clears the input, Vue sets the bound value to '' (empty string), not null, 0, or NaN. (vuejs.org)
  • The .number behavior is applied automatically for <input type="number"> even without writing .number. (vuejs.org)
  • This differs from the DOM API valueAsNumber (e.g., empty input → NaN). (vuejs.org)

If you need “empty means null (or undefined)”, you must handle it yourself (e.g., in an @input handler or a computed setter) because Vue intentionally returns '' for empty. (vuejs.org)

Citations:


🏁 Script executed:

# Find and inspect the dashboard applications index.vue file
fd -t f "index.vue" | grep -E "applications.*index\.vue"

Repository: applirank/applirank

Length of output: 105


🏁 Script executed:

# Read the file to inspect the code at the mentioned lines
wc -l app/pages/dashboard/applications/index.vue

Repository: applirank/applirank

Length of output: 109


🏁 Script executed:

# Get the relevant sections
cat -n app/pages/dashboard/applications/index.vue | sed -n '50,80p'

Repository: applirank/applirank

Length of output: 1276


🏁 Script executed:

# Check the other mentioned lines
cat -n app/pages/dashboard/applications/index.vue | sed -n '85,100p'

Repository: applirank/applirank

Length of output: 816


🏁 Script executed:

# Check lines around 263-279
cat -n app/pages/dashboard/applications/index.vue | sed -n '260,280p'

Repository: applirank/applirank

Length of output: 1687


🏁 Script executed:

# Check lines around 309-314
cat -n app/pages/dashboard/applications/index.vue | sed -n '305,320p'

Repository: applirank/applirank

Length of output: 806


🏁 Script executed:

# Find where scoreMin and scoreMax are defined
rg -n "const scoreMin|const scoreMax|useState.*score" app/pages/dashboard/applications/index.vue

Repository: applirank/applirank

Length of output: 310


Normalize score inputs to prevent sticky filters when cleared.

When a user clears a number input with v-model.number, Vue sets the value to '' (empty string), not undefined. The activeFilterCount logic at lines 62–66 and filter checks at lines 91–92 use != null comparisons, which remain true for empty strings, keeping filters visually active despite being semantically empty. Apply the suggested computed proxy with normalization (converting ''/null to undefined) at the state binding point.

This affects:

  • Filter counting (lines 62–66)
  • Filter logic (lines 91–92)
  • Input bindings (lines 263, 273)
  • Filter pill display (line 309)
Suggested fix
 const scoreMin = useState<number | undefined>('app-filter-score-min', () => undefined)
 const scoreMax = useState<number | undefined>('app-filter-score-max', () => undefined)
+const normalizeScore = (val: unknown) => {
+  if (val === '' || val == null) return undefined
+  const n = Number(val)
+  return Number.isFinite(n) ? n : undefined
+}
+const scoreMinInput = computed({
+  get: () => scoreMin.value ?? '',
+  set: val => { scoreMin.value = normalizeScore(val) },
+})
+const scoreMaxInput = computed({
+  get: () => scoreMax.value ?? '',
+  set: val => { scoreMax.value = normalizeScore(val) },
+})
-              <input
-                v-model.number="scoreMin"
+              <input
+                v-model="scoreMinInput"
                 type="number"
                 min="0"
                 max="100"
                 placeholder="Min"
-              <input
-                v-model.number="scoreMax"
+              <input
+                v-model="scoreMaxInput"
                 type="number"
                 min="0"
                 max="100"
                 placeholder="Max"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const activeFilterCount = computed(() => {
let n = selectedStatuses.value.length
if (scoreMin.value != null) n++
if (scoreMax.value != null) n++
return n
const scoreMin = useState<number | undefined>('app-filter-score-min', () => undefined)
const scoreMax = useState<number | undefined>('app-filter-score-max', () => undefined)
const normalizeScore = (val: unknown) => {
if (val === '' || val == null) return undefined
const n = Number(val)
return Number.isFinite(n) ? n : undefined
}
const scoreMinInput = computed({
get: () => scoreMin.value ?? '',
set: val => { scoreMin.value = normalizeScore(val) },
})
const scoreMaxInput = computed({
get: () => scoreMax.value ?? '',
set: val => { scoreMax.value = normalizeScore(val) },
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/applications/index.vue` around lines 62 - 66,
activeFilterCount and the score-based filter checks treat empty-string values
from v-model.number as non-null, causing sticky filters; fix by introducing
computed proxies for scoreMin and scoreMax that normalize '' and null to
undefined (getter returns undefined for '', null; setter writes normalized value
to the reactive state), replace direct uses of scoreMin/scoreMax in
activeFilterCount and the filter predicate (where checks currently use
scoreMin.value != null / scoreMax.value != null) with the normalized computed
proxies, and update the input bindings for the number fields to use these
computed proxies so the filter logic and the filter pill display (which reads
score values) see undefined for cleared inputs rather than ''.

const sorted = computed(() => {
return [...applications.value]
.filter((app) => {
if (selectedStatuses.value.length > 1 && !selectedStatuses.value.includes(app.status as Status)) return false
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Apply status filter for a single selection too.
Filtering only when multiple statuses are selected can show stale results until the server refetch completes.

🔧 Suggested fix
-      if (selectedStatuses.value.length > 1 && !selectedStatuses.value.includes(app.status as Status)) return false
+      if (selectedStatuses.value.length > 0 && !selectedStatuses.value.includes(app.status as Status)) return false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (selectedStatuses.value.length > 1 && !selectedStatuses.value.includes(app.status as Status)) return false
if (selectedStatuses.value.length > 0 && !selectedStatuses.value.includes(app.status as Status)) return false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/applications/index.vue` at line 90, The status filter
only runs when more than one status is selected, causing stale results for
single selections; update the filtering condition in the component's filter
logic (where selectedStatuses is used and app.status is checked) to run whenever
any status is selected — e.g., change the guard from
selectedStatuses.value.length > 1 to selectedStatuses.value.length > 0 (so: if
(selectedStatuses.value.length > 0 &&
!selectedStatuses.value.includes(app.status as Status)) return false) ensuring
single-selection filters are applied immediately.

Comment on lines +112 to +116
const activeFilterCount = computed(() => {
let n = selectedStatuses.value.length
if (scoreMin.value != null) n++
if (scoreMax.value != null) n++
return n
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Vue 3.5 v-model.number cleared input empty string behavior

💡 Result:

In Vue 3.5, v-model.number will still produce an empty string ('') when the user clears the input.

Reason: the .number modifier casts using parseFloat(). If parsing fails, Vue falls back to the original string value—and for a cleared field that original value is '' (this is explicitly documented, and noted as different from the DOM valueAsNumber behavior). [1]

Common workaround (if you want null instead of ''):

<input
  :value="modelValue ?? ''"
  `@input`="modelValue = $event.target.value === '' ? null : Number($event.target.value)"
>

Source: Vue Form Input Bindings docs (v-model modifiers → .number). [1]

References

[1] Vue.js docs – Form Input Bindings / .number modifier https://vuejs.org/guide/essentials/forms.html


🏁 Script executed:

find . -name "candidates.vue" -type f

Repository: applirank/applirank

Length of output: 109


🏁 Script executed:

wc -l ./app/pages/dashboard/jobs/[id]/candidates.vue

Repository: applirank/applirank

Length of output: 113


🏁 Script executed:

sed -n '1,50p' ./app/pages/dashboard/jobs/[id]/candidates.vue | head -40

Repository: applirank/applirank

Length of output: 1457


🏁 Script executed:

sed -n '110,120p' ./app/pages/dashboard/jobs/[id]/candidates.vue

Repository: applirank/applirank

Length of output: 366


🏁 Script executed:

sed -n '150,160p' ./app/pages/dashboard/jobs/[id]/candidates.vue

Repository: applirank/applirank

Length of output: 519


🏁 Script executed:

sed -n '340,370p' ./app/pages/dashboard/jobs/[id]/candidates.vue

Repository: applirank/applirank

Length of output: 1962


🏁 Script executed:

sed -n '386,400p' ./app/pages/dashboard/jobs/[id]/candidates.vue

Repository: applirank/applirank

Length of output: 629


Normalize score inputs so clearing doesn't apply a 0 filter.

v-model.number yields '' when cleared, which fails the != null checks and gets coerced to 0 in comparisons. This causes cleared score filters to remain active and filter unexpectedly. Coerce '' and NaN to undefined via a proxy computed.

🛠️ Suggested fix
 const scoreMin = useState<number | undefined>(`cand-filter-score-min-${jobId}`, () => undefined)
 const scoreMax = useState<number | undefined>(`cand-filter-score-max-${jobId}`, () => undefined)
+const normalizeScore = (val: unknown) => {
+  if (val === '' || val == null) return undefined
+  const n = Number(val)
+  return Number.isFinite(n) ? n : undefined
+}
+const scoreMinInput = computed({
+  get: () => scoreMin.value ?? '',
+  set: val => { scoreMin.value = normalizeScore(val) },
+})
+const scoreMaxInput = computed({
+  get: () => scoreMax.value ?? '',
+  set: val => { scoreMax.value = normalizeScore(val) },
+})
-                <input
-                  v-model.number="scoreMin"
+                <input
+                  v-model="scoreMinInput"
                   type="number"
                   min="0"
                   max="100"
                   placeholder="Min"
-                <input
-                  v-model.number="scoreMax"
+                <input
+                  v-model="scoreMaxInput"
                   type="number"
                   min="0"
                   max="100"
                   placeholder="Max"

Also applies to: 153-154, 392-397

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/`[id]/candidates.vue around lines 112 - 116, The
computed activeFilterCount is using raw scoreMin/scoreMax which can be '' (from
v-model.number) or NaN and still pass the != null checks, so create proxy
computed properties (e.g., normalizedScoreMin and normalizedScoreMax) that
return undefined when the source is '' or Number.isNaN(value) and otherwise
return the numeric value; then update activeFilterCount and any other logic that
reads scoreMin/scoreMax (including the other places noted around the component)
to use normalizedScoreMin/normalizedScoreMax instead of scoreMin/scoreMax so
cleared inputs no longer act as a 0 filter.

Comment on lines +151 to +152
.filter((app) => {
if (selectedStatuses.value.length > 1 && !selectedStatuses.value.includes(app.status as Status)) return false
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Apply status filter even for a single selection.
Relying solely on the API can show stale rows until refetch completes; filtering any non‑empty selection keeps the UI consistent.

🔧 Suggested fix
-      if (selectedStatuses.value.length > 1 && !selectedStatuses.value.includes(app.status as Status)) return false
+      if (selectedStatuses.value.length > 0 && !selectedStatuses.value.includes(app.status as Status)) return false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.filter((app) => {
if (selectedStatuses.value.length > 1 && !selectedStatuses.value.includes(app.status as Status)) return false
.filter((app) => {
if (selectedStatuses.value.length > 0 && !selectedStatuses.value.includes(app.status as Status)) return false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/jobs/`[id]/candidates.vue around lines 151 - 152, The
status filter currently only applies when selectedStatuses.value.length > 1,
which skips filtering for a single selection; change the condition in the
.filter callback to apply whenever selectedStatuses.value is non-empty (e.g.,
selectedStatuses.value.length > 0) so that if selectedStatuses.value does not
include app.status (app.status as Status) the row is filtered out locally to
keep the UI consistent until API refetch completes.

@JoachimLK JoachimLK merged commit cb42113 into main Feb 24, 2026
4 checks passed
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