Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Commit fcdcfef

Browse files
authored
add saved search visibility, draft, and timestamps (#63909)
**Public saved searches will let us make global saved searches for dotcom and for customers to help them discover and share awesome search queries!** Saved searches now have: - Visibility (public vs. secret). Only site admins may make a saved search public. Secret saved searches are visible only to their owners (either a user, or all members of the owning org). A public saved search can be viewed by everyone on the instance. - Draft status: If a saved search's "draft" checkbox is checked, that means that other people shouldn't use that saved search yet. You're still working on it. - Timestamps: The last user to update a saved search and the creator of the saved search are now recorded. Also adds a lot more tests for saved search UI and backend code. ![image](https://github.com/user-attachments/assets/a6fdfa54-61c5-4a0f-9f04-1bdf34ae3ad4) ![image](https://github.com/user-attachments/assets/a29567a0-9cfa-4535-99b6-7ffb4aea4102) ![image](https://github.com/user-attachments/assets/e287e18c-0b88-451d-b1c8-a26987e7ec47) ## Test plan Create a saved search. Ensure it's in secret visibility to begin with. As a site admin, make it public. Ensure other users can view it, and no edit buttons are shown. Try changing visibility back and forth. ## Changelog - Saved searches can now be made public (by site admins), which means all users can view them. This is a great way to share useful search queries with all users of a Sourcegraph instance. - Saved searches can be marked as a "draft", which is a gentle indicator that other people shouldn't use it yet.
1 parent f983676 commit fcdcfef

39 files changed

+1942
-532
lines changed

client/web/BUILD.bazel

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,8 @@ ts_project(
11001100
"src/insights/types.ts",
11011101
"src/insights/utils/use-series-toggle.ts",
11021102
"src/jscontext.ts",
1103+
"src/library/itemBadges.tsx",
1104+
"src/library/useLibraryConfiguration.ts",
11031105
"src/marketing/backend.ts",
11041106
"src/marketing/components/HubSpotForm.tsx",
11051107
"src/marketing/components/SurveyRatingRadio.tsx",
@@ -1395,6 +1397,7 @@ ts_project(
13951397
"src/savedSearches/SavedSearchModal.tsx",
13961398
"src/savedSearches/graphql.ts",
13971399
"src/savedSearches/telemetry.ts",
1400+
"src/savedSearches/util.ts",
13981401
"src/search/QuickLinks.tsx",
13991402
"src/search/SearchPageWrapper.tsx",
14001403
"src/search/autocompletion/hooks.ts",
@@ -1968,6 +1971,7 @@ ts_project(
19681971
"src/marketing/page/SurveyPage.test.tsx",
19691972
"src/marketing/toast/SurveyToast.test.tsx",
19701973
"src/monitoring/shouldErrorBeReported.test.ts",
1974+
"src/namespaces/graphql.mocks.ts",
19711975
"src/namespaces/useAffiliatedNamespaces.test.tsx",
19721976
"src/nav/GlobalNavbar.test.tsx",
19731977
"src/nav/StatusMessagesNavItem.mocks.ts",
@@ -1998,7 +2002,11 @@ ts_project(
19982002
"src/repo/releases/RepositoryReleasesTagsPage.test.tsx",
19992003
"src/repo/tree/TreePage.test.tsx",
20002004
"src/repo/utils.test.ts",
2005+
"src/savedSearches/DetailPage.test.tsx",
2006+
"src/savedSearches/EditPage.test.tsx",
20012007
"src/savedSearches/Form.test.tsx",
2008+
"src/savedSearches/ListPage.test.tsx",
2009+
"src/savedSearches/NewForm.test.tsx",
20022010
"src/savedSearches/graphql.mocks.ts",
20032011
"src/search/helpers.test.tsx",
20042012
"src/search/index.test.ts",

client/web/src/integration/search.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import expect from 'expect'
22
import { afterEach, beforeEach, describe, test } from 'mocha'
33
import { Key } from 'ts-key-enum'
44

5-
import { SymbolKind, type SharedGraphQlOperations } from '@sourcegraph/shared/src/graphql-operations'
5+
import {
6+
SavedSearchVisibility,
7+
SymbolKind,
8+
type SharedGraphQlOperations,
9+
} from '@sourcegraph/shared/src/graphql-operations'
610
import {
711
commitHighlightResult,
812
commitSearchStreamEvents,
@@ -614,8 +618,22 @@ describe('Search', () => {
614618
description: 'Demo',
615619
id: 'U2F2ZWRTZWFyY2g6NQ==',
616620
owner: { __typename: 'User', id: 'user123', namespaceName: 'test' },
621+
createdBy: {
622+
__typename: 'User',
623+
id: 'a',
624+
username: 'alice',
625+
url: '',
626+
},
617627
createdAt: '2020-04-21T10:10:10Z',
628+
updatedBy: {
629+
__typename: 'User',
630+
id: 'a',
631+
username: 'alice',
632+
url: '',
633+
},
618634
updatedAt: '2020-04-21T10:10:10Z',
635+
draft: false,
636+
visibility: SavedSearchVisibility.PUBLIC,
619637
query: 'context:global Batch Change patternType:literal',
620638
url: '/saved-searches/U2F2ZWRTZWFyY2g6NQ==',
621639
viewerCanAdminister: true,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { FunctionComponent } from 'react'
2+
3+
import classNames from 'classnames'
4+
5+
import { Badge } from '@sourcegraph/wildcard'
6+
7+
import { SavedSearchVisibility, type SavedSearchFields } from '../graphql-operations'
8+
9+
type LibraryItem = SavedSearchFields
10+
11+
export const LibraryItemStatusBadge: FunctionComponent<{
12+
item: Pick<LibraryItem, 'draft'>
13+
className?: string
14+
}> = ({ item: { draft }, className }) =>
15+
draft ? (
16+
<Badge variant="outlineSecondary" small={true} className={classNames('font-italic', className)}>
17+
Draft
18+
</Badge>
19+
) : null
20+
21+
export const LibraryItemVisibilityBadge: FunctionComponent<{
22+
item: Pick<LibraryItem, '__typename' | 'visibility' | 'owner'>
23+
className?: string
24+
}> = ({ item, className }) =>
25+
item.visibility === SavedSearchVisibility.SECRET ? (
26+
<Badge
27+
variant="outlineSecondary"
28+
small={true}
29+
className={className}
30+
tooltip={[
31+
item.owner.__typename === 'User'
32+
? `Only ${
33+
item.owner.id === window.context?.currentUser?.id ? 'you' : 'the user who owns it'
34+
} can see this ${itemNoun(item)}. Transfer it to an organization to share it with other users.`
35+
: `Only members of the "${item.owner.namespaceName}" organization can see this ${itemNoun(item)}.`,
36+
'Ask a site admin to make it public if you want all users to be able to view it.',
37+
].join(' ')}
38+
>
39+
Secret
40+
</Badge>
41+
) : (
42+
<Badge
43+
variant="outlineSecondary"
44+
small={true}
45+
className={className}
46+
tooltip={`All users can view this ${itemNoun(item)}.`}
47+
>
48+
Public
49+
</Badge>
50+
)
51+
52+
function itemNoun(item: Pick<LibraryItem, '__typename'>): string {
53+
return item.__typename === 'SavedSearch' ? 'saved search' : item.__typename === 'Prompt' ? 'prompt' : 'item'
54+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { logger } from '@sourcegraph/common'
2+
import { gql, useQuery } from '@sourcegraph/http-client'
3+
4+
import { type LibraryConfigurationResult } from '../graphql-operations'
5+
6+
type LibraryConfiguration = Pick<LibraryConfigurationResult, 'viewerCanChangeLibraryItemVisibilityToPublic'>
7+
8+
const DEFAULT_LIBRARY_CONFIGURATION: LibraryConfiguration = {
9+
viewerCanChangeLibraryItemVisibilityToPublic: false,
10+
}
11+
12+
const libraryConfigurationQuery = gql`
13+
query LibraryConfiguration {
14+
viewerCanChangeLibraryItemVisibilityToPublic
15+
}
16+
`
17+
18+
/**
19+
* A React hook to get the configuration for the saved searches library and prompt library.
20+
*/
21+
export function useLibraryConfiguration(): LibraryConfiguration {
22+
const { data } = useQuery<LibraryConfigurationResult>(libraryConfigurationQuery, {
23+
onError(error) {
24+
logger.error('Failed to fetch library configuration:', error)
25+
},
26+
})
27+
return data ?? DEFAULT_LIBRARY_CONFIGURATION
28+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { type MockedResponse } from '@apollo/client/testing'
2+
3+
import { getDocumentNode } from '@sourcegraph/http-client'
4+
5+
import { type ViewerAffiliatedNamespacesResult, type ViewerAffiliatedNamespacesVariables } from '../graphql-operations'
6+
7+
import { viewerAffiliatedNamespacesQuery } from './useAffiliatedNamespaces'
8+
9+
export const viewerAffiliatedNamespacesMock: MockedResponse<
10+
ViewerAffiliatedNamespacesResult,
11+
ViewerAffiliatedNamespacesVariables
12+
> = {
13+
request: { query: getDocumentNode(viewerAffiliatedNamespacesQuery) },
14+
result: {
15+
data: {
16+
viewer: {
17+
affiliatedNamespaces: {
18+
nodes: [
19+
{ __typename: 'User', id: 'user1', namespaceName: 'alice' },
20+
{
21+
__typename: 'Org',
22+
id: 'org1',
23+
namespaceName: 'abc',
24+
displayName: 'ABC',
25+
},
26+
{
27+
__typename: 'Org',
28+
id: 'org2',
29+
namespaceName: 'xyz',
30+
displayName: 'XYZ',
31+
},
32+
],
33+
},
34+
},
35+
},
36+
},
37+
}

client/web/src/namespaces/useAffiliatedNamespaces.test.tsx

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,11 @@
1-
import { MockedProvider, MockedResponse } from '@apollo/client/testing'
1+
import { MockedProvider } from '@apollo/client/testing'
22
import { renderHook } from '@testing-library/react'
33
import { describe, expect, test } from 'vitest'
44

5-
import { getDocumentNode } from '@sourcegraph/http-client'
65
import { waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
76

8-
import { ViewerAffiliatedNamespacesResult, ViewerAffiliatedNamespacesVariables } from '../graphql-operations'
9-
10-
import { useAffiliatedNamespaces, viewerAffiliatedNamespacesQuery } from './useAffiliatedNamespaces'
11-
12-
const viewerAffiliatedNamespacesMock: MockedResponse<
13-
ViewerAffiliatedNamespacesResult,
14-
ViewerAffiliatedNamespacesVariables
15-
> = {
16-
request: { query: getDocumentNode(viewerAffiliatedNamespacesQuery) },
17-
result: {
18-
data: {
19-
viewer: {
20-
affiliatedNamespaces: {
21-
nodes: [
22-
{ __typename: 'User', id: 'user1', namespaceName: 'alice' },
23-
{
24-
__typename: 'Org',
25-
id: 'org1',
26-
namespaceName: 'abc',
27-
displayName: 'ABC',
28-
},
29-
{
30-
__typename: 'Org',
31-
id: 'org2',
32-
namespaceName: 'xyz',
33-
displayName: 'XYZ',
34-
},
35-
],
36-
},
37-
},
38-
},
39-
},
40-
}
7+
import { viewerAffiliatedNamespacesMock } from './graphql.mocks'
8+
import { useAffiliatedNamespaces } from './useAffiliatedNamespaces'
419

4210
describe('useAffiliatedNamespaces', () => {
4311
test('fetches namespaces', async () => {

client/web/src/routes.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,13 @@ export const routes: RouteObject[] = [
186186
path: `${PageRoutes.SavedSearches}/*`,
187187
element: (
188188
<LegacyRoute
189-
render={props => <SavedSearchArea {...props} />}
189+
render={props => (
190+
<SavedSearchArea
191+
authenticatedUser={props.authenticatedUser}
192+
isSourcegraphDotCom={props.isSourcegraphDotCom}
193+
telemetryRecorder={props.telemetryRecorder}
194+
/>
195+
)}
190196
condition={() => window.context?.codeSearchEnabledOnInstance}
191197
/>
192198
),

client/web/src/savedSearches/Area.tsx

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import type { FunctionComponent } from 'react'
33
import { mdiPlus } from '@mdi/js'
44
import { Route, Routes } from 'react-router-dom'
55

6+
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
67
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
78
import { Button, Icon, Link, PageHeader } from '@sourcegraph/wildcard'
89

9-
import type { AuthenticatedUser } from '../auth'
10-
import { withAuthenticatedUser } from '../auth/withAuthenticatedUser'
10+
import { AuthenticatedUserOnly } from '../auth/withAuthenticatedUser'
1111
import { NotFoundPage } from '../components/HeroPage'
1212

1313
import { DetailPage } from './DetailPage'
@@ -16,22 +16,25 @@ import { ListPage } from './ListPage'
1616
import { NewForm } from './NewForm'
1717
import { SavedSearchPage } from './Page'
1818

19-
interface Props extends TelemetryV2Props {
20-
authenticatedUser: AuthenticatedUser
21-
isSourcegraphDotCom: boolean
22-
}
23-
24-
const AuthenticatedArea: FunctionComponent<Props> = ({ telemetryRecorder, isSourcegraphDotCom }) => (
19+
/** The saved search area. */
20+
export const Area: FunctionComponent<
21+
{
22+
authenticatedUser: Pick<AuthenticatedUser, 'id'> | null
23+
isSourcegraphDotCom: boolean
24+
} & TelemetryV2Props
25+
> = ({ authenticatedUser, isSourcegraphDotCom, telemetryRecorder }) => (
2526
<Routes>
2627
<Route
2728
path=""
2829
element={
2930
<SavedSearchPage
3031
title="Saved searches"
3132
actions={
32-
<Button to="new" variant="primary" as={Link}>
33-
<Icon aria-hidden={true} svgPath={mdiPlus} /> New saved search
34-
</Button>
33+
authenticatedUser && (
34+
<Button to="new" variant="primary" as={Link}>
35+
<Icon aria-hidden={true} svgPath={mdiPlus} /> New saved search
36+
</Button>
37+
)
3538
}
3639
>
3740
<ListPage telemetryRecorder={telemetryRecorder} />
@@ -41,22 +44,25 @@ const AuthenticatedArea: FunctionComponent<Props> = ({ telemetryRecorder, isSour
4144
<Route
4245
path="new"
4346
element={
44-
<SavedSearchPage
45-
title="New saved search"
46-
breadcrumbs={<PageHeader.Breadcrumb>New</PageHeader.Breadcrumb>}
47-
>
48-
<NewForm isSourcegraphDotCom={isSourcegraphDotCom} telemetryRecorder={telemetryRecorder} />
49-
</SavedSearchPage>
47+
<AuthenticatedUserOnly authenticatedUser={authenticatedUser}>
48+
<SavedSearchPage
49+
title="New saved search"
50+
breadcrumbs={<PageHeader.Breadcrumb>New</PageHeader.Breadcrumb>}
51+
>
52+
<NewForm isSourcegraphDotCom={isSourcegraphDotCom} telemetryRecorder={telemetryRecorder} />
53+
</SavedSearchPage>
54+
</AuthenticatedUserOnly>
5055
}
5156
/>
5257
<Route
5358
path=":id/edit"
54-
element={<EditPage isSourcegraphDotCom={isSourcegraphDotCom} telemetryRecorder={telemetryRecorder} />}
59+
element={
60+
<AuthenticatedUserOnly authenticatedUser={authenticatedUser}>
61+
<EditPage isSourcegraphDotCom={isSourcegraphDotCom} telemetryRecorder={telemetryRecorder} />
62+
</AuthenticatedUserOnly>
63+
}
5564
/>
5665
<Route path=":id" element={<DetailPage telemetryRecorder={telemetryRecorder} />} />
5766
<Route path="*" element={<NotFoundPage pageType="saved search" />} />
5867
</Routes>
5968
)
60-
61-
/** The saved search area. */
62-
export const Area = withAuthenticatedUser(AuthenticatedArea)

0 commit comments

Comments
 (0)