From 5b905f985beb9497fad5b8c98ebd28201d1c753d Mon Sep 17 00:00:00 2001 From: Mel <97147377+MelissaAutumn@users.noreply.github.com> Date: Fri, 15 Dec 2023 10:43:37 -0800 Subject: [PATCH] Additional Stores (#215) * Add AppointmentStore and CalendarStore. - CalendarStore will only pull if the `isInit` value is false. - CalendarStore cache is busted on user action. - AppointmentStore will always pull if requested (Due to some impl concerns.) * Hook up external connections store, and move the isLoaded check to inside the stores. * We don't need to retrieve appointments or calendars on the account settings page. * Provide calendar title and colour for `/me/appointments` * Remove the initial calendar/appointment load, and the calendar merge with appointments. We don't need to merge the calendar data anymore since we do on the backend, and we now call the db refresh function on all pages that may require appointments. This prevents doubling requests if you start on the Calendars page for example. --- backend/src/appointment/database/schemas.py | 6 + backend/src/appointment/routes/api.py | 7 +- frontend/src/App.vue | 117 +++++------------- frontend/src/components/SettingsAccount.vue | 20 ++- frontend/src/components/SettingsCalendar.vue | 23 ++-- frontend/src/stores/alert-store.js | 4 +- frontend/src/stores/appointment-store.js | 66 ++++++++++ frontend/src/stores/calendar-store.js | 44 +++++++ .../src/stores/external-connections-store.js | 41 ++++++ frontend/src/stores/user-store.js | 2 +- frontend/src/views/AppointmentsView.vue | 15 ++- frontend/src/views/CalendarView.vue | 38 +++--- frontend/src/views/HomeView.vue | 7 -- frontend/src/views/ProfileView.vue | 28 ++--- frontend/src/views/ScheduleView.vue | 27 ++-- frontend/src/views/SettingsView.vue | 8 +- 16 files changed, 263 insertions(+), 190 deletions(-) create mode 100644 frontend/src/stores/appointment-store.js create mode 100644 frontend/src/stores/calendar-store.js create mode 100644 frontend/src/stores/external-connections-store.js diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 23b25e5ce..d7cec473e 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -108,6 +108,12 @@ class Config: from_attributes = True +class AppointmentWithCalendarOut(Appointment): + """For /me/appointments""" + calendar_title: str + calendar_color: str + + class AppointmentOut(AppointmentBase): id: int | None = None owner_name: str | None = None diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 17c9b007a..d69a5dd15 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -63,12 +63,17 @@ def read_my_calendars( return [schemas.CalendarOut(id=c.id, title=c.title, color=c.color, connected=c.connected) for c in calendars] -@router.get("/me/appointments", response_model=list[schemas.Appointment]) +@router.get("/me/appointments", response_model=list[schemas.AppointmentWithCalendarOut]) def read_my_appointments(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """get all appointments of authenticated subscriber""" if not subscriber: raise HTTPException(status_code=401, detail="No valid authentication credentials provided") appointments = repo.get_appointments_by_subscriber(db, subscriber_id=subscriber.id) + # Mix in calendar title and color. + # Note because we `__dict__` any relationship values won't be carried over, so don't forget to manually add those! + appointments = map( + lambda x: schemas.AppointmentWithCalendarOut(**x.__dict__, slots=x.slots, calendar_title=x.calendar.title, + calendar_color=x.calendar.color), appointments) return appointments diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 26d680097..0fad1a3b8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,10 +10,7 @@ - + @@ -32,23 +29,23 @@ diff --git a/frontend/src/components/SettingsAccount.vue b/frontend/src/components/SettingsAccount.vue index 4f309c51b..0d59918c4 100644 --- a/frontend/src/components/SettingsAccount.vue +++ b/frontend/src/components/SettingsAccount.vue @@ -155,16 +155,19 @@ import TextButton from '@/elements/TextButton.vue'; // icons import { IconExternalLink } from '@tabler/icons-vue'; +// stores +import { useExternalConnectionsStore } from '@/stores/external-connections-store'; + // component constants const { t } = useI18n({ useScope: 'global' }); const call = inject('call'); -const refresh = inject('refresh'); const router = useRouter(); const user = useUserStore(); +const externalConnectionsStore = useExternalConnectionsStore(); -const externalConnections = ref({}); -const hasZoomAccountConnected = computed(() => (externalConnections.value?.zoom?.length ?? []) > 0); -const zoomAccountName = computed(() => (externalConnections.value?.zoom[0].name ?? null)); +// Currently we only support one zoom account being connected at once. +const hasZoomAccountConnected = computed(() => (externalConnectionsStore.zoom.length) > 0); +const zoomAccountName = computed(() => (externalConnectionsStore.zoom[0]?.name ?? null)); const activeUsername = ref(user.data.username); const activeDisplayName = ref(user.data.name); @@ -195,15 +198,9 @@ const getSignedUserUrl = async () => { signedUserUrl.value = data.value.url; }; -const getExternalConnections = async () => { - const { data } = await call('account/external-connections').get().json(); - externalConnections.value = data.value; -}; - const refreshData = async () => Promise.all([ getSignedUserUrl(), - getExternalConnections(), - refresh(), + externalConnectionsStore.fetch(call), ]); // save user data @@ -251,6 +248,7 @@ const connectZoom = async () => { }; const disconnectZoom = async () => { await call('zoom/disconnect').post(); + await useExternalConnectionsStore().reset(); await refreshData(); }; diff --git a/frontend/src/components/SettingsCalendar.vue b/frontend/src/components/SettingsCalendar.vue index f2041e561..39d524f5f 100644 --- a/frontend/src/components/SettingsCalendar.vue +++ b/frontend/src/components/SettingsCalendar.vue @@ -8,7 +8,7 @@ import { calendarManagementType } from '@/definitions'; import { IconArrowRight } from '@tabler/icons-vue'; -import { ref, reactive, inject, onMounted, computed } from 'vue'; +import { + ref, reactive, inject, onMounted, computed, +} from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import AlertBox from '@/elements/AlertBox'; @@ -208,12 +210,14 @@ import GoogleSignInBtn from '@/assets/img/google/1x/btn_google_signin_light_norm import GoogleSignInBtn2x from '@/assets/img/google/2x/btn_google_signin_light_normal_web@2x.png'; import PrimaryButton from '@/elements/PrimaryButton'; import SecondaryButton from '@/elements/SecondaryButton'; -import ConfirmationModal from "@/components/ConfirmationModal.vue"; +import ConfirmationModal from '@/components/ConfirmationModal.vue'; +import { useCalendarStore } from '@/stores/calendar-store'; // component constants const { t } = useI18n({ useScope: 'global' }); const call = inject('call'); const refresh = inject('refresh'); +const calendarStore = useCalendarStore(); const calendarConnectError = ref(''); @@ -223,11 +227,6 @@ const deleteCalendarModalTarget = ref(null); // Temp until we get a store solution rolling const loading = ref(false); -// view properties -defineProps({ - calendars: Array, // list of calendars from db -}); - // handle calendar user input to add or edit calendar connections const inputModes = { hidden: 0, @@ -264,7 +263,9 @@ const closeModals = async () => { }; const refreshData = async () => { - await refresh({ onlyConnectedCalendars: false }); + // Invalidate our calendar store + await calendarStore.reset(); + await refresh(); loading.value = false; }; @@ -392,6 +393,6 @@ onMounted(async () => { await router.replace(route.path); } - await refreshData(); + await refresh(); }); diff --git a/frontend/src/stores/alert-store.js b/frontend/src/stores/alert-store.js index 074c0eac2..9f0d2d153 100644 --- a/frontend/src/stores/alert-store.js +++ b/frontend/src/stores/alert-store.js @@ -13,7 +13,7 @@ const initialSiteNotificationObject = { // eslint-disable-next-line import/prefer-default-export export const useSiteNotificationStore = defineStore('siteNotification', { state: () => ({ - data: initialSiteNotificationObject, + data: structuredClone(initialSiteNotificationObject), }), getters: { isVisible() { @@ -50,7 +50,7 @@ export const useSiteNotificationStore = defineStore('siteNotification', { }); }, reset() { - this.$patch({ data: initialSiteNotificationObject }); + this.$patch({ data: structuredClone(initialSiteNotificationObject) }); }, }, }); diff --git a/frontend/src/stores/appointment-store.js b/frontend/src/stores/appointment-store.js new file mode 100644 index 000000000..e68bed600 --- /dev/null +++ b/frontend/src/stores/appointment-store.js @@ -0,0 +1,66 @@ +import { defineStore } from 'pinia'; +import { appointmentState } from '@/definitions'; +import { useUserStore } from '@/stores/user-store'; +import dj from 'dayjs'; + +const initialData = { + appointments: [], + isInit: false, +}; + +// eslint-disable-next-line import/prefer-default-export +export const useAppointmentStore = defineStore('appointments', { + state: () => ({ + data: structuredClone(initialData), + }), + getters: { + isLoaded() { + return this.data.isInit; + }, + appointments() { + return this.data.appointments; + }, + pendingAppointments() { + return this.data.appointments.filter((a) => a.status === appointmentState.pending); + }, + }, + actions: { + status(appointment) { + // check past events + if (appointment.slots.filter((s) => dj(s.start).isAfter(dj())).length === 0) { + return appointmentState.past; + } + // check booked events + if (appointment.slots.filter((s) => s.attendee_id != null).length > 0) { + return appointmentState.booked; + } + // else event is still wating to be booked + return appointmentState.pending; + }, + reset() { + this.$patch({ data: structuredClone(initialData) }); + }, + async postFetchProcess() { + const userStore = useUserStore(); + + this.data.appointments.forEach((a) => { + a.status = this.status(a); + a.active = a.status !== appointmentState.past; // TODO + // convert start dates from UTC back to users timezone + a.slots.forEach((s) => { + s.start = dj.utc(s.start).tz(userStore.data.timezone ?? dj.tz.guess()); + }); + }); + }, + async fetch(call) { + const { data, error } = await call('me/appointments').get().json(); + if (!error.value) { + if (data.value === null || typeof data.value === 'undefined') return; + this.data.appointments = data.value; + this.data.isInit = true; + } + // After we fetch the data, apply some processing + await this.postFetchProcess(); + }, + }, +}); diff --git a/frontend/src/stores/calendar-store.js b/frontend/src/stores/calendar-store.js new file mode 100644 index 000000000..589b8d4ce --- /dev/null +++ b/frontend/src/stores/calendar-store.js @@ -0,0 +1,44 @@ +import { defineStore } from 'pinia'; + +const initialData = { + calendars: [], + isInit: false, +}; + +// eslint-disable-next-line import/prefer-default-export +export const useCalendarStore = defineStore('calendars', { + state: () => ({ + data: structuredClone(initialData), + }), + getters: { + isLoaded() { + return this.data.isInit; + }, + unconnectedCalendars() { + return this.data.calendars.filter((cal) => !cal.connected); + }, + connectedCalendars() { + return this.data.calendars.filter((cal) => cal.connected); + }, + allCalendars() { + return this.data.calendars; + }, + }, + actions: { + reset() { + this.$patch({ data: structuredClone(initialData) }); + }, + async fetch(call) { + if (this.isLoaded) { + return; + } + + const { data, error } = await call('me/calendars?only_connected=false').get().json(); + if (!error.value) { + if (data.value === null || typeof data.value === 'undefined') return; + this.data.calendars = data.value; + this.data.isInit = true; + } + }, + }, +}); diff --git a/frontend/src/stores/external-connections-store.js b/frontend/src/stores/external-connections-store.js new file mode 100644 index 000000000..c3748fa12 --- /dev/null +++ b/frontend/src/stores/external-connections-store.js @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia'; + +const initialData = { + zoom: [], + fxa: [], + isInit: false, +}; + +// eslint-disable-next-line import/prefer-default-export +export const useExternalConnectionsStore = defineStore('externalConnections', { + state: () => ({ + data: structuredClone(initialData), + }), + getters: { + isLoaded() { + return this.data.isInit; + }, + connections() { + return this.data; + }, + fxa() { + return this.data.fxa ?? []; + }, + zoom() { + return this.data.zoom ?? []; + }, + }, + actions: { + reset() { + this.$patch({ data: structuredClone(initialData) }); + }, + async fetch(call) { + if (this.isLoaded) { + return; + } + + const { data } = await call('account/external-connections').get().json(); + this.$patch({ data: { ...data.value, isInit: true } }); + }, + }, +}); diff --git a/frontend/src/stores/user-store.js b/frontend/src/stores/user-store.js index 542428436..74f63aa62 100644 --- a/frontend/src/stores/user-store.js +++ b/frontend/src/stores/user-store.js @@ -20,7 +20,7 @@ export const useUserStore = defineStore('user', { return this.data.accessToken !== null; }, reset() { - this.$patch({ data: initialUserObject }); + this.$patch({ data: structuredClone(initialUserObject) }); }, async profile(fetch) { const { error, data } = await fetch('me').get().json(); diff --git a/frontend/src/views/AppointmentsView.vue b/frontend/src/views/AppointmentsView.vue index 3f0b84878..2c2824cf0 100644 --- a/frontend/src/views/AppointmentsView.vue +++ b/frontend/src/views/AppointmentsView.vue @@ -6,7 +6,7 @@ @@ -202,7 +202,7 @@ { // handle filtered appointments list const filteredAppointments = computed(() => { - let list = props.appointments ? [...props.appointments] : []; + let list = appointmentStore.appointments ? [...appointmentStore.appointments] : []; // by search input if (search.value !== '') { list = list.filter((a) => a.title.toLowerCase().includes(search.value.toLowerCase())); diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index 7891d1850..a0a2162df 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -1,6 +1,6 @@ - + {{ t('error.noConnectedCalendarsLink') }} @@ -28,7 +28,7 @@ /> @@ -43,21 +43,21 @@ v-show="tabActive === calendarViews.month" class="w-full md:w-4/5" :selected="activeDate" - :appointments="pendingAppointments" + :appointments="appointmentStore.pendingAppointments" :events="calendarEvents" /> @@ -88,7 +88,7 @@ @@ -96,13 +96,13 @@ @@ -113,7 +113,7 @@ diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index 0439fde6d..9b8c976e9 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -16,12 +16,12 @@ - {{ calendars.length }} + {{ calendarStore.connectedCalendars.length }}/∞ {{ t('heading.calendarsConnected') }} - {{ pendingAppointments.length }} + {{ appointmentStore.pendingAppointments.length }} {{ t('heading.pendingAppointments') }} @@ -31,10 +31,10 @@ diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index ae601c256..026515134 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -38,7 +38,7 @@ @@ -67,7 +67,7 @@ v-show="tabActive === calendarViews.day" class="w-full md:w-4/5" :selected="activeDate" - :appointments="pendingAppointments" + :appointments="appointmentStore.pendingAppointments" :events="calendarEvents" popup-position="top" /> @@ -75,7 +75,7 @@