Skip to content

Commit ab0042a

Browse files
accorvinclaudegithub-actions[bot]
authored
feat(team-tracker): spreadsheet-style column filters on People Directory (#14)
* feat(team-tracker): spreadsheet-style column filters on People Directory Replace the filter bar above the table with inline column header filters. Each filterable column (Org, Title, Geo, Location, Teams, custom fields) now has a small filter icon in its header that opens a multi-select dropdown — similar to spreadsheet column filtering. - Add ColumnHeaderFilter component for compact in-header filtering - Add MultiSelectDropdown component for standalone filter controls - Move Org Membership toggle above the table (only pre-table filter) - Add "Not set" option to all filters for finding empty values - Add "Clear all filters" button when any column filter is active - Rename "Type" → "Org Membership", "Non-Eng" badge → "Extended" - Add info tooltip explaining Org Member vs Extended distinction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: consolidate NOT_SET constant and fix forward reference - Extract NOT_SET constant to field-helpers.js to eliminate duplication across ColumnHeaderFilter and MultiSelectDropdown - Move filteredStats computed below filtered to fix const forward reference Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
1 parent 872488f commit ab0042a

6 files changed

Lines changed: 495 additions & 177 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<script setup>
2+
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
3+
import { NOT_SET } from '../utils/field-helpers.js'
4+
5+
const props = defineProps({
6+
options: { type: Array, default: () => [] },
7+
modelValue: { type: Array, default: () => [] },
8+
showNotSet: { type: Boolean, default: false }
9+
})
10+
11+
const emit = defineEmits(['update:modelValue'])
12+
13+
const open = ref(false)
14+
const wrapperRef = ref(null)
15+
const buttonRef = ref(null)
16+
const panelRef = ref(null)
17+
const searchQuery = ref('')
18+
const panelStyle = ref({})
19+
20+
const selectedSet = computed(() => new Set(props.modelValue))
21+
const hasFilter = computed(() => props.modelValue.length > 0)
22+
23+
const filteredOptions = computed(() => {
24+
if (!searchQuery.value) return props.options
25+
const q = searchQuery.value.toLowerCase()
26+
return props.options.filter(o => String(o).toLowerCase().includes(q))
27+
})
28+
29+
function toggle(val) {
30+
const next = selectedSet.value.has(val)
31+
? props.modelValue.filter(v => v !== val)
32+
: [...props.modelValue, val]
33+
emit('update:modelValue', next)
34+
}
35+
36+
function clearAll() {
37+
emit('update:modelValue', [])
38+
}
39+
40+
async function toggleOpen() {
41+
open.value = !open.value
42+
if (open.value) {
43+
await nextTick()
44+
positionPanel()
45+
}
46+
}
47+
48+
function positionPanel() {
49+
if (!buttonRef.value) return
50+
const rect = buttonRef.value.getBoundingClientRect()
51+
panelStyle.value = {
52+
position: 'fixed',
53+
top: `${rect.bottom + 4}px`,
54+
left: `${rect.left}px`,
55+
zIndex: 9999
56+
}
57+
}
58+
59+
function handleClickOutside(e) {
60+
if (wrapperRef.value && !wrapperRef.value.contains(e.target) &&
61+
panelRef.value && !panelRef.value.contains(e.target)) {
62+
open.value = false
63+
searchQuery.value = ''
64+
}
65+
}
66+
67+
function handleScroll() {
68+
if (open.value) positionPanel()
69+
}
70+
71+
onMounted(() => {
72+
document.addEventListener('mousedown', handleClickOutside)
73+
window.addEventListener('scroll', handleScroll, true)
74+
})
75+
onBeforeUnmount(() => {
76+
document.removeEventListener('mousedown', handleClickOutside)
77+
window.removeEventListener('scroll', handleScroll, true)
78+
})
79+
</script>
80+
81+
<template>
82+
<div ref="wrapperRef" class="relative inline-flex">
83+
<button
84+
ref="buttonRef"
85+
type="button"
86+
@click.stop="toggleOpen"
87+
class="p-0.5 rounded transition-colors"
88+
:class="hasFilter
89+
? 'text-primary-600 dark:text-primary-400'
90+
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'"
91+
>
92+
<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
93+
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
94+
</svg>
95+
</button>
96+
97+
<Teleport to="body">
98+
<div
99+
v-if="open"
100+
ref="panelRef"
101+
:style="panelStyle"
102+
class="w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg normal-case"
103+
>
104+
<div v-if="options.length > 6" class="p-2 border-b border-gray-200 dark:border-gray-700">
105+
<input
106+
v-model="searchQuery"
107+
type="text"
108+
placeholder="Filter..."
109+
class="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
110+
/>
111+
</div>
112+
<div class="max-h-48 overflow-y-auto py-1">
113+
<label
114+
v-if="showNotSet && !searchQuery"
115+
class="flex items-center gap-2 px-2.5 py-1 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer border-b border-gray-100 dark:border-gray-700 mb-0.5"
116+
>
117+
<input
118+
type="checkbox"
119+
:checked="selectedSet.has(NOT_SET)"
120+
@change="toggle(NOT_SET)"
121+
class="rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500 h-3.5 w-3.5"
122+
/>
123+
<span class="text-xs text-gray-400 dark:text-gray-500 italic flex-1">Not set</span>
124+
</label>
125+
<label
126+
v-for="opt in filteredOptions"
127+
:key="opt"
128+
class="flex items-center gap-2 px-2.5 py-1 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
129+
>
130+
<input
131+
type="checkbox"
132+
:checked="selectedSet.has(opt)"
133+
@change="toggle(opt)"
134+
class="rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500 h-3.5 w-3.5"
135+
/>
136+
<span class="text-xs text-gray-700 dark:text-gray-300 flex-1 truncate">{{ opt }}</span>
137+
</label>
138+
<div v-if="filteredOptions.length === 0 && !(showNotSet && !searchQuery)" class="px-2.5 py-1.5 text-xs text-gray-400 dark:text-gray-500">No matches</div>
139+
</div>
140+
<div v-if="hasFilter" class="border-t border-gray-200 dark:border-gray-700 px-2.5 py-1">
141+
<button @click="clearAll" class="text-xs text-primary-600 dark:text-primary-400 hover:underline">Clear</button>
142+
</div>
143+
</div>
144+
</Teleport>
145+
</div>
146+
</template>
Lines changed: 17 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup>
2-
import { ref } from 'vue'
2+
import MultiSelectDropdown from './MultiSelectDropdown.vue'
33
44
const props = defineProps({
55
fieldDefinitions: { type: Array, default: () => [] },
@@ -9,75 +9,29 @@ const props = defineProps({
99
1010
const emit = defineEmits(['update:filter', 'clear:filter', 'clear:all'])
1111
12-
const collapsed = ref({})
13-
14-
function toggle(fieldId) {
15-
collapsed.value = { ...collapsed.value, [fieldId]: !collapsed.value[fieldId] }
16-
}
17-
18-
function isSelected(fieldId, value) {
19-
return (props.activeFilters[fieldId] || []).includes(value)
20-
}
21-
22-
function toggleValue(fieldId, value) {
23-
const current = props.activeFilters[fieldId] || []
24-
const values = isSelected(fieldId, value)
25-
? current.filter(v => v !== value)
26-
: [...current, value]
12+
function onUpdate(fieldId, values) {
2713
emit('update:filter', { fieldId, values })
2814
}
2915
30-
function activeCount(fieldId) {
31-
return (props.activeFilters[fieldId] || []).length
32-
}
33-
3416
function hasAnyFilter() {
3517
return Object.values(props.activeFilters).some(v => v && v.length > 0)
3618
}
3719
</script>
3820

3921
<template>
40-
<div v-if="fieldDefinitions.length > 0" class="space-y-2">
41-
<div v-for="field in fieldDefinitions" :key="field.id">
42-
<button
43-
class="flex items-center justify-between w-full text-left"
44-
@click="toggle(field.id)"
45-
>
46-
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
47-
{{ field.label }}
48-
<span v-if="activeCount(field.id) > 0" class="text-primary-600 dark:text-primary-400 normal-case ml-1">
49-
({{ activeCount(field.id) }} selected)
50-
</span>
51-
</label>
52-
<svg
53-
class="h-3.5 w-3.5 text-gray-400 dark:text-gray-500 transition-transform"
54-
:class="{ 'rotate-180': !collapsed[field.id] }"
55-
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
56-
>
57-
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
58-
</svg>
59-
</button>
60-
<div v-if="!collapsed[field.id]" class="space-y-1 mt-1 max-h-48 overflow-y-auto">
61-
<label
62-
v-for="opt in (field.allowedValues || [])"
63-
:key="opt"
64-
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
65-
>
66-
<input
67-
type="checkbox"
68-
:checked="isSelected(field.id, opt)"
69-
@change="toggleValue(field.id, opt)"
70-
class="rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500"
71-
/>
72-
<span class="text-sm text-gray-700 dark:text-gray-300 flex-1">{{ opt }}</span>
73-
<span class="text-xs text-gray-400 dark:text-gray-500">{{ (filterCounts[field.id] || {})[opt] || 0 }}</span>
74-
</label>
75-
</div>
76-
</div>
77-
<button
78-
v-if="hasAnyFilter()"
79-
class="text-xs text-primary-600 dark:text-primary-400 hover:underline"
80-
@click="emit('clear:all')"
81-
>Clear all filters</button>
82-
</div>
22+
<template v-for="field in fieldDefinitions" :key="field.id">
23+
<MultiSelectDropdown
24+
:label="field.label"
25+
:options="field.allowedValues || []"
26+
:model-value="activeFilters[field.id] || []"
27+
:counts="filterCounts[field.id] || {}"
28+
show-not-set
29+
@update:model-value="onUpdate(field.id, $event)"
30+
/>
31+
</template>
32+
<button
33+
v-if="hasAnyFilter()"
34+
class="text-xs text-primary-600 dark:text-primary-400 hover:underline self-center"
35+
@click="emit('clear:all')"
36+
>Clear field filters</button>
8337
</template>
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<script setup>
2+
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
3+
import { NOT_SET } from '../utils/field-helpers.js'
4+
5+
const props = defineProps({
6+
label: { type: String, required: true },
7+
options: { type: Array, default: () => [] },
8+
modelValue: { type: Array, default: () => [] },
9+
optionLabel: { type: Function, default: (o) => o },
10+
optionValue: { type: Function, default: (o) => o },
11+
counts: { type: Object, default: null },
12+
showNotSet: { type: Boolean, default: false },
13+
info: { type: String, default: '' }
14+
})
15+
16+
const showInfo = ref(false)
17+
18+
const emit = defineEmits(['update:modelValue'])
19+
20+
const open = ref(false)
21+
const dropdownRef = ref(null)
22+
const searchQuery = ref('')
23+
24+
const selectedSet = computed(() => new Set(props.modelValue))
25+
26+
const filteredOptions = computed(() => {
27+
if (!searchQuery.value) return props.options
28+
const q = searchQuery.value.toLowerCase()
29+
return props.options.filter(o => String(props.optionLabel(o)).toLowerCase().includes(q))
30+
})
31+
32+
function toggle(optValue) {
33+
const current = props.modelValue
34+
const next = selectedSet.value.has(optValue)
35+
? current.filter(v => v !== optValue)
36+
: [...current, optValue]
37+
emit('update:modelValue', next)
38+
}
39+
40+
function clearAll() {
41+
emit('update:modelValue', [])
42+
}
43+
44+
function handleClickOutside(e) {
45+
if (dropdownRef.value && !dropdownRef.value.contains(e.target)) {
46+
open.value = false
47+
searchQuery.value = ''
48+
}
49+
}
50+
51+
onMounted(() => document.addEventListener('mousedown', handleClickOutside))
52+
onBeforeUnmount(() => document.removeEventListener('mousedown', handleClickOutside))
53+
</script>
54+
55+
<template>
56+
<div ref="dropdownRef" class="relative flex items-center gap-1">
57+
<button
58+
type="button"
59+
@click="open = !open"
60+
class="flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors"
61+
:class="modelValue.length > 0
62+
? 'bg-primary-50 dark:bg-primary-900/30 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-300'
63+
: 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'"
64+
>
65+
<span>{{ label }}</span>
66+
<span
67+
v-if="modelValue.length > 0"
68+
class="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-medium bg-primary-600 text-white"
69+
>{{ modelValue.length }}</span>
70+
<svg class="h-3.5 w-3.5 ml-0.5 transition-transform" :class="{ 'rotate-180': open }" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
71+
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
72+
</svg>
73+
</button>
74+
<div v-if="info" class="relative self-start">
75+
<button
76+
type="button"
77+
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
78+
@mouseenter="showInfo = true"
79+
@mouseleave="showInfo = false"
80+
@click.stop="showInfo = !showInfo"
81+
>
82+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
83+
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
84+
</svg>
85+
</button>
86+
<div
87+
v-if="showInfo"
88+
class="absolute z-50 bottom-full mb-2 left-1/2 -translate-x-1/2 w-64 p-2.5 text-xs text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg"
89+
>
90+
{{ info }}
91+
<div class="absolute top-full left-1/2 -translate-x-1/2 -mt-px">
92+
<div class="border-4 border-transparent border-t-gray-200 dark:border-t-gray-700"></div>
93+
</div>
94+
</div>
95+
</div>
96+
97+
<div
98+
v-if="open"
99+
class="absolute z-50 top-full mt-1 left-0 w-64 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg"
100+
>
101+
<div v-if="options.length > 6" class="p-2 border-b border-gray-200 dark:border-gray-700">
102+
<input
103+
v-model="searchQuery"
104+
type="text"
105+
placeholder="Search..."
106+
class="w-full px-2.5 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
107+
/>
108+
</div>
109+
<div class="max-h-60 overflow-y-auto py-1">
110+
<label
111+
v-if="showNotSet && !searchQuery"
112+
class="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer border-b border-gray-100 dark:border-gray-700 mb-0.5"
113+
>
114+
<input
115+
type="checkbox"
116+
:checked="selectedSet.has(NOT_SET)"
117+
@change="toggle(NOT_SET)"
118+
class="rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500"
119+
/>
120+
<span class="text-sm text-gray-400 dark:text-gray-500 italic flex-1">Not set</span>
121+
<span v-if="counts" class="text-xs text-gray-400 dark:text-gray-500">{{ counts[NOT_SET] || 0 }}</span>
122+
</label>
123+
<label
124+
v-for="opt in filteredOptions"
125+
:key="optionValue(opt)"
126+
class="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
127+
>
128+
<input
129+
type="checkbox"
130+
:checked="selectedSet.has(optionValue(opt))"
131+
@change="toggle(optionValue(opt))"
132+
class="rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500"
133+
/>
134+
<span class="text-sm text-gray-700 dark:text-gray-300 flex-1 truncate">{{ optionLabel(opt) }}</span>
135+
<span v-if="counts" class="text-xs text-gray-400 dark:text-gray-500">{{ counts[optionValue(opt)] || 0 }}</span>
136+
</label>
137+
<div v-if="filteredOptions.length === 0 && !(showNotSet && !searchQuery)" class="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">No matches</div>
138+
</div>
139+
<div v-if="modelValue.length > 0" class="border-t border-gray-200 dark:border-gray-700 px-3 py-1.5">
140+
<button @click="clearAll" class="text-xs text-primary-600 dark:text-primary-400 hover:underline">Clear all</button>
141+
</div>
142+
</div>
143+
</div>
144+
</template>

0 commit comments

Comments
 (0)