feat(editor): [Insights] Add filter by project (#19643)

This commit is contained in:
Raúl Gómez Morales
2025-09-18 08:50:15 +02:00
committed by GitHub
parent 51b8f8c7dc
commit 5cef76ae57
5 changed files with 272 additions and 30 deletions

View File

@@ -1,15 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { AllRolesMap } from '@n8n/permissions'; import ProjectSharingInfo from '@/components/Projects/ProjectSharingInfo.vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from '@n8n/i18n';
import { import {
ProjectTypes, ProjectTypes,
type ProjectListItem, type ProjectListItem,
type ProjectSharingData, type ProjectSharingData,
} from '@/types/projects.types'; } 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 { 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(); const locale = useI18n();
@@ -21,6 +22,8 @@ type Props = {
static?: boolean; static?: boolean;
placeholder?: string; placeholder?: string;
emptyOptionsText?: string; emptyOptionsText?: string;
size?: SelectSize;
clearable?: boolean;
}; };
const props = defineProps<Props>(); const props = defineProps<Props>();
@@ -30,6 +33,7 @@ const model = defineModel<(ProjectSharingData | null) | ProjectSharingData[]>({
const emit = defineEmits<{ const emit = defineEmits<{
projectAdded: [value: ProjectSharingData]; projectAdded: [value: ProjectSharingData];
projectRemoved: [value: ProjectSharingData]; projectRemoved: [value: ProjectSharingData];
clear: [];
}>(); }>();
const selectedProject = ref(Array.isArray(model.value) ? '' : (model.value?.id ?? '')); const selectedProject = ref(Array.isArray(model.value) ? '' : (model.value?.id ?? ''));
@@ -122,9 +126,11 @@ watch(
:placeholder="selectPlaceholder" :placeholder="selectPlaceholder"
:default-first-option="true" :default-first-option="true"
:no-data-text="noDataText" :no-data-text="noDataText"
size="large" :size="size ?? 'medium'"
:disabled="props.readonly" :disabled="props.readonly"
:clearable
@update:model-value="onProjectSelected" @update:model-value="onProjectSelected"
@clear="emit('clear')"
> >
<template #prefix> <template #prefix>
<N8nIcon v-if="projectIcon.type === 'icon'" :icon="projectIcon.value" color="text-dark" /> <N8nIcon v-if="projectIcon.type === 'icon'" :icon="projectIcon.value" color="text-dark" />

View File

@@ -4,9 +4,17 @@ import InsightsDashboard from './InsightsDashboard.vue';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { defaultSettings } from '@/__tests__/defaults'; import { defaultSettings } from '@/__tests__/defaults';
import { useInsightsStore } from '@/features/insights/insights.store'; 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 { within, screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createProjectListItem } from '@/__tests__/data/projects';
import { useProjectsStore } from '@/stores/projects.store';
import type { import type {
FrontendModuleSettings, FrontendModuleSettings,
InsightsByTime, InsightsByTime,
@@ -194,6 +202,11 @@ const mockTableData: InsightsByWorkflow = {
}; };
let insightsStore: MockedStore<typeof useInsightsStore>; 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', () => { describe('InsightsDashboard', () => {
beforeEach(() => { beforeEach(() => {
@@ -206,10 +219,15 @@ describe('InsightsDashboard', () => {
}); });
insightsStore = mockedStore(useInsightsStore); insightsStore = mockedStore(useInsightsStore);
projectsStore = mockedStore(useProjectsStore);
insightsStore.isSummaryEnabled = true; insightsStore.isSummaryEnabled = true;
insightsStore.isDashboardEnabled = true; insightsStore.isDashboardEnabled = true;
// Mock projects store
projectsStore.availableProjects = projects;
projectsStore.getAvailableProjects = vi.fn().mockResolvedValue(projects);
// Mock async states // Mock async states
insightsStore.summary = { insightsStore.summary = {
state: mockSummaryData, 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,
});
});
});
}); });

View File

