From 29e259cabbf6d75c7008d748e8f6e89e5bd09d71 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 6 Mar 2025 23:44:45 +0100
Subject: [PATCH] Speed TCC raster up by refactoring components and using
 v-lazy

---
 .../TimeboundCourseConfigRaster.vue           | 330 ++++++++++--------
 .../TimeboundCourseConfigRasterCell.vue       | 121 +++++++
 2 files changed, 299 insertions(+), 152 deletions(-)
 create mode 100644 aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 9eb6360..af32206 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -1,157 +1,187 @@
 <script setup>
-import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
 import ValidityRangeField from "../validity_range/ValidityRangeField.vue";
 import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
-import TeacherField from "aleksis.apps.cursus/components/TeacherField.vue"
+import TimeboundCourseConfigRasterCell from "./TimeboundCourseConfigRasterCell.vue";
 </script>
 
 <template>
-  <div>
-    <v-data-table
-      disable-sort
-      disable-filtering
-      disable-pagination
-      hide-default-footer
-      :headers="headers"
-      :items="items"
-      :loading="loading"
-    >
-      <template #top>
-        <v-row>
-          <v-col
-            cols="6"
-            lg="3"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <v-autocomplete
-              outlined
-              filled
-              multiple
-              hide-details
-              :items="groupsForPlanning"
-              item-text="shortName"
-              item-value="id"
-              return-object
-              :disabled="$apollo.queries.groupsForPlanning.loading"
-              :label="$t('lesrooster.timebound_course_config.groups')"
-              :loading="$apollo.queries.groupsForPlanning.loading"
-              v-model="selectedGroups"
-              class="mr-4"
-            />
-          </v-col>
+  <v-data-table
+    disable-sort
+    disable-filtering
+    disable-pagination
+    hide-default-footer
+    :headers="headers"
+    :items="items"
+    :loading="loading"
+  >
+    <template #top>
+      <v-row>
+        <v-col
+          cols="6"
+          lg="3"
+          class="d-flex justify-space-between flex-wrap align-center"
+        >
+          <v-autocomplete
+            outlined
+            filled
+            multiple
+            hide-details
+            :items="groupsForPlanning"
+            item-text="shortName"
+            item-value="id"
+            return-object
+            :disabled="$apollo.queries.groupsForPlanning.loading"
+            :label="$t('lesrooster.timebound_course_config.groups')"
+            :loading="$apollo.queries.groupsForPlanning.loading"
+            v-model="selectedGroups"
+            class="mr-4"
+          />
+        </v-col>
 
-          <v-col
-            cols="6"
-            lg="3"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <validity-range-field
-              outlined
-              filled
-              hide-details
-              v-model="internalValidityRange"
-              :loading="$apollo.queries.currentValidityRange.loading"
-            />
-          </v-col>
+        <v-col
+          cols="6"
+          lg="3"
+          class="d-flex justify-space-between flex-wrap align-center"
+        >
+          <validity-range-field
+            outlined
+            filled
+            hide-details
+            v-model="internalValidityRange"
+            :loading="$apollo.queries.currentValidityRange.loading"
+          />
+        </v-col>
 
-          <v-col
-            cols="6"
-            lg="2"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <v-switch
-              v-model="includeChildGroups"
-              inset
-              :label="
-                $t(
-                  'lesrooster.timebound_course_config.filters.include_child_groups',
-                )
-              "
-              :loading="$apollo.queries.subjects.loading"
-            ></v-switch>
-          </v-col>
+        <v-col
+          cols="6"
+          lg="2"
+          class="d-flex justify-space-between flex-wrap align-center"
+        >
+          <v-switch
+            v-model="includeChildGroups"
+            inset
+            :label="
+              $t(
+                'lesrooster.timebound_course_config.filters.include_child_groups',
+              )
+            "
+            :loading="$apollo.queries.subjects.loading"
+          ></v-switch>
+        </v-col>
 
-          <v-spacer />
-        </v-row>
-      </template>
+        <v-spacer />
+      </v-row>
+    </template>
 
-      <!-- eslint-disable-next-line vue/valid-v-slot -->
-      <template #item.subject="{ item, value }">
-        <subject-chip v-if="value" :subject="value" />
-      </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #item.subject="{ item, value }">
+      <subject-chip v-if="value" :subject="value" />
+    </template>
 
