Skip to content

Commit

Permalink
Additional Stores (#215)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
MelissaAutumn authored Dec 15, 2023
1 parent f7f7931 commit 5b905f9
Show file tree
Hide file tree
Showing 16 changed files with 263 additions and 190 deletions.
6 changes: 6 additions & 0 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion backend/src/appointment/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
117 changes: 29 additions & 88 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@
</site-notification>
<nav-bar :nav-items="navItems" />
<main :class="{'mx-4 pt-24 lg:mx-8 min-h-full pb-24': !routeIsHome, 'pt-32': routeIsHome}">
<router-view
:calendars="calendars"
:appointments="appointments"
/>
<router-view />
</main>
<footer-bar />
</template>
Expand All @@ -32,23 +29,23 @@
</template>

<script setup>
import { appointmentState } from "@/definitions";
import { createFetch } from "@vueuse/core";
import { ref, inject, provide, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import NavBar from "@/components/NavBar";
import TitleBar from "@/components/TitleBar";
import FooterBar from "@/components/FooterBar.vue";
import SiteNotification from "@/elements/SiteNotification";
import { useSiteNotificationStore } from "@/stores/alert-store";
import { createFetch } from '@vueuse/core';
import { inject, provide, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import NavBar from '@/components/NavBar';
import TitleBar from '@/components/TitleBar';
import FooterBar from '@/components/FooterBar.vue';
import SiteNotification from '@/elements/SiteNotification';
import { useSiteNotificationStore } from '@/stores/alert-store';
// stores
import { useUserStore } from '@/stores/user-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAppointmentStore } from '@/stores/appointment-store';
// component constants
const currentUser = useUserStore(); // data: { username, email, name, level, timezone, id }
const apiUrl = inject("apiUrl");
const dj = inject("dayjs");
const apiUrl = inject('apiUrl');
const route = useRoute();
const router = useRouter();
const siteNotificationStore = useSiteNotificationStore();
Expand All @@ -68,8 +65,8 @@ const call = createFetch({
async onFetchError({ data, response, error }) {
// Catch any google refresh error that may occur
if (
data?.detail?.error === 'google_refresh_error' &&
!siteNotificationStore.isSameNotification('google_refresh_error')
data?.detail?.error === 'google_refresh_error'
&& !siteNotificationStore.isSameNotification('google_refresh_error')
) {
// Ensure other async calls don't reach here
siteNotificationStore.lock(data.detail.error);
Expand Down Expand Up @@ -97,26 +94,26 @@ const call = createFetch({
},
},
fetchOptions: {
mode: "cors",
credentials: "include",
mode: 'cors',
credentials: 'include',
},
});
provide("call", call);
provide('call', call);
provide('isPasswordAuth', import.meta.env?.VITE_AUTH_SCHEME === 'password');
provide('isFxaAuth', import.meta.env?.VITE_AUTH_SCHEME === 'fxa');
provide('fxaEditProfileUrl', import.meta.env?.VITE_FXA_EDIT_PROFILE);
// menu items for main navigation
const navItems = [
"calendar",
"schedule",
"appointments",
"settings",
'calendar',
'schedule',
'appointments',
'settings',
];
// db tables
const calendars = ref([]);
const appointments = ref([]);
const calendarStore = useCalendarStore();
const appointmentStore = useAppointmentStore();
// true if route can be accessed without authentication
const routeIsPublic = computed(
Expand All @@ -126,76 +123,20 @@ const routeIsHome = computed(
() => ['home'].includes(route.name),
);
// query db for all calendar data
const getDbCalendars = async (onlyConnected = true) => {
const { data, error } = await call(`me/calendars?only_connected=${onlyConnected}`).get().json();
if (!error.value) {
if (data.value === null || typeof data.value === "undefined") return;
calendars.value = data.value;
}
};
// query db for all appointments data
const getDbAppointments = async () => {
const { data, error } = await call("me/appointments").get().json();
if (!error.value) {
if (data.value === null || typeof data.value === "undefined") return;
appointments.value = data.value;
}
};
// check appointment status for current state (past|pending|booked)
const getAppointmentStatus = (a) => {
// check past events
if (a.slots.filter((s) => dj(s.start).isAfter(dj())).length === 0) {
return appointmentState.past;
}
// check booked events
if (a.slots.filter((s) => s.attendee_id != null).length > 0) {
return appointmentState.booked;
}
// else event is still wating to be booked
return appointmentState.pending;
};
// extend retrieved data
const extendDbData = () => {
// build { calendarId => calendarData } object for direct lookup
const calendarsById = {};
calendars.value.forEach((c) => {
calendarsById[c.id] = c;
});
// extend appointments data with active state and calendar title and color
appointments.value.forEach((a) => {
a.calendar_title = calendarsById[a.calendar_id]?.title;
a.calendar_color = calendarsById[a.calendar_id]?.color;
a.status = getAppointmentStatus(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(currentUser.data.timezone ?? dj.tz.guess());
});
});
};
const getAppointmentStatus = (a) => appointmentStore.status(a);
// retrieve calendars and appointments after checking login and persisting user to db
const getDbData = async (options = {}) => {
const { onlyConnectedCalendars = true } = options;
const getDbData = async () => {
if (currentUser?.exists()) {
await Promise.all([
getDbCalendars(onlyConnectedCalendars),
getDbAppointments(),
calendarStore.fetch(call),
appointmentStore.fetch(call),
]);
extendDbData();
}
};
// get the data initially
onMounted(async () => {
await getDbData();
});
// provide refresh functions for components
provide("refresh", getDbData);
provide("getAppointmentStatus", getAppointmentStatus);
provide('refresh', getDbData);
provide('getAppointmentStatus', getAppointmentStatus);
</script>
20 changes: 9 additions & 11 deletions frontend/src/components/SettingsAccount.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -251,6 +248,7 @@ const connectZoom = async () => {
};
const disconnectZoom = async () => {
await call('zoom/disconnect').post();
await useExternalConnectionsStore().reset();
await refreshData();
};
Expand Down
23 changes: 12 additions & 11 deletions frontend/src/components/SettingsCalendar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<calendar-management
:title="t('heading.calendarsUnconnected')"
:type="calendarManagementType.connect"
:calendars="calendars"
:calendars="calendarStore.unconnectedCalendars"
:loading="loading"
@sync="syncCalendars"
@modify="connectCalendar"
Expand All @@ -18,7 +18,7 @@
<calendar-management
:title="t('heading.calendarsConnected')"
:type="calendarManagementType.edit"
:calendars="calendars"
:calendars="calendarStore.connectedCalendars"
:loading="loading"
@remove="deleteCalendar"
@modify="editCalendar"
Expand Down Expand Up @@ -199,7 +199,9 @@
<script setup>
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';
Expand All @@ -208,12 +210,14 @@ import GoogleSignInBtn from '@/assets/img/google/1x/btn_google_signin_light_norm
import GoogleSignInBtn2x from '@/assets/img/google/2x/[email protected]';
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('');
Expand All @@ -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,
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -392,6 +393,6 @@ onMounted(async () => {
await router.replace(route.path);
}
await refreshData();
await refresh();
});
</script>
4 changes: 2 additions & 2 deletions frontend/src/stores/alert-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -50,7 +50,7 @@ export const useSiteNotificationStore = defineStore('siteNotification', {
});
},
reset() {
this.$patch({ data: initialSiteNotificationObject });
this.$patch({ data: structuredClone(initialSiteNotificationObject) });
},
},
});
Loading

0 comments on commit 5b905f9

Please sign in to comment.