Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/web/src/api-clients/_common/composables/use-scoped-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* useScopedQuery - A custom wrapper around `useQuery` to enforce scope-based API fetching.
*
* ## Why this hook exists?
* This hook was created to integrate **scope-based API access control** with Vue Query.
* It ensures that queries are only executed when the user's granted scope matches the required scope.
* Additionally, it automatically handles loading states and prevents unnecessary queries.
*
* ## Functionality
* - Extends `useQuery` with **grant scope validation**.
* - Runs queries only when **the user’s scope is valid** and the app is **ready**.
* - Uses Vue’s **reactivity** to dynamically compute the `enabled` state.
* - Supports both **static and reactive `enabled` values**.
*
* ## Parameters:
* - `options`: Standard **Vue Query options** (`UseQueryOptions`).
* - `requiredScopes`: A list of **required grant scopes** to determine if the query should execute.
*
* ## Supported Query Options:
* The following options are commonly used within our team:
* - `queryKey`: **Unique key** to identify the query in the cache.
* - `queryFn`: **Function** to fetch data from the API.
* - `select`: **Transform function** to modify response data.
* - `initialData`: **Default value** for the query when no data is available.
* - `staleTime`: **Cache duration** before the query becomes stale.
* - `enabled`: **Boolean or reactive ref** to control when the query should execute.
*
* ## Example Usage:
*
* const myQuery = useScopedQuery({
* queryKey: ['dashboard', dashboardId],
* queryFn: () => fetchDashboardData(dashboardId),
* select: (data) => data.results,
* initialData: { results: [] },
* staleTime: 1000 * 60 * 5, // 5 minutes
* enabled: computed(() => isUserAuthorized.value),
* }, ['DOMAIN', 'WORKSPACE']);
*
*/


import type { MaybeRef } from '@vueuse/core';
import { toValue } from '@vueuse/core';
import { computed, reactive } from 'vue';

import type {
UseQueryOptions,
} from '@tanstack/vue-query';
import { useQuery } from '@tanstack/vue-query';

import type { GrantScope } from '@/schema/identity/token/type';

import { useAppContextStore } from '@/store/app-context/app-context-store';
import { useUserStore } from '@/store/user/user-store';