-      <template
-        v-for="(groupHeader, index) in groupHeaders"
-        #[tableItemSlotName(groupHeader)]="{ item, value, header }"
+    <template
+      v-for="(groupHeader, index) in groupHeaders"
+      #[tableItemSlotName(groupHeader)]="{ item, value, header }"
+    >
+      <timebound-course-config-raster-cell
+        :value="value"
+        :subject="item.subject"
+        :header="header"
+        :loading="loading"
+        @addCourse="addCourse"
+        @setCourseConfigData="setCourseConfigData"
+      />
+    </template>
+  </v-data-table>
+
+  <!-- <div>
+    <v-row>
+      <v-col
+        cols="6"
+        lg="3"
+        class="d-flex justify-space-between flex-wrap align-center"
+      >
+        <v-autocomplete
+          outlined
+          filled
+          multiple
+          hide-details
+          :items="groupsForPlanning"
+          item-text="shortName"
+          item-value="id"
+          return-object
+          :disabled="$apollo.queries.groupsForPlanning.loading"
+          :label="$t('lesrooster.timebound_course_config.groups')"
+          :loading="$apollo.queries.groupsForPlanning.loading"
+          v-model="selectedGroups"
+          class="mr-4"
+        />
+      </v-col>
+
+      <v-col
+        cols="6"
+        lg="3"
+        class="d-flex justify-space-between flex-wrap align-center"
       >
-        <div :key="index">
-          <div v-if="value.length">
-            <v-row
-              v-for="(course, index) in value"
-              :key="index"
-              no-gutters
-              class="mt-2"
-            >
-              <v-col cols="6">
-                <positive-small-integer-field
-                  dense
-                  filled
-                  class="mx-1"
-                  :disabled="loading"
-                  :value="
-                    getCurrentCourseConfig(course)
-                      ? getCurrentCourseConfig(course).lessonQuota
-                      : course.lessonQuota
-                  "
-                  :label="$t('lesrooster.timebound_course_config.lesson_quota')"
-                  @change="
-                    (event) =>
-                      setCourseConfigData(course, item.subject, header, {
-                        lessonQuota: event,
-                      })
-                  "
-                />
-              </v-col>
-              <v-col cols="6">
-                <teacher-field
-                  dense
-                  filled
-                  class="mx-1"
-                  :disabled="loading"
-                  :label="$t('lesrooster.timebound_course_config.teachers')"
-                  :value="
-                    getCurrentCourseConfig(course)
-                      ? getCurrentCourseConfig(course).teachers
-                      : course.teachers
-                  "
-                  :show-subjects="true"
-                  :priority-subject="item.subject"
-                  :rules="$rules().isNonEmpty.build()"
-                  @input="
-                    (event) =>
-                      setCourseConfigData(course, item.subject, header, {
-                        teachers: event,
-                      })
-                  "
-                />
-              </v-col>
-            </v-row>
-          </div>
-          <div v-if="!value.length">
-            <v-btn
-              block
-              icon
-              tile
-              outlined
-              @click="addCourse(item.subject.id, header.value)"
-            >
-              <v-icon>mdi-plus</v-icon>
-            </v-btn>
-          </div>
-        </div>
-      </template>
-    </v-data-table>
-  </div>
+        <validity-range-field
+          outlined
+          filled
+          hide-details
+          v-model="internalValidityRange"
+          :loading="$apollo.queries.currentValidityRange.loading"
+        />
+      </v-col>
+
+      <v-col
+        cols="6"
+        lg="2"
+        class="d-flex justify-space-between flex-wrap align-center"
+      >
+        <v-switch
+          v-model="includeChildGroups"
+          inset
+          :label="
+            $t(
+              'lesrooster.timebound_course_config.filters.include_child_groups',
+            )
+          "
+          :loading="$apollo.queries.subjects.loading"
+        ></v-switch>
+      </v-col>
+
+      <v-spacer />
+    </v-row>
+    <v-simple-table
+      fixed-header
+    >
+      <thead>
+        <tr>
+          <th v-for="header in headers" :key="header.value" class="text-left">
+            {{ header.text }}
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr
+          v-for="(item, index) in items"
+          :key="index"
+        >
+          <td>
+            <subject-chip :subject="item.subject" />
+          </td>
+          <td v-for="header in groupHeaders" :key="header.value">
+            <timebound-course-config-raster-cell
+              :value="item[header.value]"
+              :subject="item.subject"
+              :header="header"
+              :loading="loading"
+              @addCourse="addCourse"
+              @setCourseConfigData="setCourseConfigData"
+            />
+          </td>
+        </tr>
+      </tbody>
+    </v-simple-table>
+  </div> -->
 </template>
 
 <script>
