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

Download Faculty Leaves Planning Report tabs as spreadsheet #113

Merged
merged 47 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
bfbe0f0
starting with naive direct table download as xlsx
jxjj Feb 22, 2024
2a3727c
add jest for testing
jxjj Feb 22, 2024
d031a9a
fix missing jobCode
jxjj Feb 22, 2024
823fb8f
fix missing courseCode
jxjj Feb 22, 2024
28395ac
type fixes
jxjj Feb 22, 2024
763bb1f
jest setup
jxjj Feb 22, 2024
2babd38
fix: classNumber should be returned even if null
jxjj Feb 22, 2024
01ed6bc
transform lookup data to person table data
jxjj Feb 22, 2024
91c7d23
downloadable person table
jxjj Feb 23, 2024
1a3962d
apply course type and level filters to export
jxjj Feb 23, 2024
5c36e09
filter people by academic appointment
jxjj Feb 23, 2024
83dcb1f
apply term filters
jxjj Feb 23, 2024
b18b4ab
filter for published state and planning mode
jxjj Feb 23, 2024
c49fb79
filter people with no enrollments or leaves
jxjj Feb 23, 2024
1ea4f8b
use filters from store in `usePersonTableData` composeable
jxjj Feb 23, 2024
1a6815f
filter for enrollment role
jxjj Feb 23, 2024
2b3a67b
only include rows with enrollments (not leaves)
jxjj Feb 23, 2024
74b2b9b
include planned sections in download when planning mode is checked
jxjj Feb 23, 2024
b2d3fcd
this too
jxjj Feb 23, 2024
a7d43b3
fix test
jxjj Feb 23, 2024
7f608f9
run unit tests
jxjj Feb 23, 2024
d1ef912
oops
jxjj Feb 23, 2024
1367644
fix tests: don't set csrf token until axios is invoked
jxjj Feb 26, 2024
a7a82b0
remove unused
jxjj Feb 26, 2024
3052644
add download spreadsheet button
jxjj Feb 26, 2024
41ca23e
use download spreadsheet button
jxjj Feb 26, 2024
ecf5f34
fix getLeavesByTermId
jxjj Feb 27, 2024
7183972
extract getJoinedEnrollmentRecord
jxjj Feb 27, 2024
1d587ca
add useCourseTableData composable
jxjj Feb 27, 2024
8fe8374
just warn on empty functions
jxjj Feb 28, 2024
f10400c
shuffle courseplanning files - move to CoursePlanningService
jxjj Feb 28, 2024
e72eafb
course spreadsheet data, move stuff around
jxjj Feb 28, 2024
a6cbbe3
move spreadsheet data from computed to regular methods
jxjj Feb 29, 2024
0187069
include person name and emplid with leave info on course tab
jxjj Feb 29, 2024
240636f
cleanup
jxjj Feb 29, 2024
8ba7d77
cleanup
jxjj Feb 29, 2024
b26f70e
apply filters to course table
jxjj Feb 29, 2024
ef490d7
fix leaves not appearing in table
jxjj Feb 29, 2024
6b969ee
add id to joined enrollment record
jxjj Feb 29, 2024
5589f7d
refactor visibleTerms
jxjj Feb 29, 2024
f2e17ff
fix leave person variant
jxjj Feb 29, 2024
d81fc9c
handle errors that are strings
jxjj Feb 29, 2024
a0e53b7
wip spreadsheet workers
jxjj Feb 29, 2024
b90045e
fix browser lockup for large groups with web workers
jxjj Feb 29, 2024
eadb681
fix: reset filters when group changes
jxjj Feb 29, 2024
a971b2f
Add simulated progress and logging for sheet completion
jxjj Feb 29, 2024
332ad53
remove console.log
jxjj Feb 29, 2024
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
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module.exports = {
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-empty-function": "warn",
"vue/multi-word-component-names": "off",
"vue/attribute-hyphenation": ["error", "never"],
"vue/v-on-event-hyphenation": ["error", "never"],
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ jobs:
- name: Run integration tests with Pest
run: docker-compose exec -T app ./vendor/bin/pest

- name: Run unit tests with Jest
run: yarn test:unit

- name: Run E2E tests with Cypress
uses: cypress-io/github-action@v5
with:
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/CourseSectionResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public function sisSectionToArray($request) {
public function dbSectionToArray($request) {
return [
'id' => $this->getSectionId(),
'classNumber' => null,
'dbId' => $this->id,
'courseId' => $this->course_id,
'termId' => $this->term_id,
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/PersonResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public function toArray($request) {
'title' => $this->title,
'leaveIds' => $this->leaveIds,
'academicAppointment' => Utilities::trimWithFallback($this->jobCategory),
'jobCode' => $this->jobCode,
'emplid' => $this->emplid,
'sslEligible' => $this->ssl_eligible,
'midcareerEligible' => $this->midcareer_eligible,
Expand Down
14 changes: 14 additions & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
testEnvironment: "jsdom",
testMatch: [
"**/resources/js/**/__tests__/**/*.?(m)[jt]s?(x)",
"**/resources/js/**/?(*.)+(spec|test).?(m)[tj]s?(x)",
],
preset: "ts-jest/presets/js-with-ts-esm",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/resources/js/$1",
"^lodash-es$": "lodash",
},
prettierPath: require.resolve("prettier-2"),
};
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"lint": "eslint --ext .js,.vue resources/js",
"lint:watch": "nodemon --exec 'npm run lint' --ext js,vue",
"test": "npm run test:unit && npm run test:e2e",
"test:unit": "jest --passWithNoTests",
"test:e2e": "npm run cypress:headless"
},
"dependencies": {
Expand All @@ -32,10 +33,12 @@
"popper.js": "^1.16.1",
"v-tooltip": "^2.0.3",
"vue": "^3.1.0",
"vue-router": "4"
"vue-router": "4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.6",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.14.195",
"@types/sortablejs": "^1.15.7",
"@typescript-eslint/eslint-plugin": "^5.62.0",
Expand All @@ -48,12 +51,16 @@
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-cypress": "^2.14.0",
"eslint-plugin-vue": "^9.15.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"laravel-vite-plugin": "^0.7.8",
"nodemon": "^3.0.1",
"postcss": "^8.4.27",
"prettier-2": "npm:prettier@^2",
"prettier": "3.0.0",
"sass": "^1.44.0",
"tailwindcss": "^3.3.3",
"ts-jest": "^29.1.2",
"typescript": "^5.1.6",
"vite": "^4.4.3"
}
Expand Down
89 changes: 89 additions & 0 deletions resources/js/components/DownloadSpreadsheetButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<template>
<Button
variant="tertiary"
title="Download Spreadsheet"
@click="handleDownloadClick"
>
<template v-if="downloadStatus === 'idle'">
<DownloadIcon /> <span class="tw-sr-only">Download</span>
</template>
<template v-else-if="downloadStatus === 'loading'">
<Spinner class="tw-w-5 tw-h-5 tw-text-blue-500" />
<span class="tw-sr-only">Loading</span>
{{ Math.round(progress * 100) }}%
</template>
<template v-else-if="downloadStatus === 'complete'">
<CheckIcon /> <span class="tw-sr-only">Complete</span>
</template>
<template v-else>
<CircleXIcon /> <span class="tw-sr-only">Error</span>
</template>
</Button>
</template>
<script setup lang="ts">
import * as T from "@/types";
import { useErrorStore } from "@/stores/useErrorStore";
import Button from "./Button.vue";
import { DownloadIcon } from "@/icons";
import { LoadState } from "@/types";
import { nextTick, ref } from "vue";
import { utils, writeFileXLSX } from "xlsx";
import Spinner from "./Spinner.vue";
import { CheckIcon, CircleXIcon } from "@/icons";
import { useSimulatedProgress } from "@/features/course-planning/helpers/useSimulatedProgress";

const props = defineProps<{
filename: string;
sheetData: T.SpreadsheetData[];
}>();

const downloadStatus = ref<LoadState>("idle");

const errorStore = useErrorStore();
const { progress, simulateUpTo } = useSimulatedProgress();

async function downloadSpreadsheet(): Promise<void> {
const wb = utils.book_new();
for (const sheet of props.sheetData) {
if (typeof sheet.data === "function") {
const complete = simulateUpTo(1 / props.sheetData.length);
sheet.data = await sheet.data();
complete();
}
const ws = utils.json_to_sheet(sheet.data);
utils.book_append_sheet(wb, ws, sheet.sheetName);
}
writeFileXLSX(wb, props.filename);
}

function resetDownloadStatus(delay = 3000): void {
setTimeout(() => {
downloadStatus.value = "idle";
}, delay);
}

async function handleDownloadClick(): Promise<void> {
downloadStatus.value = "loading";

// Wait for the next tick to ensure the loading state is rendered
await nextTick();

// also wrap in a setTimeout to push the download to the end of the
// event loop, allowing the state change to render
setTimeout(async () => {
try {
await downloadSpreadsheet();
downloadStatus.value = "complete";
resetDownloadStatus();
} catch (error) {
console.error(error);
downloadStatus.value = "error";
if (error instanceof Error) {
errorStore.setError(error);
}
resetDownloadStatus(10000);
}
}, 0);
}
</script>
<style scoped></style>
5 changes: 4 additions & 1 deletion resources/js/components/ErrorModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
>
<Notification
:title="errorTitle"
:message="error.name"
type="danger"
:isDismissable="true"
class="tw-w-full tw-max-w-md tw-border-none max-h-[80vh] !overflow-auto"
Expand Down Expand Up @@ -60,6 +59,10 @@ const messages: Record<number | string, string> = {
};

const message = computed(() => {
if (typeof error.value === "string") {
return error.value;
}

if (!(error.value instanceof ApiError)) {
return error.value?.message || "An unknown error occurred.";
}
Expand Down
17 changes: 17 additions & 0 deletions resources/js/components/Table/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
class="tw-shadow-sm tw-ring-1 tw-ring-black tw-ring-opacity-5 sm:tw-rounded-lg tw-overflow-auto tw-max-h-[90vh]"
>
<table
ref="tableElement"
class="better-table tw-min-w-full tw-divide-y tw-divide-gray-300 tw-h-full"
:class="{
'better-table--sticky-header': stickyHeader,
Expand All @@ -16,6 +17,8 @@
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';

withDefaults(
defineProps<{
stickyHeader?: boolean;
Expand All @@ -26,6 +29,20 @@ withDefaults(
stickyFirstColumn: false,
},
);

const tableElement = ref<HTMLTableElement | null>(null);

export interface Exposed {
getTableElement: () => HTMLTableElement | null;
}

function getTableElement() {
return tableElement.value;
}

defineExpose<Exposed>({
getTableElement,
});
</script>
<style>
.better-table.better-table--sticky-header tbody tr {
Expand Down
8 changes: 7 additions & 1 deletion resources/js/components/Table/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
export { default as Table } from "./Table.vue";
import { Exposed as TableExposed } from "./Table.vue";
// import separately so that we can use in the TableType definition
import Table from "./Table.vue";

export { Table };
export { default as TBody } from "./TBody.vue";
export { default as THead } from "./THead.vue";
export { default as Tr } from "./Tr.vue";
export { default as Th } from "./Th.vue";
export { default as Td } from "./Td.vue";

export type TableType = InstanceType<typeof Table> & TableExposed;
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@
import { computed } from "vue";
import SelectGroup from "@/components/SelectGroup.vue";
import Button from "@/components/Button.vue";
import { useRootCoursePlanningStore } from "../stores/useRootCoursePlanningStore";
import { useCoursePlanningStore } from "../stores/useCoursePlanningStore";
import { storeToRefs } from "pinia";

const coursePlanningStore = useRootCoursePlanningStore();
const coursePlanningStore = useCoursePlanningStore();

const { filters, sortedCourseLevels, sortedCourseTypes } =
storeToRefs(coursePlanningStore);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
class="term-col"
:class="{
'tw-bg-striped':
coursePlanningStore.isInPlanningMode &&
coursePlanningStore.filters.inPlanningMode &&
!coursePlanningStore.termsStore.isTermPlannable(term.id),
}"
/>
Expand All @@ -36,7 +36,7 @@ import { Table, TBody, THead } from "@/components/Table";
import CourseTableCourseRow from "./CourseTableCourseRow.vue";
import CourseTableLeavesRow from "./CourseTableLeavesRow.vue";
import ReportTableHeaderRow from "../ReportTableHeaderRow.vue";
import { useRootCoursePlanningStore } from "../../stores/useRootCoursePlanningStore";
import { useCoursePlanningStore } from "../../stores/useCoursePlanningStore";
import { Group } from "@/types";
import { computed } from "vue";

Expand All @@ -45,7 +45,7 @@ defineProps<{
groupId: Group["id"];
}>();

const coursePlanningStore = useRootCoursePlanningStore();
const coursePlanningStore = useCoursePlanningStore();
const courses = computed(() => coursePlanningStore.courseStore.allCourses);
</script>
<style scoped lang="scss">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import * as T from "@/types";
import { useRootCoursePlanningStore } from "../../stores/useRootCoursePlanningStore";
import { useCoursePlanningStore } from "../../stores/useCoursePlanningStore";
import EnrollmentInPublishedSection from "./EnrollmentInPublishedSection.vue";
import EnrollmentInUnpublishedSection from "./EnrollmentInUnpublishedSection.vue";
import { partition } from "lodash";
Expand All @@ -60,7 +60,7 @@ const props = defineProps<{
term: T.Term;
}>();

const coursePlanningStore = useRootCoursePlanningStore();
const coursePlanningStore = useCoursePlanningStore();
const isShowingEditModal = ref(false);

const enrollmentsInCourseByTermLookup = computed(
Expand Down Expand Up @@ -91,7 +91,7 @@ const enrollmentsInUnpublishedSection = computed(() => {

const arePlannedSectionsViewable = computed(() => {
return (
coursePlanningStore.isInPlanningMode &&
coursePlanningStore.filters.inPlanningMode &&
coursePlanningStore.termsStore.isTermPlannable(props.term.id) &&
$can("view planned courses")
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@
<script setup lang="ts">
import { Td } from "@/components/Table";
import CourseTableCell from "./CourseTableCell.vue";
import { useRootCoursePlanningStore } from "../../stores/useRootCoursePlanningStore";
import { useCoursePlanningStore } from "../../stores/useCoursePlanningStore";
import * as T from "@/types";
import { computed } from "vue";

const props = defineProps<{
course: T.Course;
}>();

const coursePlanningStore = useRootCoursePlanningStore();
const coursePlanningStore = useCoursePlanningStore();
const visibleTerms = computed(() => coursePlanningStore.visibleTerms);

const isCourseVisible = computed(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
<template>
<tr
v-if="hasVisibleLeaves"
class="course-table-leaves-row"
:class="{
'course-table-leaves-row--sticky': sticky,
}"
>
<Td> Leaves </Td>
<Td>Leaves</Td>
<Td
v-for="term in coursePlanningStore.visibleTerms"
v-for="{ term, leaves } in termLeaves"
:key="term.id"
class="term-data-column"
:class="{
Expand All @@ -19,8 +18,7 @@
>
<div class="leaves-container tw-flex tw-flex-col tw-gap-1">
<LeaveChip
v-for="leave in getLeavesByTermId(term.id)"
v-show="coursePlanningStore.isPersonVisibleById(leave.user_id)"
v-for="leave in leaves"
:key="leave.id"
:leave="leave"
variant="person"
Expand All @@ -35,10 +33,10 @@
<script setup lang="ts">
import LeaveChip from "../LeaveChip.vue";
import { Td } from "@/components/Table";
import type { Term, Group } from "@/types";
import { useRootCoursePlanningStore } from "../../stores/useRootCoursePlanningStore";
import type { Group } from "@/types";
import { useCoursePlanningStore } from "../../stores/useCoursePlanningStore";
import { computed } from "vue";
// import { doesInstructorNameMatchSearchTerm } from "./doesInstructorNameMatchSearchTerm";
import { getListOfTermLeaves } from "../../helpers/getListOfTermLeaves";

withDefaults(
defineProps<{
Expand All @@ -50,15 +48,16 @@ withDefaults(
},
);

const coursePlanningStore = useRootCoursePlanningStore();
const getLeavesByTermId = computed(
() => coursePlanningStore.leaveStore.getLeavesByTermId,
);
const coursePlanningStore = useCoursePlanningStore();

const termLeaves = computed(() => {
const lookups = coursePlanningStore.getCoursePlanningLookups();
const filters = coursePlanningStore.getCoursePlanningFilters();

const hasVisibleLeaves = computed(() => {
return coursePlanningStore.leaveStore.leaves.some((leave) =>
coursePlanningStore.isPersonVisibleById(leave.user_id),
);
return getListOfTermLeaves({
lookups,
filters,
});
});
</script>
<style scoped>
Expand Down
Loading
Loading