@@ -1,15 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'; import ProjectSharing from '@/components/Projects/ProjectSharing.vue';
import { useRoute } from 'vue-router'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue'; import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/insights/insights.store'; 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 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 { INSIGHT_TYPES, TELEMETRY_TIME_RANGE, UNLICENSED_TIME_RANGE } from '../insights.constants';
import InsightsDateRangeSelect from './InsightsDateRangeSelect.vue'; import InsightsDateRangeSelect from './InsightsDateRangeSelect.vue';
import InsightsUpgradeModal from './InsightsUpgradeModal.vue'; import InsightsUpgradeModal from './InsightsUpgradeModal.vue';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
const InsightsPaywall = defineAsyncComponent( const InsightsPaywall = defineAsyncComponent(
async () => await import('@/features/insights/components/InsightsPaywall.vue'), async () => await import('@/features/insights/components/InsightsPaywall.vue'),
@@ -42,6 +45,7 @@ const i18n = useI18n();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const insightsStore = useInsightsStore(); const insightsStore = useInsightsStore();
const projectsStore = useProjectsStore();
const isTimeSavedRoute = computed(() => route.params.insightType === INSIGHT_TYPES.TIME_SAVED); 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; 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 = ({ const fetchPaginatedTableData = ({
page = 0, page = 0,
itemsPerPage = 25, itemsPerPage = 25,
sortBy, sortBy,
dateRange = selectedDateRange.value, dateRange = selectedDateRange.value,
projectId = selectedProject.value?.id,
}: { }: {
page?: number; page?: number;
itemsPerPage?: number; itemsPerPage?: number;
sortBy: Array<{ id: string; desc: boolean }>; sortBy: Array<{ id: string; desc: boolean }>;
dateRange?: InsightsDateRange['key']; dateRange?: InsightsDateRange['key'];
projectId?: string;
}) => { }) => {
const skip = page * itemsPerPage; const skip = page * itemsPerPage;
const take = itemsPerPage; const take = itemsPerPage;
@@ -80,19 +97,10 @@ const fetchPaginatedTableData = ({
take, take,
sortBy: sortKey, sortBy: sortKey,
dateRange, 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) { function handleTimeChange(value: InsightsDateRange['key'] | typeof UNLICENSED_TIME_RANGE) {
if (value === UNLICENSED_TIME_RANGE) { if (value === UNLICENSED_TIME_RANGE) {
upgradeModalVisible.value = true; upgradeModalVisible.value = true;
@@ -104,17 +112,27 @@ function handleTimeChange(value: InsightsDateRange['key'] | typeof UNLICENSED_TI
} }
watch( watch(
() => [props.insightType, selectedDateRange.value], () => [props.insightType, selectedDateRange.value, selectedProject.value],
() => { () => {
sortTableBy.value = [{ id: props.insightType, desc: true }]; sortTableBy.value = [{ id: props.insightType, desc: true }];
if (insightsStore.isSummaryEnabled) { 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) { 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(() => { onMounted(() => {
useDocumentTitle().set(i18n.baseText('insights.heading')); useDocumentTitle().set(i18n.baseText('insights.heading'));
}); });
onBeforeMount(async () => {
await projectsStore.getAvailableProjects();
});
</script> </script>
<template> <template>
@@ -134,7 +155,18 @@ onMounted(() => {
{{ i18n.baseText('insights.dashboard.title') }} {{ i18n.baseText('insights.dashboard.title') }}
</N8nHeading> </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 <InsightsDateRangeSelect
:model-value="selectedDateRange" :model-value="selectedDateRange"
style="width: 173px" style="width: 173px"
@@ -282,4 +314,11 @@ onMounted(() => {
z-index: 1; z-index: 1;
} }
} }
.projectSelect {
min-width: 200px;
:global(.el-input--suffix .el-input__inner) {
padding-right: 26px;
}
}
</style> </style>

View File

@@ -16,13 +16,13 @@ export const fetchInsightsSummary = async (
export const fetchInsightsByTime = async ( export const fetchInsightsByTime = async (
context: IRestApiContext, context: IRestApiContext,
filter?: { dateRange: InsightsDateRange['key'] }, filter?: { dateRange: InsightsDateRange['key']; projectId?: string },
): Promise<InsightsByTime[]> => ): Promise<InsightsByTime[]> =>
await makeRestApiRequest(context, 'GET', '/insights/by-time', filter); await makeRestApiRequest(context, 'GET', '/insights/by-time', filter);
export const fetchInsightsTimeSaved = async ( export const fetchInsightsTimeSaved = async (
context: IRestApiContext, context: IRestApiContext,
filter?: { dateRange: InsightsDateRange['key'] }, filter?: { dateRange: InsightsDateRange['key']; projectId?: string },
): Promise<InsightsByTime[]> => ): Promise<InsightsByTime[]> =>
await makeRestApiRequest(context, 'GET', '/insights/by-time/time-saved', filter); await makeRestApiRequest(context, 'GET', '/insights/by-time/time-saved', filter);

View File

@@ -38,7 +38,7 @@ export const useInsightsStore = defineStore('insights', () => {
); );
const summary = useAsyncState( const summary = useAsyncState(
async (filter?: { dateRange: InsightsDateRange['key'] }) => { async (filter?: { dateRange: InsightsDateRange['key']; projectId?: string }) => {
const raw = await insightsApi.fetchInsightsSummary(rootStore.restApiContext, filter); const raw = await insightsApi.fetchInsightsSummary(rootStore.restApiContext, filter);
return transformInsightsSummary(raw); return transformInsightsSummary(raw);
}, },
@@ -47,7 +47,7 @@ export const useInsightsStore = defineStore('insights', () => {
); );
const charts = useAsyncState( const charts = useAsyncState(
async (filter?: { dateRange: InsightsDateRange['key'] }) => { async (filter?: { dateRange: InsightsDateRange['key']; projectId?: string }) => {
const dataFetcher = isDashboardEnabled.value const dataFetcher = isDashboardEnabled.value
? insightsApi.fetchInsightsByTime ? insightsApi.fetchInsightsByTime
: insightsApi.fetchInsightsTimeSaved; : insightsApi.fetchInsightsTimeSaved;