From 7e7e970502b46f202ea04f72dffc37168355bd39 Mon Sep 17 00:00:00 2001 From: tristandubbeld Date: Fri, 21 Nov 2025 09:47:33 +0100 Subject: [PATCH 1/4] refactor(dashboard): use minimal fragment for search sandboxes --- .../overmind/effects/gql/dashboard/queries.ts | 38 ++++++++++++++++++- .../overmind/namespaces/dashboard/actions.ts | 3 +- .../namespaces/dashboard/internalActions.ts | 10 +++-- .../overmind/namespaces/dashboard/state.ts | 7 ++-- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/packages/app/src/app/overmind/effects/gql/dashboard/queries.ts b/packages/app/src/app/overmind/effects/gql/dashboard/queries.ts index 441a9a24fe7..4d722645d28 100644 --- a/packages/app/src/app/overmind/effects/gql/dashboard/queries.ts +++ b/packages/app/src/app/overmind/effects/gql/dashboard/queries.ts @@ -303,6 +303,40 @@ export const getTeams: Query = gql` ${teamFragmentDashboard} `; +const SEARCH_TEAM_SANDBOX_FRAGMENT = gql` + fragment searchTeamSandbox on Sandbox { + id + alias + title + description + updatedAt + viewCount + isV2 + draft + restricted + privacy + screenshotUrl + + source { + template + } + + customTemplate { + id + iconUrl + } + + author { + username + } + + collection { + path + id + } + } +`; + export const searchTeamSandboxes: Query< _SearchTeamSandboxesQuery, _SearchTeamSandboxesQueryVariables @@ -313,12 +347,12 @@ export const searchTeamSandboxes: Query< team(id: $teamId) { sandboxes(orderBy: { field: "updated_at", direction: DESC }) { - ...sandboxFragmentDashboard + ...searchTeamSandbox } } } } - ${sandboxFragmentDashboard} + ${SEARCH_TEAM_SANDBOX_FRAGMENT} `; /** diff --git a/packages/app/src/app/overmind/namespaces/dashboard/actions.ts b/packages/app/src/app/overmind/namespaces/dashboard/actions.ts index 5eb692d6e22..2b7da2d80fc 100755 --- a/packages/app/src/app/overmind/namespaces/dashboard/actions.ts +++ b/packages/app/src/app/overmind/namespaces/dashboard/actions.ts @@ -9,6 +9,7 @@ import { DraftSandboxFragment, RepoFragmentDashboardFragment, ProjectFragment, + SearchTeamSandboxFragment, } from 'app/graphql/types'; import { sandboxUrl, @@ -751,7 +752,7 @@ export const getSearchSandboxes = async ({ state, effects }: Context) => { try { const activeTeam = state.activeTeam; - let sandboxes: SandboxFragmentDashboardFragment[] = []; + let sandboxes: SearchTeamSandboxFragment[] = []; if (activeTeam) { const data = await effects.gql.queries.searchTeamSandboxes({ teamId: activeTeam, diff --git a/packages/app/src/app/overmind/namespaces/dashboard/internalActions.ts b/packages/app/src/app/overmind/namespaces/dashboard/internalActions.ts index 8e282535f81..dda89640f5e 100644 --- a/packages/app/src/app/overmind/namespaces/dashboard/internalActions.ts +++ b/packages/app/src/app/overmind/namespaces/dashboard/internalActions.ts @@ -3,6 +3,7 @@ import { SandboxFragmentDashboardFragment, SandboxByPathFragment, DraftSandboxFragment, + SearchTeamSandboxFragment, } from 'app/graphql/types'; /** @@ -19,14 +20,14 @@ export const changeSandboxesInState = ( * The mutation that happens on the sandbox, make sure to return a *new* sandbox here, to make sure * that we can still rollback easily in the future. */ - sandboxMutation: ( + sandboxMutation: ( sandbox: T ) => T; } ) => { const changedSandboxes: Set> = new Set(); - const doMutateSandbox = ( + const doMutateSandbox = ( sandbox: T ): T => { changedSandboxes.add(sandbox); @@ -110,7 +111,7 @@ export const deleteSandboxesFromState = ( ids: string[]; } ) => { - const sandboxFilter = ( + const sandboxFilter = ( sandbox: T ): boolean => !ids.includes(sandbox.id); @@ -165,7 +166,8 @@ export const deleteSandboxesFromState = ( } else if (type !== 'RECENT_BRANCHES') { const newSandboxes = sandboxStructure[type].filter(sandboxFilter); if (newSandboxes.length !== sandboxStructure[type].length) { - dashboard.sandboxes[type] = newSandboxes; + // TypeScript can't narrow the union type based on the key, so we need to assert + (dashboard.sandboxes as any)[type] = newSandboxes; } } }); diff --git a/packages/app/src/app/overmind/namespaces/dashboard/state.ts b/packages/app/src/app/overmind/namespaces/dashboard/state.ts index d314d957ed5..bcb866e8c12 100755 --- a/packages/app/src/app/overmind/namespaces/dashboard/state.ts +++ b/packages/app/src/app/overmind/namespaces/dashboard/state.ts @@ -9,6 +9,7 @@ import { ProjectFragment as Repository, ProjectWithBranchesFragment as RepositoryWithBranches, RecentlyDeletedTeamSandboxesFragment, + SearchTeamSandboxFragment, } from 'app/graphql/types'; import isSameWeek from 'date-fns/isSameWeek'; import { sortBy } from 'lodash-es'; @@ -23,7 +24,7 @@ export type DashboardSandboxStructure = { DELETED: RecentlyDeletedTeamSandboxesFragment[] | null; RECENT_SANDBOXES: (Sandbox | DraftSandboxFragment)[] | null; RECENT_BRANCHES: Branch[] | null; - SEARCH: (Sandbox | DraftSandboxFragment)[] | null; + SEARCH: (Sandbox | DraftSandboxFragment | SearchTeamSandboxFragment)[] | null; TEMPLATE_HOME: Template[] | null; SHARED: (Sandbox | DraftSandboxFragment)[] | null; ALL: { @@ -50,7 +51,7 @@ export type State = { viewMode: 'grid' | 'list'; orderBy: OrderBy; getFilteredSandboxes: ( - sandboxes: Array + sandboxes: Array ) => Sandbox[]; deletedSandboxesByTime: { week: RecentlyDeletedTeamSandboxesFragment[]; @@ -139,7 +140,7 @@ export const state: State = { }, getFilteredSandboxes: derived( ({ orderBy }: State) => ( - sandboxes: Array + sandboxes: Array ) => { const orderField = orderBy.field; const orderOrder = orderBy.order; From ad9e5c9fe4458828b9b647dfdf2eb25a26ec2069 Mon Sep 17 00:00:00 2001 From: tristandubbeld Date: Fri, 21 Nov 2025 09:48:14 +0100 Subject: [PATCH 2/4] refactor(dashboard): generated types for search fragment --- packages/app/src/app/graphql/types.ts | 51 ++++++++++++++++----------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/app/src/app/graphql/types.ts b/packages/app/src/app/graphql/types.ts index 506efa19fe5..6fcf48961c4 100644 --- a/packages/app/src/app/graphql/types.ts +++ b/packages/app/src/app/graphql/types.ts @@ -5113,6 +5113,33 @@ export type AllTeamsQuery = { } | null; }; +export type SearchTeamSandboxFragment = { + __typename?: 'Sandbox'; + id: string; + alias: string | null; + title: string | null; + description: string | null; + updatedAt: string; + viewCount: number; + isV2: boolean; + draft: boolean; + restricted: boolean; + privacy: number; + screenshotUrl: string | null; + source: { __typename?: 'Source'; template: string | null }; + customTemplate: { + __typename?: 'Template'; + id: any | null; + iconUrl: string | null; + } | null; + author: { __typename?: 'User'; username: string } | null; + collection: { + __typename?: 'Collection'; + path: string; + id: any | null; + } | null; +}; + export type _SearchTeamSandboxesQueryVariables = Exact<{ teamId: Scalars['UUID4']; }>; @@ -5130,43 +5157,25 @@ export type _SearchTeamSandboxesQuery = { alias: string | null; title: string | null; description: string | null; - lastAccessedAt: any; - insertedAt: string; updatedAt: string; - removedAt: string | null; - privacy: number; - isFrozen: boolean; - screenshotUrl: string | null; viewCount: number; - likeCount: number; isV2: boolean; draft: boolean; restricted: boolean; - authorId: any | null; - teamId: any | null; + privacy: number; + screenshotUrl: string | null; source: { __typename?: 'Source'; template: string | null }; customTemplate: { __typename?: 'Template'; id: any | null; iconUrl: string | null; } | null; - forkedTemplate: { - __typename?: 'Template'; - id: any | null; - color: string | null; - iconUrl: string | null; - } | null; + author: { __typename?: 'User'; username: string } | null; collection: { __typename?: 'Collection'; path: string; id: any | null; } | null; - author: { __typename?: 'User'; username: string } | null; - permissions: { - __typename?: 'SandboxProtectionSettings'; - preventSandboxLeaving: boolean; - preventSandboxExport: boolean; - } | null; }>; } | null; } | null; From d3cddef2073e477164af69f6ec4154d46c40c801 Mon Sep 17 00:00:00 2001 From: tristandubbeld Date: Fri, 21 Nov 2025 09:56:13 +0100 Subject: [PATCH 3/4] feat(dashboard): track dashboard search --- .../src/app/pages/Dashboard/Header/index.tsx | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/app/src/app/pages/Dashboard/Header/index.tsx b/packages/app/src/app/pages/Dashboard/Header/index.tsx index 801a83d21b4..2b7c9792643 100644 --- a/packages/app/src/app/pages/Dashboard/Header/index.tsx +++ b/packages/app/src/app/pages/Dashboard/Header/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import { Link, useHistory } from 'react-router-dom'; import { Combobox, ComboboxInput } from '@reach/combobox'; @@ -163,17 +163,38 @@ const SearchInputGroup = () => { new URLSearchParams(window.location.search).get('query') || '' ); + const lastTrackedQuery = useRef(null); + const search = (queryString: string) => { + // Track search start when executing a new search query + if (lastTrackedQuery.current !== queryString && queryString.length >= 1) { + track('Dashboard - Topbar - Search'); + lastTrackedQuery.current = queryString; + } + history.push(dashboardUrls.search(queryString, activeTeam)); }; const [debouncedSearch] = useDebouncedCallback(search, 200); + const onFocus = () => { + track('Dashboard - Topbar - Search Focus'); + }; + const onChange = (event: React.ChangeEvent) => { - setQuery(event.target.value); - if (event.target.value.length >= 1) { - debouncedSearch(event.target.value); + const newValue = event.target.value; + + setQuery(newValue); + + // Reset tracking when query becomes empty + if (newValue.length === 0) { + lastTrackedQuery.current = null; + } + + if (newValue.length >= 1) { + debouncedSearch(newValue); } - if (!event.target.value) { + + if (!newValue) { history.push(dashboardUrls.recent(activeTeam)); } }; @@ -214,6 +235,7 @@ const SearchInputGroup = () => { as={Input} value={query} onChange={onChange} + onFocus={onFocus} // onKeyPress={handleEnter} placeholder="Search in workspace" icon="search" From 15e0523fb7bb47ae31ca274675db4bd20f377476 Mon Sep 17 00:00:00 2001 From: tristandubbeld Date: Fri, 21 Nov 2025 10:05:05 +0100 Subject: [PATCH 4/4] fix: add type guards for SearchTeamSandboxFragment missing properties --- .../overmind/namespaces/dashboard/actions.ts | 128 +++++++++++++----- 1 file changed, 92 insertions(+), 36 deletions(-) diff --git a/packages/app/src/app/overmind/namespaces/dashboard/actions.ts b/packages/app/src/app/overmind/namespaces/dashboard/actions.ts index 2b7da2d80fc..45151813162 100755 --- a/packages/app/src/app/overmind/namespaces/dashboard/actions.ts +++ b/packages/app/src/app/overmind/namespaces/dashboard/actions.ts @@ -6,6 +6,7 @@ import { uniq } from 'lodash-es'; import { TemplateFragmentDashboardFragment, SandboxFragmentDashboardFragment, + SandboxByPathFragment, DraftSandboxFragment, RepoFragmentDashboardFragment, ProjectFragment, @@ -30,6 +31,19 @@ import { import { OrderBy, PageTypes, sandboxesTypes } from './types'; import * as internalActions from './internalActions'; +// Type guards to check if sandbox has specific properties +function hasIsFrozen( + sandbox: SandboxFragmentDashboardFragment | SandboxByPathFragment | DraftSandboxFragment | SearchTeamSandboxFragment +): sandbox is SandboxFragmentDashboardFragment | SandboxByPathFragment | DraftSandboxFragment { + return 'isFrozen' in sandbox; +} + +function hasPermissions( + sandbox: SandboxFragmentDashboardFragment | SandboxByPathFragment | DraftSandboxFragment | SearchTeamSandboxFragment +): sandbox is SandboxFragmentDashboardFragment | SandboxByPathFragment | DraftSandboxFragment { + return 'permissions' in sandbox; +} + export const internal = internalActions; export const dashboardMounted = withLoadApp(); @@ -981,21 +995,35 @@ export const changeSandboxesFrozen = async ( changedSandboxes, } = actions.dashboard.internal.changeSandboxesInState({ sandboxIds, - sandboxMutation: sandbox => ({ ...sandbox, isFrozen }), + sandboxMutation: sandbox => { + // Only update isFrozen if the property exists on the sandbox type + if (hasIsFrozen(sandbox)) { + return { ...sandbox, isFrozen }; + } + return sandbox; + }, }); try { await effects.gql.mutations.changeFrozen({ sandboxIds, isFrozen }); } catch (error) { - changedSandboxes.forEach(oldSandbox => - actions.dashboard.internal.changeSandboxesInState({ - sandboxIds: [oldSandbox.id], - sandboxMutation: sandbox => ({ - ...sandbox, - isFrozen: oldSandbox.isFrozen, - }), - }) - ); + changedSandboxes.forEach(oldSandbox => { + // Only rollback if the sandbox has isFrozen property + if (hasIsFrozen(oldSandbox)) { + actions.dashboard.internal.changeSandboxesInState({ + sandboxIds: [oldSandbox.id], + sandboxMutation: sandbox => { + if (hasIsFrozen(sandbox)) { + return { + ...sandbox, + isFrozen: oldSandbox.isFrozen, + }; + } + return sandbox; + }, + }); + } + }); actions.internal.handleError({ message: @@ -1129,10 +1157,16 @@ export const setPreventSandboxesLeavingWorkspace = async ( changedSandboxes, } = actions.dashboard.internal.changeSandboxesInState({ sandboxIds, - sandboxMutation: sandbox => ({ - ...sandbox, - permissions: { ...sandbox.permissions, preventSandboxLeaving }, - }), + sandboxMutation: sandbox => { + // Only update permissions if the property exists on the sandbox type + if (hasPermissions(sandbox) && sandbox.permissions) { + return { + ...sandbox, + permissions: { ...sandbox.permissions, preventSandboxLeaving }, + }; + } + return sandbox; + }, }); effects.analytics.track(`Dashboard - Change sandbox permissions`, { @@ -1147,15 +1181,23 @@ export const setPreventSandboxesLeavingWorkspace = async ( effects.notificationToast.success('Sandbox permissions updated.'); } catch (error) { - changedSandboxes.forEach(oldSandbox => - actions.dashboard.internal.changeSandboxesInState({ - sandboxIds: [oldSandbox.id], - sandboxMutation: sandbox => ({ - ...sandbox, - permissions: { ...oldSandbox.permissions }, - }), - }) - ); + changedSandboxes.forEach(oldSandbox => { + // Only rollback if the sandbox has permissions property + if (hasPermissions(oldSandbox) && oldSandbox.permissions) { + actions.dashboard.internal.changeSandboxesInState({ + sandboxIds: [oldSandbox.id], + sandboxMutation: sandbox => { + if (hasPermissions(sandbox) && sandbox.permissions) { + return { + ...sandbox, + permissions: { ...oldSandbox.permissions }, + }; + } + return sandbox; + }, + }); + } + }); effects.notificationToast.error( 'There was a problem updating your sandbox permissions' ); @@ -1177,10 +1219,16 @@ export const setPreventSandboxesExport = async ( changedSandboxes, } = actions.dashboard.internal.changeSandboxesInState({ sandboxIds, - sandboxMutation: sandbox => ({ - ...sandbox, - permissions: { ...sandbox.permissions, preventSandboxExport }, - }), + sandboxMutation: sandbox => { + // Only update permissions if the property exists on the sandbox type + if (hasPermissions(sandbox) && sandbox.permissions) { + return { + ...sandbox, + permissions: { ...sandbox.permissions, preventSandboxExport }, + }; + } + return sandbox; + }, }); effects.analytics.track(`Dashboard - Change sandbox permissions`, { @@ -1195,15 +1243,23 @@ export const setPreventSandboxesExport = async ( effects.notificationToast.success('Sandbox permissions updated.'); } catch (error) { - changedSandboxes.forEach(oldSandbox => - actions.dashboard.internal.changeSandboxesInState({ - sandboxIds: [oldSandbox.id], - sandboxMutation: sandbox => ({ - ...sandbox, - permissions: { ...oldSandbox.permissions }, - }), - }) - ); + changedSandboxes.forEach(oldSandbox => { + // Only rollback if the sandbox has permissions property + if (hasPermissions(oldSandbox) && oldSandbox.permissions) { + actions.dashboard.internal.changeSandboxesInState({ + sandboxIds: [oldSandbox.id], + sandboxMutation: sandbox => { + if (hasPermissions(sandbox) && sandbox.permissions) { + return { + ...sandbox, + permissions: { ...oldSandbox.permissions }, + }; + } + return sandbox; + }, + }); + } + }); effects.notificationToast.error( 'There was a problem updating your sandbox permissions' );