+
import { computed, onMounted, ref, watch } from 'vue';
-import { useQuery } from '@tanstack/vue-query';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
-import { storeToRefs } from 'pinia';
-import { batchGetDocs } from '@/helpers/query/utils';
-import { taskDisplayNames } from '@/helpers/reports';
-import { useAuthStore } from '@/store/auth';
-import { removeEmptyOrgs } from '@/helpers';
import { useRouter } from 'vue-router';
-import _flattenDeep from 'lodash/flattenDeep';
import _fromPairs from 'lodash/fromPairs';
import _isEmpty from 'lodash/isEmpty';
import _mapValues from 'lodash/mapValues';
import _toPairs from 'lodash/toPairs';
import _without from 'lodash/without';
import _zip from 'lodash/zip';
+import { batchGetDocs } from '@/helpers/query/utils';
+import { taskDisplayNames } from '@/helpers/reports';
+import { removeEmptyOrgs } from '@/helpers';
import { setBarChartData, setBarChartOptions } from '@/helpers/plotting';
-import { isLevante } from '@/helpers';
+import useDsgfOrgQuery from '@/composables/queries/useDsgfOrgQuery';
+import useTasksDictionaryQuery from '@/composables/queries/useTasksDictionaryQuery';
+import useDeleteAdministrationMutation from '@/composables/mutations/useDeleteAdministrationMutation';
+import { SINGULAR_ORG_TYPES } from '@/constants/orgTypes';
+import { FIRESTORE_COLLECTIONS } from '@/constants/firebase';
+import { TOAST_SEVERITIES, TOAST_DEFAULT_LIFE_DURATION } from '@/constants/toasts';
const router = useRouter();
-const authStore = useAuthStore();
-const { roarfirekit, administrationQueryKeyIndex, uid, tasksDictionary } = storeToRefs(authStore);
-
const props = defineProps({
id: { type: String, required: true },
title: { type: String, required: true },
@@ -185,6 +189,8 @@ const props = defineProps({
const confirm = useConfirm();
const toast = useToast();
+const { mutateAsync: deleteAdministration } = useDeleteAdministrationMutation();
+
const speedDialItems = ref([
{
label: 'Delete',
@@ -195,18 +201,22 @@ const speedDialItems = ref([
message: 'Are you sure you want to delete this administration?',
icon: 'pi pi-exclamation-triangle',
accept: async () => {
- await roarfirekit.value.deleteAdministration(props.id).then(() => {
- toast.add({
- severity: 'info',
- summary: 'Confirmed',
- detail: `Deleted administration ${props.title}`,
- life: 3000,
- });
- administrationQueryKeyIndex.value += 1;
+ await deleteAdministration(props.id);
+
+ toast.add({
+ severity: TOAST_SEVERITIES.INFO,
+ summary: 'Confirmed',
+ detail: `Deleted administration ${props.title}`,
+ life: TOAST_DEFAULT_LIFE_DURATION,
});
},
reject: () => {
- toast.add({ severity: 'error', summary: 'Rejected', detail: 'Deletion aborted', life: 3000 });
+ toast.add({
+ severity: TOAST_SEVERITIES.ERROR,
+ summary: 'Rejected',
+ detail: `Failed to delete administration ${props.title}`,
+ life: TOAST_DEFAULT_LIFE_DURATION,
+ });
},
});
},
@@ -276,152 +286,14 @@ const isWideScreen = computed(() => {
return window.innerWidth > 768;
});
-const singularOrgTypes = {
- districts: 'district',
- schools: 'school',
- classes: 'class',
- groups: 'group',
- families: 'families',
-};
-
-// dsgf: districts, schools, groups, families
-const fetchTreeOrgs = async () => {
- const orgTypes = ['districts', 'schools', 'groups', 'families'];
- const orgPaths = _flattenDeep(
- orgTypes.map((orgType) => (props.assignees[orgType] ?? []).map((orgId) => `${orgType}/${orgId}`) ?? []),
- );
-
- const statsPaths = _flattenDeep(
- orgTypes.map(
- (orgType) => (props.assignees[orgType] ?? []).map((orgId) => `administrations/${props.id}/stats/${orgId}`) ?? [],
- ),
- );
-
- const promises = [batchGetDocs(orgPaths, ['name', 'schools', 'classes', 'districtId']), batchGetDocs(statsPaths)];
-
- const [orgDocs, statsDocs] = await Promise.all(promises);
+const { data: tasksDictionary, isLoading: isLoadingTasksDictionary } = useTasksDictionaryQuery();
- const dsgfOrgs = _zip(orgDocs, statsDocs).map(([orgDoc, stats], index) => {
- const { classes, schools, collection, ...nodeData } = orgDoc;
- const node = {
- key: String(index),
- data: {
- orgType: singularOrgTypes[collection],
- schools,
- classes,
- stats,
- ...nodeData,
- },
- };
- if (classes)
- node.children = classes.map((classId) => {
- return {
- key: `${node.key}-${classId}`,
- data: {
- orgType: 'class',
- id: classId,
- },
- };
- });
- return node;
- });
-
- const dependentSchoolIds = _flattenDeep(dsgfOrgs.map((node) => node.data.schools ?? []));
- const independentSchoolIds =
- dsgfOrgs.length > 0 ? _without(props.assignees.schools, ...dependentSchoolIds) : props.assignees.schools;
- const dependentClassIds = _flattenDeep(dsgfOrgs.map((node) => node.data.classes ?? []));
- const independentClassIds =
- dsgfOrgs.length > 0 ? _without(props.assignees.classes, ...dependentClassIds) : props.assignees.classes;
-
- const independentSchools = (dsgfOrgs ?? []).filter((node) => {
- return node.data.orgType === 'school' && independentSchoolIds.includes(node.data.id);
- });
-
- const dependentSchools = (dsgfOrgs ?? []).filter((node) => {
- return node.data.orgType === 'school' && !independentSchoolIds.includes(node.data.id);
- });
-
- const independentClassPaths = independentClassIds.map((classId) => `classes/${classId}`);
- const independentClassStatPaths = independentClassIds.map(
- (classId) => `administrations/${props.id}/stats/${classId}`,
- );
-
- const classPromises = [
- batchGetDocs(independentClassPaths, ['name', 'schoolId']),
- batchGetDocs(independentClassStatPaths),
- ];
-
- const [classDocs, classStats] = await Promise.all(classPromises);
-
- const independentClasses = _without(
- _zip(classDocs, classStats).map(([orgDoc, stats], index) => {
- const { collection = 'classes', ...nodeData } = orgDoc ?? {};
-
- if (_isEmpty(nodeData)) return undefined;
-
- const node = {
- key: String(dsgfOrgs.length + index),
- data: {
- orgType: singularOrgTypes[collection],
- ...(stats && { stats }),
- ...nodeData,
- },
- };
- return node;
- }),
- undefined,
- );
-
- const treeTableOrgs = dsgfOrgs.filter((node) => node.data.orgType === 'district');
- treeTableOrgs.push(...independentSchools);
-
- for (const school of dependentSchools) {
- const districtId = school.data.districtId;
- const districtIndex = treeTableOrgs.findIndex((node) => node.data.id === districtId);
- if (districtIndex !== -1) {
- if (treeTableOrgs[districtIndex].children === undefined) {
- treeTableOrgs[districtIndex].children = [
- {
- ...school,
- key: `${treeTableOrgs[districtIndex].key}-${school.key}`,
- },
- ];
- } else {
- treeTableOrgs[districtIndex].children.push(school);
- }
- } else {
- treeTableOrgs.push(school);
- }
- }
-
- treeTableOrgs.push(...(independentClasses ?? []));
- treeTableOrgs.push(...dsgfOrgs.filter((node) => node.data.orgType === 'group'));
- treeTableOrgs.push(...dsgfOrgs.filter((node) => node.data.orgType === 'family'));
-
- treeTableOrgs.forEach((node) => {
- // Sort the schools by existance of stats then alphabetically
- if (node.children) {
- node.children.sort((a, b) => {
- if (!a.data.stats) return 1;
- if (!b.data.stats) return -1;
- return a.data.name.localeCompare(b.data.name);
- });
- }
- });
-
- return treeTableOrgs;
-};
-
-const { data: orgs, isLoading: loadingDsgfOrgs } = useQuery({
- queryKey: ['dsgfOrgs', uid, props.id],
- queryFn: () => fetchTreeOrgs(),
- keepPreviousData: true,
- staleTime: 5 * 60 * 1000, // 5 minutes
+const { data: orgs, isLoading: isLoadingDsgfOrgs } = useDsgfOrgQuery(props.id, props.assignees, {
enabled: enableQueries,
});
const loadingTreeTable = computed(() => {
- return loadingDsgfOrgs.value || expanding.value;
+ return isLoadingDsgfOrgs.value || expanding.value;
});
const treeTableOrgs = ref([]);
@@ -435,7 +307,7 @@ watch(showTable, (newValue) => {
const expanding = ref(false);
const onExpand = async (node) => {
- if (node.data.orgType === 'school' && node.children?.length > 0 && !node.data.expanded) {
+ if (node.data.orgType === SINGULAR_ORG_TYPES.SCHOOLS && node.children?.length > 0 && !node.data.expanded) {
expanding.value = true;
const classPaths = node.children.map(({ data }) => `classes/${data.id}`);
@@ -457,14 +329,14 @@ const onExpand = async (node) => {
const childNodes = _without(
_zip(classDocs, classStats).map(([orgDoc, stats], index) => {
- const { collection = 'classes', ...nodeData } = orgDoc ?? {};
+ const { collection = FIRESTORE_COLLECTIONS.CLASSES, ...nodeData } = orgDoc ?? {};
if (_isEmpty(nodeData)) return undefined;
return {
key: `${node.key}-${index}`,
data: {
- orgType: singularOrgTypes[collection],
+ orgType: SINGULAR_ORG_TYPES[collection.toUpperCase()],
...(stats && { stats }),
...nodeData,
},
@@ -500,18 +372,23 @@ const onExpand = async (node) => {
return n;
});
- // Sort the classes by existance of stats then alphabetically
- newNodes.forEach((districtNode) => {
- districtNode.children.forEach((schoolNode) => {
+ // Sort the classes by existence of stats then alphabetically
+ // TODO: This fails currently as it tries to set a read only reactive handler
+ // Specifically, setting the `children` key fails because the
+ // schoolNode target is read-only.
+ // Also, I'm pretty sure this is useless now because all classes will have stats
+ // due to preallocation of accounts.
+ for (const districtNode of newNodes ?? []) {
+ for (const schoolNode of districtNode?.children ?? []) {
if (schoolNode.children) {
- schoolNode.children.sort((a, b) => {
+ schoolNode.children = schoolNode.children.toSorted((a, b) => {
if (!a.data.stats) return 1;
if (!b.data.stats) return -1;
return a.data.name.localeCompare(b.data.name);
});
}
- });
- });
+ }
+ }
treeTableOrgs.value = newNodes;
expanding.value = false;
diff --git a/src/components/ConsentModal.vue b/src/components/ConsentModal.vue
index 892422809..857b56c56 100644
--- a/src/components/ConsentModal.vue
+++ b/src/components/ConsentModal.vue
@@ -1,8 +1,15 @@
-
+
@@ -11,39 +18,41 @@
diff --git a/src/components/CreateOrgs.vue b/src/components/CreateOrgs.vue
index 961fd8e66..4faa021cc 100644
--- a/src/components/CreateOrgs.vue
+++ b/src/components/CreateOrgs.vue
@@ -206,12 +206,13 @@ import { storeToRefs } from 'pinia';
import _capitalize from 'lodash/capitalize';
import _union from 'lodash/union';
import _without from 'lodash/without';
-import { useQuery } from '@tanstack/vue-query';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { useAuthStore } from '@/store/auth';
-import { fetchDocById } from '@/helpers/query/utils';
-import { orgFetcher } from '@/helpers/query/orgs';
+import useDistrictsListQuery from '@/composables/queries/useDistrictsListQuery';
+import useDistrictSchoolsQuery from '@/composables/queries/useDistrictSchoolsQuery';
+import useSchoolClassesQuery from '@/composables/queries/useSchoolClassesQuery';
+import useGroupsListQuery from '@/composables/queries/useGroupsListQuery';
import { isLevante } from '@/helpers';
const initialized = ref(false);
@@ -219,7 +220,7 @@ const isTestData = ref(false);
const isDemoData = ref(false);
const toast = useToast();
const authStore = useAuthStore();
-const { roarfirekit, uid } = storeToRefs(authStore);
+const { roarfirekit } = storeToRefs(authStore);
const state = reactive({
orgName: '',
@@ -246,51 +247,26 @@ onMounted(() => {
if (roarfirekit.value.restConfig) initTable();
});
-const { isLoading: isLoadingClaims, data: userClaims } = useQuery({
- queryKey: ['userClaims', uid],
- queryFn: () => fetchDocById('userClaims', uid.value),
- keepPreviousData: true,
+const { isLoading: isLoadingDistricts, data: districts } = useDistrictsListQuery({
enabled: initialized,
- staleTime: 5 * 60 * 1000, // 5 minutes
});
-const isSuperAdmin = computed(() => Boolean(userClaims.value?.claims?.super_admin));
-const adminOrgs = computed(() => userClaims.value?.claims?.minimalAdminOrgs);
-
-const claimsLoaded = computed(() => !isLoadingClaims.value);
-
-const { isLoading: isLoadingDistricts, data: districts } = useQuery({
- queryKey: ['districts'],
- queryFn: () => orgFetcher('districts', undefined, isSuperAdmin, adminOrgs, ['name', 'id', 'tags']),
- keepPreviousData: true,
- enabled: claimsLoaded,
- staleTime: 5 * 60 * 1000, // 5 minutes
-});
-
-const { data: groups } = useQuery({
- queryKey: ['groups'],
- queryFn: () => orgFetcher('groups', undefined, isSuperAdmin, adminOrgs, ['name', 'id', 'tags']),
- keepPreviousData: true,
- enabled: claimsLoaded,
- staleTime: 5 * 60 * 1000, // 5 minutes
+const { data: groups } = useGroupsListQuery({
+ enabled: initialized,
});
const schoolQueryEnabled = computed(() => {
- return claimsLoaded.value && state.parentDistrict !== undefined;
+ return initialized.value && state.parentDistrict !== undefined;
});
const selectedDistrict = computed(() => state.parentDistrict?.id);
-const { isFetching: isFetchingSchools, data: schools } = useQuery({
- queryKey: ['schools', selectedDistrict],
- queryFn: () => orgFetcher('schools', selectedDistrict, isSuperAdmin, adminOrgs, ['name', 'id', 'tags']),
- keepPreviousData: true,
+const { isFetching: isFetchingSchools, data: schools } = useDistrictSchoolsQuery(selectedDistrict, {
enabled: schoolQueryEnabled,
- staleTime: 5 * 60 * 1000, // 5 minutes
});
const classQueryEnabled = computed(() => {
- return claimsLoaded.value && state.parentSchool !== undefined;
+ return initialized.value && state.parentSchool !== undefined;
});
const schoolDropdownEnabled = computed(() => {
@@ -299,12 +275,8 @@ const schoolDropdownEnabled = computed(() => {
const selectedSchool = computed(() => state.parentSchool?.id);
-const { data: classes } = useQuery({
- queryKey: ['classes', selectedSchool],
- queryFn: () => orgFetcher('classes', selectedSchool, isSuperAdmin, adminOrgs, ['name', 'id', 'tags']),
- keepPreviousData: true,
+const { data: classes } = useSchoolClassesQuery(selectedSchool, {
enabled: classQueryEnabled,
- staleTime: 5 * 60 * 1000, // 5 minutes
});
const rules = {
diff --git a/src/components/EditOrgsForm.vue b/src/components/EditOrgsForm.vue
new file mode 100644
index 000000000..86888062a
--- /dev/null
+++ b/src/components/EditOrgsForm.vue
@@ -0,0 +1,206 @@
+
+
+
+
+
diff --git a/src/components/EditUsersForm.vue b/src/components/EditUsersForm.vue
index 9d1bd0d44..4b5e85344 100644
--- a/src/components/EditUsersForm.vue
+++ b/src/components/EditUsersForm.vue
@@ -204,13 +204,13 @@
diff --git a/src/components/views/UserInfoView.vue b/src/components/views/UserInfoView.vue
index 4fde8c26f..6464624d5 100644
--- a/src/components/views/UserInfoView.vue
+++ b/src/components/views/UserInfoView.vue
@@ -31,21 +31,20 @@
diff --git a/src/firebaseInit.js b/src/firebaseInit.js
index 2fa6f4bae..a9244bb49 100644
--- a/src/firebaseInit.js
+++ b/src/firebaseInit.js
@@ -14,8 +14,12 @@ export async function initNewFirekit() {
db: false,
functions: false,
},
-
verboseLogging: isLevante ? false : true,
+
+ // The site key is used for app check token verification
+ // The debug token is used to bypass app check for local development
+ siteKey: roarConfig.siteKey,
+ debugToken: roarConfig?.debugToken,
});
return await firekit.init();
}
diff --git a/src/helpers/computeQueryOverrides.js b/src/helpers/computeQueryOverrides.js
new file mode 100644
index 000000000..7bbb4de9f
--- /dev/null
+++ b/src/helpers/computeQueryOverrides.js
@@ -0,0 +1,30 @@
+import { computed, reactive, toRefs, toRaw, toValue } from 'vue';
+
+/**
+ * Computes the isQueryEnabled value and the options with the enabled property removed.
+ *
+ * @param {Array
boolean)>} conditions - An array of boolean values or functions for evaluation.
+ * @param {QueryOptions | undefined} queryOptions - The query options object.
+ * @returns {{ isQueryEnabled: ComputedRef, options: QueryOptions }} The response object.
+ */
+export const computeQueryOverrides = (conditions, queryOptions) => {
+ const reactiveQueryOptions = reactive(toRaw(queryOptions) || {});
+ const enabled = 'enabled' in reactiveQueryOptions ? toRefs(reactiveQueryOptions).enabled : undefined;
+
+ const isQueryEnabled = computed(() => {
+ // Check if all conditions are met.
+ const allConditionsMet = conditions.every((condition) => {
+ return typeof condition === 'function' ? condition() : !!condition;
+ });
+
+ // Only allow the query to run if all conditions are met and the query is enabled.
+ return toValue(allConditionsMet && (enabled?.value === undefined ? true : enabled));
+ });
+
+ // Remove the enabled property from the query options to avoid overriding the computed value.
+ // This options object will be passed to the useQuery function in the composable.
+ const options = queryOptions ? { ...queryOptions } : {};
+ delete options.enabled;
+
+ return { isQueryEnabled, options };
+};
diff --git a/src/helpers/computeQueryOverrides.test.js b/src/helpers/computeQueryOverrides.test.js
new file mode 100644
index 000000000..b9d6c509b
--- /dev/null
+++ b/src/helpers/computeQueryOverrides.test.js
@@ -0,0 +1,55 @@
+import { reactive } from 'vue';
+import { describe, it, expect, vi } from 'vitest';
+import { computeQueryOverrides } from './computeQueryOverrides';
+
+describe('computeQueryOverrides', () => {
+ it('should return isQueryEnabled as true when all conditions are met and enabled is not provided', () => {
+ const conditions = [true, true];
+ const { isQueryEnabled, options } = computeQueryOverrides(conditions, undefined);
+
+ expect(isQueryEnabled.value).toBe(true);
+ expect(options).toEqual({});
+ });
+
+ it('should return isQueryEnabled as false when any condition is not met', () => {
+ const conditions = [true, false];
+ const { isQueryEnabled } = computeQueryOverrides(conditions, undefined);
+
+ expect(isQueryEnabled.value).toBe(false);
+ });
+
+ it('should return isQueryEnabled as true when all conditions are met and enabled is true', () => {
+ const conditions = [true, true];
+ const queryOptions = reactive({ enabled: true });
+ const { isQueryEnabled } = computeQueryOverrides(conditions, queryOptions);
+
+ expect(isQueryEnabled.value).toBe(true);
+ });
+
+ it('should return isQueryEnabled as false when all conditions are met but enabled is false', () => {
+ const conditions = [true, true];
+ const queryOptions = reactive({ enabled: false });
+ const { isQueryEnabled } = computeQueryOverrides(conditions, queryOptions);
+
+ expect(isQueryEnabled.value).toBe(false);
+ });
+
+ it('should return options without enabled property', () => {
+ const conditions = [true, true];
+ const queryOptions = { enabled: true, other: 'value' };
+ const { options } = computeQueryOverrides(conditions, queryOptions);
+
+ expect(options).toEqual({ other: 'value' });
+ });
+
+ it('should handle conditions as functions', () => {
+ const mockFn1 = vi.fn(() => true);
+ const mockFn2 = vi.fn(() => false);
+ const conditions = [mockFn1, mockFn2];
+ const { isQueryEnabled } = computeQueryOverrides(conditions, undefined);
+
+ expect(isQueryEnabled.value).toBe(false);
+ expect(mockFn1).toHaveBeenCalled();
+ expect(mockFn2).toHaveBeenCalled();
+ });
+});
diff --git a/src/helpers/getDynamicRouterPath.js b/src/helpers/getDynamicRouterPath.js
new file mode 100644
index 000000000..cf2c5e364
--- /dev/null
+++ b/src/helpers/getDynamicRouterPath.js
@@ -0,0 +1,37 @@
+/**
+ * Dynamic Router Path
+ *
+ * Use to generate a router path from a route and a mapping of dynamic parameters.
+ *
+ * @param {string} route – The APP_ROUTES route to convert.
+ * @param {Object} mapping – The mapping of dynamic parameters to their corresponding values.
+ * @returns {string} The converted route path.
+ */
+export const getDynamicRouterPath = (route, mapping) => {
+ if (typeof route !== 'string') {
+ throw new Error('Route must be a string');
+ }
+
+ if (!mapping || typeof mapping !== 'object') {
+ throw new Error('Mapping must be an object');
+ }
+
+ // Split the route into segments
+ const segments = route.split('/');
+
+ // Filter out empty segments
+ const filteredSegments = segments.filter((segment) => segment !== '');
+
+ // Replace dynamic parameters with their corresponding values
+ const routePath = filteredSegments
+ .map((segment) => {
+ if (segment.startsWith(':')) {
+ const paramName = segment.slice(1);
+ return mapping[paramName] || `:${paramName}`;
+ }
+ return segment;
+ })
+ .join('/');
+
+ return `/${routePath}`;
+};
diff --git a/src/helpers/getDynamicRouterPath.test.js b/src/helpers/getDynamicRouterPath.test.js
new file mode 100644
index 000000000..860626671
--- /dev/null
+++ b/src/helpers/getDynamicRouterPath.test.js
@@ -0,0 +1,48 @@
+import { describe, it, expect } from 'vitest';
+import { getDynamicRouterPath } from './getDynamicRouterPath';
+
+describe('getDynamicRouterPath', () => {
+ it('should return the correct path with dynamic parameters', () => {
+ const route = '/users/:userId/posts/:postId';
+ const mapping = { userId: '123', postId: '456' };
+ const expected = '/users/123/posts/456';
+ const result = getDynamicRouterPath(route, mapping);
+ expect(result).toBe(expected);
+ });
+
+ it('should return the correct path with missing dynamic parameters', () => {
+ const route = '/users/:userId/posts/:postId';
+ const mapping = { userId: '123' };
+ const expected = '/users/123/posts/:postId';
+ const result = getDynamicRouterPath(route, mapping);
+ expect(result).toBe(expected);
+ });
+
+ it('should return the correct path with no dynamic parameters', () => {
+ const route = '/about';
+ const mapping = {};
+ const expected = '/about';
+ const result = getDynamicRouterPath(route, mapping);
+ expect(result).toBe(expected);
+ });
+
+ it('should return the correct path with empty route', () => {
+ const route = '';
+ const mapping = {};
+ const expected = '/';
+ const result = getDynamicRouterPath(route, mapping);
+ expect(result).toBe(expected);
+ });
+
+ it('should throw an error when route is not a string', () => {
+ const route = 123;
+ const mapping = { userId: '123' };
+ expect(() => getDynamicRouterPath(route, mapping)).toThrow('Route must be a string');
+ });
+
+ it('should throw an error when mapping is not an object', () => {
+ const route = '/users/:userId';
+ const mapping = 'invalid';
+ expect(() => getDynamicRouterPath(route, mapping)).toThrow('Mapping must be an object');
+ });
+});
diff --git a/src/helpers/hasArrayEntries.js b/src/helpers/hasArrayEntries.js
new file mode 100644
index 000000000..2b06e90de
--- /dev/null
+++ b/src/helpers/hasArrayEntries.js
@@ -0,0 +1,11 @@
+import { toValue } from 'vue';
+
+/**
+ * Test if an array has entries.
+ *
+ * @param {Array} array – The array to check for entries.
+ * @returns {boolean} Whether the array has entries.
+ */
+export const hasArrayEntries = (array) => {
+ return Array.isArray(toValue(array)) && toValue(array).length > 0;
+};
diff --git a/src/helpers/hasArrayEntries.test.js b/src/helpers/hasArrayEntries.test.js
new file mode 100644
index 000000000..398cd8940
--- /dev/null
+++ b/src/helpers/hasArrayEntries.test.js
@@ -0,0 +1,41 @@
+import { describe, it, expect } from 'vitest';
+import { ref } from 'vue';
+import { hasArrayEntries } from './hasArrayEntries';
+
+describe('hasArrayEntries', () => {
+ it('should return true for non-empty arrays', () => {
+ expect(hasArrayEntries([1, 2, 3])).toBe(true);
+ });
+
+ it('should return false for empty arrays', () => {
+ expect(hasArrayEntries([])).toBe(false);
+ });
+
+ it('should return false for null', () => {
+ expect(hasArrayEntries(null)).toBe(false);
+ });
+
+ it('should return false for undefined', () => {
+ expect(hasArrayEntries(undefined)).toBe(false);
+ });
+
+ it('should return true for non-empty Vue ref with arrays', () => {
+ const refWithArray = ref([1, 2, 3]);
+ expect(hasArrayEntries(refWithArray)).toBe(true);
+ });
+
+ it('should return false for empty Vue ref with arrays', () => {
+ const emptyRefWithArray = ref([]);
+ expect(hasArrayEntries(emptyRefWithArray)).toBe(false);
+ });
+
+ it('should return false for Vue ref with null', () => {
+ const refWithNull = ref(null);
+ expect(hasArrayEntries(refWithNull)).toBe(false);
+ });
+
+ it('should return false for Vue ref with undefined', () => {
+ const refWithUndefined = ref(undefined);
+ expect(hasArrayEntries(refWithUndefined)).toBe(false);
+ });
+});
diff --git a/src/helpers/query/administrations.js b/src/helpers/query/administrations.js
index ec0e0b97f..f9ba2e6ee 100644
--- a/src/helpers/query/administrations.js
+++ b/src/helpers/query/administrations.js
@@ -1,9 +1,11 @@
+import { toValue } from 'vue';
import _chunk from 'lodash/chunk';
-import _flatten from 'lodash/flatten';
+import _last from 'lodash/last';
import _mapValues from 'lodash/mapValues';
-import _uniqBy from 'lodash/uniqBy';
import _without from 'lodash/without';
-import { convertValues, getAxiosInstance, mapFields, orderByDefault } from './utils';
+import { storeToRefs } from 'pinia';
+import { useAuthStore } from '@/store/auth';
+import { convertValues, getAxiosInstance, orderByDefault } from './utils';
import { filterAdminOrgs } from '@/helpers';
export function getTitle(item, isSuperAdmin) {
@@ -15,90 +17,6 @@ export function getTitle(item, isSuperAdmin) {
}
}
-const getAdministrationsRequestBody = ({
- orderBy,
- aggregationQuery,
- paginate = true,
- page,
- pageLimit,
- skinnyQuery = false,
- assigningOrgCollection,
- assigningOrgIds,
-}) => {
- const requestBody = {
- structuredQuery: {
- orderBy: orderBy ?? orderByDefault,
- },
- };
-
- if (!aggregationQuery) {
- if (paginate) {
- requestBody.structuredQuery.limit = pageLimit;
- requestBody.structuredQuery.offset = page * pageLimit;
- }
-
- if (skinnyQuery) {
- requestBody.structuredQuery.select = {
- fields: [{ fieldPath: 'id' }, { fieldPath: 'name' }],
- };
- } else {
- requestBody.structuredQuery.select = {
- fields: [
- { fieldPath: 'id' },
- { fieldPath: 'name' },
- { fieldPath: 'publicName' },
- { fieldPath: 'assessments' },
- { fieldPath: 'dateClosed' },
- { fieldPath: 'dateCreated' },
- { fieldPath: 'dateOpened' },
- { fieldPath: 'districts' },
- { fieldPath: 'schools' },
- { fieldPath: 'classes' },
- { fieldPath: 'groups' },
- { fieldPath: 'families' },
- ],
- };
- }
- }
-
- requestBody.structuredQuery.from = [
- {
- collectionId: 'administrations',
- allDescendants: false,
- },
- ];
-
- if (assigningOrgCollection && assigningOrgIds) {
- requestBody.structuredQuery.where = {
- fieldFilter: {
- field: { fieldPath: `readOrgs.${assigningOrgCollection}` },
- op: 'ARRAY_CONTAINS_ANY',
- value: {
- arrayValue: {
- values: assigningOrgIds.map((orgId) => ({ stringValue: orgId })),
- },
- },
- },
- };
- }
-
- if (aggregationQuery) {
- return {
- structuredAggregationQuery: {
- ...requestBody,
- aggregations: [
- {
- alias: 'count',
- count: {},
- },
- ],
- },
- };
- }
-
- return requestBody;
-};
-
const processBatchStats = async (axiosInstance, statsPaths, batchSize = 5) => {
const batchStatsDocs = [];
const statsPathChunks = _chunk(statsPaths, batchSize);
@@ -126,86 +44,43 @@ const processBatchStats = async (axiosInstance, statsPaths, batchSize = 5) => {
return batchStatsDocs;
};
-export const administrationCounter = async (orderBy, isSuperAdmin, adminOrgs) => {
- const axiosInstance = getAxiosInstance();
- if (isSuperAdmin.value) {
- const requestBody = getAdministrationsRequestBody({
- aggregationQuery: true,
- orderBy: orderBy.value,
- paginate: false,
- skinnyQuery: true,
- });
- console.log(`Fetching count for administrations`);
- return axiosInstance.post(':runAggregationQuery', requestBody).then(({ data }) => {
- return Number(convertValues(data[0].result?.aggregateFields?.count));
- });
- } else {
- const promises = [];
- // Iterate through each adminOrg type
- for (const [orgType, orgIds] of Object.entries(adminOrgs.value)) {
- // Then chunk those arrays into chunks of 10
- if ((orgIds ?? []).length > 0) {
- const requestBodies = _chunk(orgIds, 10).map((orgChunk) =>
- getAdministrationsRequestBody({
- aggregationQuery: false,
- paginate: false,
- skinnyQuery: true,
- assigningOrgCollection: orgType,
- assigningOrgIds: orgChunk,
- }),
- );
-
- promises.push(
- requestBodies.map((requestBody) =>
- axiosInstance.post(':runQuery', requestBody).then(async ({ data }) => {
- return mapFields(data);
- }),
- ),
- );
- }
- }
-
- const flattened = _flatten(await Promise.all(_flatten(promises)));
- const orderField = (orderBy?.value ?? orderByDefault)[0].field.fieldPath;
- const administrations = _uniqBy(flattened, 'id').filter((a) => a[orderField] !== undefined);
- return administrations.length;
- }
-};
-
const mapAdministrations = async ({ isSuperAdmin, data, adminOrgs }) => {
// First format the administration documents
- const administrationData = mapFields(data).map((a) => {
- let assignedOrgs = {
- districts: a.districts,
- schools: a.schools,
- classes: a.classes,
- groups: a.groups,
- families: a.families,
- };
- if (!isSuperAdmin.value) {
- assignedOrgs = filterAdminOrgs(adminOrgs.value, assignedOrgs);
- }
- return {
- id: a.id,
- name: a.name,
- publicName: a.publicName,
- dates: {
- start: a.dateOpened,
- end: a.dateClosed,
- created: a.dateCreated,
- },
- assessments: a.assessments,
- assignedOrgs,
- ...(a.testData ?? { testData: true }),
- };
- });
+ const administrationData = data
+ .map((a) => a.data)
+ .map((a) => {
+ let assignedOrgs = {
+ districts: a.districts,
+ schools: a.schools,
+ classes: a.classes,
+ groups: a.groups,
+ families: a.families,
+ };
+ if (!isSuperAdmin.value) {
+ assignedOrgs = filterAdminOrgs(adminOrgs.value, assignedOrgs);
+ }
+ return {
+ id: a.id,
+ name: a.name,
+ publicName: a.publicName,
+ dates: {
+ start: a.dateOpened,
+ end: a.dateClosed,
+ created: a.dateCreated,
+ },
+ assessments: a.assessments,
+ assignedOrgs,
+ // If testData is not defined, default to false when mapping
+ testData: a.testData ?? false,
+ };
+ });
// Create a list of all the stats document paths we need to get
const statsPaths = data
// First filter out any missing administration documents
- .filter((item) => item.document !== undefined)
+ .filter((item) => item.name !== undefined)
// Then map to the total stats document
- .map(({ document }) => `${document.name}/stats/total`);
+ .map(({ name }) => `${name}/stats/total`);
const axiosInstance = getAxiosInstance();
const batchStatsDocs = await processBatchStats(axiosInstance, statsPaths);
@@ -221,64 +96,48 @@ const mapAdministrations = async ({ isSuperAdmin, data, adminOrgs }) => {
return administrations;
};
-export const administrationPageFetcher = async (
- orderBy,
- pageLimit,
- page,
- isSuperAdmin,
- adminOrgs,
- exhaustiveAdminOrgs,
-) => {
+export const administrationPageFetcher = async (isSuperAdmin, exhaustiveAdminOrgs, fetchTestData = false, orderBy) => {
+ const authStore = useAuthStore();
+ const { roarfirekit } = storeToRefs(authStore);
+ const administrationIds = await roarfirekit.value.getAdministrations({ testData: toValue(fetchTestData) });
+
const axiosInstance = getAxiosInstance();
- if (isSuperAdmin.value) {
- const requestBody = getAdministrationsRequestBody({
- aggregationQuery: false,
- orderBy: orderBy.value,
- paginate: true,
- page: page.value,
- skinnyQuery: false,
- pageLimit: pageLimit.value,
- });
- console.log(`Fetching page ${page.value} for administrations`);
- return axiosInstance.post(':runQuery', requestBody).then(async ({ data }) => {
- return mapAdministrations({ isSuperAdmin, data, adminOrgs });
- });
- } else {
- const promises = [];
- // Iterate through each adminOrg type
- for (const [orgType, orgIds] of Object.entries(adminOrgs.value)) {
- // Then chunk those arrays into chunks of 10
- if ((orgIds ?? []).length > 0) {
- const requestBodies = _chunk(orgIds, 10).map((orgChunk) =>
- getAdministrationsRequestBody({
- aggregationQuery: false,
- paginate: false,
- skinnyQuery: false,
- assigningOrgCollection: orgType,
- assigningOrgIds: orgChunk,
- }),
- );
- // Map all of those request bodies into axios promises
- promises.push(
- requestBodies.map((requestBody) =>
- axiosInstance.post(':runQuery', requestBody).then(async ({ data }) => {
- return mapAdministrations({ isSuperAdmin, data, adminOrgs: exhaustiveAdminOrgs });
- }),
- ),
- );
+ const documentPrefix = axiosInstance.defaults.baseURL.replace('https://firestore.googleapis.com/v1/', '');
+ const documents = administrationIds.map((id) => `${documentPrefix}/administrations/${id}`);
+
+ const { data } = await axiosInstance.post(':batchGet', { documents });
+
+ const administrationData = _without(
+ data.map(({ found }) => {
+ if (found) {
+ return {
+ name: found.name,
+ data: {
+ id: _last(found.name.split('/')),
+ ..._mapValues(found.fields, (value) => convertValues(value)),
+ },
+ };
}
- }
+ return undefined;
+ }),
+ undefined,
+ );
+
+ const administrations = await mapAdministrations({
+ isSuperAdmin,
+ data: administrationData,
+ adminOrgs: exhaustiveAdminOrgs,
+ });
- const orderField = (orderBy?.value ?? orderByDefault)[0].field.fieldPath;
- const orderDirection = (orderBy?.value ?? orderByDefault)[0].direction;
- const flattened = _flatten(await Promise.all(_flatten(promises)));
- const administrations = _uniqBy(flattened, 'id')
- .filter((a) => a[orderField] !== undefined)
- .sort((a, b) => {
- if (orderDirection === 'ASCENDING') return 2 * +(a[orderField] > b[orderField]) - 1;
- if (orderDirection === 'DESCENDING') return 2 * +(b[orderField] > a[orderField]) - 1;
- return 0;
- });
- return administrations.slice(page.value * pageLimit.value, (page.value + 1) * pageLimit.value);
- }
+ const orderField = (orderBy?.value ?? orderByDefault)[0].field.fieldPath;
+ const orderDirection = (orderBy?.value ?? orderByDefault)[0].direction;
+ const sortedAdministrations = administrations
+ .filter((a) => a[orderField] !== undefined)
+ .sort((a, b) => {
+ if (orderDirection === 'ASCENDING') return 2 * +(a[orderField] > b[orderField]) - 1;
+ if (orderDirection === 'DESCENDING') return 2 * +(b[orderField] > a[orderField]) - 1;
+ return 0;
+ });
+
+ return sortedAdministrations;
};
diff --git a/src/helpers/query/assignments.js b/src/helpers/query/assignments.js
index 2d79091ca..5e0e842f4 100644
--- a/src/helpers/query/assignments.js
+++ b/src/helpers/query/assignments.js
@@ -1,3 +1,4 @@
+import { toValue, toRaw } from 'vue';
import _find from 'lodash/find';
import _flatten from 'lodash/flatten';
import _get from 'lodash/get';
@@ -9,7 +10,6 @@ import _without from 'lodash/without';
import _isEmpty from 'lodash/isEmpty';
import { convertValues, getAxiosInstance, getProjectId, mapFields } from './utils';
import { pluralizeFirestoreCollection } from '@/helpers';
-import { toRaw } from 'vue';
const userSelectFields = ['name', 'assessmentPid', 'username', 'studentData', 'schools', 'classes'];
@@ -20,9 +20,13 @@ const assignmentSelectFields = [
'dateAssigned',
'dateClosed',
'dateOpened',
+ 'id',
+ 'legal',
+ 'name',
+ 'publicName',
'readOrgs',
+ 'sequential',
'started',
- 'id',
];
export const getAssignmentsRequestBody = ({
@@ -173,90 +177,6 @@ export const getAssignmentsRequestBody = ({
return requestBody;
};
-export const getUsersByAssignmentIdRequestBody = ({
- adminId,
- orgType,
- orgId,
- filter,
- aggregationQuery,
- pageLimit,
- page,
- paginate = true,
- select = userSelectFields,
-}) => {
- const requestBody = {
- structuredQuery: {},
- };
-
- if (!aggregationQuery) {
- if (paginate) {
- requestBody.structuredQuery.limit = pageLimit;
- requestBody.structuredQuery.offset = page * pageLimit;
- }
-
- if (select.length > 0) {
- requestBody.structuredQuery.select = {
- fields: select.map((field) => ({ fieldPath: field })),
- };
- }
- }
-
- requestBody.structuredQuery.from = [
- {
- collectionId: 'users',
- allDescendants: false,
- },
- ];
-
- requestBody.structuredQuery.where = {
- compositeFilter: {
- op: 'AND',
- filters: [
- {
- fieldFilter: {
- field: { fieldPath: `${pluralizeFirestoreCollection(orgType)}.current` },
- op: 'ARRAY_CONTAINS',
- value: { stringValue: orgId },
- },
- },
- {
- fieldFilter: {
- field: { fieldPath: `assignments.assigned` },
- op: 'ARRAY_CONTAINS_ANY',
- value: { arrayValue: { values: [{ stringValue: adminId }] } },
- },
- },
- ],
- },
- };
-
- if (filter) {
- requestBody.structuredQuery.where.compositeFilter.filters.push({
- fieldFilter: {
- field: { fieldPath: filter[0].field },
- op: 'EQUAL',
- value: { stringValue: filter[0].value },
- },
- });
- }
-
- if (aggregationQuery) {
- return {
- structuredAggregationQuery: {
- ...requestBody,
- aggregations: [
- {
- alias: 'count',
- count: {},
- },
- ],
- },
- };
- }
-
- return requestBody;
-};
-
export const getFilteredScoresRequestBody = ({
adminId,
orgId,
@@ -1058,6 +978,13 @@ export const assignmentPageFetcher = async (
}
};
+/**
+/**
+ * Fetches the assignments that are currently open for a user.
+ *
+ * @param {ref} roarUid - A Vue ref containing the user's ROAR ID.
+ * @returns {Promise} - A promise that resolves to an array of open assignments for the user.
+ */
export const getUserAssignments = async (roarUid) => {
const adminAxiosInstance = getAxiosInstance();
const assignmentRequest = getAssignmentsRequestBody({
@@ -1065,11 +992,14 @@ export const getUserAssignments = async (roarUid) => {
paginate: false,
isCollectionGroupQuery: false,
});
- return await adminAxiosInstance.post(`/users/${roarUid}:runQuery`, assignmentRequest).then(async ({ data }) => {
- const assignmentData = mapFields(data);
- const openAssignments = assignmentData.filter((assignment) => new Date(assignment.dateOpened) <= new Date());
- return openAssignments;
- });
+ const userId = toValue(roarUid);
+ return await adminAxiosInstance
+ .post(`/users/${toValue(userId)}:runQuery`, assignmentRequest)
+ .then(async ({ data }) => {
+ const assignmentData = mapFields(data);
+ const openAssignments = assignmentData.filter((assignment) => new Date(assignment.dateOpened) <= new Date());
+ return openAssignments;
+ });
};
export const assignmentFetchAll = async (adminId, orgType, orgId, includeScores = false) => {
diff --git a/src/helpers/query/legal.js b/src/helpers/query/legal.js
index 115de9fa7..101533357 100644
--- a/src/helpers/query/legal.js
+++ b/src/helpers/query/legal.js
@@ -1,6 +1,11 @@
import _capitalize from 'lodash/capitalize';
import { convertValues, getAxiosInstance } from './utils';
+/**
+ * Fetches legal documents.
+ *
+ * @returns {Promise>} A promise that resolves to an array of legal document objects.
+ */
export const fetchLegalDocs = () => {
const axiosInstance = getAxiosInstance('admin');
return axiosInstance.get('/legal').then(({ data }) => {
diff --git a/src/helpers/query/orgs.js b/src/helpers/query/orgs.js
index f569c0d58..f78e9328a 100644
--- a/src/helpers/query/orgs.js
+++ b/src/helpers/query/orgs.js
@@ -1,6 +1,20 @@
+import { toValue } from 'vue';
import _intersection from 'lodash/intersection';
import _uniq from 'lodash/uniq';
-import { convertValues, fetchDocById, getAxiosInstance, mapFields, orderByDefault } from './utils';
+import _flattenDeep from 'lodash/flattenDeep';
+import _isEmpty from 'lodash/isEmpty';
+import _without from 'lodash/without';
+import _zip from 'lodash/zip';
+import {
+ batchGetDocs,
+ convertValues,
+ fetchDocById,
+ getAxiosInstance,
+ mapFields,
+ orderByDefault,
+} from '@/helpers/query/utils';
+import { ORG_TYPES, SINGULAR_ORG_TYPES } from '@/constants/orgTypes';
+import { FIRESTORE_COLLECTIONS } from '@/constants/firebase';
export const getOrgsRequestBody = ({
orgType,
@@ -49,77 +63,82 @@ export const getOrgsRequestBody = ({
},
];
+ requestBody.structuredQuery.where = {
+ compositeFilter: {
+ op: 'AND',
+ filters: [
+ {
+ fieldFilter: {
+ field: { fieldPath: 'archived' },
+ op: 'EQUAL',
+ value: { booleanValue: false },
+ },
+ },
+ ],
+ },
+ };
+
if (orgName && !(parentDistrict || parentSchool)) {
- requestBody.structuredQuery.where = {
+ requestBody.structuredQuery.where.compositeFilter.filters.push({
fieldFilter: {
field: { fieldPath: 'name' },
op: 'EQUAL',
value: { stringValue: orgName },
},
- };
+ });
} else if (orgType === 'schools' && parentDistrict) {
if (orgName) {
- requestBody.structuredQuery.where = {
- compositeFilter: {
- op: 'AND',
- filters: [
- {
- fieldFilter: {
- field: { fieldPath: 'name' },
- op: 'EQUAL',
- value: { stringValue: orgName },
- },
- },
- {
- fieldFilter: {
- field: { fieldPath: 'districtId' },
- op: 'EQUAL',
- value: { stringValue: parentDistrict },
- },
- },
- ],
+ requestBody.structuredQuery.where.compositeFilter.filters.push(
+ {
+ fieldFilter: {
+ field: { fieldPath: 'name' },
+ op: 'EQUAL',
+ value: { stringValue: orgName },
+ },
},
- };
+ {
+ fieldFilter: {
+ field: { fieldPath: 'districtId' },
+ op: 'EQUAL',
+ value: { stringValue: parentDistrict },
+ },
+ },
+ );
} else {
- requestBody.structuredQuery.where = {
+ requestBody.structuredQuery.where.compositeFilter.filters.push({
fieldFilter: {
field: { fieldPath: 'districtId' },
op: 'EQUAL',
value: { stringValue: parentDistrict },
},
- };
+ });
}
} else if (orgType === 'classes' && parentSchool) {
if (orgName) {
- requestBody.structuredQuery.where = {
- compositeFilter: {
- op: 'AND',
- filters: [
- {
- fieldFilter: {
- field: { fieldPath: 'name' },
- op: 'EQUAL',
- value: { stringValue: orgName },
- },
- },
- {
- fieldFilter: {
- field: { fieldPath: 'schoolId' },
- op: 'EQUAL',
- value: { stringValue: parentSchool },
- },
- },
- ],
+ requestBody.structuredQuery.where.compositeFilter.filters.push(
+ {
+ fieldFilter: {
+ field: { fieldPath: 'name' },
+ op: 'EQUAL',
+ value: { stringValue: orgName },
+ },
},
- };
+ {
+ fieldFilter: {
+ field: { fieldPath: 'schoolId' },
+ op: 'EQUAL',
+ value: { stringValue: parentSchool },
+ },
+ },
+ );
} else {
- requestBody.structuredQuery.where = {
+ requestBody.structuredQuery.where.compositeFilter.filters.push({
fieldFilter: {
field: { fieldPath: 'schoolId' },
op: 'EQUAL',
value: { stringValue: parentSchool },
},
- };
+ });
}
}
@@ -236,13 +255,15 @@ export const orgFetcher = async (
selectedDistrict,
isSuperAdmin,
adminOrgs,
- select = ['name', 'id', 'currentActivationCode'],
+ select = ['name', 'id', 'tags', 'currentActivationCode'],
) => {
+ const districtId = toValue(selectedDistrict);
+
if (isSuperAdmin.value) {
const axiosInstance = getAxiosInstance();
const requestBody = getOrgsRequestBody({
orgType: orgType,
- parentDistrict: orgType === 'schools' ? selectedDistrict.value : null,
+ parentDistrict: orgType === 'schools' ? districtId : null,
aggregationQuery: false,
paginate: false,
select: select,
@@ -251,7 +272,7 @@ export const orgFetcher = async (
if (orgType === 'districts') {
console.log(`Fetching ${orgType}`);
} else if (orgType === 'schools') {
- console.log(`Fetching ${orgType} for ${selectedDistrict.value}`);
+ console.log(`Fetching ${orgType} for ${districtId}`);
}
return axiosInstance.post(':runQuery', requestBody).then(({ data }) => mapFields(data));
@@ -287,8 +308,8 @@ export const orgFetcher = async (
return Promise.all(promises);
} else if (orgType === 'schools') {
- const districtDoc = await fetchDocById('districts', selectedDistrict.value, ['schools']);
- if ((adminOrgs.value['districts'] ?? []).includes(selectedDistrict.value)) {
+ const districtDoc = await fetchDocById('districts', districtId, ['schools']);
+ if ((adminOrgs.value['districts'] ?? []).includes(districtId)) {
const promises = (districtDoc.schools ?? []).map((schoolId) => {
return fetchDocById('schools', schoolId, select);
});
@@ -357,6 +378,7 @@ export const orgPageFetcher = async (
}
const orgIds = adminOrgs.value[activeOrgType.value] ?? [];
+ // @TODO: Refactor to a single query for all orgs instead of multiple parallel queries.
const promises = orgIds.map((orgId) => fetchDocById(activeOrgType.value, orgId));
const orderField = (orderBy?.value ?? orderByDefault)[0].field.fieldPath;
const orderDirection = (orderBy?.value ?? orderByDefault)[0].direction;
@@ -405,3 +427,200 @@ export const orgFetchAll = async (
);
}
};
+
+/**
+ * Fetches Districts Schools Groups Families (DSGF) Org data for a given administration.
+ *
+ * @param {String} administrationId – The ID of the administration to fetch DSGF orgs data for.
+ * @param {Object} assignedOrgs – The orgs assigned to the administration being processed.
+ * @returns {Promise>} A promise that resolves to an array of org objects.
+ */
+export const fetchTreeOrgs = async (administrationId, assignedOrgs) => {
+ const orgTypes = [ORG_TYPES.DISTRICTS, ORG_TYPES.SCHOOLS, ORG_TYPES.GROUPS, ORG_TYPES.FAMILIES];
+
+ const orgPaths = _flattenDeep(
+ orgTypes.map((orgType) => (assignedOrgs[orgType] ?? []).map((orgId) => `${orgType}/${orgId}`) ?? []),
+ );
+
+ const statsPaths = _flattenDeep(
+ orgTypes.map(
+ (orgType) =>
+ (assignedOrgs[orgType] ?? []).map((orgId) => `administrations/${administrationId}/stats/${orgId}`) ?? [],
+ ),
+ );
+
+ const promises = [
+ batchGetDocs(orgPaths, ['name', 'schools', 'classes', 'archivedSchools', 'archivedClasses', 'districtId']),
+ batchGetDocs(statsPaths),
+ ];
+
+ const [orgDocs, statsDocs] = await Promise.all(promises);
+
+ const dsgfOrgs = _without(
+ _zip(orgDocs, statsDocs).map(([orgDoc, stats], index) => {
+ if (!orgDoc || _isEmpty(orgDoc)) {
+ return undefined;
+ }
+ const { classes, schools, archivedSchools, archivedClasses, collection, ...nodeData } = orgDoc;
+ const node = {
+ key: String(index),
+ data: {
+ orgType: SINGULAR_ORG_TYPES[collection.toUpperCase()],
+ schools,
+ classes,
+ archivedSchools,
+ archivedClasses,
+ stats,
+ ...nodeData,
+ },
+ };
+ if (classes || archivedClasses)
+ node.children = [...(classes ?? []), ...(archivedClasses ?? [])].map((classId) => {
+ return {
+ key: `${node.key}-${classId}`,
+ data: {
+ orgType: SINGULAR_ORG_TYPES.CLASSES,
+ id: classId,
+ },
+ };
+ });
+ return node;
+ }),
+ undefined,
+ );
+
+ const districtIds = dsgfOrgs
+ .filter((node) => node.data.orgType === SINGULAR_ORG_TYPES.DISTRICTS)
+ .map((node) => node.data.id);
+
+ const dependentSchoolIds = _flattenDeep(
+ dsgfOrgs.map((node) => [...(node.data.schools ?? []), ...(node.data.archivedSchools ?? [])]),
+ );
+ const independentSchoolIds =
+ dsgfOrgs.length > 0 ? _without(assignedOrgs.schools, ...dependentSchoolIds) : assignedOrgs.schools;
+ const dependentClassIds = _flattenDeep(
+ dsgfOrgs.map((node) => [...(node.data.classes ?? []), ...(node.data.archivedClasses ?? [])]),
+ );
+ const independentClassIds =
+ dsgfOrgs.length > 0 ? _without(assignedOrgs.classes, ...dependentClassIds) : assignedOrgs.classes;
+
+ const independentSchools = (dsgfOrgs ?? []).filter((node) => {
+ return node.data.orgType === SINGULAR_ORG_TYPES.SCHOOLS && independentSchoolIds.includes(node.data.id);
+ });
+
+ const dependentSchools = (dsgfOrgs ?? []).filter((node) => {
+ return node.data.orgType === SINGULAR_ORG_TYPES.SCHOOLS && !independentSchoolIds.includes(node.data.id);
+ });
+
+ const independentClassPaths = independentClassIds.map((classId) => `classes/${classId}`);
+ const independentClassStatPaths = independentClassIds.map(
+ (classId) => `administrations/${administrationId}/stats/${classId}`,
+ );
+
+ const classPromises = [
+ batchGetDocs(independentClassPaths, ['name', 'schoolId', 'districtId']),
+ batchGetDocs(independentClassStatPaths),
+ ];
+
+ const [classDocs, classStats] = await Promise.all(classPromises);
+
+ let independentClasses = _without(
+ _zip(classDocs, classStats).map(([orgDoc, stats], index) => {
+ const { collection = FIRESTORE_COLLECTIONS.CLASSES, ...nodeData } = orgDoc ?? {};
+
+ if (_isEmpty(nodeData)) return undefined;
+
+ const node = {
+ key: String(dsgfOrgs.length + index),
+ data: {
+ orgType: SINGULAR_ORG_TYPES[collection.toUpperCase()],
+ ...(stats && { stats }),
+ ...nodeData,
+ },
+ };
+ return node;
+ }),
+ undefined,
+ );
+
+ // These are classes that are directly under a district, without a school
+ // They were eroneously categorized as independent classes but now we need
+ // to remove them from the independent classes array
+ const directReportClasses = independentClasses.filter((node) => districtIds.includes(node.data.districtId));
+ independentClasses = independentClasses.filter((node) => !districtIds.includes(node.data.districtId));
+
+ const treeTableOrgs = dsgfOrgs.filter((node) => node.data.orgType === SINGULAR_ORG_TYPES.DISTRICTS);
+ treeTableOrgs.push(...independentSchools);
+
+ for (const school of dependentSchools) {
+ const districtId = school.data.districtId;
+ const districtIndex = treeTableOrgs.findIndex((node) => node.data.id === districtId);
+ if (districtIndex !== -1) {
+ if (treeTableOrgs[districtIndex].children === undefined) {
+ treeTableOrgs[districtIndex].children = [
+ {
+ ...school,
+ key: `${treeTableOrgs[districtIndex].key}-${school.key}`,
+ },
+ ];
+ } else {
+ treeTableOrgs[districtIndex].children.push(school);
+ }
+ } else {
+ treeTableOrgs.push(school);
+ }
+ }
+
+ for (const _class of directReportClasses) {
+ const districtId = _class.data.districtId;
+ const districtIndex = treeTableOrgs.findIndex((node) => node.data.id === districtId);
+ if (districtIndex !== -1) {
+ const directReportSchoolKey = `${treeTableOrgs[districtIndex].key}-9999`;
+ const directReportSchool = {
+ key: directReportSchoolKey,
+ data: {
+ orgType: SINGULAR_ORG_TYPES.SCHOOLS,
+ orgId: '9999',
+ name: 'Direct Report Classes',
+ },
+ children: [
+ {
+ ..._class,
+ key: `${directReportSchoolKey}-${_class.key}`,
+ },
+ ],
+ };
+ if (treeTableOrgs[districtIndex].children === undefined) {
+ treeTableOrgs[districtIndex].children = [directReportSchool];
+ } else {
+ const schoolIndex = treeTableOrgs[districtIndex].children.findIndex(
+ (node) => node.key === directReportSchoolKey,
+ );
+ if (schoolIndex === -1) {
+ treeTableOrgs[districtIndex].children.push(directReportSchool);
+ } else {
+ treeTableOrgs[districtIndex].children[schoolIndex].children.push(_class);
+ }
+ }
+ } else {
+ treeTableOrgs.push(_class);
+ }
+ }
+
+ treeTableOrgs.push(...(independentClasses ?? []));
+ treeTableOrgs.push(...dsgfOrgs.filter((node) => node.data.orgType === SINGULAR_ORG_TYPES.GROUPS));
+ treeTableOrgs.push(...dsgfOrgs.filter((node) => node.data.orgType === SINGULAR_ORG_TYPES.FAMILIES));
+
+ (treeTableOrgs ?? []).forEach((node) => {
+ // Sort the schools by existance of stats then alphabetically
+ if (node.children) {
+ node.children.sort((a, b) => {
+ if (!a.data.stats) return 1;
+ if (!b.data.stats) return -1;
+ return a.data.name.localeCompare(b.data.name);
+ });
+ }
+ });
+
+ return treeTableOrgs;
+};
diff --git a/src/helpers/query/runs.js b/src/helpers/query/runs.js
index 839042ebb..f41c46a33 100644
--- a/src/helpers/query/runs.js
+++ b/src/helpers/query/runs.js
@@ -1,3 +1,4 @@
+import { toValue } from 'vue';
import _pick from 'lodash/pick';
import _get from 'lodash/get';
import _mapValues from 'lodash/mapValues';
@@ -6,6 +7,23 @@ import _without from 'lodash/without';
import { convertValues, getAxiosInstance, mapFields } from './utils';
import { pluralizeFirestoreCollection } from '@/helpers';
+/**
+ * Constructs the request body for fetching runs based on the provided parameters.
+ *
+ * @param {Object} params - The parameters for constructing the request body.
+ * @param {string} params.administrationId - The administration ID.
+ * @param {string} params.orgType - The type of the organization.
+ * @param {string} params.orgId - The ID of the organization.
+ * @param {string} [params.taskId] - The task ID.
+ * @param {boolean} [params.aggregationQuery] - Whether to use aggregation query.
+ * @param {number} [params.pageLimit] - The page limit for pagination.
+ * @param {number} [params.page] - The page number for pagination.
+ * @param {boolean} [params.paginate=true] - Whether to paginate the results.
+ * @param {Array} [params.select=['scores.computed.composite']] - The fields to select.
+ * @param {boolean} [params.allDescendants=true] - Whether to include all descendants.
+ * @param {boolean} [params.requireCompleted=false] - Whether to require completed runs.
+ * @returns {Object} The constructed request body.
+ */
export const getRunsRequestBody = ({
administrationId,
orgType,
@@ -44,7 +62,6 @@ export const getRunsRequestBody = ({
];
if (administrationId && (orgId || !allDescendants)) {
- console.log('adding assignmentId and bestRun to structuredQuery');
requestBody.structuredQuery.where = {
compositeFilter: {
op: 'AND',
@@ -68,7 +85,6 @@ export const getRunsRequestBody = ({
};
if (orgId) {
- console.log('adding orgId to structuredQuery');
requestBody.structuredQuery.where.compositeFilter.filters.push({
fieldFilter: {
field: { fieldPath: `readOrgs.${pluralizeFirestoreCollection(orgType)}` },
@@ -131,12 +147,20 @@ export const getRunsRequestBody = ({
return requestBody;
};
-export const runCounter = (administrationId, orgType, orgId) => {
+/**
+ * Counts the number of runs for a given administration and organization.
+ *
+ * @param {string} administrationId - The administration ID.
+ * @param {string} orgType - The type of the organization.
+ * @param {string} orgId - The ID of the organization.
+ * @returns {Promise} The count of runs.
+ */
+export const runCounter = async (administrationId, orgType, orgId) => {
const axiosInstance = getAxiosInstance('app');
const requestBody = getRunsRequestBody({
- administrationId,
- orgType,
- orgId,
+ administrationId: toValue(administrationId),
+ orgType: toValue(orgType),
+ orgId: toValue(orgId),
aggregationQuery: true,
});
return axiosInstance.post(':runAggregationQuery', requestBody).then(({ data }) => {
@@ -144,6 +168,22 @@ export const runCounter = (administrationId, orgType, orgId) => {
});
};
+/**
+ * Fetches run page data for a given set of parameters.
+ *
+ * @param {Object} params - The parameters for fetching run page data.
+ * @param {string} params.administrationId - The administration ID.
+ * @param {string} [params.userId] - The user ID.
+ * @param {string} params.orgType - The organization type.
+ * @param {string} params.orgId - The organization ID.
+ * @param {string} [params.taskId] - The task ID.
+ * @param {number} [params.pageLimit] - The page limit for pagination.
+ * @param {number} [params.page] - The page number for pagination.
+ * @param {Array} [params.select=['scores.computed.composite']] - The fields to select.
+ * @param {string} [params.scoreKey='scores.computed.composite'] - The key for scores.
+ * @param {boolean} [params.paginate=true] - Whether to paginate the results.
+ * @returns {Promise>} The fetched run page data.
+ */
export const runPageFetcher = async ({
administrationId,
userId,
@@ -158,18 +198,18 @@ export const runPageFetcher = async ({
}) => {
const appAxiosInstance = getAxiosInstance('app');
const requestBody = getRunsRequestBody({
- administrationId,
- orgType,
- orgId,
- taskId,
- allDescendants: userId === undefined,
+ administrationId: toValue(administrationId),
+ orgType: toValue(orgType),
+ orgId: toValue(orgId),
+ taskId: toValue(taskId),
+ allDescendants: toValue(userId) === undefined,
aggregationQuery: false,
- pageLimit: paginate ? pageLimit.value : undefined,
- page: paginate ? page.value : undefined,
- paginate: paginate,
- select: select,
+ pageLimit: paginate ? toValue(pageLimit) : undefined,
+ page: paginate ? toValue(page) : undefined,
+ paginate: toValue(paginate),
+ select: toValue(select),
});
- const runQuery = userId === undefined ? ':runQuery' : `/users/${userId}:runQuery`;
+ const runQuery = toValue(userId) === undefined ? ':runQuery' : `/users/${toValue(userId)}:runQuery`;
return appAxiosInstance.post(runQuery, requestBody).then(async ({ data }) => {
const runData = mapFields(data, true);
diff --git a/src/helpers/query/tasks.js b/src/helpers/query/tasks.js
index 66ac6fdf3..9b94f7fd4 100644
--- a/src/helpers/query/tasks.js
+++ b/src/helpers/query/tasks.js
@@ -1,7 +1,9 @@
+import { toValue } from 'vue';
import _mapValues from 'lodash/mapValues';
import _uniq from 'lodash/uniq';
import _without from 'lodash/without';
-import { convertValues, getAxiosInstance, mapFields } from './utils';
+import { convertValues, getAxiosInstance, mapFields, fetchDocsById } from './utils';
+import { FIRESTORE_DATABASES, FIRESTORE_COLLECTIONS } from '../../constants/firebase';
export const getTasksRequestBody = ({
registered = true,
@@ -79,6 +81,21 @@ export const taskFetcher = async (registered = true, allData = false, select = [
return axiosInstance.post(':runQuery', requestBody).then(({ data }) => mapFields(data));
};
+/**
+ * Fetch task documents by their IDs.
+ *
+ * @param {Array} taskIds – The array of task IDs to fetch.
+ * @returns {Promise>} The array of task documents.
+ */
+export const fetchByTaskId = async (taskIds) => {
+ const taskDocs = toValue(taskIds).map((taskId) => ({
+ collection: FIRESTORE_COLLECTIONS.TASKS,
+ docId: taskId,
+ }));
+
+ return fetchDocsById(taskDocs, FIRESTORE_DATABASES.APP);
+};
+
export const getVariantsRequestBody = ({ registered = false, aggregationQuery, pageLimit, page, paginate = false }) => {
const requestBody = { structuredQuery: {} };
diff --git a/src/helpers/query/users.js b/src/helpers/query/users.js
index 6b334df9d..7eabd8c9d 100644
--- a/src/helpers/query/users.js
+++ b/src/helpers/query/users.js
@@ -1,5 +1,22 @@
+import { toValue } from 'vue';
import { convertValues, getAxiosInstance, mapFields } from './utils';
+/**
+ * Constructs the request body for fetching users.
+ *
+ * @param {Object} params - The parameters for constructing the request body.
+ * @param {Array} [params.userIds=[]] - The IDs of the users to fetch.
+ * @param {string} params.orgType - The type of the organization (e.g., 'districts', 'schools').
+ * @param {string} params.orgId - The ID of the organization.
+ * @param {boolean} params.aggregationQuery - Whether to perform an aggregation query.
+ * @param {number} params.pageLimit - The maximum number of users to fetch per page.
+ * @param {number} params.page - The page number to fetch.
+ * @param {boolean} [params.paginate=true] - Whether to paginate the results.
+ * @param {Array} [params.select=['name']] - The fields to select in the query.
+ * @param {string} params.orderBy - The field to order the results by.
+ * @returns {Object} The constructed request body.
+ * @throws {Error} If neither userIds nor orgType and orgId are provided.
+ */
export const getUsersRequestBody = ({
userIds = [],
orgType,
@@ -10,6 +27,7 @@ export const getUsersRequestBody = ({
paginate = true,
select = ['name'],
orderBy,
+ restrictToActiveUsers = false,
}) => {
const requestBody = {
structuredQuery: {},
@@ -36,8 +54,25 @@ export const getUsersRequestBody = ({
},
];
+ requestBody.structuredQuery.where = {
+ compositeFilter: {
+ op: 'AND',
+ filters: [],
+ },
+ };
+
+ if (restrictToActiveUsers) {
+ requestBody.structuredQuery.where.compositeFilter.filters.push({
+ fieldFilter: {
+ field: { fieldPath: 'archived' },
+ op: 'EQUAL',
+ value: { booleanValue: false },
+ },
+ });
+ }
+
if (userIds.length > 0) {
- requestBody.structuredQuery.where = {
+ requestBody.structuredQuery.where.compositeFilter.filters.push({
fieldFilter: {
field: { fieldPath: 'id' }, // change this to accept document Id, if we need
op: 'IN',
@@ -51,15 +86,15 @@ export const getUsersRequestBody = ({
},
},
},
- };
+ });
} else if (orgType && orgId) {
- requestBody.structuredQuery.where = {
+ requestBody.structuredQuery.where.compositeFilter.filters.push({
fieldFilter: {
field: { fieldPath: `${orgType}.current` }, // change this to accept document Id, if we need
op: 'ARRAY_CONTAINS',
value: { stringValue: orgId },
},
- };
+ });
} else {
throw new Error('Must provide either userIds or orgType and orgId');
}
@@ -81,45 +116,53 @@ export const getUsersRequestBody = ({
return requestBody;
};
-export const usersPageFetcher = async (userIds, pageLimit, page) => {
- const axiosInstance = getAxiosInstance();
- const requestBody = getUsersRequestBody({
- userIds,
- aggregationQuery: false,
- pageLimit: pageLimit || 100,
- page: page || 1,
- paginate: true,
- });
-
- console.log(`Fetching page ${page.value} for ${userIds}`);
- return axiosInstance.post(':runQuery', requestBody).then(({ data }) => mapFields(data));
-};
-
-export const fetchUsersByOrg = async (orgType, orgId, pageLimit, page, orderBy) => {
+/**
+ * Fetches a page of users based on the provided organization type and ID.
+ *
+ * @param {string} orgType - The type of the organization (e.g., 'districts', 'schools').
+ * @param {string} orgId - The ID of the organization.
+ * @param {number} pageLimit - The maximum number of users to fetch per page.
+ * @param {number} page - The page number to fetch.
+ * @param {string} orderBy - The field to order the results by.
+ * @param {boolean} restrictToActiveUsers - Whether to restrict the count to active users.
+ * @returns {Promise