export const useScopedQuery = <TQueryFnData = unknown, TError = unknown, TData = TQueryFnData>(
options: UseQueryOptions<TQueryFnData, TError, TData>,
requiredScopes: GrantScope[] = [],
) => {
const appContextStore = useAppContextStore();
const userStore = useUserStore();

const _state = reactive({
currentGrantScope: computed<GrantScope>(() => userStore.state.currentGrantInfo?.scope || 'USER'),
isLoading: computed(() => appContextStore.getters.globalGrantLoading),
isValidScope: computed(() => requiredScopes.includes(_state.currentGrantScope)),
});

const queryEnabled = computed<boolean>(() => {
const _inheritedEnabled = options?.enabled as MaybeRef<boolean> | undefined;

if (_inheritedEnabled !== undefined && !toValue(_inheritedEnabled)) {
return false;
}
return _state.isValidScope && !_state.isLoading;
});

return useQuery<TQueryFnData, TError, TData>({
...options,
enabled: queryEnabled,
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import type { ComputedRef } from 'vue';
import { computed } from 'vue';

import type { QueryClient, QueryKey } from '@tanstack/vue-query';
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { useQueryClient } from '@tanstack/vue-query';

import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query';
import type { WidgetModel, WidgetUpdateParams } from '@/api-clients/dashboard/_types/widget-type';
import { usePrivateDataTableApi } from '@/api-clients/dashboard/private-data-table/composables/use-private-data-table-api';
import { usePrivateWidgetApi } from '@/api-clients/dashboard/private-widget/composables/use-private-widget-api';
Expand Down Expand Up @@ -101,23 +102,23 @@ export const useWidgetFormQuery = ({
]);

/* Querys */
const publicWidgetQuery = useQuery({
const publicWidgetQuery = useScopedQuery({
queryKey: _publicWidgetQueryKey,
queryFn: () => publicWidgetAPI.get({
widget_id: widgetId?.value as string,
}),
enabled: computed(() => !!widgetId?.value && !isPrivate.value && !preventLoad),
staleTime: STALE_TIME,
});
const privateWidgetQuery = useQuery({
}, ['DOMAIN', 'WORKSPACE']);
const privateWidgetQuery = useScopedQuery({
queryKey: _privateWidgetQueryKey,
queryFn: () => privateWidgetAPI.get({
widget_id: widgetId?.value as string,
}),
enabled: computed(() => !!widgetId?.value && isPrivate.value && !preventLoad),
staleTime: STALE_TIME,
});
const publicDataTableListQuery = useQuery({
}, ['WORKSPACE']);
const publicDataTableListQuery = useScopedQuery({
queryKey: _publicDataTableListQueryKey,
queryFn: () => publicDataTableAPI.list({
widget_id: widgetId?.value as string,
Expand All @@ -127,8 +128,8 @@ export const useWidgetFormQuery = ({
initialData: DEFAULT_LIST_DATA,
initialDataUpdatedAt: 0,
staleTime: STALE_TIME,
});
const privateDataTableListQuery = useQuery({
}, ['DOMAIN', 'WORKSPACE']);
const privateDataTableListQuery = useScopedQuery({
queryKey: _privateDataTableListQueryKey,
queryFn: () => privateDataTableAPI.list({
widget_id: widgetId?.value as string,
Expand All @@ -138,7 +139,7 @@ export const useWidgetFormQuery = ({
initialData: DEFAULT_LIST_DATA,
initialDataUpdatedAt: 0,
staleTime: STALE_TIME,
});
}, ['WORKSPACE']);

/* Fetchers */
const updateDataTableFn = (params: DataTableUpdateParameters): Promise<DataTableModel> => {
Expand All @@ -164,7 +165,7 @@ export const useWidgetFormQuery = ({

return {
widget: computed(() => (isPrivate.value ? privateWidgetQuery.data.value : publicWidgetQuery.data.value)),
dataTableList: computed(() => (isPrivate.value ? privateDataTableListQuery.data.value : publicDataTableListQuery.data.value)),
dataTableList: computed(() => (isPrivate.value ? privateDataTableListQuery.data.value : publicDataTableListQuery.data.value) ?? []),
dataTableListLoading,
widgetLoading,
api: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ const state = reactive({
return { start: _start, end: _end };
}),
dateFormat: computed<string|undefined>(() => DATE_FORMAT?.[widgetOptionsState.dateFormatInfo?.format]?.[widgetOptionsState.granularityInfo?.granularity]),
dataTableLoading: false,
});

const widgetOptionsState = reactive({
Expand Down Expand Up @@ -222,7 +223,7 @@ const queryResult = useQuery({
staleTime: WIDGET_LOAD_STALE_TIME,
});

const widgetLoading = computed<boolean>(() => queryResult.isFetching.value);
const widgetLoading = computed<boolean>(() => queryResult.isFetching.value || state.dataTableLoading);
const errorMessage = computed<string|undefined>(() => {
if (!state.dataTable) return i18n.t('COMMON.WIDGETS.NO_DATA_TABLE_ERROR_MESSAGE');
return queryResult.error?.value?.message;
Expand Down Expand Up @@ -317,13 +318,16 @@ useResizeObserver(chartContext, throttle(() => {

watch(() => props.dataTableId, async (newDataTableId) => {
if (!newDataTableId) return;
state.dataTableLoading = true;
const fetcher = state.isPrivateWidget
? api.privateDataTableAPI.get
: api.publicDataTableAPI.get;
try {
state.dataTable = await fetcher({ data_table_id: newDataTableId });
} catch (e) {
ErrorHandler.handleError(e);
} finally {
state.dataTableLoading = false;
}
}, { immediate: true });
defineExpose<WidgetExpose>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const state = reactive({
return widgetContentWidth / 8 < BOX_MIN_WIDTH ? BOX_MIN_WIDTH : widgetContentWidth / 8;
}),
legendList: [] as WidgetLegend[],
dataTableLoading: false,
});

const widgetOptionsState = reactive({
Expand Down Expand Up @@ -118,7 +119,7 @@ const queryResult = useQuery({
staleTime: WIDGET_LOAD_STALE_TIME,
});

const widgetLoading = computed<boolean>(() => queryResult.isFetching.value);
const widgetLoading = computed<boolean>(() => queryResult.isFetching.value || state.dataTableLoading);
const errorMessage = computed<string|undefined>(() => {
if (!state.dataTable) return i18n.t('COMMON.WIDGETS.NO_DATA_TABLE_ERROR_MESSAGE');
return queryResult.error?.value?.message;
Expand Down Expand Up @@ -175,13 +176,16 @@ watch(() => widgetOptionsState.formatRulesInfo, async () => {

watch(() => props.dataTableId, async (newDataTableId) => {
if (!newDataTableId) return;
state.dataTableLoading = true;
const fetcher = state.isPrivateWidget
? api.privateDataTableAPI.get
: api.publicDataTableAPI.get;
try {
state.dataTable = await fetcher({ data_table_id: newDataTableId });
} catch (e) {
ErrorHandler.handleError(e);
} finally {
state.dataTableLoading = false;
}
}, { immediate: true });
defineExpose<WidgetExpose>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const state = reactive({
}
return { start: _start, end: _end };
}),
dataTableLoading: false,
});

const widgetOptionsState = reactive({
Expand Down Expand Up @@ -155,7 +156,7 @@ const queryResult = useQuery({
staleTime: WIDGET_LOAD_STALE_TIME,
});

const widgetLoading = computed<boolean>(() => queryResult.isFetching.value);
const widgetLoading = computed<boolean>(() => queryResult.isFetching.value || state.dataTableLoading);
const errorMessage = computed<string|undefined>(() => {
if (!state.dataTable) return i18n.t('COMMON.WIDGETS.NO_DATA_TABLE_ERROR_MESSAGE');
return queryResult.error?.value?.message;
Expand Down Expand Up @@ -241,13 +242,16 @@ watch(() => widgetOptionsState, () => {

watch(() => props.dataTableId, async (newDataTableId) => {
if (!newDataTableId) return;
state.dataTableLoading = true;
const fetcher = state.isPrivateWidget
? api.privateDataTableAPI.get
: api.publicDataTableAPI.get;
try {
state.dataTable = await fetcher({ data_table_id: newDataTableId });
} catch (e) {
ErrorHandler.handleError(e);
} finally {
state.dataTableLoading = false;
}
}, { immediate: true });
defineExpose<WidgetExpose>({
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/common/modules/widgets/_widgets/gauge/Gauge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const state = reactive({
});
return _color;
}),
dataTableLoading: false,
});

const widgetOptionsState = reactive({
Expand Down Expand Up @@ -179,7 +180,7 @@ const queryResult = useQuery({
staleTime: WIDGET_LOAD_STALE_TIME,
});

const widgetLoading = computed<boolean>(() => queryResult.isFetching.value);
const widgetLoading = computed<boolean>(() => queryResult.isFetching.value || state.dataTableLoading);
const errorMessage = computed<string|undefined>(() => {
if (!state.dataTable) return i18n.t('COMMON.WIDGETS.NO_DATA_TABLE_ERROR_MESSAGE');
return queryResult.error?.value?.message;
Expand Down Expand Up @@ -209,13 +210,16 @@ watch([() => state.data, () => props.widgetOptions], ([newData]) => {
}, { immediate: true });
watch(() => props.dataTableId, async (newDataTableId) => {
if (!newDataTableId) return;
state.dataTableLoading = true;
const fetcher = state.isPrivateWidget
? api.privateDataTableAPI.get
: api.publicDataTableAPI.get;
try {
state.dataTable = await fetcher({ data_table_id: newDataTableId });
} catch (e) {
ErrorHandler.handleError(e);
} finally {
state.dataTableLoading = false;
}
}, { immediate: true });
defineExpose<WidgetExpose>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const state = reactive({
},
],
})),
dataTableLoading: false,
});

const widgetOptionsState = reactive({
Expand Down Expand Up @@ -156,7 +157,7 @@ const queryResult = useQuery({
staleTime: WIDGET_LOAD_STALE_TIME,
});

const widgetLoading = computed<boolean>(() => queryResult.isFetching.value);
const widgetLoading = computed<boolean>(() => queryResult.isFetching.value || state.dataTableLoading);
const errorMessage = computed<string>(() => {
if (!state.dataTable) return i18n.t('COMMON.WIDGETS.NO_DATA_TABLE_ERROR_MESSAGE');
return queryResult.error?.value?.message;
Expand Down Expand Up @@ -216,13 +217,16 @@ useResizeObserver(chartContext, throttle(() => {

watch(() => props.dataTableId, async (newDataTableId) => {
if (!newDataTableId) return;
state.dataTableLoading = true;
const fetcher = state.isPrivateWidget
? api.privateDataTableAPI.get
: api.publicDataTableAPI.get;
try {
state.dataTable = await fetcher({ data_table_id: newDataTableId });
} catch (e) {
ErrorHandler.handleError(e);
} finally {
state.dataTableLoading = false;
}
}, { immediate: true });
defineExpose<WidgetExpose>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ const state = reactive({
}
return { start: _start, end: _end };
}),
dataTableLoading: false,
});

const widgetOptionsState = reactive({
Expand Down Expand Up @@ -220,7 +221,7 @@ const queryResult = useQuery({
staleTime: WIDGET_LOAD_STALE_TIME,
});

const widgetLoading = computed<boolean>(() => queryResult.isFetching.value);
const widgetLoading = computed<boolean>(() => queryResult.isFetching.value || state.dataTableLoading);
const errorMessage = computed<string>(() => {
if (!state.dataTable) return i18n.t('COMMON.WIDGETS.NO_DATA_TABLE_ERROR_MESSAGE');
return queryResult.error?.value?.message;
Expand Down Expand Up @@ -268,13 +269,16 @@ useResizeObserver(chartContext, throttle(() => {

watch(() => props.dataTableId, async (newDataTableId) => {
if (!newDataTableId) return;
state.dataTableLoading = true;
const fetcher = state.isPrivateWidget
? api.privateDataTableAPI.get
: api.publicDataTableAPI.get;
try {
state.dataTable = await fetcher({ data_table_id: newDataTableId });
} catch (e) {
ErrorHandler.handleError(e);
} finally {
state.dataTableLoading = false;
}
}, { immediate: true });
defineExpose<WidgetExpose>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ const state = reactive({
return { start: _start, end: _end };
}),
dateFormat: computed<string|undefined>(() => DATE_FORMAT?.[widgetOptionsState.dateFormatInfo?.format]?.[widgetOptionsState.granularityInfo?.granularity]),
dataTableLoading: false,
});

const widgetOptionsState = reactive({
Expand Down Expand Up @@ -227,7 +228,7 @@ const queryResult = useQuery({
staleTime: WIDGET_LOAD_STALE_TIME,
});

const widgetLoading = computed<boolean>(() => queryResult.isFetching.value);
const widgetLoading = computed<boolean>(() => queryResult.isFetching.value || state.dataTableLoading);
const errorMessage = computed<string|undefined>(() => {
if (!state.dataTable) return i18n.t('COMMON.WIDGETS.NO_DATA_TABLE_ERROR_MESSAGE');
return queryResult.error?.value?.message;
Expand Down Expand Up @@ -298,13 +299,16 @@ useResizeObserver(chartContext, throttle(() => {

watch(() => props.dataTableId, async (newDataTableId) => {
if (!newDataTableId) return;
state.dataTableLoading = true;
const fetcher = state.isPrivateWidget
? api.privateDataTableAPI.get
: api.publicDataTableAPI.get;
try {
state.dataTable = await fetcher({ data_table_id: newDataTableId });
} catch (e) {
ErrorHandler.handleError(e);
} finally {
state.dataTableLoading = false;
}
}, { immediate: true });
defineExpose<WidgetExpose>({
Expand Down
Loading
Loading