@@ -167,11 +197,10 @@ import { currentValidityRange as gqlCurrentValidityRange } from "../validity_ran
 import { gqlGroupsForPlanning } from "../helper.graphql";
 
 import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
-import formRulesMixin from "aleksis.core/mixins/formRulesMixin";
 
 export default {
   name: "TimeboungCourseConfigRaster",
-  mixins: [formRulesMixin, mutateMixin],
+  mixins: [mutateMixin],
   data() {
     return {
       i18nKey: "lesrooster.timebound_course_config",
@@ -210,13 +239,6 @@ export default {
     tableItemSlotName(header) {
       return "item." + header.value;
     },
-    getCurrentCourseConfig(course) {
-      if (course.lrTimeboundCourseConfigs?.length) {
-        return course.lrTimeboundCourseConfigs[0];
-      } else {
-        return null;
-      }
-    },
     setCourseConfigData(course, subject, header, newValue) {
       if (course.newCourse) {
         let existingCreatedCourse = this.createdCourses.find(
@@ -311,7 +333,8 @@ export default {
       );
     },
     generateTableItems(subjects) {
-      return subjects.map((subject) => {
+      const start = performance.now();
+      const subjectsWithSortedCourses = subjects.map((subject) => {
         let { courses, ...reducedSubject } = subject;
         let groupCombinations = {};
 
@@ -359,6 +382,9 @@ export default {
           ...groupCombinations,
         };
       });
+      const end = performance.now();
+      console.log(`Execution time: ${end - start} ms`);
+      return subjectsWithSortedCourses;
     },
   },
   computed: {
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue
new file mode 100644
index 0000000..71efac9
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue
@@ -0,0 +1,121 @@
+<script setup>
+import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
+import TeacherField from "aleksis.apps.cursus/components/TeacherField.vue"
+</script>
+
+<template>
+  <v-lazy
+    v-model="active"
+    :options="{
+      threshold: .5
+    }"
+    transition="fade-transition"
+  >
+    <div v-if="value.length">
+      <v-row
+        v-for="(course, index) in value"
+        :key="index"
+        no-gutters
+        class="mt-2"
+      >
+        <v-col cols="6">
+          <positive-small-integer-field
+            dense
+            filled
+            class="mx-1"
+            :disabled="loading"
+            :value="
+              getCurrentCourseConfig(course)
+                ? getCurrentCourseConfig(course).lessonQuota
+                : course.lessonQuota
+            "
+            :label="$t('lesrooster.timebound_course_config.lesson_quota')"
+            @change="
+              (event) =>
+                $emit('setCourseConfigData', course, subject, header, {
+                  lessonQuota: event,
+                })
+            "
+          />
+        </v-col>
+        <v-col cols="6">
+          <teacher-field
+            dense
+            filled
+            class="mx-1"
+            :disabled="loading"
+            :label="$t('lesrooster.timebound_course_config.teachers')"
+            :value="
+              getCurrentCourseConfig(course)
+                ? getCurrentCourseConfig(course).teachers
+                : course.teachers
+            "
+            :show-subjects="true"
+            :priority-subject="subject"
+            :rules="$rules().isNonEmpty.build()"
+            @input="
+              (event) =>
+                $emit('setCourseConfigData', course, subject, header, {
+                  teachers: event,
+                })
+            "
+          />
+        </v-col>
+      </v-row>
+    </div>
+    <div v-else>
+      <v-btn
+        block
+        icon
+        tile
+        outlined
+        @click="$emit('addCourse', subject.id, header.value)"
+      >
+        <v-icon>mdi-plus</v-icon>
+      </v-btn>
+    </div>
+  </v-lazy>
+</template>
+
+<script>
+import formRulesMixin from "aleksis.core/mixins/formRulesMixin";
+
+export default {
+  name: "TimeboundCourseConfigRasterCell",
+  mixins: [formRulesMixin],
+  emits: ["addCourse", "setCourseConfigData"],
+  props: {
+    value: {
+      type: Array,
+      required: true,
+    },
+    subject: {
+      type: Object,
+      required: true,
+    },
+    header: {
+      type: Object,
+      required: true,
+    },
+    loading: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      active: false,
+    };
+  },
+  methods: {
+    getCurrentCourseConfig(course) {
+      if (course.lrTimeboundCourseConfigs?.length) {
+        return course.lrTimeboundCourseConfigs[0];
+      } else {
+        return null;
+      }
+    },
+  },
+};
+</script>
-- 
GitLab