mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(editor): [Insights] Add filter by project (#19643)
This commit is contained in:
committed by
GitHub
parent
51b8f8c7dc
commit
5cef76ae57
@@ -1,15 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AllRolesMap } from '@n8n/permissions';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import ProjectSharingInfo from '@/components/Projects/ProjectSharingInfo.vue';
|
||||
import {
|
||||
ProjectTypes,
|
||||
type ProjectListItem,
|
||||
type ProjectSharingData,
|
||||
} from '@/types/projects.types';
|
||||
import ProjectSharingInfo from '@/components/Projects/ProjectSharingInfo.vue';
|
||||
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
|
||||
import { isIconOrEmoji, type IconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
|
||||
import type { SelectSize } from '@n8n/design-system/types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { AllRolesMap } from '@n8n/permissions';
|
||||
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
@@ -21,6 +22,8 @@ type Props = {
|
||||
static?: boolean;
|
||||
placeholder?: string;
|
||||
emptyOptionsText?: string;
|
||||
size?: SelectSize;
|
||||
clearable?: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -30,6 +33,7 @@ const model = defineModel<(ProjectSharingData | null) | ProjectSharingData[]>({
|
||||
const emit = defineEmits<{
|
||||
projectAdded: [value: ProjectSharingData];
|
||||
projectRemoved: [value: ProjectSharingData];
|
||||
clear: [];
|
||||
}>();
|
||||
|
||||
const selectedProject = ref(Array.isArray(model.value) ? '' : (model.value?.id ?? ''));
|
||||
@@ -122,9 +126,11 @@ watch(
|
||||
:placeholder="selectPlaceholder"
|
||||
:default-first-option="true"
|
||||
:no-data-text="noDataText"
|
||||
size="large"
|
||||
:size="size ?? 'medium'"
|
||||
:disabled="props.readonly"
|
||||
:clearable
|
||||
@update:model-value="onProjectSelected"
|
||||
@clear="emit('clear')"
|
||||
>
|
||||
<template #prefix>
|
||||
<N8nIcon v-if="projectIcon.type === 'icon'" :icon="projectIcon.value" color="text-dark" />
|
||||
|
||||
@@ -4,9 +4,17 @@ import InsightsDashboard from './InsightsDashboard.vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { defaultSettings } from '@/__tests__/defaults';
|
||||
import { useInsightsStore } from '@/features/insights/insights.store';
|
||||
import { mockedStore, type MockedStore, useEmitters, waitAllPromises } from '@/__tests__/utils';
|
||||
import {
|
||||
mockedStore,
|
||||
type MockedStore,
|
||||
useEmitters,
|
||||
waitAllPromises,
|
||||
getDropdownItems,
|
||||
} from '@/__tests__/utils';
|
||||
import { within, screen, waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createProjectListItem } from '@/__tests__/data/projects';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import type {
|
||||
FrontendModuleSettings,
|
||||
InsightsByTime,
|
||||
@@ -194,6 +202,11 @@ const mockTableData: InsightsByWorkflow = {
|
||||
};
|
||||
|
||||
let insightsStore: MockedStore<typeof useInsightsStore>;
|
||||
let projectsStore: MockedStore<typeof useProjectsStore>;
|
||||
|
||||
const personalProject = createProjectListItem('personal');
|
||||
const teamProjects = Array.from({ length: 2 }, () => createProjectListItem('team'));
|
||||
const projects = [personalProject, ...teamProjects];
|
||||
|
||||
describe('InsightsDashboard', () => {
|
||||
beforeEach(() => {
|
||||
@@ -206,10 +219,15 @@ describe('InsightsDashboard', () => {
|
||||
});
|
||||
|
||||
insightsStore = mockedStore(useInsightsStore);
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
|
||||
insightsStore.isSummaryEnabled = true;
|
||||
insightsStore.isDashboardEnabled = true;
|
||||
|
||||
// Mock projects store
|
||||
projectsStore.availableProjects = projects;
|
||||
projectsStore.getAvailableProjects = vi.fn().mockResolvedValue(projects);
|
||||
|
||||
// Mock async states
|
||||
insightsStore.summary = {
|
||||
state: mockSummaryData,
|
||||
@@ -511,4 +529,183 @@ describe('InsightsDashboard', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project Filter Functionality', () => {
|
||||
it('should render project sharing component', async () => {
|
||||
renderComponent({
|
||||
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('project-sharing-select')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should select a project and filter data by project ID', async () => {
|
||||
renderComponent({
|
||||
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('project-sharing-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const projectSelect = screen.getByTestId('project-sharing-select');
|
||||
|
||||
// Click to open the dropdown
|
||||
await userEvent.click(projectSelect);
|
||||
|
||||
// Get dropdown items
|
||||
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
expect(projectSelectDropdownItems.length).toBeGreaterThan(0);
|
||||
|
||||
// Find and click the first team project
|
||||
const teamProject = [...projectSelectDropdownItems].find(
|
||||
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
|
||||
);
|
||||
expect(teamProject).toBeDefined();
|
||||
|
||||
await userEvent.click(teamProject as Element);
|
||||
|
||||
// Verify that all data fetching methods were called with the selected project ID
|
||||
expect(insightsStore.summary.execute).toHaveBeenCalledWith(0, {
|
||||
dateRange: 'week',
|
||||
projectId: teamProjects[0].id,
|
||||
});
|
||||
expect(insightsStore.charts.execute).toHaveBeenCalledWith(0, {
|
||||
dateRange: 'week',
|
||||
projectId: teamProjects[0].id,
|
||||
});
|
||||
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||
skip: 0,
|
||||
take: 25,
|
||||
sortBy: 'total:desc',
|
||||
dateRange: 'week',
|
||||
projectId: teamProjects[0].id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should combine project filter with date range changes', async () => {
|
||||
renderComponent({
|
||||
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||
});
|
||||
|
||||
// Select a project first
|
||||
const projectSelect = screen.getByTestId('project-sharing-select');
|
||||
await userEvent.click(projectSelect);
|
||||
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
const teamProject = [...projectSelectDropdownItems].find(
|
||||
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
|
||||
);
|
||||
await userEvent.click(teamProject as Element);
|
||||
|
||||
// Clear previous calls
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Now change the date range
|
||||
const dateRangeSelect = within(screen.getByTestId('range-select')).getByRole('combobox');
|
||||
await userEvent.click(dateRangeSelect);
|
||||
|
||||
const controllingId = dateRangeSelect.getAttribute('aria-controls');
|
||||
const actions = document.querySelector(`#${controllingId}`);
|
||||
if (!actions) {
|
||||
throw new Error('Actions menu not found');
|
||||
}
|
||||
|
||||
// Select the first option (day range)
|
||||
await userEvent.click(actions.querySelectorAll('li')[0]);
|
||||
|
||||
// Verify both project ID and new date range are passed
|
||||
expect(insightsStore.summary.execute).toHaveBeenCalledWith(0, {
|
||||
dateRange: 'day',
|
||||
projectId: teamProjects[0].id,
|
||||
});
|
||||
expect(insightsStore.charts.execute).toHaveBeenCalledWith(0, {
|
||||
dateRange: 'day',
|
||||
projectId: teamProjects[0].id,
|
||||
});
|
||||
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||
skip: 0,
|
||||
take: 25,
|
||||
sortBy: 'total:desc',
|
||||
dateRange: 'day',
|
||||
projectId: teamProjects[0].id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain project filter when insight type changes', async () => {
|
||||
const { rerender } = renderComponent({
|
||||
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||
});
|
||||
|
||||
// Select a project
|
||||
const projectSelect = screen.getByTestId('project-sharing-select');
|
||||
await userEvent.click(projectSelect);
|
||||
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
const teamProject = [...projectSelectDropdownItems].find(
|
||||
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
|
||||
);
|
||||
await userEvent.click(teamProject as Element);
|
||||
|
||||
// Clear previous calls
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Change insight type
|
||||
await rerender({ insightType: INSIGHT_TYPES.FAILED });
|
||||
|
||||
// Verify the project ID is still passed with the new insight type
|
||||
expect(insightsStore.summary.execute).toHaveBeenCalledWith(0, {
|
||||
dateRange: 'week',
|
||||
projectId: teamProjects[0].id,
|
||||
});
|
||||
expect(insightsStore.charts.execute).toHaveBeenCalledWith(0, {
|
||||
dateRange: 'week',
|
||||
projectId: teamProjects[0].id,
|
||||
});
|
||||
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||
skip: 0,
|
||||
take: 25,
|
||||
sortBy: 'failed:desc',
|
||||
dateRange: 'week',
|
||||
projectId: teamProjects[0].id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass project ID to table pagination and sorting events', async () => {
|
||||
renderComponent({
|
||||
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||
});
|
||||
|
||||
// Select a project
|
||||
const projectSelect = screen.getByTestId('project-sharing-select');
|
||||
await userEvent.click(projectSelect);
|
||||
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
const teamProject = [...projectSelectDropdownItems].find(
|
||||
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
|
||||
);
|
||||
await userEvent.click(teamProject as Element);
|
||||
|
||||
await waitAllPromises();
|
||||
|
||||
// Clear previous calls to focus on pagination event
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Simulate pagination event - note that the function uses the current selectedProject value
|
||||
// not the projectId parameter when called from table events
|
||||
emitters.n8nDataTableServer.emit('update:options', {
|
||||
page: 1,
|
||||
itemsPerPage: 50,
|
||||
sortBy: [{ id: 'failed', desc: true }],
|
||||
});
|
||||
|
||||
// The function should use the selectedProject.value?.id when projectId is not explicitly passed
|
||||
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||
skip: 50,
|
||||
take: 50,
|
||||
sortBy: 'failed:desc',
|
||||
dateRange: 'week',
|
||||
projectId: teamProjects[0].id,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import ProjectSharing from '@/components/Projects/ProjectSharing.vue';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
||||
import { useInsightsStore } from '@/features/insights/insights.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import type { ProjectSharingData } from '@/types/projects.types';
|
||||
import type { InsightsDateRange, InsightsSummaryType } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, defineAsyncComponent, onBeforeMount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { INSIGHT_TYPES, TELEMETRY_TIME_RANGE, UNLICENSED_TIME_RANGE } from '../insights.constants';
|
||||
import InsightsDateRangeSelect from './InsightsDateRangeSelect.vue';
|
||||
import InsightsUpgradeModal from './InsightsUpgradeModal.vue';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
|
||||
const InsightsPaywall = defineAsyncComponent(
|
||||
async () => await import('@/features/insights/components/InsightsPaywall.vue'),
|
||||
@@ -42,6 +45,7 @@ const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const insightsStore = useInsightsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const isTimeSavedRoute = computed(() => route.params.insightType === INSIGHT_TYPES.TIME_SAVED);
|
||||
|
||||
@@ -59,16 +63,29 @@ const transformFilter = ({ id, desc }: { id: string; desc: boolean }) => {
|
||||
return `${key}:${order}` as const;
|
||||
};
|
||||
|
||||
const sortTableBy = ref([{ id: props.insightType, desc: true }]);
|
||||
|
||||
const upgradeModalVisible = ref(false);
|
||||
const selectedDateRange = ref<InsightsDateRange['key']>('week');
|
||||
const granularity = computed(
|
||||
() =>
|
||||
insightsStore.dateRanges.find((item) => item.key === selectedDateRange.value)?.granularity ??
|
||||
'day',
|
||||
);
|
||||
const selectedProject = ref<ProjectSharingData | null>(null);
|
||||
|
||||
const fetchPaginatedTableData = ({
|
||||
page = 0,
|
||||
itemsPerPage = 25,
|
||||
sortBy,
|
||||
dateRange = selectedDateRange.value,
|
||||
projectId = selectedProject.value?.id,
|
||||
}: {
|
||||
page?: number;
|
||||
itemsPerPage?: number;
|
||||
sortBy: Array<{ id: string; desc: boolean }>;
|
||||
dateRange?: InsightsDateRange['key'];
|
||||
projectId?: string;
|
||||
}) => {
|
||||
const skip = page * itemsPerPage;
|
||||
const take = itemsPerPage;
|
||||
@@ -80,19 +97,10 @@ const fetchPaginatedTableData = ({
|
||||
take,
|
||||
sortBy: sortKey,
|
||||
dateRange,
|
||||
projectId,
|
||||
});
|
||||
};
|
||||
|
||||
const sortTableBy = ref([{ id: props.insightType, desc: true }]);
|
||||
|
||||
const upgradeModalVisible = ref(false);
|
||||
const selectedDateRange = ref<InsightsDateRange['key']>('week');
|
||||
const granularity = computed(
|
||||
() =>
|
||||
insightsStore.dateRanges.find((item) => item.key === selectedDateRange.value)?.granularity ??
|
||||
'day',
|
||||
);
|
||||
|
||||
function handleTimeChange(value: InsightsDateRange['key'] | typeof UNLICENSED_TIME_RANGE) {
|
||||
if (value === UNLICENSED_TIME_RANGE) {
|
||||
upgradeModalVisible.value = true;
|
||||
@@ -104,17 +112,27 @@ function handleTimeChange(value: InsightsDateRange['key'] | typeof UNLICENSED_TI
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.insightType, selectedDateRange.value],
|
||||
() => [props.insightType, selectedDateRange.value, selectedProject.value],
|
||||
() => {
|
||||
sortTableBy.value = [{ id: props.insightType, desc: true }];
|
||||
|
||||
if (insightsStore.isSummaryEnabled) {
|
||||
void insightsStore.summary.execute(0, { dateRange: selectedDateRange.value });
|
||||
void insightsStore.summary.execute(0, {
|
||||
dateRange: selectedDateRange.value,
|
||||
projectId: selectedProject.value?.id,
|
||||
});
|
||||
}
|
||||
|
||||
void insightsStore.charts.execute(0, { dateRange: selectedDateRange.value });
|
||||
void insightsStore.charts.execute(0, {
|
||||
dateRange: selectedDateRange.value,
|
||||
projectId: selectedProject.value?.id,
|
||||
});
|
||||
if (insightsStore.isDashboardEnabled) {
|
||||
fetchPaginatedTableData({ sortBy: sortTableBy.value, dateRange: selectedDateRange.value });
|
||||
fetchPaginatedTableData({
|
||||
sortBy: sortTableBy.value,
|
||||
dateRange: selectedDateRange.value,
|
||||
projectId: selectedProject.value?.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -125,6 +143,9 @@ watch(
|
||||
onMounted(() => {
|
||||
useDocumentTitle().set(i18n.baseText('insights.heading'));
|
||||
});
|
||||
onBeforeMount(async () => {
|
||||
await projectsStore.getAvailableProjects();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -134,7 +155,18 @@ onMounted(() => {
|
||||
{{ i18n.baseText('insights.dashboard.title') }}
|
||||
</N8nHeading>
|
||||
|
||||
<div class="mt-s">
|
||||
<div class="mt-s" style="display: flex; gap: 12px; align-items: center">
|
||||
<ProjectSharing
|
||||
v-model="selectedProject"
|
||||
:projects="projectsStore.availableProjects"
|
||||
:placeholder="i18n.baseText('forms.resourceFiltersDropdown.owner.placeholder')"
|
||||
:empty-options-text="i18n.baseText('projects.sharing.noMatchingProjects')"
|
||||
size="mini"
|
||||
:class="$style.projectSelect"
|
||||
clearable
|
||||
@clear="selectedProject = null"
|
||||
/>
|
||||
|
||||
<InsightsDateRangeSelect
|
||||
:model-value="selectedDateRange"
|
||||
style="width: 173px"
|
||||
@@ -282,4 +314,11 @@ onMounted(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.projectSelect {
|
||||
min-width: 200px;
|
||||
:global(.el-input--suffix .el-input__inner) {
|
||||
padding-right: 26px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,13 +16,13 @@ export const fetchInsightsSummary = async (
|
||||
|
||||
export const fetchInsightsByTime = async (
|
||||
context: IRestApiContext,
|
||||
filter?: { dateRange: InsightsDateRange['key'] },
|
||||
filter?: { dateRange: InsightsDateRange['key']; projectId?: string },
|
||||
): Promise<InsightsByTime[]> =>
|
||||
await makeRestApiRequest(context, 'GET', '/insights/by-time', filter);
|
||||
|
||||
export const fetchInsightsTimeSaved = async (
|
||||
context: IRestApiContext,
|
||||
filter?: { dateRange: InsightsDateRange['key'] },
|
||||
filter?: { dateRange: InsightsDateRange['key']; projectId?: string },
|
||||
): Promise<InsightsByTime[]> =>
|
||||
await makeRestApiRequest(context, 'GET', '/insights/by-time/time-saved', filter);
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const useInsightsStore = defineStore('insights', () => {
|
||||
);
|
||||
|
||||
const summary = useAsyncState(
|
||||
async (filter?: { dateRange: InsightsDateRange['key'] }) => {
|
||||
async (filter?: { dateRange: InsightsDateRange['key']; projectId?: string }) => {
|
||||
const raw = await insightsApi.fetchInsightsSummary(rootStore.restApiContext, filter);
|
||||
return transformInsightsSummary(raw);
|
||||
},
|
||||
@@ -47,7 +47,7 @@ export const useInsightsStore = defineStore('insights', () => {
|
||||
);
|
||||
|
||||
const charts = useAsyncState(
|
||||
async (filter?: { dateRange: InsightsDateRange['key'] }) => {
|
||||
async (filter?: { dateRange: InsightsDateRange['key']; projectId?: string }) => {
|
||||
const dataFetcher = isDashboardEnabled.value
|
||||
? insightsApi.fetchInsightsByTime
|
||||
: insightsApi.fetchInsightsTimeSaved;
|
||||
|
||||
Reference in New Issue
Block a user