Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Filtering to the Roster Table #448

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions src/views/pages/RosterPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<div v-else-if="fetched && error !== ''">
<div class="m-3">Error getting roster: {{ error }}</div>
</div>
<div v-else><RosterTable :roster="activeControllers"></RosterTable></div>
<div v-else><RosterTable :roster="activeControllers" :certifications="certifications"></RosterTable></div>
</div>
</div>
</template>
Expand All @@ -22,7 +22,7 @@ import useRosterStore from "@/stores/roster";
const fetched = ref(false);
const error = ref("");
const rosterStore = useRosterStore();
const { controllers } = storeToRefs(rosterStore);
const { controllers, certifications } = storeToRefs(rosterStore);

const activeControllers = computed(() => controllers.value.filter((c) => c.controller_type !== "none"));

Expand Down
10 changes: 8 additions & 2 deletions src/views/partials/roster/RosterTable.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<template>
<RosterFilterPanel v-model="filteredRoster" :roster="roster" :certifications="certifications" />
<div>
<table
v-for="(controller, index) in props.roster"
v-for="(controller, index) in filteredRoster"
:key="controller.cid"
class="w-full cursor-pointer"
:class="{ 'bg-slate-100 dark:bg-slate-800': (index - 1) % 2, 'dark:bg-slate-900 bg-slate-50': index % 2 }"
Expand Down Expand Up @@ -37,15 +38,20 @@
<script setup lang="ts">
import { useRouter } from "vue-router";

import type { Controller } from "@/types";
import type { CertificationItem, Controller } from "@/types";
import ControllerCertificationBadges from "@/components/ControllerCertificationBadges.vue";
import { getControllerTitle } from "@/utils/helpers";
import RosterFilterPanel from "@/views/partials/roster/filtering/RosterFilterPanel.vue";
import { ref } from "vue";

const router = useRouter();
const props = defineProps<{
roster: Controller[];
certifications: CertificationItem[];
}>();

const filteredRoster = ref(props.roster);

const goToController = (cid: number): void => {
router.push(`/roster/${cid}`);
};
Expand Down
155 changes: 155 additions & 0 deletions src/views/partials/roster/filtering/RosterFilterPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<template>
<div class="p-4 dark:bg-slate-900 bg-slate-50">
<!-- Input Box Filter -->
<div class="flex flex-col items-start pb-3">
<div class="flex justify-between items-center pb-3 w-full">
<label for="search" class="text-lg font-medium text-gray-700 dark:text-white">Search</label>
<button
@click="clearFilters"
class="text-sm dark:text-white text-gray-600 hover:text-gray-800 hover:underline"
v-if="isDirty"
>
Clear Filters
</button>
</div>
<input
id="search"
v-model="search"
type="text"
class="rounded-md border border-gray-500 dark:border-gray-100 p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-black-deep dark:text-white"
placeholder="Search for a controller..."
@input="filterAndEmit"
/>
</div>

<!-- Dropdown Filters -->
<div class="flex flex-wrap space-x-4">
<!-- Generate dropdown for each filter option -->
<div
v-for="(filter, filterKey) in filters"
:key="filterKey"
class="flex flex-col items-start space-y-1 space-x-1 mb-4 flex-grow"
>
<label :for="`filter_${filterKey}`" class="text-lg capitalize font-medium text-gray-700 dark:text-white">
{{ filter.label }}
</label>
<select
:id="`filter_${filterKey}`"
v-model="filter.value"
class="rounded-md border border-gray-500 dark:border-gray-100 p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-black-deep dark:text-white"
@change="filterAndEmit"
>
<option value="">Select {{ filter.label }}</option>
<option v-for="option in filter.options" :key="option" :value="option">
{{ option }}
</option>
</select>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";
import { CertificationItem, Controller } from "@/types";
import { useSessionStorage } from "@vueuse/core";

// Props
const props = defineProps<{ roster: Controller[]; modelValue: Controller[]; certifications: CertificationItem[] }>();

// Refs
const search = ref("");
const isDirty = ref(false);
const filterSessionState = useSessionStorage("roster-search", {
search: "",
filters: props.certifications.map((cert) => ({ label: cert.display_name, value: "" })),
} as FilterSessionState);
const filters: Filter[] = [
{
label: "Rating",
filterFunction: (controller: Controller, value: string) => controller.rating === value,
options: ["OBS", "S1", "S2", "S3", "C1", "C2", "I1", "I2", "I3", "SUP", "ADM"],
value: "",
},
{
label: "Certification",
filterFunction: (controller: Controller, value: string) => {
return Object.values(controller.certifications).some(
(cert) => cert.display_name === value && cert.value !== "none"
);
},
options: props.certifications.map((cert) => cert.display_name),
value: "",
},
{
label: "Type",
filterFunction: (controller: Controller, value: string) =>
controller.controller_type.toLowerCase() === value.toLowerCase(),
options: ["Home", "Visitor"],
value: "",
},
];

// Emits
const emit = defineEmits(["update:modelValue"]);

/**
* Clear all filters and search
*/
function clearFilters(): void {
search.value = ""; // Reset search
filters.forEach((filter) => (filter.value = "")); // Reset each filter value
filterAndEmit(); // Emit changes
filterSessionState.value = { search: "", filters: filters }; // Update session storage
}

/**
* Apply filters and emit the filtered roster
*/
function filterAndEmit(): void {
const searchFiltered = props.roster.filter((controller) => {
return `${controller.operating_initials} ${controller.first_name} ${controller.last_name}`
.toLowerCase()
.includes(search.value.toLowerCase());
});

const dropdownFiltered = searchFiltered.filter((controller) => {
return filters.every((filter) => {
return filter.value === "" || filter.filterFunction(controller, filter.value);
});
});

filterSessionState.value = { search: search.value, filters: filters };

isDirty.value = search.value !== "" || filters.some((filter) => filter.value !== "");

emit("update:modelValue", dropdownFiltered);
}

// Lifecycle
onMounted(() => {
search.value = filterSessionState.value.search;
filters.forEach((filter, index) => {
filter.value = filterSessionState.value.filters[index].value;
});
filterAndEmit();
});

/**
* Filter configuration for the dropdowns.
*/
type Filter = {
label: string;
filterFunction: (controller: Controller, value: string) => boolean;
options: string[];
value: string;
};

/**
* Filter session state. Used to store the search and filter values in session storage.
*/
type FilterSessionState = {
search: string;
filters: Filter[];
};
</script>