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
18 changes: 15 additions & 3 deletions site/src/components/hub/SearchPopover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*/
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import { watchDebounced } from '@vueuse/core';
import { useHubStore } from '@/composables/useHubStore';
import { useHubStore, type FilterBadge } from '@/composables/useHubStore';
import { search as searchIndex, type SearchResults } from '@/lib/search';
import { Badge } from '@/components/ui/badge';
import { IconApps, IconWorkflow } from '@/components/ui/icons';
Expand Down Expand Up @@ -72,6 +72,10 @@ const overflowCountDesktop = computed(() =>
Math.max(0, store.filterBadges.value.length - MAX_BADGES_DESKTOP)
);

function serializeFilters(badges: FilterBadge[]): string[] {
return badges.map((badge) => `${badge.type}:${badge.value}`);
}

const allTags = computed(() => {
const counts = new Map<string, number>();
for (const tmpl of props.templates) {
Expand Down Expand Up @@ -179,11 +183,19 @@ watchDebounced(
return;
}
isSearching.value = true;
const filtersApplied = serializeFilters(store.filterBadges.value);
const creatorResultCount = matchedCreators.value.length;
try {
searchResults.value = await searchIndex(trimmed, {
const results = await searchIndex(trimmed, {
allowedNames: badgeFilteredNames.value ?? undefined,
});
trackSearchPerformed(trimmed);
searchResults.value = results;
trackSearchPerformed({
query: trimmed,
workflowResultCount: results.workflows.length,
creatorResultCount,
filtersApplied,
});
} catch (err) {
console.error('Search failed:', err);
searchResults.value = { workflows: [], creators: [] };
Expand Down
4 changes: 2 additions & 2 deletions site/src/composables/useHubStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function useHubStore() {
(b) => b.type === badge.type && b.value === badge.value
);
if (!exists) {
filterBadges.value.push(badge);
filterBadges.value = [...filterBadges.value, badge];
}
},

Expand All @@ -73,7 +73,7 @@ export function useHubStore() {
(b) => !(b.type === badge.type && b.value === badge.value)
);
} else {
filterBadges.value.push(badge);
filterBadges.value = [...filterBadges.value, badge];
}
},

Expand Down
26 changes: 23 additions & 3 deletions site/src/lib/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ export function initPostHog(): void {
* - past tense verbs
* - e.g. hub:run_button_clicked, hub:template_viewed
*/
type EventProperties = Record<string, string | number | boolean | undefined>;
type EventPropertyValue = string | number | boolean | string[] | undefined;
type EventProperties = Record<string, EventPropertyValue>;

interface SearchPerformedProperties {
query: string;
workflowResultCount: number;
creatorResultCount: number;
filtersApplied: string[];
}

export function capture(eventName: string, properties?: EventProperties): void {
if (typeof window === 'undefined' || !initialized) return;
Expand Down Expand Up @@ -74,9 +82,21 @@ export function trackTemplateViewed(
});
}

export function trackSearchPerformed(query: string): void {
export function trackSearchPerformed({
query,
workflowResultCount,
creatorResultCount,
filtersApplied,
}: SearchPerformedProperties): void {
const trimmedQuery = query.trim();
if (!trimmedQuery) return;

capture('hub:search_performed', {
query,
query: trimmedQuery,
workflow_result_count: workflowResultCount,
creator_result_count: creatorResultCount,
surface: 'workflows',
filters_applied: filtersApplied,
});
}

Expand Down
81 changes: 81 additions & 0 deletions site/tests/unit/posthog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

const posthogMock = vi.hoisted(() => ({
init: vi.fn(),
capture: vi.fn(),
}));

vi.mock('posthog-js', () => ({
default: posthogMock,
}));

async function loadPostHog() {
vi.resetModules();
vi.stubGlobal('window', {});
vi.stubEnv('PUBLIC_POSTHOG_KEY', 'phc_test');
return import('../../src/lib/posthog');
}

afterEach(() => {
posthogMock.init.mockReset();
posthogMock.capture.mockReset();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.resetModules();
});

describe('trackSearchPerformed', () => {
it('tracks workflow search metadata', async () => {
const { initPostHog, trackSearchPerformed } = await loadPostHog();

initPostHog();
trackSearchPerformed({
query: ' flux ',
workflowResultCount: 10,
creatorResultCount: 2,
filtersApplied: ['model:Flux', 'mode:app'],
});

expect(posthogMock.capture).toHaveBeenCalledWith('hub:search_performed', {
query: 'flux',
workflow_result_count: 10,
creator_result_count: 2,
surface: 'workflows',
filters_applied: ['model:Flux', 'mode:app'],
});
});

it('tracks zero-result searches', async () => {
const { initPostHog, trackSearchPerformed } = await loadPostHog();

initPostHog();
trackSearchPerformed({
query: 'nonexistent workflow',
workflowResultCount: 0,
creatorResultCount: 0,
filtersApplied: [],
});

expect(posthogMock.capture).toHaveBeenCalledWith('hub:search_performed', {
query: 'nonexistent workflow',
workflow_result_count: 0,
creator_result_count: 0,
surface: 'workflows',
filters_applied: [],
});
});

it('skips empty queries', async () => {
const { initPostHog, trackSearchPerformed } = await loadPostHog();

initPostHog();
trackSearchPerformed({
query: ' ',
workflowResultCount: 1,
creatorResultCount: 0,
filtersApplied: ['tag:image'],
});

expect(posthogMock.capture).not.toHaveBeenCalled();
});
});
Loading