search contexts: backend for setting as default and starring (#44624)

* db schema and graphql schema updates

* default contexts backend

* query for stars

* backend for settings stars and defaults

* sg generate

* fixes?

* add foreign keys

* idempotent constraints

* again

* search contexts: remove autodefined contexts from frontend, add to normal query (#44875)

* search contexts: remove autodefined contexts from frontend, add to normal query

* generate

* remove real global context, use union with dummy row instead

* fix some tests

* fix CountSearchContexts

* add test for CountSearchContexts

* search contexts: order contexts list by default first, then starred, then others (#44876)

* search contexts: order contexts list by default first, then starred, then others

* generate mocks

* Add test for new order

* explicit select and remove unused scan var

* simplify getting authenticated user

* fix go lint

* unit test for default contexts

* tests for starred contexts

* change graphql api to take user as param, add tests for this

* update db schema to make contraints prettier

* clean up db keys

* search contexts: update header in list page (#44966)

* search contexts: update header

* add back tab

* fix spacing

* fix action button width

* search contexts: table view in management page (#45001)

* search contexts: update header

* search contexts: table view in management page

* add menu

* fix build failure

* add back tab

* fix spacing

* fix action button width

* address minor issues from figma

* search contexts: card view for mobile (#45040)

* search contexts: card view for mobile

* fix build failure

* give up on sr-only mixin
This commit is contained in:
Juliana Peña 2022-12-05 11:22:35 -08:00 committed by GitHub
parent d04f2f605a
commit ff6f03a5f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 2071 additions and 973 deletions

View File

@ -3,13 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Observable, of, Subscription } from 'rxjs'
import { requestGraphQLCommon } from '@sourcegraph/http-client'
import {
fetchAutoDefinedSearchContexts,
fetchSearchContexts,
getUserSearchContextNamespaces,
QueryState,
SearchPatternType,
} from '@sourcegraph/search'
import { fetchSearchContexts, getUserSearchContextNamespaces, QueryState, SearchPatternType } from '@sourcegraph/search'
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
import type { PlatformContext } from '@sourcegraph/shared/src/platform/context'
import {
@ -269,7 +263,6 @@ export const App: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
setSelectedSearchContextSpec={contextSpec => onSubmit({ contextSpec })}
selectedSearchContextSpec={lastSearch.selectedSearchContextSpec}
fetchSearchContexts={fetchSearchContexts}
fetchAutoDefinedSearchContexts={fetchAutoDefinedSearchContexts}
getUserSearchContextNamespaces={getUserSearchContextNamespaces}
fetchStreamSuggestions={fetchStreamSuggestionsWithStaticUrl}
settingsCascade={settingsCascade}

View File

@ -65,7 +65,6 @@ export const JetBrainsSearchBoxStory: Story = () => {
fetchSearchContexts={() => {
throw new Error('fetchSearchContexts')
}}
fetchAutoDefinedSearchContexts={() => NEVER}
getUserSearchContextNamespaces={() => []}
fetchStreamSuggestions={() => NEVER}
settingsCascade={EMPTY_SETTINGS_CASCADE}

View File

@ -103,7 +103,6 @@ export const JetBrainsSearchBox: React.FunctionComponent<React.PropsWithChildren
setSelectedSearchContextSpec={props.setSelectedSearchContextSpec}
selectedSearchContextSpec={props.selectedSearchContextSpec}
fetchSearchContexts={props.fetchSearchContexts}
fetchAutoDefinedSearchContexts={props.fetchAutoDefinedSearchContexts}
getUserSearchContextNamespaces={props.getUserSearchContextNamespaces}
telemetryService={props.telemetryService}
platformContext={props.platformContext}

View File

@ -4,7 +4,6 @@ import { BrandedStory } from '@sourcegraph/branded/src/components/BrandedStory'
import { SearchMode, SearchPatternType } from '@sourcegraph/search'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import {
mockFetchAutoDefinedSearchContexts,
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
@ -47,7 +46,6 @@ const defaultProps: SearchBoxProps = {
defaultSearchContextSpec: 'global',
onChange: () => {},
onSubmit: () => {},
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(),
fetchSearchContexts: mockFetchSearchContexts,
authenticatedUser: null,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,

View File

@ -7,7 +7,6 @@ import { assertAriaDisabled, assertAriaEnabled } from '@sourcegraph/shared/dev/a
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { MockIntersectionObserver } from '@sourcegraph/shared/src/testing/MockIntersectionObserver'
import {
mockFetchAutoDefinedSearchContexts,
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
@ -20,7 +19,6 @@ describe('SearchContextDropdown', () => {
telemetryService: NOOP_TELEMETRY_SERVICE,
query: '',
showSearchContextManagement: false,
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(1),
fetchSearchContexts: mockFetchSearchContexts,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
defaultSearchContextSpec: '',

View File

@ -50,7 +50,6 @@ export const SearchContextDropdown: FC<SearchContextDropdownProps> = props => {
showSearchContextManagement,
selectedSearchContextSpec,
setSelectedSearchContextSpec,
fetchAutoDefinedSearchContexts,
fetchSearchContexts,
submitSearch,
className,
@ -168,7 +167,6 @@ export const SearchContextDropdown: FC<SearchContextDropdownProps> = props => {
{...props}
showSearchContextManagement={showSearchContextManagement}
selectSearchContextSpec={selectSearchContextSpec}
fetchAutoDefinedSearchContexts={fetchAutoDefinedSearchContexts}
fetchSearchContexts={fetchSearchContexts}
className={menuClassName}
onMenuClose={handleMenuClose}

View File

@ -5,7 +5,6 @@ import { BrandedStory } from '@sourcegraph/branded/src/components/BrandedStory'
import { ListSearchContextsResult } from '@sourcegraph/search'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import {
mockFetchAutoDefinedSearchContexts,
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
@ -37,7 +36,6 @@ const defaultProps: SearchContextMenuProps = {
authenticatedUser: null,
isSourcegraphDotCom: false,
showSearchContextManagement: false,
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(2),
fetchSearchContexts: ({
first,
query,
@ -64,6 +62,8 @@ const defaultProps: SearchContextMenuProps = {
description: 'Only code in version 1.5',
updatedAt: '2021-03-15T19:39:11Z',
viewerCanManage: true,
viewerHasAsDefault: true,
viewerHasStarred: false,
query: '',
repositories: [],
},
@ -85,7 +85,6 @@ const defaultProps: SearchContextMenuProps = {
}
const emptySearchContexts = {
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(),
fetchSearchContexts: mockFetchSearchContexts,
}

View File

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react-dom/test-utils'
import { Observable, of, throwError } from 'rxjs'
import { of } from 'rxjs'
import sinon from 'sinon'
import { ListSearchContextsResult, SearchContextMinimalFields } from '@sourcegraph/search'
@ -12,44 +12,24 @@ import { NOOP_PLATFORM_CONTEXT } from '@sourcegraph/shared/src/testing/searchTes
import { SearchContextMenu, SearchContextMenuProps } from './SearchContextMenu'
const mockFetchAutoDefinedSearchContexts = () =>
of([
const mockFetchSearchContexts = ({ query }: { first: number; query?: string; after?: string }) => {
const nodes = [
{
__typename: 'SearchContext',
id: '1',
id: '0',
spec: 'global',
name: 'global',
namespace: null,
autoDefined: true,
description: 'All repositories on Sourcegraph',
query: '',
repositories: [],
public: true,
updatedAt: '2021-03-15T19:39:11Z',
viewerCanManage: false,
},
{
__typename: 'SearchContext',
id: '2',
spec: '@username',
name: 'username',
namespace: {
__typename: 'User',
id: 'u1',
namespaceName: 'username',
},
autoDefined: true,
description: 'Your repositories on Sourcegraph',
description: 'All code on Sourcegraph',
query: '',
repositories: [],
public: true,
updatedAt: '2021-03-15T19:39:11Z',
repositories: [],
viewerCanManage: false,
viewerHasAsDefault: true,
viewerHasStarred: false,
},
] as SearchContextMinimalFields[])
const mockFetchSearchContexts = ({ query }: { first: number; query?: string; after?: string }) => {
const nodes = [
{
__typename: 'SearchContext',
id: '3',
@ -67,6 +47,8 @@ const mockFetchSearchContexts = ({ query }: { first: number; query?: string; aft
updatedAt: '2021-03-15T19:39:11Z',
repositories: [],
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
},
{
__typename: 'SearchContext',
@ -85,6 +67,8 @@ const mockFetchSearchContexts = ({ query }: { first: number; query?: string; aft
updatedAt: '2021-03-15T19:39:11Z',
repositories: [],
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
},
].filter(
context => !query || context.spec.toLowerCase().includes(query.toLowerCase())
@ -108,7 +92,6 @@ describe('SearchContextMenu', () => {
defaultSearchContextSpec: 'global',
selectedSearchContextSpec: 'global',
selectSearchContextSpec: () => {},
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts,
fetchSearchContexts: mockFetchSearchContexts,
onMenuClose: () => {},
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
@ -140,11 +123,11 @@ describe('SearchContextMenu', () => {
clock.tick(50)
})
const item = screen.getAllByTestId('search-context-menu-item')[1]
const item = screen.getAllByTestId('search-context-menu-item')[0]
userEvent.click(item)
sinon.assert.calledOnce(selectSearchContextSpec)
sinon.assert.calledWithExactly(selectSearchContextSpec, '@username')
sinon.assert.calledWithExactly(selectSearchContextSpec, 'global')
})
it('should filter list by spec when searching', () => {
@ -159,9 +142,8 @@ describe('SearchContextMenu', () => {
})
const items = screen.getAllByTestId('search-context-menu-item')
expect(items.length).toBe(2)
expect(items[0]).toHaveTextContent('@username, Your repositories on Sourcegraph')
expect(items[1]).toHaveTextContent('@username/test-version-1.5, Only code in version 1.5')
expect(items.length).toBe(1)
expect(items[0]).toHaveTextContent('@username/test-version-1.5, Only code in version 1.5')
expect(items).toMatchSnapshot()
})
@ -209,22 +191,4 @@ describe('SearchContextMenu', () => {
const items = screen.getAllByTestId('search-context-menu-item')
expect(items[items.length - 1]).toHaveTextContent('Error occurred while loading search contexts')
})
it('should default to empty array if fetching auto-defined contexts fails', () => {
const errorFetchAutoDefinedSearchContexts: () => Observable<SearchContextMinimalFields[]> = () =>
throwError(new Error('unknown error'))
render(
<SearchContextMenu {...defaultProps} fetchAutoDefinedSearchContexts={errorFetchAutoDefinedSearchContexts} />
)
act(() => {
// Wait for debounce
clock.tick(50)
})
const items = screen.getAllByTestId('search-context-menu-item')
// With no auto-defined contexts, the first context should be a user-defined context
expect(items[0]).toHaveTextContent('@username/test-version-1.5, Only code in version 1.5')
})
})

View File

@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect, FormEvent, useMemo, useState, FC } from 'react'
import { useCallback, useRef, useEffect, FormEvent, useState, FC } from 'react'
import { mdiClose, mdiArrowRight } from '@mdi/js'
import VisuallyHidden from '@reach/visually-hidden'
@ -14,7 +14,6 @@ import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryServi
import {
Badge,
Button,
useObservable,
Icon,
ButtonLink,
Link,
@ -62,7 +61,6 @@ export const SearchContextMenu: FC<SearchContextMenuProps> = props => {
defaultSearchContextSpec,
selectSearchContextSpec,
getUserSearchContextNamespaces,
fetchAutoDefinedSearchContexts,
fetchSearchContexts,
onMenuClose,
showSearchContextManagement,
@ -161,16 +159,6 @@ export const SearchContextMenu: FC<SearchContextMenuProps> = props => {
platformContext,
])
const autoDefinedSearchContexts = useObservable(
useMemo(
() =>
fetchAutoDefinedSearchContexts({ platformContext, useMinimalFields: true }).pipe(
catchError(error => [asError(error)])
),
[fetchAutoDefinedSearchContexts, platformContext]
)
)
const reset = useCallback(() => {
selectSearchContextSpec(defaultSearchContextSpec)
onMenuClose()
@ -185,22 +173,6 @@ export const SearchContextMenu: FC<SearchContextMenuProps> = props => {
[onMenuClose, selectSearchContextSpec, telemetryService]
)
const filteredAutoDefinedSearchContexts = useMemo(
() =>
autoDefinedSearchContexts && !isErrorLike(autoDefinedSearchContexts)
? autoDefinedSearchContexts.filter(context =>
context.spec.toLowerCase().includes(searchFilter.toLowerCase())
)
: [],
[autoDefinedSearchContexts, searchFilter]
)
// Merge auto-defined contexts and user-defined contexts
const filteredList = useMemo(() => filteredAutoDefinedSearchContexts.concat(searchContexts), [
filteredAutoDefinedSearchContexts,
searchContexts,
])
return (
<Combobox openOnFocus={true} className={classNames(styles.container, className)} onSelect={handleContextSelect}>
<div className={styles.title}>
@ -225,7 +197,7 @@ export const SearchContextMenu: FC<SearchContextMenuProps> = props => {
</div>
<ComboboxList ref={infiniteScrollList} data-testid="search-context-menu-list" className={styles.list}>
{loadingState !== 'LOADING' &&
filteredList.map(context => (
searchContexts.map(context => (
<SearchContextMenuItem
key={context.id}
spec={context.spec}
@ -245,7 +217,7 @@ export const SearchContextMenu: FC<SearchContextMenuProps> = props => {
<small>Error occurred while loading search contexts</small>
</div>
)}
{loadingState === 'DONE' && filteredList.length === 0 && (
{loadingState === 'DONE' && searchContexts.length === 0 && (
<div data-testid="search-context-menu-item" className={styles.item}>
<small>No contexts found</small>
</div>

View File

@ -2,51 +2,6 @@
exports[`SearchContextMenu should filter list by spec when searching 1`] = `
Array [
<li
aria-selected="false"
class="item"
data-reach-combobox-option=""
data-search-context-spec="@username"
data-testid="search-context-menu-item"
id="21317910"
role="option"
tabindex="-1"
>
<small
class="itemName"
data-testid="search-context-menu-item-name"
>
<span
data-reach-combobox-option-text=""
data-suggested-value="true"
>
@u
</span>
<span
data-reach-combobox-option-text=""
data-user-value="true"
>
ser
</span>
<span
data-reach-combobox-option-text=""
data-suggested-value="true"
>
name
</span>
</small>
<span
style="border: 0px; height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; word-wrap: normal;"
>
,
</span>
<small
class="itemDescription"
>
Your repositories on Sourcegraph
</small>
</li>,
<li
aria-selected="false"
class="item"

View File

@ -11,8 +11,6 @@ import {
EventLogsDataVariables,
ListSearchContextsResult,
ListSearchContextsVariables,
AutoDefinedSearchContextsResult,
AutoDefinedSearchContextsVariables,
IsSearchContextAvailableResult,
IsSearchContextAvailableVariables,
Scalars,
@ -49,6 +47,8 @@ const searchContextFragment = gql`
autoDefined
updatedAt
viewerCanManage
viewerHasStarred
viewerHasAsDefault
query
repositories {
...SearchContextRepositoryRevisionsFields
@ -75,6 +75,8 @@ const searchContextWithSkippableFieldsFragment = gql`
autoDefined
updatedAt
viewerCanManage
viewerHasStarred
viewerHasAsDefault
namespace @skip(if: $useMinimalFields) {
__typename
id
@ -90,34 +92,6 @@ const searchContextWithSkippableFieldsFragment = gql`
}
`
export function fetchAutoDefinedSearchContexts({
platformContext,
useMinimalFields,
}: {
platformContext: Pick<PlatformContext, 'requestGraphQL'>
useMinimalFields?: boolean
}): Observable<AutoDefinedSearchContextsResult['autoDefinedSearchContexts']> {
return platformContext
.requestGraphQL<AutoDefinedSearchContextsResult, AutoDefinedSearchContextsVariables>({
request: gql`
query AutoDefinedSearchContexts($useMinimalFields: Boolean!) {
autoDefinedSearchContexts {
...SearchContextMinimalFields
}
}
${searchContextWithSkippableFieldsFragment}
`,
variables: {
useMinimalFields: useMinimalFields ?? false,
},
mightContainPrivateInfo: false,
})
.pipe(
map(dataOrThrowErrors),
map(({ autoDefinedSearchContexts }) => autoDefinedSearchContexts)
)
}
export function getUserSearchContextNamespaces(
authenticatedUser: Pick<AuthenticatedUser, 'id' | 'organizations'> | null
): Maybe<Scalars['ID']>[] {

View File

@ -4,7 +4,6 @@ import { memoizeObservable } from '@sourcegraph/common'
import { PlatformContext } from '@sourcegraph/shared/src/platform/context'
import {
fetchAutoDefinedSearchContexts,
fetchSearchContexts,
getUserSearchContextNamespaces,
fetchSearchContext,
@ -48,7 +47,6 @@ export interface SearchContextProps {
selectedSearchContextSpec?: string
setSelectedSearchContextSpec: (spec: string) => void
getUserSearchContextNamespaces: typeof getUserSearchContextNamespaces
fetchAutoDefinedSearchContexts: typeof fetchAutoDefinedSearchContexts
fetchSearchContexts: typeof fetchSearchContexts
isSearchContextSpecAvailable: typeof isSearchContextSpecAvailable
fetchSearchContext: typeof fetchSearchContext
@ -64,7 +62,6 @@ export type SearchContextInputProps = Pick<
| 'defaultSearchContextSpec'
| 'selectedSearchContextSpec'
| 'setSelectedSearchContextSpec'
| 'fetchAutoDefinedSearchContexts'
| 'fetchSearchContexts'
| 'getUserSearchContextNamespaces'
>

View File

@ -1,9 +1,7 @@
import { subDays } from 'date-fns'
import { range } from 'lodash'
import { Observable, of } from 'rxjs'
import { Maybe, Scalars } from '../../graphql-operations'
import { ISearchContext } from '../../schema'
interface SearchContextFields {
__typename: 'SearchContext'
@ -15,6 +13,8 @@ interface SearchContextFields {
autoDefined: boolean
updatedAt: string
viewerCanManage: boolean
viewerHasAsDefault: boolean
viewerHasStarred: boolean
namespace: Maybe<
| { __typename: 'User'; id: string; namespaceName: string }
| { __typename: 'Org'; id: string; namespaceName: string }
@ -33,26 +33,6 @@ interface ListSearchContexts {
pageInfo: { hasNextPage: boolean; endCursor: Maybe<string> }
}
export function mockFetchAutoDefinedSearchContexts(numberContexts = 0): () => Observable<ISearchContext[]> {
return () =>
of(
range(0, numberContexts).map(index => ({
__typename: 'SearchContext',
id: index.toString(),
spec: `auto-defined-${index}`,
name: `auto-defined-${index}`,
namespace: null,
public: true,
autoDefined: true,
viewerCanManage: false,
description: 'Repositories on Sourcegraph',
repositories: [],
query: '',
updatedAt: subDays(new Date(), 1).toISOString(),
})) as ISearchContext[]
)
}
export function mockFetchSearchContexts({
first,
query,
@ -63,7 +43,24 @@ export function mockFetchSearchContexts({
after?: string
}): Observable<ListSearchContexts> {
const result: ListSearchContexts = {
nodes: [],
nodes: [
{
__typename: 'SearchContext',
id: '0',
spec: 'global',
name: 'global',
namespace: null,
public: true,
autoDefined: true,
viewerCanManage: false,
viewerHasAsDefault: false,
viewerHasStarred: false,
description: 'All code on Sourcegraph',
repositories: [],
query: '',
updatedAt: subDays(new Date(), 1).toISOString(),
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,

View File

@ -4,13 +4,7 @@ import classNames from 'classnames'
import { Observable } from 'rxjs'
import { useDeepCompareEffectNoCheck } from 'use-deep-compare-effect'
import {
SearchPatternType,
getUserSearchContextNamespaces,
fetchAutoDefinedSearchContexts,
QueryState,
SearchMode,
} from '@sourcegraph/search'
import { SearchPatternType, getUserSearchContextNamespaces, QueryState, SearchMode } from '@sourcegraph/search'
import { SearchBox } from '@sourcegraph/search-ui'
import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common'
import { collectMetrics } from '@sourcegraph/shared/src/search/query/metrics'
@ -188,7 +182,6 @@ export const SearchHomeView: React.FunctionComponent<React.PropsWithChildren<Sea
setSelectedSearchContextSpec={setSelectedSearchContextSpec}
selectedSearchContextSpec={context.selectedSearchContextSpec}
fetchSearchContexts={fetchSearchContexts}
fetchAutoDefinedSearchContexts={fetchAutoDefinedSearchContexts}
getUserSearchContextNamespaces={getUserSearchContextNamespaces}
fetchStreamSuggestions={fetchStreamSuggestions}
settingsCascade={settingsCascade}

View File

@ -4,13 +4,7 @@ import classNames from 'classnames'
import { Observable } from 'rxjs'
import { useDeepCompareEffectNoCheck } from 'use-deep-compare-effect'
import {
SearchPatternType,
fetchAutoDefinedSearchContexts,
getUserSearchContextNamespaces,
QueryState,
SearchMode,
} from '@sourcegraph/search'
import { SearchPatternType, getUserSearchContextNamespaces, QueryState, SearchMode } from '@sourcegraph/search'
import { IEditor, SearchBox, StreamingProgress, StreamingSearchResultsList } from '@sourcegraph/search-ui'
import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common'
import { FetchFileParameters, fetchHighlightedFileLineRanges } from '@sourcegraph/shared/src/backend/file'
@ -350,7 +344,6 @@ export const SearchResultsView: React.FunctionComponent<React.PropsWithChildren<
setSelectedSearchContextSpec={setSelectedSearchContextSpec}
selectedSearchContextSpec={context.selectedSearchContextSpec}
fetchSearchContexts={fetchSearchContexts}
fetchAutoDefinedSearchContexts={fetchAutoDefinedSearchContexts}
getUserSearchContextNamespaces={getUserSearchContextNamespaces}
fetchStreamSuggestions={fetchStreamSuggestions}
settingsCascade={settingsCascade}

View File

@ -54,6 +54,8 @@ const searchContextFragment = gql`
autoDefined
updatedAt
viewerCanManage
viewerHasStarred
viewerHasAsDefault
repositories {
__typename
repository {
@ -138,6 +140,8 @@ export interface SearchContextFields {
autoDefined: boolean
updatedAt: string
viewerCanManage: boolean
viewerHasAsDefault: boolean
viewerHasStarred: boolean
query: string
namespace: Maybe<
| { __typename: 'User'; id: string; namespaceName: string }
@ -150,13 +154,6 @@ export interface SearchContextFields {
}[]
}
export type AutoDefinedSearchContextsVariables = Exact<{ [key: string]: never }>
export interface AutoDefinedSearchContextsResult {
__typename?: 'Query'
autoDefinedSearchContexts: ({ __typename?: 'SearchContext' } & SearchContextFields)[]
}
export type ListSearchContextsVariables = Exact<{
first: Scalars['Int']
after: Maybe<Scalars['String']>

View File

@ -26,42 +26,6 @@ export const commonVSCodeGraphQlResults: Partial<
pageInfo: { hasNextPage: false, endCursor: null },
},
}),
AutoDefinedSearchContexts: () => ({
autoDefinedSearchContexts: [
{
__typename: 'SearchContext',
id: '1',
spec: 'global',
name: 'global',
namespace: null,
autoDefined: true,
public: true,
description: 'All repositories on Sourcegraph',
updatedAt: '2021-03-15T19:39:11Z',
repositories: [],
query: '',
viewerCanManage: false,
},
{
__typename: 'SearchContext',
id: '2',
spec: '@test',
name: 'test',
namespace: {
__typename: 'User',
id: 'u1',
namespaceName: 'test',
},
autoDefined: true,
public: true,
description: 'Your repositories on Sourcegraph',
updatedAt: '2021-03-15T19:39:11Z',
repositories: [],
query: '',
viewerCanManage: false,
},
],
}),
IsSearchContextAvailable: () => ({
isSearchContextAvailable: true,
}),

View File

@ -15,7 +15,6 @@ import { logger } from '@sourcegraph/common'
import { GraphQLClient, HTTPStatusError } from '@sourcegraph/http-client'
import { SharedSpanName, TraceSpanProvider } from '@sourcegraph/observability-client'
import {
fetchAutoDefinedSearchContexts,
getUserSearchContextNamespaces,
SearchContextProps,
fetchSearchContexts,
@ -399,7 +398,6 @@ export class SourcegraphWebApp extends React.Component<
selectedSearchContextSpec={this.getSelectedSearchContextSpec()}
setSelectedSearchContextSpec={this.setSelectedSearchContextSpec}
getUserSearchContextNamespaces={getUserSearchContextNamespaces}
fetchAutoDefinedSearchContexts={fetchAutoDefinedSearchContexts}
fetchSearchContexts={fetchSearchContexts}
fetchSearchContextBySpec={fetchSearchContextBySpec}
fetchSearchContext={fetchSearchContext}

View File

@ -7,7 +7,6 @@ import { subtypeOf } from '@sourcegraph/common'
import { SearchContextFields } from '@sourcegraph/search'
import { ActionItemComponentProps } from '@sourcegraph/shared/src/actions/ActionItem'
import {
mockFetchAutoDefinedSearchContexts,
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
@ -110,6 +109,8 @@ const fetchCommunitySearchContext = (): Observable<SearchContextFields> =>
repositories,
updatedAt: subDays(new Date(), 1).toISOString(),
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
})
const commonProps = () =>
@ -134,7 +135,6 @@ const commonProps = () =>
authenticatedUser: authUser,
communitySearchContextMetadata: temporal,
globbing: false,
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(),
fetchSearchContexts: mockFetchSearchContexts,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
fetchSearchContextBySpec: fetchCommunitySearchContext,

View File

@ -2,4 +2,5 @@
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}

View File

@ -85,8 +85,8 @@ export const FilterControl: React.FunctionComponent<React.PropsWithChildren<Filt
const filterLabelId = `filtered-select-label-${filter.id}`
return (
<div key={filter.id} className="d-inline-flex flex-row align-center flex-wrap">
<div className="d-inline-flex flex-row mr-3 align-items-baseline">
<Text className="text-xl-center text-nowrap mr-2" id={filterLabelId}>
<div className="d-inline-flex flex-row align-items-baseline">
<Text className="text-xl-center text-nowrap mr-2 mb-0" id={filterLabelId}>
{filter.label}:
</Text>
<Select

View File

@ -84,6 +84,12 @@ interface FilteredConnectionDisplayProps extends ConnectionNodesDisplayProps, Co
* the list to screen reader users (e.g. reading out nodes after they have finished loading).
*/
ariaLive?: 'polite' | 'off'
/**
* A component that wraps around everything after the connection form. This is useful
* for adding additional padding/background to the list, errors, or loading indicators.
*/
contentWrapperComponent?: React.ComponentType<{ children: React.ReactNode }>
}
/**
@ -524,6 +530,8 @@ export class FilteredConnection<
const inputPlaceholder = this.props.inputPlaceholder || `Search ${this.props.pluralNoun}...`
const ContentWrapperComponent = this.props.contentWrapperComponent || React.Fragment
return (
<ConnectionContainer
compact={this.props.compact}
@ -534,6 +542,7 @@ export class FilteredConnection<
<ConnectionForm
ref={this.setFilterRef}
hideSearch={this.props.hideSearch}
showSearchFirst={this.props.showSearchFirst}
inputClassName={this.props.inputClassName}
inputPlaceholder={inputPlaceholder}
inputAriaLabel={this.props.inputAriaLabel || inputPlaceholder}
@ -547,40 +556,43 @@ export class FilteredConnection<
formClassName={this.props.formClassName}
/>
)}
{errors.length > 0 && <ConnectionError errors={errors} compact={this.props.compact} />}
{this.state.connectionOrError && !isErrorLike(this.state.connectionOrError) && (
<ConnectionNodes
connection={this.state.connectionOrError}
loading={this.state.loading}
connectionQuery={this.state.connectionQuery}
first={this.state.first}
query={this.state.query}
noun={this.props.noun}
pluralNoun={this.props.pluralNoun}
listComponent={this.props.listComponent}
listClassName={this.props.listClassName}
summaryClassName={this.props.summaryClassName}
headComponent={this.props.headComponent}
headComponentProps={this.props.headComponentProps}
footComponent={this.props.footComponent}
showMoreClassName={this.props.showMoreClassName}
nodeComponent={this.props.nodeComponent}
nodeComponentProps={this.props.nodeComponentProps}
noShowMore={this.props.noShowMore}
noSummaryIfAllNodesVisible={this.props.noSummaryIfAllNodesVisible}
onShowMore={this.onClickShowMore}
location={this.props.location}
emptyElement={this.props.emptyElement}
totalCountSummaryComponent={this.props.totalCountSummaryComponent}
withCenteredSummary={this.props.withCenteredSummary}
ariaLabelFunction={this.props.ariaLabelFunction}
/>
)}
<ContentWrapperComponent>
{errors.length > 0 && <ConnectionError errors={errors} compact={this.props.compact} />}
{this.state.loading && (
<ConnectionLoading compact={this.props.compact} className={this.props.loaderClassName} />
)}
{this.state.connectionOrError && !isErrorLike(this.state.connectionOrError) && (
<ConnectionNodes
connection={this.state.connectionOrError}
loading={this.state.loading}
connectionQuery={this.state.connectionQuery}
first={this.state.first}
query={this.state.query}
noun={this.props.noun}
pluralNoun={this.props.pluralNoun}
listComponent={this.props.listComponent}
listClassName={this.props.listClassName}
summaryClassName={this.props.summaryClassName}
headComponent={this.props.headComponent}
headComponentProps={this.props.headComponentProps}
footComponent={this.props.footComponent}
showMoreClassName={this.props.showMoreClassName}
nodeComponent={this.props.nodeComponent}
nodeComponentProps={this.props.nodeComponentProps}
noShowMore={this.props.noShowMore}
noSummaryIfAllNodesVisible={this.props.noSummaryIfAllNodesVisible}
onShowMore={this.onClickShowMore}
location={this.props.location}
emptyElement={this.props.emptyElement}
totalCountSummaryComponent={this.props.totalCountSummaryComponent}
withCenteredSummary={this.props.withCenteredSummary}
ariaLabelFunction={this.props.ariaLabelFunction}
/>
)}
{this.state.loading && (
<ConnectionLoading compact={this.props.compact} className={this.props.loaderClassName} />
)}
</ContentWrapperComponent>
</ConnectionContainer>
)
}

View File

@ -11,9 +11,12 @@ import { FilterControl, FilteredConnectionFilter, FilteredConnectionFilterValue
import styles from './ConnectionForm.module.scss'
export interface ConnectionFormProps {
/** Hides the filter input field. */
/** Hides the search input field. */
hideSearch?: boolean
/** Shows the search input field before the filter controls */
showSearchFirst?: boolean
/** CSS class name for the <input> element */
inputClassName?: string
@ -60,6 +63,7 @@ export const ConnectionForm = React.forwardRef<HTMLInputElement, ConnectionFormP
(
{
hideSearch,
showSearchFirst,
formClassName,
inputClassName,
inputPlaceholder,
@ -84,34 +88,37 @@ export const ConnectionForm = React.forwardRef<HTMLInputElement, ConnectionFormP
useAutoFocus({ autoFocus, reference: localReference })
const searchControl = !hideSearch && (
<Input
className={classNames(styles.input, inputClassName)}
type="search"
placeholder={inputPlaceholder}
name="query"
value={inputValue}
onChange={onInputChange}
autoFocus={autoFocus}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
ref={mergedReference}
spellCheck={false}
aria-label={inputAriaLabel}
variant={compact ? 'small' : 'regular'}
/>
)
return (
<Form
className={classNames(styles.form, !compact && styles.noncompact, formClassName)}
onSubmit={handleSubmit}
>
{showSearchFirst && searchControl}
{filters && onFilterSelect && filterValues && (
<FilterControl filters={filters} onValueSelect={onFilterSelect} values={filterValues}>
{additionalFilterElement}
</FilterControl>
)}
{!hideSearch && (
<Input
className={classNames(styles.input, inputClassName)}
type="search"
placeholder={inputPlaceholder}
name="query"
value={inputValue}
onChange={onInputChange}
autoFocus={autoFocus}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
ref={mergedReference}
spellCheck={false}
aria-label={inputAriaLabel}
variant={compact ? 'small' : 'regular'}
/>
)}
{!showSearchFirst && searchControl}
</Form>
)
}

View File

@ -38,6 +38,8 @@ const onSubmit = (): Observable<SearchContextFields> =>
query: '',
updatedAt: subDays(new Date(), 1).toISOString(),
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
})
const searchContextToEdit: SearchContextFields = {
@ -59,6 +61,8 @@ const searchContextToEdit: SearchContextFields = {
],
updatedAt: subDays(new Date(), 1).toISOString(),
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
}
const authUser: AuthenticatedUser = {

View File

@ -1,38 +1,74 @@
@import 'wildcard/src/global-styles/breakpoints';
.search-context-node {
display: flex;
align-items: center;
.row {
td {
padding: 0.25rem 0;
border-bottom: 1px solid var(--border-color-2);
&:first-child {
border-top: 1px solid var(--border-color-2);
.spec {
font-weight: 500;
}
}
border-bottom: 1px solid var(--border-color-2);
@media (--xs-breakpoint-down) {
flex-direction: column;
align-items: flex-start;
@media (--sm-breakpoint-down) {
margin-left: -1rem;
margin-right: -1rem;
padding-left: 1rem;
padding-right: 1rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.75rem;
margin-bottom: 0.75rem;
display: grid;
grid-template-columns: auto auto 1fr auto;
grid-template-rows: auto auto auto auto;
grid-template-areas:
'star name name actions'
'star tags tags actions'
'star description description description'
'star contents updated updated';
td {
padding: 0;
border-bottom: none;
&.star {
grid-area: star;
}
&.name {
grid-area: name;
}
&.description {
grid-area: description;
}
&.last-updated {
grid-area: updated;
}
&.contents {
grid-area: contents;
}
&.tags {
grid-area: tags;
}
&.actions {
grid-area: actions;
}
}
}
}
.left {
/* Enables responsive ellipsis text truncation */
min-width: 0;
max-width: 100%;
.button {
color: var(--icon-color);
font-size: 1rem;
&-description {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.right {
font-size: 0.75rem;
margin-left: 1rem;
flex-shrink: 0;
@media (--xs-breakpoint-down) {
margin-left: 0;
&:hover {
background-color: var(--color-bg-2);
}
}

View File

@ -1,11 +1,12 @@
import React from 'react'
import { mdiDotsHorizontal } from '@mdi/js'
import classNames from 'classnames'
import * as H from 'history'
import { pluralize } from '@sourcegraph/common'
import { SearchContextMinimalFields } from '@sourcegraph/search'
import { SyntaxHighlightedSearchQuery } from '@sourcegraph/search-ui'
import { Badge, Link } from '@sourcegraph/wildcard'
import { Badge, Icon, Link, Menu, MenuButton, MenuLink, MenuList, Tooltip } from '@sourcegraph/wildcard'
import { Timestamp } from '../../components/time/Timestamp'
@ -19,36 +20,91 @@ export interface SearchContextNodeProps {
export const SearchContextNode: React.FunctionComponent<React.PropsWithChildren<SearchContextNodeProps>> = ({
node,
}: SearchContextNodeProps) => (
<li className={classNames('py-3', styles.searchContextNode)}>
<div className={classNames('flex-grow-1', styles.left)}>
<div>
<Link to={`/contexts/${node.spec}`}>
<strong>{node.spec}</strong>
</Link>
{!node.public && (
<Badge variant="secondary" pill={true} className="ml-1" as="div">
Private
</Badge>
)}
</div>
{node.query.length > 0 && (
<small>
<SyntaxHighlightedSearchQuery query={node.query} key={node.name} />
</small>
)}
}: SearchContextNodeProps) => {
const contents =
node.repositories && node.repositories.length > 0 ? (
<>
{node.repositories.length} {pluralize('repository', node.repositories.length, 'repositories')}
&nbsp;
</>
) : node.query ? (
<>Query based&nbsp;</>
) : null
{node.description.length > 0 && (
<div className={classNames('text-muted mt-1', styles.leftDescription)}>{node.description}</div>
)}
</div>
<div className={classNames('text-muted d-flex', styles.right)}>
{node.repositories && node.repositories.length > 0 && (
<div className="mr-2">{node.repositories.length} repositories</div>
)}
<div>
Updated <Timestamp date={node.updatedAt} noAbout={true} />
</div>
</div>
</li>
)
const tags = (
<>
{!node.public ? (
<Badge variant="secondary" pill={true}>
Private
</Badge>
) : null}{' '}
{node.autoDefined ? (
<Badge variant="outlineSecondary" pill={true}>
Auto
</Badge>
) : null}
</>
)
const timestamp = node.autoDefined ? null : (
<>
<span className="d-md-none" aria-hidden={true}>
Updated{' '}
</span>{' '}
<Timestamp date={node.updatedAt} noAbout={true} />
</>
)
return (
<tr className={styles.row}>
<td className={styles.star} />
<td className={styles.name}>
<Link to={`/contexts/${node.spec}`}>{node.spec}</Link>{' '}
<span className="d-none d-md-inline-block">{tags}</span>
</td>
<td className={styles.description}>
{node.description ? <div className="text-muted">{node.description}</div> : null}
</td>
<td className={classNames(styles.contents, 'text-muted')}>{contents}</td>
<td className={classNames(styles.lastUpdated, 'text-muted')}>
{contents && timestamp && (
<span className="d-md-none" aria-hidden={true}>
{' '}
{' '}
</span>
)}
{timestamp}
</td>
<td className={styles.tags}>
{node.viewerHasAsDefault ? (
<Badge variant="secondary" className="text-uppercase">
Default
</Badge>
) : null}
<span className="d-md-none">{tags}</span>
</td>
<td className={styles.actions}>
<Menu>
<MenuButton variant="icon" className={styles.button}>
<Icon svgPath={mdiDotsHorizontal} aria-label="Actions" />
</MenuButton>
<MenuList>
<Tooltip
content={
node.autoDefined
? "Auto-defined contexts can't be edited."
: !node.viewerCanManage
? "You don't have permissions to edit this context."
: undefined
}
>
<MenuLink as={Link} to={`/contexts/${node.spec}/edit`} disabled={!node.viewerCanManage}>
Edit...
</MenuLink>
</Tooltip>
</MenuList>
</Menu>
</td>
</tr>
)
}

View File

@ -53,6 +53,8 @@ const mockContext: SearchContextFields = {
repositories,
updatedAt: subDays(new Date(), 1).toISOString(),
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
}
const fetchPublicContext = (): Observable<SearchContextFields> => of(mockContext)

View File

@ -0,0 +1,63 @@
@import 'wildcard/src/global-styles/breakpoints';
.badge {
cursor: default;
}
.filters-form {
margin-top: 1rem;
margin-bottom: 1rem;
@media (--xs-breakpoint-down) {
display: flex;
flex-direction: column;
gap: 1rem;
}
}
.filter-input {
max-width: 30%;
@media (--xs-breakpoint-down) {
max-width: 100%;
}
}
.table-wrapper {
padding: 0.75rem 1rem;
border: 1px solid var(--border-color);
background-color: var(--color-bg-1);
border-radius: var(--border-radius);
th {
border-bottom: 2px solid var(--border-color-2);
padding-bottom: 0.75rem;
color: var(--text-muted);
font-weight: 500;
}
@media (--sm-breakpoint-down) {
table {
display: block;
}
thead {
// Header should still be visible to screen readers, otherwise the table is not accessible.
// Because this style is conditional, we can't simply use "sr-only" class.
// So this is the same as the "sr-only" class, but copied here to work with the media query.
position: absolute;
width: 0.0625rem;
height: 0.0625rem;
padding: 0;
margin: -0.0625rem; // Fix for https://github.com/twbs/bootstrap/issues/25686
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
tbody {
display: block;
}
}
}

View File

@ -0,0 +1,36 @@
import { DecoratorFn, Meta, Story } from '@storybook/react'
import {
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
import { NOOP_PLATFORM_CONTEXT } from '@sourcegraph/shared/src/testing/searchTestHelpers'
import { WebStory } from '../../components/WebStory'
import { SearchContextsList, SearchContextsListProps } from './SearchContextsList'
const decorator: DecoratorFn = story => (
<div className="p-3 container" style={{ position: 'static' }}>
{story()}
</div>
)
const config: Meta = {
title: 'web/enterprise/searchContexts/SearchContextsListTab',
decorators: [decorator],
parameters: {
chromatic: { viewports: [1200], disableSnapshot: false },
},
}
export default config
const defaultProps: SearchContextsListProps = {
authenticatedUser: null,
fetchSearchContexts: mockFetchSearchContexts,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
platformContext: NOOP_PLATFORM_CONTEXT,
}
export const Default: Story = () => <WebStory>{() => <SearchContextsList {...defaultProps} />}</WebStory>

View File

@ -0,0 +1,210 @@
import React, { PropsWithChildren, useCallback, useMemo } from 'react'
import classNames from 'classnames'
import { useHistory, useLocation } from 'react-router'
import {
SearchContextProps,
ListSearchContextsResult,
ListSearchContextsVariables,
SearchContextsOrderBy,
SearchContextMinimalFields,
} from '@sourcegraph/search'
import { PlatformContextProps } from '@sourcegraph/shared/src/platform/context'
import { AuthenticatedUser } from '../../auth'
import {
FilteredConnection,
FilteredConnectionFilter,
FilteredConnectionFilterValue,
} from '../../components/FilteredConnection'
import { SearchContextNode, SearchContextNodeProps } from './SearchContextNode'
import styles from './SearchContextsList.module.scss'
export interface SearchContextsListProps
extends Pick<SearchContextProps, 'fetchSearchContexts' | 'getUserSearchContextNamespaces'>,
PlatformContextProps<'requestGraphQL'> {
authenticatedUser: AuthenticatedUser | null
}
export const SearchContextsList: React.FunctionComponent<SearchContextsListProps> = ({
authenticatedUser,
getUserSearchContextNamespaces,
fetchSearchContexts,
platformContext,
}) => {
const queryConnection = useCallback(
(args: Partial<ListSearchContextsVariables>) => {
const { namespace, orderBy, descending } = args as {
namespace: string | undefined
orderBy: SearchContextsOrderBy
descending: boolean
}
const namespaces = namespace
? [namespace === 'global' ? null : namespace]
: getUserSearchContextNamespaces(authenticatedUser)
return fetchSearchContexts({
first: args.first ?? 10,
query: args.query ?? undefined,
after: args.after ?? undefined,
namespaces,
orderBy,
descending,
platformContext,
})
},
[authenticatedUser, fetchSearchContexts, getUserSearchContextNamespaces, platformContext]
)
const ownerNamespaceFilterValues: FilteredConnectionFilterValue[] = useMemo(
() =>
authenticatedUser
? [
{
value: authenticatedUser.id,
label: authenticatedUser.username,
args: {
namespace: authenticatedUser.id,
},
},
...authenticatedUser.organizations.nodes.map(org => ({
value: org.id,
label: org.displayName || org.name,
args: {
namespace: org.id,
},
})),
]
: [],
[authenticatedUser]
)
const filters: FilteredConnectionFilter[] = useMemo(
() => [
{
label: 'Sort',
type: 'select',
id: 'order',
tooltip: 'Order search contexts',
values: [
{
value: 'spec-asc',
label: 'A-Z',
args: {
orderBy: SearchContextsOrderBy.SEARCH_CONTEXT_SPEC,
descending: false,
},
},
{
value: 'spec-desc',
label: 'Z-A',
args: {
orderBy: SearchContextsOrderBy.SEARCH_CONTEXT_SPEC,
descending: true,
},
},
{
value: 'updated-at-asc',
label: 'Oldest updates',
args: {
orderBy: SearchContextsOrderBy.SEARCH_CONTEXT_UPDATED_AT,
descending: false,
},
},
{
value: 'updated-at-desc',
label: 'Newest updates',
args: {
orderBy: SearchContextsOrderBy.SEARCH_CONTEXT_UPDATED_AT,
descending: true,
},
},
],
},
{
label: 'Owner',
type: 'select',
id: 'owner',
tooltip: 'Search context owner',
values: [
{
value: 'all',
label: 'All',
args: {},
},
{
value: 'global-owner',
label: 'Global',
args: {
namespace: 'global',
},
},
...ownerNamespaceFilterValues,
],
},
],
[ownerNamespaceFilterValues]
)
const history = useHistory()
const location = useLocation()
return (
<FilteredConnection<
SearchContextMinimalFields,
Omit<SearchContextNodeProps, 'node'>,
ListSearchContextsResult['searchContexts']
>
listComponent="table"
contentWrapperComponent={SearchContextsTableWrapper}
headComponent={SearchContextsTableHeader}
history={history}
location={location}
defaultFirst={10}
compact={false}
queryConnection={queryConnection}
filters={filters}
hideSearch={false}
showSearchFirst={true}
nodeComponent={SearchContextNode}
nodeComponentProps={{
location,
history,
}}
noun="search context"
pluralNoun="search contexts"
cursorPaging={true}
inputClassName={classNames(styles.filterInput)}
inputPlaceholder="Find a context"
inputAriaLabel="Find a context"
formClassName={styles.filtersForm}
/>
)
}
const SearchContextsTableWrapper: React.FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
<div className={styles.tableWrapper}>{children}</div>
)
const SearchContextsTableHeader: React.FunctionComponent = () => (
<thead>
<tr>
<th>
<span className="sr-only">Starred</span>
</th>
<th>Name</th>
<th>Description</th>
<th>Contents</th>
<th>Last updated</th>
<th>
<span className="sr-only">Tags</span>
</th>
<th>
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
)

View File

@ -0,0 +1,5 @@
.actions {
display: flex;
flex-direction: column;
width: fit-content;
}

View File

@ -1,8 +1,6 @@
import React, { useCallback, useState } from 'react'
import React from 'react'
import { mdiMagnify, mdiPlus } from '@mdi/js'
import classNames from 'classnames'
import * as H from 'history'
import { SearchContextProps } from '@sourcegraph/search'
import { PlatformContextProps } from '@sourcegraph/shared/src/platform/context'
@ -12,123 +10,88 @@ import { AuthenticatedUser } from '../../auth'
import { Page } from '../../components/Page'
import { eventLogger } from '../../tracking/eventLogger'
import { SearchContextsListTab } from './SearchContextsListTab'
import { SearchContextsList } from './SearchContextsList'
import styles from './SearchContextsListPage.module.scss'
export interface SearchContextsListPageProps
extends Pick<
SearchContextProps,
'fetchSearchContexts' | 'fetchAutoDefinedSearchContexts' | 'getUserSearchContextNamespaces'
>,
extends Pick<SearchContextProps, 'fetchSearchContexts' | 'getUserSearchContextNamespaces'>,
PlatformContextProps<'requestGraphQL'> {
location: H.Location
history: H.History
isSourcegraphDotCom: boolean
authenticatedUser: AuthenticatedUser | null
}
type SelectedTab = 'list'
function getSelectedTabFromLocation(locationSearch: string): SelectedTab {
const urlParameters = new URLSearchParams(locationSearch)
switch (urlParameters.get('tab')) {
case 'list':
return 'list'
}
return 'list'
}
function setSelectedLocationTab(location: H.Location, history: H.History, selectedTab: SelectedTab): void {
const urlParameters = new URLSearchParams(location.search)
urlParameters.set('tab', selectedTab)
if (location.search !== urlParameters.toString()) {
history.replace({ ...location, search: urlParameters.toString() })
}
}
export const SearchContextsListPage: React.FunctionComponent<
React.PropsWithChildren<SearchContextsListPageProps>
> = props => {
const [selectedTab, setSelectedTab] = useState<SelectedTab>(getSelectedTabFromLocation(props.location.search))
const setTab = useCallback(
(tab: SelectedTab) => {
setSelectedTab(tab)
setSelectedLocationTab(props.location, props.history, tab)
},
[props.location, props.history]
)
const onSelectSearchContextsList = useCallback<React.MouseEventHandler>(
event => {
event.preventDefault()
setTab('list')
},
[setTab]
)
return (
<div data-testid="search-contexts-list-page" className="w-100">
<Page>
<PageHeader
actions={
<>
<Button to="/contexts/new" variant="primary" as={Link}>
<Icon aria-hidden={true} svgPath={mdiPlus} />
Create search context
export const SearchContextsListPage: React.FunctionComponent<SearchContextsListPageProps> = ({
authenticatedUser,
getUserSearchContextNamespaces,
fetchSearchContexts,
platformContext,
isSourcegraphDotCom,
}) => (
<div data-testid="search-contexts-list-page" className="w-100">
<Page>
<PageHeader
actions={
<div className={styles.actions}>
<Button to="/contexts/new" variant="primary" as={Link}>
<Icon aria-hidden={true} svgPath={mdiPlus} />
Create search context
</Button>
{isSourcegraphDotCom && (
<Button
to="https://signup.sourcegraph.com/?p=context"
className="mt-2"
as={Link}
variant="secondary"
onClick={() => eventLogger.log('ClickedOnCloudCTA')}
>
Search private code
</Button>
{props.isSourcegraphDotCom && (
<Button
to="https://signup.sourcegraph.com/?p=context"
className="d-block mt-2"
as={Link}
variant="secondary"
onClick={() => eventLogger.log('ClickedOnCloudCTA')}
>
Search private code
</Button>
)}
</>
}
description={
<span className="text-muted">
Search code you care about with search contexts.{' '}
<Link
to="/help/code_search/explanations/features#search-contexts"
target="_blank"
rel="noopener noreferrer"
>
Learn more
</Link>
</span>
}
className="align-items-center mb-3"
>
<PageHeader.Heading as="h2" styleAs="h1">
<PageHeader.Breadcrumb icon={mdiMagnify} to="/search" aria-label="Code Search" />
<PageHeader.Breadcrumb>Contexts</PageHeader.Breadcrumb>
</PageHeader.Heading>
</PageHeader>
<div className="mb-4">
<div id="search-context-tabs-list" className="nav nav-tabs">
<div className="nav-item">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<Link
to=""
role="tab"
aria-selected={selectedTab === 'list'}
aria-controls="search-context-tabs-list"
onClick={onSelectSearchContextsList}
className={classNames('nav-link', selectedTab === 'list' && 'active')}
>
<span className="text-content" data-tab-content="Your search contexts">
Your search contexts
</span>
</Link>
</div>
)}
</div>
}
description={
<span className="text-muted">
Search code you care about with search contexts.{' '}
<Link
to="/help/code_search/explanations/features#search-contexts"
target="_blank"
rel="noopener noreferrer"
>
Learn more
</Link>
</span>
}
className="mb-3"
>
<PageHeader.Heading as="h2" styleAs="h1">
<PageHeader.Breadcrumb icon={mdiMagnify} to="/search" aria-label="Code Search" />
<PageHeader.Breadcrumb>Contexts</PageHeader.Breadcrumb>
</PageHeader.Heading>
</PageHeader>
<div id="search-context-tabs-list" className="nav nav-tabs">
<div className="nav-item" role="tablist">
<Link
to="/contexts"
role="tab"
aria-selected={true}
aria-controls="search-context-list"
className="nav-link active"
>
<span className="text-content" data-tab-content="Your search contexts">
Available contexts
</span>
</Link>
</div>
{selectedTab === 'list' && <SearchContextsListTab {...props} />}
</Page>
</div>
)
}
</div>
<div role="tabpanel" id="search-context-list">
<SearchContextsList
authenticatedUser={authenticatedUser}
getUserSearchContextNamespaces={getUserSearchContextNamespaces}
fetchSearchContexts={fetchSearchContexts}
platformContext={platformContext}
/>
</div>
</Page>
</div>
)

View File

@ -1,20 +0,0 @@
@import 'wildcard/src/global-styles/breakpoints';
.badge {
cursor: default;
}
.filters-form {
@media (--xs-breakpoint-down) {
display: flex;
flex-direction: column;
}
}
.filter-input {
max-width: 30%;
@media (--xs-breakpoint-down) {
max-width: 100%;
}
}

View File

@ -1,152 +0,0 @@
import { DecoratorFn, Meta, Story } from '@storybook/react'
import { subDays } from 'date-fns'
import { Observable, of } from 'rxjs'
import { ListSearchContextsResult } from '@sourcegraph/search'
import {
mockFetchAutoDefinedSearchContexts,
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
import { NOOP_PLATFORM_CONTEXT } from '@sourcegraph/shared/src/testing/searchTestHelpers'
import { WebStory } from '../../components/WebStory'
import { SearchContextsListTab, SearchContextsListTabProps } from './SearchContextsListTab'
const decorator: DecoratorFn = story => (
<div className="p-3 container" style={{ position: 'static' }}>
{story()}
</div>
)
const config: Meta = {
title: 'web/enterprise/searchContexts/SearchContextsListTab',
decorators: [decorator],
parameters: {
chromatic: { viewports: [1200], disableSnapshot: false },
},
}
export default config
const defaultProps: SearchContextsListTabProps = {
authenticatedUser: null,
isSourcegraphDotCom: true,
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(),
fetchSearchContexts: mockFetchSearchContexts,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
platformContext: NOOP_PLATFORM_CONTEXT,
}
const propsWithContexts: SearchContextsListTabProps = {
...defaultProps,
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(1),
fetchSearchContexts: ({
first,
query,
after,
}: {
first: number
query?: string
after?: string
}): Observable<ListSearchContextsResult['searchContexts']> =>
of({
nodes: [
{
__typename: 'SearchContext',
id: '3',
spec: '@username/test-version-1.5',
name: 'test-version-1.5',
namespace: {
__typename: 'User',
id: 'u1',
namespaceName: 'username',
},
autoDefined: false,
public: true,
description: 'Only code in version 1.5',
query: '',
updatedAt: subDays(new Date(), 1).toISOString(),
repositories: [],
viewerCanManage: true,
},
{
__typename: 'SearchContext',
id: '4',
spec: '@username/test-version-1.6',
namespace: {
__typename: 'User',
id: 'u1',
namespaceName: 'username',
},
name: 'test-version-1.6',
autoDefined: false,
public: false,
description: 'Only code in version 1.6',
query: '',
updatedAt: subDays(new Date(), 1).toISOString(),
repositories: [],
viewerCanManage: true,
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
totalCount: 1,
}),
}
export const Default: Story = () => <WebStory>{() => <SearchContextsListTab {...defaultProps} />}</WebStory>
export const WithSourcegraphDotComDisabled: Story = () => (
<WebStory>{() => <SearchContextsListTab {...propsWithContexts} isSourcegraphDotCom={false} />}</WebStory>
)
WithSourcegraphDotComDisabled.storyName = 'with SourcegraphDotCom disabled'
export const With1AutoDefinedContext: Story = () => (
<WebStory>{() => <SearchContextsListTab {...propsWithContexts} />}</WebStory>
)
With1AutoDefinedContext.storyName = 'with 1 auto-defined context'
export const With2AutoDefinedContexts: Story = () => (
<WebStory>
{() => (
<SearchContextsListTab
{...propsWithContexts}
fetchAutoDefinedSearchContexts={mockFetchAutoDefinedSearchContexts(2)}
/>
)}
</WebStory>
)
With2AutoDefinedContexts.storyName = 'with 2 auto-defined contexts'
export const With3AutoDefinedContexts: Story = () => (
<WebStory>
{() => (
<SearchContextsListTab
{...propsWithContexts}
fetchAutoDefinedSearchContexts={mockFetchAutoDefinedSearchContexts(3)}
/>
)}
</WebStory>
)
With3AutoDefinedContexts.storyName = 'with 3 auto-defined contexts'
export const With4AutoDefinedContexts: Story = () => (
<WebStory>
{() => (
<SearchContextsListTab
{...propsWithContexts}
fetchAutoDefinedSearchContexts={mockFetchAutoDefinedSearchContexts(4)}
/>
)}
</WebStory>
)
With4AutoDefinedContexts.storyName = 'with 4 auto-defined contexts'

View File

@ -1,183 +0,0 @@
import React, { useCallback } from 'react'
import classNames from 'classnames'
import { useHistory, useLocation } from 'react-router'
import {
SearchContextProps,
ListSearchContextsResult,
ListSearchContextsVariables,
SearchContextsOrderBy,
SearchContextMinimalFields,
} from '@sourcegraph/search'
import { PlatformContextProps } from '@sourcegraph/shared/src/platform/context'
import { AuthenticatedUser } from '../../auth'
import {
FilteredConnection,
FilteredConnectionFilter,
FilteredConnectionFilterValue,
} from '../../components/FilteredConnection'
import { SearchContextNode, SearchContextNodeProps } from './SearchContextNode'
import styles from './SearchContextsListTab.module.scss'
export interface SearchContextsListTabProps
extends Pick<
SearchContextProps,
'fetchSearchContexts' | 'fetchAutoDefinedSearchContexts' | 'getUserSearchContextNamespaces'
>,
PlatformContextProps<'requestGraphQL'> {
isSourcegraphDotCom: boolean
authenticatedUser: AuthenticatedUser | null
}
export const SearchContextsListTab: React.FunctionComponent<React.PropsWithChildren<SearchContextsListTabProps>> = ({
isSourcegraphDotCom,
authenticatedUser,
getUserSearchContextNamespaces,
fetchSearchContexts,
fetchAutoDefinedSearchContexts,
platformContext,
}) => {
const queryConnection = useCallback(
(args: Partial<ListSearchContextsVariables>) => {
const { namespace, orderBy, descending } = args as {
namespace: string | undefined
orderBy: SearchContextsOrderBy
descending: boolean
}
const namespaces = namespace
? [namespace === 'global' ? null : namespace]
: getUserSearchContextNamespaces(authenticatedUser)
return fetchSearchContexts({
first: args.first ?? 10,
query: args.query ?? undefined,
after: args.after ?? undefined,
namespaces,
orderBy,
descending,
platformContext,
})
},
[authenticatedUser, fetchSearchContexts, getUserSearchContextNamespaces, platformContext]
)
const ownerNamespaceFilterValues: FilteredConnectionFilterValue[] = authenticatedUser
? [
{
value: authenticatedUser.id,
label: authenticatedUser.username,
args: {
namespace: authenticatedUser.id,
},
},
...authenticatedUser.organizations.nodes.map(org => ({
value: org.id,
label: org.displayName || org.name,
args: {
namespace: org.id,
},
})),
]
: []
const filters: FilteredConnectionFilter[] = [
{
label: 'Owner',
type: 'select',
id: 'owner',
tooltip: 'Search context owner',
values: [
{
value: 'all',
label: 'All',
args: {},
},
{
value: 'global-owner',
label: 'Global',
args: {
namespace: 'global',
},
},
...ownerNamespaceFilterValues,
],
},
{
label: 'Sort by',
type: 'select',
id: 'order',
tooltip: 'Order search contexts',
values: [
{
value: 'spec-asc',
label: 'A-Z',
args: {
orderBy: SearchContextsOrderBy.SEARCH_CONTEXT_SPEC,
descending: false,
},
},
{
value: 'spec-desc',
label: 'Z-A',
args: {
orderBy: SearchContextsOrderBy.SEARCH_CONTEXT_SPEC,
descending: true,
},
},
{
value: 'updated-at-asc',
label: 'Oldest updates',
args: {
orderBy: SearchContextsOrderBy.SEARCH_CONTEXT_UPDATED_AT,
descending: false,
},
},
{
value: 'updated-at-desc',
label: 'Newest updates',
args: {
orderBy: SearchContextsOrderBy.SEARCH_CONTEXT_UPDATED_AT,
descending: true,
},
},
],
},
]
const history = useHistory()
const location = useLocation()
return (
<>
<FilteredConnection<
SearchContextMinimalFields,
Omit<SearchContextNodeProps, 'node'>,
ListSearchContextsResult['searchContexts']
>
history={history}
location={location}
defaultFirst={10}
compact={false}
queryConnection={queryConnection}
filters={filters}
hideSearch={false}
nodeComponent={SearchContextNode}
nodeComponentProps={{
location,
history,
}}
noun="search context"
pluralNoun="search contexts"
noSummaryIfAllNodesVisible={true}
cursorPaging={true}
inputClassName={classNames(styles.filterInput)}
inputPlaceholder="Filter search contexts..."
inputAriaLabel="Filter search contexts"
formClassName={styles.filtersForm}
/>
</>
)
}

View File

@ -27,9 +27,6 @@ describe('Code monitoring', () => {
})
testContext.overrideGraphQL({
...commonWebGraphQlResults,
AutoDefinedSearchContexts: () => ({
autoDefinedSearchContexts: [],
}),
ViewerSettings: () => ({
viewerSettings: {
__typename: 'SettingsCascade',

View File

@ -134,42 +134,6 @@ export const commonWebGraphQlResults: Partial<
alwaysNil: null,
},
}),
AutoDefinedSearchContexts: () => ({
autoDefinedSearchContexts: [
{
__typename: 'SearchContext',
id: '1',
spec: 'global',
name: 'global',
namespace: null,
autoDefined: true,
public: true,
description: 'All repositories on Sourcegraph',
updatedAt: '2021-03-15T19:39:11Z',
repositories: [],
query: '',
viewerCanManage: false,
},
{
__typename: 'SearchContext',
id: '2',
spec: '@test',
name: 'test',
namespace: {
__typename: 'User',
id: 'u1',
namespaceName: 'test',
},
autoDefined: true,
public: true,
description: 'Your repositories on Sourcegraph',
updatedAt: '2021-03-15T19:39:11Z',
repositories: [],
query: '',
viewerCanManage: false,
},
],
}),
ListSearchContexts: () => ({
searchContexts: {
nodes: [],

View File

@ -142,6 +142,8 @@ describe('Search contexts', () => {
autoDefined: false,
updatedAt: '',
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
query: searchContext.query,
repositories: repositories.map(repository => ({
__typename: 'SearchContextRepositoryRevisions',
@ -215,6 +217,8 @@ describe('Search contexts', () => {
autoDefined: false,
updatedAt: '',
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
query: searchContext.query,
repositories: repositories.map(repository => ({
__typename: 'SearchContextRepositoryRevisions',
@ -291,6 +295,8 @@ describe('Search contexts', () => {
autoDefined: false,
updatedAt: subDays(new Date(), 1).toISOString(),
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
query: '',
repositories: repositories.map(repository => ({
__typename: 'SearchContextRepositoryRevisions',
@ -315,6 +321,8 @@ describe('Search contexts', () => {
autoDefined: false,
updatedAt: subDays(new Date(), 1).toISOString(),
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
query: '',
repositories: [
{
@ -386,6 +394,8 @@ describe('Search contexts', () => {
autoDefined: false,
updatedAt: subDays(new Date(), 1).toISOString(),
viewerCanManage: false,
viewerHasAsDefault: false,
viewerHasStarred: false,
query: '',
repositories: [],
},
@ -425,6 +435,8 @@ describe('Search contexts', () => {
autoDefined: false,
updatedAt: subDays(new Date(), 1).toISOString(),
viewerCanManage: true,
viewerHasAsDefault: false,
viewerHasStarred: false,
query: '',
repositories: [
{
@ -457,9 +469,6 @@ describe('Search contexts', () => {
testContext.overrideGraphQL({
...testContextForSearchContexts,
AutoDefinedSearchContexts: () => ({
autoDefinedSearchContexts: [],
}),
ListSearchContexts: ({ after }) => {
const searchContexts = range(0, searchContextsCount).map(index => ({
__typename: 'SearchContext',
@ -470,6 +479,8 @@ describe('Search contexts', () => {
public: true,
autoDefined: false,
viewerCanManage: false,
viewerHasAsDefault: false,
viewerHasStarred: false,
description: '',
repositories: [],
query: '',
@ -537,9 +548,6 @@ describe('Search contexts', () => {
IsSearchContextAvailable: () => ({
isSearchContextAvailable: true,
}),
AutoDefinedSearchContexts: () => ({
autoDefinedSearchContexts: [],
}),
ListSearchContexts: () => {
const nodes = range(0, 2).map(index => ({
__typename: 'SearchContext',
@ -550,6 +558,8 @@ describe('Search contexts', () => {
public: true,
autoDefined: false,
viewerCanManage: false,
viewerHasAsDefault: false,
viewerHasStarred: false,
description: '',
repositories: [],
query: '',

View File

@ -3,7 +3,6 @@ import { createMemoryHistory } from 'history'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import {
mockFetchAutoDefinedSearchContexts,
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
@ -43,7 +42,6 @@ const getDefaultProps = (props: ThemeProps): GlobalNavbarProps => ({
batchChangesExecutionEnabled: false,
batchChangesWebhookLogsEnabled: false,
routes: [],
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(),
fetchSearchContexts: mockFetchSearchContexts,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
showKeyboardShortcutsHelp: () => undefined,

View File

@ -5,7 +5,6 @@ import { createLocation, createMemoryHistory } from 'history'
import { renderWithBrandedContext } from '@sourcegraph/shared/src/testing'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import {
mockFetchAutoDefinedSearchContexts,
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
@ -43,7 +42,6 @@ const PROPS: React.ComponentProps<typeof GlobalNavbar> = {
branding: undefined,
routes: [],
searchContextsEnabled: true,
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(),
fetchSearchContexts: mockFetchSearchContexts,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
showKeyboardShortcutsHelp: () => undefined,

View File

@ -6,7 +6,6 @@ import { getDocumentNode } from '@sourcegraph/http-client'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import {
mockFetchAutoDefinedSearchContexts,
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
@ -55,7 +54,6 @@ const defaultProps = (props: ThemeProps): SearchPageProps => ({
defaultSearchContextSpec: '',
isLightTheme: props.isLightTheme,
now: () => parseISO('2020-09-16T23:15:01Z'),
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(),
fetchSearchContexts: mockFetchSearchContexts,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
})

View File

@ -6,7 +6,6 @@ import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/teleme
import { renderWithBrandedContext } from '@sourcegraph/shared/src/testing'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import {
mockFetchAutoDefinedSearchContexts,
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
@ -106,7 +105,6 @@ describe('SearchPage', () => {
setSelectedSearchContextSpec: () => {},
defaultSearchContextSpec: '',
isLightTheme: true,
fetchAutoDefinedSearchContexts: mockFetchAutoDefinedSearchContexts(),
fetchSearchContexts: mockFetchSearchContexts,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
}

View File

@ -29,6 +29,10 @@ type SearchContextsResolver interface {
UpdateSearchContext(ctx context.Context, args UpdateSearchContextArgs) (SearchContextResolver, error)
DeleteSearchContext(ctx context.Context, args DeleteSearchContextArgs) (*EmptyResponse, error)
CreateSearchContextStar(ctx context.Context, args CreateSearchContextStarArgs) (*EmptyResponse, error)
DeleteSearchContextStar(ctx context.Context, args DeleteSearchContextStarArgs) (*EmptyResponse, error)
SetDefaultSearchContext(ctx context.Context, args SetDefaultSearchContextArgs) (*EmptyResponse, error)
NodeResolvers() map[string]NodeByIDFunc
SearchContextsToResolvers(searchContexts []*types.SearchContext) []SearchContextResolver
}
@ -43,6 +47,8 @@ type SearchContextResolver interface {
UpdatedAt() gqlutil.DateTime
Namespace(ctx context.Context) (*NamespaceResolver, error)
ViewerCanManage(ctx context.Context) bool
ViewerHasAsDefault(ctx context.Context) bool
ViewerHasStarred(ctx context.Context) bool
Repositories(ctx context.Context) ([]SearchContextRepositoryRevisionsResolver, error)
Query() string
}
@ -93,6 +99,21 @@ type DeleteSearchContextArgs struct {
ID graphql.ID
}
type CreateSearchContextStarArgs struct {
SearchContextID graphql.ID
UserID graphql.ID
}
type DeleteSearchContextStarArgs struct {
SearchContextID graphql.ID
UserID graphql.ID
}
type SetDefaultSearchContextArgs struct {
SearchContextID graphql.ID
UserID graphql.ID
}
type SearchContextBySpecArgs struct {
Spec string
}

View File

@ -33,10 +33,26 @@ extend type Mutation {
"""
repositories: [SearchContextRepositoryRevisionsInput!]!
): SearchContext!
"""
Add a star on a search context for the specified user.
Only one star can be created per context and user pair.
If the star already exists, this is a no-op.
"""
createSearchContextStar(searchContextId: ID!, userId: ID!): EmptyResponse!
"""
Delete a star on a search context for the specified user.
If the star does not exist, this is a no-op.
"""
deleteSearchContextStar(searchContextId: ID!, userId: ID!): EmptyResponse!
"""
Set the default search context for the specified user.
"""
setDefaultSearchContext(searchContextId: ID!, userId: ID!): EmptyResponse!
}
extend type Query {
"""
DEPRECATED: Auto-defined contexts are now included in the searchContexts query.
Auto-defined search contexts available to the current user.
"""
autoDefinedSearchContexts: [SearchContext!]!
@ -66,6 +82,10 @@ extend type Query {
namespaces: [ID] = []
"""
Sort field.
Despite the value, the results are always sorted with the global context first,
user's default context next, followed by the user's starred contexts,
followed by the rest of the contexts.
This controls the order of these internal groups.
"""
orderBy: SearchContextsOrderBy = SEARCH_CONTEXT_SPEC
"""
@ -140,6 +160,14 @@ type SearchContext implements Node {
If current viewer can manage (edit, delete) the search context.
"""
viewerCanManage: Boolean!
"""
If the viewer has set this context as default.
"""
viewerHasAsDefault: Boolean!
"""
If the viewer has starred this context.
"""
viewerHasStarred: Boolean!
}
"""

View File

@ -10,6 +10,7 @@ import (
"github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend/graphqlutil"
"github.com/sourcegraph/sourcegraph/internal/actor"
"github.com/sourcegraph/sourcegraph/internal/api"
"github.com/sourcegraph/sourcegraph/internal/auth"
"github.com/sourcegraph/sourcegraph/internal/database"
"github.com/sourcegraph/sourcegraph/internal/gitserver"
"github.com/sourcegraph/sourcegraph/internal/gqlutil"
@ -195,6 +196,93 @@ func (r *Resolver) DeleteSearchContext(ctx context.Context, args graphqlbackend.
return &graphqlbackend.EmptyResponse{}, nil
}
func (r *Resolver) CreateSearchContextStar(ctx context.Context, args graphqlbackend.CreateSearchContextStarArgs) (*graphqlbackend.EmptyResponse, error) {
// 🚨 SECURITY: Make sure the current user has permission to star the search context.
userID, err := graphqlbackend.UnmarshalUserID(args.UserID)
if err != nil {
return nil, err
}
if err := auth.CheckSiteAdminOrSameUser(ctx, r.db, userID); err != nil {
return nil, err
}
searchContextSpec, err := unmarshalSearchContextID(args.SearchContextID)
if err != nil {
return nil, err
}
searchContext, err := searchcontexts.ResolveSearchContextSpec(ctx, r.db, searchContextSpec)
if err != nil {
return nil, err
}
err = searchcontexts.CreateSearchContextStarForUser(ctx, r.db, searchContext, userID)
if err != nil {
return nil, err
}
return &graphqlbackend.EmptyResponse{}, nil
}
func (r *Resolver) DeleteSearchContextStar(ctx context.Context, args graphqlbackend.DeleteSearchContextStarArgs) (*graphqlbackend.EmptyResponse, error) {
// 🚨 SECURITY: Make sure the current user has permission to star the search context.
userID, err := graphqlbackend.UnmarshalUserID(args.UserID)
if err != nil {
return nil, err
}
if err := auth.CheckSiteAdminOrSameUser(ctx, r.db, userID); err != nil {
return nil, err
}
searchContextSpec, err := unmarshalSearchContextID(args.SearchContextID)
if err != nil {
return nil, err
}
searchContext, err := searchcontexts.ResolveSearchContextSpec(ctx, r.db, searchContextSpec)
if err != nil {
return nil, err
}
err = searchcontexts.DeleteSearchContextStarForUser(ctx, r.db, searchContext, userID)
if err != nil {
return nil, err
}
return &graphqlbackend.EmptyResponse{}, nil
}
func (r *Resolver) SetDefaultSearchContext(ctx context.Context, args graphqlbackend.SetDefaultSearchContextArgs) (*graphqlbackend.EmptyResponse, error) {
// 🚨 SECURITY: Make sure the current user has permission to set the search context as default.
userID, err := graphqlbackend.UnmarshalUserID(args.UserID)
if err != nil {
return nil, err
}
if err := auth.CheckSiteAdminOrSameUser(ctx, r.db, userID); err != nil {
return nil, err
}
searchContextSpec, err := unmarshalSearchContextID(args.SearchContextID)
if err != nil {
return nil, err
}
searchContext, err := searchcontexts.ResolveSearchContextSpec(ctx, r.db, searchContextSpec)
if err != nil {
return nil, err
}
err = searchcontexts.SetDefaultSearchContextForUser(ctx, r.db, searchContext, userID)
if err != nil {
return nil, err
}
return &graphqlbackend.EmptyResponse{}, nil
}
func unmarshalSearchContextCursor(cursor *string) (int32, error) {
var after int32
if cursor == nil {
@ -392,6 +480,14 @@ func (r *searchContextResolver) ViewerCanManage(ctx context.Context) bool {
return !searchcontexts.IsAutoDefinedSearchContext(r.sc) && hasWriteAccess
}
func (r *searchContextResolver) ViewerHasAsDefault(ctx context.Context) bool {
return r.sc.Default
}
func (r *searchContextResolver) ViewerHasStarred(ctx context.Context) bool {
return r.sc.Starred
}
func (r *searchContextResolver) Repositories(ctx context.Context) ([]graphqlbackend.SearchContextRepositoryRevisionsResolver, error) {
if searchcontexts.IsAutoDefinedSearchContext(r.sc) {
return []graphqlbackend.SearchContextRepositoryRevisionsResolver{}, nil

View File

@ -126,3 +126,79 @@ func TestSearchContexts(t *testing.T) {
})
}
}
func TestSearchContextsStarDefaultPermissions(t *testing.T) {
t.Parallel()
userID := int32(1)
graphqlUserID := graphqlbackend.MarshalUserID(userID)
username := "alice"
ctx := context.Background()
ctx = actor.WithActor(ctx, &actor.Actor{UID: userID})
orig := envvar.SourcegraphDotComMode()
envvar.MockSourcegraphDotComMode(true)
defer envvar.MockSourcegraphDotComMode(orig) // reset
users := database.NewMockUserStore()
users.GetByIDFunc.SetDefaultReturn(&types.User{Username: username}, nil)
users.GetByCurrentAuthUserFunc.SetDefaultReturn(&types.User{ID: userID, Username: username}, nil)
searchContextSpec := "test"
graphqlSearchContextID := marshalSearchContextID(searchContextSpec)
sc := database.NewMockSearchContextsStore()
sc.GetSearchContextFunc.SetDefaultReturn(&types.SearchContext{ID: 0, Name: searchContextSpec}, nil)
db := database.NewMockDB()
db.UsersFunc.SetDefaultReturn(users)
db.SearchContextsFunc.SetDefaultReturn(sc)
// User not admin, trying to set things for themselves
_, err := (&Resolver{db: db}).SetDefaultSearchContext(ctx, graphqlbackend.SetDefaultSearchContextArgs{SearchContextID: graphqlSearchContextID, UserID: graphqlUserID})
if err != nil {
t.Fatalf("expected no error, got %s", err)
}
_, err = (&Resolver{db: db}).CreateSearchContextStar(ctx, graphqlbackend.CreateSearchContextStarArgs{SearchContextID: graphqlSearchContextID, UserID: graphqlUserID})
if err != nil {
t.Fatalf("expected no error, got %s", err)
}
_, err = (&Resolver{db: db}).DeleteSearchContextStar(ctx, graphqlbackend.DeleteSearchContextStarArgs{SearchContextID: graphqlSearchContextID, UserID: graphqlUserID})
if err != nil {
t.Fatalf("expected no error, got %s", err)
}
// User not admin, trying to set things for another user
graphqlUserID2 := graphqlbackend.MarshalUserID(int32(2))
unauthorizedError := "must be authenticated as the authorized user or site admin"
_, err = (&Resolver{db: db}).SetDefaultSearchContext(ctx, graphqlbackend.SetDefaultSearchContextArgs{SearchContextID: graphqlSearchContextID, UserID: graphqlUserID2})
if err.Error() != unauthorizedError {
t.Fatalf("expected error %s, got %s", unauthorizedError, err)
}
_, err = (&Resolver{db: db}).CreateSearchContextStar(ctx, graphqlbackend.CreateSearchContextStarArgs{SearchContextID: graphqlSearchContextID, UserID: graphqlUserID2})
if err.Error() != unauthorizedError {
t.Fatalf("expected error %s, got %s", unauthorizedError, err)
}
_, err = (&Resolver{db: db}).DeleteSearchContextStar(ctx, graphqlbackend.DeleteSearchContextStarArgs{SearchContextID: graphqlSearchContextID, UserID: graphqlUserID2})
if err.Error() != unauthorizedError {
t.Fatalf("expected error %s, got %s", unauthorizedError, err)
}
// User is admin, trying to set things for another user
users.GetByIDFunc.SetDefaultReturn(&types.User{Username: username, SiteAdmin: true}, nil)
users.GetByCurrentAuthUserFunc.SetDefaultReturn(&types.User{Username: username, SiteAdmin: true}, nil)
_, err = (&Resolver{db: db}).SetDefaultSearchContext(ctx, graphqlbackend.SetDefaultSearchContextArgs{SearchContextID: graphqlSearchContextID, UserID: graphqlUserID2})
if err != nil {
t.Fatalf("expected no error, got %s", err)
}
_, err = (&Resolver{db: db}).CreateSearchContextStar(ctx, graphqlbackend.CreateSearchContextStarArgs{SearchContextID: graphqlSearchContextID, UserID: graphqlUserID2})
if err != nil {
t.Fatalf("expected no error, got %s", err)
}
_, err = (&Resolver{db: db}).DeleteSearchContextStar(ctx, graphqlbackend.DeleteSearchContextStarArgs{SearchContextID: graphqlSearchContextID, UserID: graphqlUserID2})
if err != nil {
t.Fatalf("expected no error, got %s", err)
}
}

View File

@ -35566,6 +35566,10 @@ type MockSearchContextsStore struct {
// CountSearchContextsFunc is an instance of a mock function object
// controlling the behavior of the method CountSearchContexts.
CountSearchContextsFunc *SearchContextsStoreCountSearchContextsFunc
// CreateSearchContextStarForUserFunc is an instance of a mock function
// object controlling the behavior of the method
// CreateSearchContextStarForUser.
CreateSearchContextStarForUserFunc *SearchContextsStoreCreateSearchContextStarForUserFunc
// CreateSearchContextWithRepositoryRevisionsFunc is an instance of a
// mock function object controlling the behavior of the method
// CreateSearchContextWithRepositoryRevisions.
@ -35573,6 +35577,10 @@ type MockSearchContextsStore struct {
// DeleteSearchContextFunc is an instance of a mock function object
// controlling the behavior of the method DeleteSearchContext.
DeleteSearchContextFunc *SearchContextsStoreDeleteSearchContextFunc
// DeleteSearchContextStarForUserFunc is an instance of a mock function
// object controlling the behavior of the method
// DeleteSearchContextStarForUser.
DeleteSearchContextStarForUserFunc *SearchContextsStoreDeleteSearchContextStarForUserFunc
// DoneFunc is an instance of a mock function object controlling the
// behavior of the method Done.
DoneFunc *SearchContextsStoreDoneFunc
@ -35602,6 +35610,10 @@ type MockSearchContextsStore struct {
// function object controlling the behavior of the method
// SetSearchContextRepositoryRevisions.
SetSearchContextRepositoryRevisionsFunc *SearchContextsStoreSetSearchContextRepositoryRevisionsFunc
// SetUserDefaultSearchContextIDFunc is an instance of a mock function
// object controlling the behavior of the method
// SetUserDefaultSearchContextID.
SetUserDefaultSearchContextIDFunc *SearchContextsStoreSetUserDefaultSearchContextIDFunc
// TransactFunc is an instance of a mock function object controlling the
// behavior of the method Transact.
TransactFunc *SearchContextsStoreTransactFunc
@ -35621,6 +35633,11 @@ func NewMockSearchContextsStore() *MockSearchContextsStore {
return
},
},
CreateSearchContextStarForUserFunc: &SearchContextsStoreCreateSearchContextStarForUserFunc{
defaultHook: func(context.Context, int32, int64) (r0 error) {
return
},
},
CreateSearchContextWithRepositoryRevisionsFunc: &SearchContextsStoreCreateSearchContextWithRepositoryRevisionsFunc{
defaultHook: func(context.Context, *types.SearchContext, []*types.SearchContextRepositoryRevisions) (r0 *types.SearchContext, r1 error) {
return
@ -35631,6 +35648,11 @@ func NewMockSearchContextsStore() *MockSearchContextsStore {
return
},
},
DeleteSearchContextStarForUserFunc: &SearchContextsStoreDeleteSearchContextStarForUserFunc{
defaultHook: func(context.Context, int32, int64) (r0 error) {
return
},
},
DoneFunc: &SearchContextsStoreDoneFunc{
defaultHook: func(error) (r0 error) {
return
@ -35676,6 +35698,11 @@ func NewMockSearchContextsStore() *MockSearchContextsStore {
return
},
},
SetUserDefaultSearchContextIDFunc: &SearchContextsStoreSetUserDefaultSearchContextIDFunc{
defaultHook: func(context.Context, int32, int64) (r0 error) {
return
},
},
TransactFunc: &SearchContextsStoreTransactFunc{
defaultHook: func(context.Context) (r0 SearchContextsStore, r1 error) {
return
@ -35699,6 +35726,11 @@ func NewStrictMockSearchContextsStore() *MockSearchContextsStore {
panic("unexpected invocation of MockSearchContextsStore.CountSearchContexts")
},
},
CreateSearchContextStarForUserFunc: &SearchContextsStoreCreateSearchContextStarForUserFunc{
defaultHook: func(context.Context, int32, int64) error {
panic("unexpected invocation of MockSearchContextsStore.CreateSearchContextStarForUser")
},
},
CreateSearchContextWithRepositoryRevisionsFunc: &SearchContextsStoreCreateSearchContextWithRepositoryRevisionsFunc{
defaultHook: func(context.Context, *types.SearchContext, []*types.SearchContextRepositoryRevisions) (*types.SearchContext, error) {
panic("unexpected invocation of MockSearchContextsStore.CreateSearchContextWithRepositoryRevisions")
@ -35709,6 +35741,11 @@ func NewStrictMockSearchContextsStore() *MockSearchContextsStore {
panic("unexpected invocation of MockSearchContextsStore.DeleteSearchContext")
},
},
DeleteSearchContextStarForUserFunc: &SearchContextsStoreDeleteSearchContextStarForUserFunc{
defaultHook: func(context.Context, int32, int64) error {
panic("unexpected invocation of MockSearchContextsStore.DeleteSearchContextStarForUser")
},
},
DoneFunc: &SearchContextsStoreDoneFunc{
defaultHook: func(error) error {
panic("unexpected invocation of MockSearchContextsStore.Done")
@ -35754,6 +35791,11 @@ func NewStrictMockSearchContextsStore() *MockSearchContextsStore {
panic("unexpected invocation of MockSearchContextsStore.SetSearchContextRepositoryRevisions")
},
},
SetUserDefaultSearchContextIDFunc: &SearchContextsStoreSetUserDefaultSearchContextIDFunc{
defaultHook: func(context.Context, int32, int64) error {
panic("unexpected invocation of MockSearchContextsStore.SetUserDefaultSearchContextID")
},
},
TransactFunc: &SearchContextsStoreTransactFunc{
defaultHook: func(context.Context) (SearchContextsStore, error) {
panic("unexpected invocation of MockSearchContextsStore.Transact")
@ -35775,12 +35817,18 @@ func NewMockSearchContextsStoreFrom(i SearchContextsStore) *MockSearchContextsSt
CountSearchContextsFunc: &SearchContextsStoreCountSearchContextsFunc{
defaultHook: i.CountSearchContexts,
},
CreateSearchContextStarForUserFunc: &SearchContextsStoreCreateSearchContextStarForUserFunc{
defaultHook: i.CreateSearchContextStarForUser,
},
CreateSearchContextWithRepositoryRevisionsFunc: &SearchContextsStoreCreateSearchContextWithRepositoryRevisionsFunc{
defaultHook: i.CreateSearchContextWithRepositoryRevisions,
},
DeleteSearchContextFunc: &SearchContextsStoreDeleteSearchContextFunc{
defaultHook: i.DeleteSearchContext,
},
DeleteSearchContextStarForUserFunc: &SearchContextsStoreDeleteSearchContextStarForUserFunc{
defaultHook: i.DeleteSearchContextStarForUser,
},
DoneFunc: &SearchContextsStoreDoneFunc{
defaultHook: i.Done,
},
@ -35808,6 +35856,9 @@ func NewMockSearchContextsStoreFrom(i SearchContextsStore) *MockSearchContextsSt
SetSearchContextRepositoryRevisionsFunc: &SearchContextsStoreSetSearchContextRepositoryRevisionsFunc{
defaultHook: i.SetSearchContextRepositoryRevisions,
},
SetUserDefaultSearchContextIDFunc: &SearchContextsStoreSetUserDefaultSearchContextIDFunc{
defaultHook: i.SetUserDefaultSearchContextID,
},
TransactFunc: &SearchContextsStoreTransactFunc{
defaultHook: i.Transact,
},
@ -35929,6 +35980,118 @@ func (c SearchContextsStoreCountSearchContextsFuncCall) Results() []interface{}
return []interface{}{c.Result0, c.Result1}
}
// SearchContextsStoreCreateSearchContextStarForUserFunc describes the
// behavior when the CreateSearchContextStarForUser method of the parent
// MockSearchContextsStore instance is invoked.
type SearchContextsStoreCreateSearchContextStarForUserFunc struct {
defaultHook func(context.Context, int32, int64) error
hooks []func(context.Context, int32, int64) error
history []SearchContextsStoreCreateSearchContextStarForUserFuncCall
mutex sync.Mutex
}
// CreateSearchContextStarForUser delegates to the next hook function in the
// queue and stores the parameter and result values of this invocation.
func (m *MockSearchContextsStore) CreateSearchContextStarForUser(v0 context.Context, v1 int32, v2 int64) error {
r0 := m.CreateSearchContextStarForUserFunc.nextHook()(v0, v1, v2)
m.CreateSearchContextStarForUserFunc.appendCall(SearchContextsStoreCreateSearchContextStarForUserFuncCall{v0, v1, v2, r0})
return r0
}
// SetDefaultHook sets function that is called when the
// CreateSearchContextStarForUser method of the parent
// MockSearchContextsStore instance is invoked and the hook queue is empty.
func (f *SearchContextsStoreCreateSearchContextStarForUserFunc) SetDefaultHook(hook func(context.Context, int32, int64) error) {
f.defaultHook = hook
}
// PushHook adds a function to the end of hook queue. Each invocation of the
// CreateSearchContextStarForUser method of the parent
// MockSearchContextsStore instance invokes the hook at the front of the
// queue and discards it. After the queue is empty, the default hook
// function is invoked for any future action.
func (f *SearchContextsStoreCreateSearchContextStarForUserFunc) PushHook(hook func(context.Context, int32, int64) error) {
f.mutex.Lock()
f.hooks = append(f.hooks, hook)
f.mutex.Unlock()
}
// SetDefaultReturn calls SetDefaultHook with a function that returns the
// given values.
func (f *SearchContextsStoreCreateSearchContextStarForUserFunc) SetDefaultReturn(r0 error) {
f.SetDefaultHook(func(context.Context, int32, int64) error {
return r0
})
}
// PushReturn calls PushHook with a function that returns the given values.
func (f *SearchContextsStoreCreateSearchContextStarForUserFunc) PushReturn(r0 error) {
f.PushHook(func(context.Context, int32, int64) error {
return r0
})
}
func (f *SearchContextsStoreCreateSearchContextStarForUserFunc) nextHook() func(context.Context, int32, int64) error {
f.mutex.Lock()
defer f.mutex.Unlock()
if len(f.hooks) == 0 {
return f.defaultHook
}
hook := f.hooks[0]
f.hooks = f.hooks[1:]
return hook
}
func (f *SearchContextsStoreCreateSearchContextStarForUserFunc) appendCall(r0 SearchContextsStoreCreateSearchContextStarForUserFuncCall) {
f.mutex.Lock()
f.history = append(f.history, r0)
f.mutex.Unlock()
}
// History returns a sequence of
// SearchContextsStoreCreateSearchContextStarForUserFuncCall objects
// describing the invocations of this function.
func (f *SearchContextsStoreCreateSearchContextStarForUserFunc) History() []SearchContextsStoreCreateSearchContextStarForUserFuncCall {
f.mutex.Lock()
history := make([]SearchContextsStoreCreateSearchContextStarForUserFuncCall, len(f.history))
copy(history, f.history)
f.mutex.Unlock()
return history
}
// SearchContextsStoreCreateSearchContextStarForUserFuncCall is an object
// that describes an invocation of method CreateSearchContextStarForUser on
// an instance of MockSearchContextsStore.
type SearchContextsStoreCreateSearchContextStarForUserFuncCall struct {
// Arg0 is the value of the 1st argument passed to this method
// invocation.
Arg0 context.Context
// Arg1 is the value of the 2nd argument passed to this method
// invocation.
Arg1 int32
// Arg2 is the value of the 3rd argument passed to this method
// invocation.
Arg2 int64
// Result0 is the value of the 1st result returned from this method
// invocation.
Result0 error
}
// Args returns an interface slice containing the arguments of this
// invocation.
func (c SearchContextsStoreCreateSearchContextStarForUserFuncCall) Args() []interface{} {
return []interface{}{c.Arg0, c.Arg1, c.Arg2}
}
// Results returns an interface slice containing the results of this
// invocation.
func (c SearchContextsStoreCreateSearchContextStarForUserFuncCall) Results() []interface{} {
return []interface{}{c.Result0}
}
// SearchContextsStoreCreateSearchContextWithRepositoryRevisionsFunc
// describes the behavior when the
// CreateSearchContextWithRepositoryRevisions method of the parent
@ -36156,6 +36319,118 @@ func (c SearchContextsStoreDeleteSearchContextFuncCall) Results() []interface{}
return []interface{}{c.Result0}
}
// SearchContextsStoreDeleteSearchContextStarForUserFunc describes the
// behavior when the DeleteSearchContextStarForUser method of the parent
// MockSearchContextsStore instance is invoked.
type SearchContextsStoreDeleteSearchContextStarForUserFunc struct {
defaultHook func(context.Context, int32, int64) error
hooks []func(context.Context, int32, int64) error
history []SearchContextsStoreDeleteSearchContextStarForUserFuncCall
mutex sync.Mutex
}
// DeleteSearchContextStarForUser delegates to the next hook function in the
// queue and stores the parameter and result values of this invocation.
func (m *MockSearchContextsStore) DeleteSearchContextStarForUser(v0 context.Context, v1 int32, v2 int64) error {
r0 := m.DeleteSearchContextStarForUserFunc.nextHook()(v0, v1, v2)
m.DeleteSearchContextStarForUserFunc.appendCall(SearchContextsStoreDeleteSearchContextStarForUserFuncCall{v0, v1, v2, r0})
return r0
}
// SetDefaultHook sets function that is called when the
// DeleteSearchContextStarForUser method of the parent
// MockSearchContextsStore instance is invoked and the hook queue is empty.
func (f *SearchContextsStoreDeleteSearchContextStarForUserFunc) SetDefaultHook(hook func(context.Context, int32, int64) error) {
f.defaultHook = hook
}
// PushHook adds a function to the end of hook queue. Each invocation of the
// DeleteSearchContextStarForUser method of the parent
// MockSearchContextsStore instance invokes the hook at the front of the
// queue and discards it. After the queue is empty, the default hook
// function is invoked for any future action.
func (f *SearchContextsStoreDeleteSearchContextStarForUserFunc) PushHook(hook func(context.Context, int32, int64) error) {
f.mutex.Lock()
f.hooks = append(f.hooks, hook)
f.mutex.Unlock()
}
// SetDefaultReturn calls SetDefaultHook with a function that returns the
// given values.
func (f *SearchContextsStoreDeleteSearchContextStarForUserFunc) SetDefaultReturn(r0 error) {
f.SetDefaultHook(func(context.Context, int32, int64) error {
return r0
})
}
// PushReturn calls PushHook with a function that returns the given values.
func (f *SearchContextsStoreDeleteSearchContextStarForUserFunc) PushReturn(r0 error) {
f.PushHook(func(context.Context, int32, int64) error {
return r0
})
}
func (f *SearchContextsStoreDeleteSearchContextStarForUserFunc) nextHook() func(context.Context, int32, int64) error {
f.mutex.Lock()
defer f.mutex.Unlock()
if len(f.hooks) == 0 {
return f.defaultHook
}
hook := f.hooks[0]
f.hooks = f.hooks[1:]
return hook
}
func (f *SearchContextsStoreDeleteSearchContextStarForUserFunc) appendCall(r0 SearchContextsStoreDeleteSearchContextStarForUserFuncCall) {
f.mutex.Lock()
f.history = append(f.history, r0)
f.mutex.Unlock()
}
// History returns a sequence of
// SearchContextsStoreDeleteSearchContextStarForUserFuncCall objects
// describing the invocations of this function.
func (f *SearchContextsStoreDeleteSearchContextStarForUserFunc) History() []SearchContextsStoreDeleteSearchContextStarForUserFuncCall {
f.mutex.Lock()
history := make([]SearchContextsStoreDeleteSearchContextStarForUserFuncCall, len(f.history))
copy(history, f.history)
f.mutex.Unlock()
return history
}
// SearchContextsStoreDeleteSearchContextStarForUserFuncCall is an object
// that describes an invocation of method DeleteSearchContextStarForUser on
// an instance of MockSearchContextsStore.
type SearchContextsStoreDeleteSearchContextStarForUserFuncCall struct {
// Arg0 is the value of the 1st argument passed to this method
// invocation.
Arg0 context.Context
// Arg1 is the value of the 2nd argument passed to this method
// invocation.
Arg1 int32
// Arg2 is the value of the 3rd argument passed to this method
// invocation.
Arg2 int64
// Result0 is the value of the 1st result returned from this method
// invocation.
Result0 error
}
// Args returns an interface slice containing the arguments of this
// invocation.
func (c SearchContextsStoreDeleteSearchContextStarForUserFuncCall) Args() []interface{} {
return []interface{}{c.Arg0, c.Arg1, c.Arg2}
}
// Results returns an interface slice containing the results of this
// invocation.
func (c SearchContextsStoreDeleteSearchContextStarForUserFuncCall) Results() []interface{} {
return []interface{}{c.Result0}
}
// SearchContextsStoreDoneFunc describes the behavior when the Done method
// of the parent MockSearchContextsStore instance is invoked.
type SearchContextsStoreDoneFunc struct {
@ -37136,6 +37411,118 @@ func (c SearchContextsStoreSetSearchContextRepositoryRevisionsFuncCall) Results(
return []interface{}{c.Result0}
}
// SearchContextsStoreSetUserDefaultSearchContextIDFunc describes the
// behavior when the SetUserDefaultSearchContextID method of the parent
// MockSearchContextsStore instance is invoked.
type SearchContextsStoreSetUserDefaultSearchContextIDFunc struct {
defaultHook func(context.Context, int32, int64) error
hooks []func(context.Context, int32, int64) error
history []SearchContextsStoreSetUserDefaultSearchContextIDFuncCall
mutex sync.Mutex
}
// SetUserDefaultSearchContextID delegates to the next hook function in the
// queue and stores the parameter and result values of this invocation.
func (m *MockSearchContextsStore) SetUserDefaultSearchContextID(v0 context.Context, v1 int32, v2 int64) error {
r0 := m.SetUserDefaultSearchContextIDFunc.nextHook()(v0, v1, v2)
m.SetUserDefaultSearchContextIDFunc.appendCall(SearchContextsStoreSetUserDefaultSearchContextIDFuncCall{v0, v1, v2, r0})
return r0
}
// SetDefaultHook sets function that is called when the
// SetUserDefaultSearchContextID method of the parent
// MockSearchContextsStore instance is invoked and the hook queue is empty.
func (f *SearchContextsStoreSetUserDefaultSearchContextIDFunc) SetDefaultHook(hook func(context.Context, int32, int64) error) {
f.defaultHook = hook
}
// PushHook adds a function to the end of hook queue. Each invocation of the
// SetUserDefaultSearchContextID method of the parent
// MockSearchContextsStore instance invokes the hook at the front of the
// queue and discards it. After the queue is empty, the default hook
// function is invoked for any future action.
func (f *SearchContextsStoreSetUserDefaultSearchContextIDFunc) PushHook(hook func(context.Context, int32, int64) error) {
f.mutex.Lock()
f.hooks = append(f.hooks, hook)
f.mutex.Unlock()
}
// SetDefaultReturn calls SetDefaultHook with a function that returns the
// given values.
func (f *SearchContextsStoreSetUserDefaultSearchContextIDFunc) SetDefaultReturn(r0 error) {
f.SetDefaultHook(func(context.Context, int32, int64) error {
return r0
})
}
// PushReturn calls PushHook with a function that returns the given values.
func (f *SearchContextsStoreSetUserDefaultSearchContextIDFunc) PushReturn(r0 error) {
f.PushHook(func(context.Context, int32, int64) error {
return r0
})
}
func (f *SearchContextsStoreSetUserDefaultSearchContextIDFunc) nextHook() func(context.Context, int32, int64) error {
f.mutex.Lock()
defer f.mutex.Unlock()
if len(f.hooks) == 0 {
return f.defaultHook
}
hook := f.hooks[0]
f.hooks = f.hooks[1:]
return hook
}
func (f *SearchContextsStoreSetUserDefaultSearchContextIDFunc) appendCall(r0 SearchContextsStoreSetUserDefaultSearchContextIDFuncCall) {
f.mutex.Lock()
f.history = append(f.history, r0)
f.mutex.Unlock()
}
// History returns a sequence of
// SearchContextsStoreSetUserDefaultSearchContextIDFuncCall objects
// describing the invocations of this function.
func (f *SearchContextsStoreSetUserDefaultSearchContextIDFunc) History() []SearchContextsStoreSetUserDefaultSearchContextIDFuncCall {
f.mutex.Lock()
history := make([]SearchContextsStoreSetUserDefaultSearchContextIDFuncCall, len(f.history))
copy(history, f.history)
f.mutex.Unlock()
return history
}
// SearchContextsStoreSetUserDefaultSearchContextIDFuncCall is an object
// that describes an invocation of method SetUserDefaultSearchContextID on
// an instance of MockSearchContextsStore.
type SearchContextsStoreSetUserDefaultSearchContextIDFuncCall struct {
// Arg0 is the value of the 1st argument passed to this method
// invocation.
Arg0 context.Context
// Arg1 is the value of the 2nd argument passed to this method
// invocation.
Arg1 int32
// Arg2 is the value of the 3rd argument passed to this method
// invocation.
Arg2 int64
// Result0 is the value of the 1st result returned from this method
// invocation.
Result0 error
}
// Args returns an interface slice containing the arguments of this
// invocation.
func (c SearchContextsStoreSetUserDefaultSearchContextIDFuncCall) Args() []interface{} {
return []interface{}{c.Arg0, c.Arg1, c.Arg2}
}
// Results returns an interface slice containing the results of this
// invocation.
func (c SearchContextsStoreSetUserDefaultSearchContextIDFuncCall) Results() []interface{} {
return []interface{}{c.Result0}
}
// SearchContextsStoreTransactFunc describes the behavior when the Transact
// method of the parent MockSearchContextsStore instance is invoked.
type SearchContextsStoreTransactFunc struct {

View File

@ -17833,6 +17833,67 @@
],
"Triggers": []
},
{
"Name": "search_context_default",
"Comment": "When a user sets a search context as default, a row is inserted into this table. A user can only have one default search context. If the user has not set their default search context, it will fall back to `global`.",
"Columns": [
{
"Name": "search_context_id",
"Index": 2,
"TypeName": "bigint",
"IsNullable": false,
"Default": "",
"CharacterMaximumLength": 0,
"IsIdentity": false,
"IdentityGeneration": "",
"IsGenerated": "NEVER",
"GenerationExpression": "",
"Comment": ""
},
{
"Name": "user_id",
"Index": 1,
"TypeName": "integer",
"IsNullable": false,
"Default": "",
"CharacterMaximumLength": 0,
"IsIdentity": false,
"IdentityGeneration": "",
"IsGenerated": "NEVER",
"GenerationExpression": "",
"Comment": ""
}
],
"Indexes": [
{
"Name": "search_context_default_pkey",
"IsPrimaryKey": true,
"IsUnique": true,
"IsExclusion": false,
"IsDeferrable": false,
"IndexDefinition": "CREATE UNIQUE INDEX search_context_default_pkey ON search_context_default USING btree (user_id)",
"ConstraintType": "p",
"ConstraintDefinition": "PRIMARY KEY (user_id)"
}
],
"Constraints": [
{
"Name": "search_context_default_search_context_id_fkey",
"ConstraintType": "f",
"RefTableName": "search_contexts",
"IsDeferrable": true,
"ConstraintDefinition": "FOREIGN KEY (search_context_id) REFERENCES search_contexts(id) ON DELETE CASCADE DEFERRABLE"
},
{
"Name": "search_context_default_user_id_fkey",
"ConstraintType": "f",
"RefTableName": "users",
"IsDeferrable": true,
"ConstraintDefinition": "FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE DEFERRABLE"
}
],
"Triggers": []
},
{
"Name": "search_context_repos",
"Comment": "",
@ -17907,6 +17968,80 @@
],
"Triggers": []
},
{
"Name": "search_context_stars",
"Comment": "When a user stars a search context, a row is inserted into this table. If the user unstars the search context, the row is deleted. The global context is not in the database, and therefore cannot be starred.",
"Columns": [
{
"Name": "created_at",
"Index": 3,
"TypeName": "timestamp with time zone",
"IsNullable": false,
"Default": "now()",
"CharacterMaximumLength": 0,
"IsIdentity": false,
"IdentityGeneration": "",
"IsGenerated": "NEVER",
"GenerationExpression": "",
"Comment": ""
},
{
"Name": "search_context_id",
"Index": 1,
"TypeName": "bigint",
"IsNullable": false,
"Default": "",
"CharacterMaximumLength": 0,
"IsIdentity": false,
"IdentityGeneration": "",
"IsGenerated": "NEVER",
"GenerationExpression": "",
"Comment": ""
},
{
"Name": "user_id",
"Index": 2,
"TypeName": "integer",
"IsNullable": false,
"Default": "",
"CharacterMaximumLength": 0,
"IsIdentity": false,
"IdentityGeneration": "",
"IsGenerated": "NEVER",
"GenerationExpression": "",
"Comment": ""
}
],
"Indexes": [
{
"Name": "search_context_stars_pkey",
"IsPrimaryKey": true,
"IsUnique": true,
"IsExclusion": false,
"IsDeferrable": false,
"IndexDefinition": "CREATE UNIQUE INDEX search_context_stars_pkey ON search_context_stars USING btree (search_context_id, user_id)",
"ConstraintType": "p",
"ConstraintDefinition": "PRIMARY KEY (search_context_id, user_id)"
}
],
"Constraints": [
{
"Name": "search_context_stars_search_context_id_fkey",
"ConstraintType": "f",
"RefTableName": "search_contexts",
"IsDeferrable": true,
"ConstraintDefinition": "FOREIGN KEY (search_context_id) REFERENCES search_contexts(id) ON DELETE CASCADE DEFERRABLE"
},
{
"Name": "search_context_stars_user_id_fkey",
"ConstraintType": "f",
"RefTableName": "users",
"IsDeferrable": true,
"ConstraintDefinition": "FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE DEFERRABLE"
}
],
"Triggers": []
},
{
"Name": "search_contexts",
"Comment": "",

View File

@ -2780,6 +2780,22 @@ Foreign-key constraints:
```
# Table "public.search_context_default"
```
Column | Type | Collation | Nullable | Default
-------------------+---------+-----------+----------+---------
user_id | integer | | not null |
search_context_id | bigint | | not null |
Indexes:
"search_context_default_pkey" PRIMARY KEY, btree (user_id)
Foreign-key constraints:
"search_context_default_search_context_id_fkey" FOREIGN KEY (search_context_id) REFERENCES search_contexts(id) ON DELETE CASCADE DEFERRABLE
"search_context_default_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE DEFERRABLE
```
When a user sets a search context as default, a row is inserted into this table. A user can only have one default search context. If the user has not set their default search context, it will fall back to `global`.
# Table "public.search_context_repos"
```
Column | Type | Collation | Nullable | Default
@ -2795,6 +2811,23 @@ Foreign-key constraints:
```
# Table "public.search_context_stars"
```
Column | Type | Collation | Nullable | Default
-------------------+--------------------------+-----------+----------+---------
search_context_id | bigint | | not null |
user_id | integer | | not null |
created_at | timestamp with time zone | | not null | now()
Indexes:
"search_context_stars_pkey" PRIMARY KEY, btree (search_context_id, user_id)
Foreign-key constraints:
"search_context_stars_search_context_id_fkey" FOREIGN KEY (search_context_id) REFERENCES search_contexts(id) ON DELETE CASCADE DEFERRABLE
"search_context_stars_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE DEFERRABLE
```
When a user stars a search context, a row is inserted into this table. If the user unstars the search context, the row is deleted. The global context is not in the database, and therefore cannot be starred.
# Table "public.search_contexts"
```
Column | Type | Collation | Nullable | Default
@ -2821,7 +2854,9 @@ Foreign-key constraints:
"search_contexts_namespace_org_id_fk" FOREIGN KEY (namespace_org_id) REFERENCES orgs(id) ON DELETE CASCADE
"search_contexts_namespace_user_id_fk" FOREIGN KEY (namespace_user_id) REFERENCES users(id) ON DELETE CASCADE
Referenced by:
TABLE "search_context_default" CONSTRAINT "search_context_default_search_context_id_fkey" FOREIGN KEY (search_context_id) REFERENCES search_contexts(id) ON DELETE CASCADE DEFERRABLE
TABLE "search_context_repos" CONSTRAINT "search_context_repos_search_context_id_fk" FOREIGN KEY (search_context_id) REFERENCES search_contexts(id) ON DELETE CASCADE
TABLE "search_context_stars" CONSTRAINT "search_context_stars_search_context_id_fkey" FOREIGN KEY (search_context_id) REFERENCES search_contexts(id) ON DELETE CASCADE DEFERRABLE
```
@ -3153,6 +3188,8 @@ Referenced by:
TABLE "registry_extension_releases" CONSTRAINT "registry_extension_releases_creator_user_id_fkey" FOREIGN KEY (creator_user_id) REFERENCES users(id)
TABLE "registry_extensions" CONSTRAINT "registry_extensions_publisher_user_id_fkey" FOREIGN KEY (publisher_user_id) REFERENCES users(id)
TABLE "saved_searches" CONSTRAINT "saved_searches_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id)
TABLE "search_context_default" CONSTRAINT "search_context_default_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE DEFERRABLE
TABLE "search_context_stars" CONSTRAINT "search_context_stars_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE DEFERRABLE
TABLE "search_contexts" CONSTRAINT "search_contexts_namespace_user_id_fk" FOREIGN KEY (namespace_user_id) REFERENCES users(id) ON DELETE CASCADE
TABLE "settings" CONSTRAINT "settings_author_user_id_fkey" FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE RESTRICT
TABLE "settings" CONSTRAINT "settings_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT

View File

@ -40,6 +40,9 @@ type SearchContextsStore interface {
SetSearchContextRepositoryRevisions(context.Context, int64, []*types.SearchContextRepositoryRevisions) error
Transact(context.Context) (SearchContextsStore, error)
UpdateSearchContextWithRepositoryRevisions(context.Context, *types.SearchContext, []*types.SearchContextRepositoryRevisions) (*types.SearchContext, error)
SetUserDefaultSearchContextID(ctx context.Context, userID int32, searchContextID int64) error
CreateSearchContextStarForUser(ctx context.Context, userID int32, searchContextID int64) error
DeleteSearchContextStarForUser(ctx context.Context, userID int32, searchContextID int64) error
}
type searchContextsStore struct {
@ -59,61 +62,102 @@ const searchContextsPermissionsConditionFmtStr = `(
-- Bypass permission check
%s
-- Happy path of public search contexts
OR sc.public
OR public
-- Private user contexts are available only to its creator
OR (sc.namespace_user_id IS NOT NULL AND sc.namespace_user_id = %d)
OR (namespace_user_id IS NOT NULL AND namespace_user_id = %d)
-- Private org contexts are available only to its members
OR (sc.namespace_org_id IS NOT NULL AND EXISTS (SELECT FROM org_members om WHERE om.org_id = sc.namespace_org_id AND om.user_id = %d))
OR (namespace_org_id IS NOT NULL AND EXISTS (SELECT FROM org_members om WHERE om.org_id = namespace_org_id AND om.user_id = %d))
-- Private instance-level contexts are available only to site-admins
OR (sc.namespace_user_id IS NULL AND sc.namespace_org_id IS NULL AND EXISTS (SELECT FROM users u WHERE u.id = %d AND u.site_admin))
OR (namespace_user_id IS NULL AND namespace_org_id IS NULL AND EXISTS (SELECT FROM users u WHERE u.id = %d AND u.site_admin))
)`
func searchContextsPermissionsCondition(ctx context.Context, logger log.Logger, store basestore.ShareableStore) (*sqlf.Query, error) {
func searchContextsPermissionsCondition(ctx context.Context) *sqlf.Query {
a := actor.FromContext(ctx)
authenticatedUserID := int32(0)
authenticatedUserID := a.UID
bypassPermissionsCheck := a.Internal
if !bypassPermissionsCheck && a.IsAuthenticated() {
currentUser, err := UsersWith(logger, store).GetByCurrentAuthUser(ctx)
if err != nil {
return nil, err
}
authenticatedUserID = currentUser.ID
}
q := sqlf.Sprintf(searchContextsPermissionsConditionFmtStr, bypassPermissionsCheck, authenticatedUserID, authenticatedUserID, authenticatedUserID)
return q, nil
return q
}
const searchContextQueryFmtStr = `
SELECT -- The global context is not in the database, it needs to be added here for the sake of pagination.
0 as id, -- All other contexts have a non-zero ID.
'global' as context_name,
'All repositories on Sourcegraph' as description,
true as public,
true as autodefined,
NULL as namespace_user_id,
NULL as namespace_org_id,
TIMESTAMP WITH TIME ZONE 'epoch' as updated_at, -- Timestamp is not used for global context, but we need to return something.
NULL as query,
NULL as namespace_name,
NULL as namespace_username,
NULL as namespace_org_name,
NOT EXISTS (SELECT FROM search_context_default scd WHERE scd.user_id = %d) as user_default, -- Global context is the default if there is no default set.
false as user_starred -- Global context cannot be starred.
UNION ALL
SELECT
sc.id as id,
sc.name as context_name,
sc.description as description,
sc.public as public,
false as autodefined, -- Context in the database are never autodefined.
sc.namespace_user_id as namespace_user_id,
sc.namespace_org_id as namespace_org_id,
sc.updated_at as updated_at,
sc.query as query,
COALESCE(u.username, o.name) as namespace_name,
u.username as namespace_username,
o.name as namespace_org_name,
scd.search_context_id IS NOT NULL as user_default,
scs.search_context_id IS NOT NULL as user_starred
FROM search_contexts sc
LEFT JOIN users u on sc.namespace_user_id = u.id
LEFT JOIN orgs o on sc.namespace_org_id = o.id
LEFT JOIN search_context_stars scs
ON scs.user_id = %d AND scs.search_context_id = sc.id
LEFT JOIN search_context_default scd
ON scd.user_id = %d AND scd.search_context_id = sc.id
`
const listSearchContextsFmtStr = `
SELECT
sc.id,
sc.name,
sc.description,
sc.public,
sc.namespace_user_id,
sc.namespace_org_id,
sc.updated_at,
sc.query,
u.username,
o.name
FROM search_contexts sc
LEFT JOIN users u on sc.namespace_user_id = u.id
LEFT JOIN orgs o on sc.namespace_org_id = o.id
id,
context_name,
description,
public,
autodefined,
namespace_user_id,
namespace_org_id,
updated_at,
query,
namespace_username,
namespace_org_name,
user_default,
user_starred
FROM (
` + searchContextQueryFmtStr + `
) AS t
WHERE
(%s) -- permission conditions
AND (%s) -- query conditions
ORDER BY %s
ORDER BY
autodefined DESC, -- Always show global context first
user_default DESC,
user_starred DESC,
%s
LIMIT %d
OFFSET %d
`
const countSearchContextsFmtStr = `
SELECT COUNT(*)
FROM search_contexts sc
LEFT JOIN users u on sc.namespace_user_id = u.id
LEFT JOIN orgs o on sc.namespace_org_id = o.id
FROM (
` + searchContextQueryFmtStr + `
) AS t
WHERE
(%s) -- permission conditions
AND (%s) -- query conditions
(%s) -- permission conditions
AND (%s) -- query conditions
`
type SearchContextsOrderByOption uint8
@ -159,11 +203,11 @@ func getSearchContextOrderByClause(orderBy SearchContextsOrderByOption, descendi
}
switch orderBy {
case SearchContextsOrderBySpec:
return sqlf.Sprintf(fmt.Sprintf("COALESCE(u.username, o.name) %s, sc.name %s", orderDirection, orderDirection))
return sqlf.Sprintf(fmt.Sprintf("namespace_name %s, context_name %s", orderDirection, orderDirection))
case SearchContextsOrderByUpdatedAt:
return sqlf.Sprintf("sc.updated_at " + orderDirection)
return sqlf.Sprintf("updated_at " + orderDirection)
case SearchContextsOrderByID:
return sqlf.Sprintf("sc.id " + orderDirection)
return sqlf.Sprintf("id " + orderDirection)
}
panic("invalid SearchContextsOrderByOption option")
}
@ -174,10 +218,10 @@ func getSearchContextNamespaceQueryConditions(namespaceUserID, namespaceOrgID in
return nil, errors.New("options NamespaceUserID and NamespaceOrgID are mutually exclusive")
}
if namespaceUserID > 0 {
conds = append(conds, sqlf.Sprintf("sc.namespace_user_id = %s", namespaceUserID))
conds = append(conds, sqlf.Sprintf("namespace_user_id = %s", namespaceUserID))
}
if namespaceOrgID > 0 {
conds = append(conds, sqlf.Sprintf("sc.namespace_org_id = %s", namespaceOrgID))
conds = append(conds, sqlf.Sprintf("namespace_org_id = %s", namespaceOrgID))
}
return conds, nil
}
@ -193,13 +237,13 @@ func idsToQueries(ids []int32) []*sqlf.Query {
func getSearchContextsQueryConditions(opts ListSearchContextsOptions) []*sqlf.Query {
namespaceConds := []*sqlf.Query{}
if opts.NoNamespace {
namespaceConds = append(namespaceConds, sqlf.Sprintf("(sc.namespace_user_id IS NULL AND sc.namespace_org_id IS NULL)"))
namespaceConds = append(namespaceConds, sqlf.Sprintf("(namespace_user_id IS NULL AND namespace_org_id IS NULL)"))
}
if len(opts.NamespaceUserIDs) > 0 {
namespaceConds = append(namespaceConds, sqlf.Sprintf("sc.namespace_user_id IN (%s)", sqlf.Join(idsToQueries(opts.NamespaceUserIDs), ",")))
namespaceConds = append(namespaceConds, sqlf.Sprintf("namespace_user_id IN (%s)", sqlf.Join(idsToQueries(opts.NamespaceUserIDs), ",")))
}
if len(opts.NamespaceOrgIDs) > 0 {
namespaceConds = append(namespaceConds, sqlf.Sprintf("sc.namespace_org_id IN (%s)", sqlf.Join(idsToQueries(opts.NamespaceOrgIDs), ",")))
namespaceConds = append(namespaceConds, sqlf.Sprintf("namespace_org_id IN (%s)", sqlf.Join(idsToQueries(opts.NamespaceOrgIDs), ",")))
}
conds := []*sqlf.Query{}
@ -209,11 +253,11 @@ func getSearchContextsQueryConditions(opts ListSearchContextsOptions) []*sqlf.Qu
if opts.Name != "" {
// name column has type citext which automatically performs case-insensitive comparison
conds = append(conds, sqlf.Sprintf("sc.name LIKE %s", "%"+opts.Name+"%"))
conds = append(conds, sqlf.Sprintf("context_name LIKE %s", "%"+opts.Name+"%"))
}
if opts.NamespaceName != "" {
conds = append(conds, sqlf.Sprintf("COALESCE(u.username, o.name, '') ILIKE %s", "%"+opts.NamespaceName+"%"))
conds = append(conds, sqlf.Sprintf("COALESCE(namespace_username, namespace_org_name, '') ILIKE %s", "%"+opts.NamespaceName+"%"))
}
if len(conds) == 0 {
@ -225,11 +269,11 @@ func getSearchContextsQueryConditions(opts ListSearchContextsOptions) []*sqlf.Qu
}
func (s *searchContextsStore) listSearchContexts(ctx context.Context, cond *sqlf.Query, orderBy *sqlf.Query, limit int32, offset int32) ([]*types.SearchContext, error) {
permissionsCond, err := searchContextsPermissionsCondition(ctx, s.logger, s)
if err != nil {
return nil, err
}
rows, err := s.Query(ctx, sqlf.Sprintf(listSearchContextsFmtStr, permissionsCond, cond, orderBy, limit, offset))
permissionsCond := searchContextsPermissionsCondition(ctx)
authenticatedUserId := actor.FromContext(ctx).UID
query := sqlf.Sprintf(listSearchContextsFmtStr, authenticatedUserId, authenticatedUserId, authenticatedUserId, permissionsCond, cond, orderBy, limit, offset)
rows, err := s.Query(ctx, query)
if err != nil {
return nil, err
}
@ -245,12 +289,12 @@ func (s *searchContextsStore) ListSearchContexts(ctx context.Context, pageOpts L
func (s *searchContextsStore) CountSearchContexts(ctx context.Context, opts ListSearchContextsOptions) (int32, error) {
conds := getSearchContextsQueryConditions(opts)
permissionsCond, err := searchContextsPermissionsCondition(ctx, s.logger, s)
if err != nil {
return -1, err
}
permissionsCond := searchContextsPermissionsCondition(ctx)
authenticatedUserId := actor.FromContext(ctx).UID
var count int32
err = s.QueryRow(ctx, sqlf.Sprintf(countSearchContextsFmtStr, permissionsCond, sqlf.Join(conds, "\n AND "))).Scan(&count)
query := sqlf.Sprintf(countSearchContextsFmtStr, authenticatedUserId, authenticatedUserId, authenticatedUserId, permissionsCond, sqlf.Join(conds, "\n AND "))
err := s.QueryRow(ctx, query).Scan(&count)
if err != nil {
return -1, err
}
@ -266,7 +310,7 @@ type GetSearchContextOptions struct {
func (s *searchContextsStore) GetSearchContext(ctx context.Context, opts GetSearchContextOptions) (*types.SearchContext, error) {
conds := []*sqlf.Query{}
if opts.NamespaceUserID == 0 && opts.NamespaceOrgID == 0 {
conds = append(conds, sqlf.Sprintf("sc.namespace_user_id IS NULL"), sqlf.Sprintf("sc.namespace_org_id IS NULL"))
conds = append(conds, sqlf.Sprintf("namespace_user_id IS NULL"), sqlf.Sprintf("namespace_org_id IS NULL"))
} else {
namespaceConds, err := getSearchContextNamespaceQueryConditions(opts.NamespaceUserID, opts.NamespaceOrgID)
if err != nil {
@ -274,16 +318,17 @@ func (s *searchContextsStore) GetSearchContext(ctx context.Context, opts GetSear
}
conds = append(conds, namespaceConds...)
}
conds = append(conds, sqlf.Sprintf("sc.name = %s", opts.Name))
conds = append(conds, sqlf.Sprintf("context_name = %s", opts.Name))
permissionsCond, err := searchContextsPermissionsCondition(ctx, s.logger, s)
if err != nil {
return nil, err
}
permissionsCond := searchContextsPermissionsCondition(ctx)
authenticatedUserId := actor.FromContext(ctx).UID
rows, err := s.Query(
ctx,
sqlf.Sprintf(
listSearchContextsFmtStr,
authenticatedUserId,
authenticatedUserId,
authenticatedUserId,
permissionsCond,
sqlf.Join(conds, "\n AND "),
getSearchContextOrderByClause(SearchContextsOrderByID, false),
@ -457,12 +502,15 @@ func scanSearchContexts(rows *sql.Rows) ([]*types.SearchContext, error) {
&sc.Name,
&sc.Description,
&sc.Public,
&sc.Autodefined,
&dbutil.NullInt32{N: &sc.NamespaceUserID},
&dbutil.NullInt32{N: &sc.NamespaceOrgID},
&sc.UpdatedAt,
&dbutil.NullString{S: &sc.Query},
&dbutil.NullString{S: &sc.NamespaceUserName},
&dbutil.NullString{S: &sc.NamespaceOrgName},
&sc.Default,
&sc.Starred,
)
if err != nil {
return nil, err
@ -603,3 +651,39 @@ func (s *searchContextsStore) GetAllQueries(ctx context.Context) (qs []string, _
return qs, s.QueryRow(ctx, q).Scan(pq.Array(&qs))
}
// 🚨 SECURITY: The caller must ensure that the actor is the user setting the context as their default.
func (s *searchContextsStore) SetUserDefaultSearchContextID(ctx context.Context, userID int32, searchContextID int64) error {
if searchContextID == 0 {
// If the search context ID is 0, we want to delete the default search context for the user.
// This will cause the user to use the global search context as their default.
return s.Exec(ctx, sqlf.Sprintf("DELETE FROM search_context_default WHERE user_id = %d", userID))
}
q := sqlf.Sprintf(
`INSERT INTO search_context_default (user_id, search_context_id)
VALUES (%d, %d)
ON CONFLICT (user_id) DO
UPDATE SET search_context_id=EXCLUDED.search_context_id`,
userID,
searchContextID)
return s.Exec(ctx, q)
}
// 🚨 SECURITY: The caller must ensure that the actor is the user creating the star for themselves.
func (s *searchContextsStore) CreateSearchContextStarForUser(ctx context.Context, userID int32, searchContextID int64) error {
q := sqlf.Sprintf(
`INSERT INTO search_context_stars (user_id, search_context_id)
VALUES (%d, %d)
ON CONFLICT DO NOTHING`, userID, searchContextID)
return s.Exec(ctx, q)
}
// 🚨 SECURITY: The caller must ensure that the actor is the user deleting the star for themselves.
func (s *searchContextsStore) DeleteSearchContextStarForUser(ctx context.Context, userID int32, searchContextID int64) error {
q := sqlf.Sprintf(
`DELETE FROM search_context_stars
WHERE user_id = %d AND search_context_id = %d`,
userID, searchContextID)
return s.Exec(ctx, q)
}

View File

@ -179,14 +179,14 @@ func TestSearchContexts_List(t *testing.T) {
wantInstanceLevelSearchContexts := createdSearchContexts[:1]
gotInstanceLevelSearchContexts, err := sc.ListSearchContexts(
ctx,
ListSearchContextsPageOptions{First: 1},
ListSearchContextsPageOptions{First: 2},
ListSearchContextsOptions{NoNamespace: true},
)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if !reflect.DeepEqual(wantInstanceLevelSearchContexts, gotInstanceLevelSearchContexts) {
t.Fatalf("wanted %v search contexts, got %v", wantInstanceLevelSearchContexts, gotInstanceLevelSearchContexts)
if !reflect.DeepEqual(wantInstanceLevelSearchContexts, gotInstanceLevelSearchContexts[1:]) { // Ignore the first result since it's the global search context
t.Fatalf("wanted %#v search contexts, got %#v", wantInstanceLevelSearchContexts, &gotInstanceLevelSearchContexts)
}
wantUserSearchContexts := createdSearchContexts[1:]
@ -520,7 +520,7 @@ func TestSearchContexts_Permissions(t *testing.T) {
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if !reflect.DeepEqual(tt.wantSearchContexts, gotSearchContexts) {
if !reflect.DeepEqual(tt.wantSearchContexts, gotSearchContexts[1:]) { // Ignore the first result since it's the global search context
t.Fatalf("wanted %v search contexts, got %v", tt.wantSearchContexts, gotSearchContexts)
}
@ -665,6 +665,56 @@ func TestSearchContexts_Delete(t *testing.T) {
}
}
func TestSearchContexts_Count(t *testing.T) {
logger := logtest.Scoped(t)
db := NewDB(logger, dbtest.NewDB(logger, t))
t.Parallel()
ctx := context.Background()
sc := db.SearchContexts()
// With no contexts added yet, count should be 1 (the global context only)
count, err := sc.CountSearchContexts(ctx, ListSearchContextsOptions{})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if count != 1 {
t.Fatalf("Expected count to be 1, got %d", count)
}
_, err = createSearchContexts(ctx, sc, []*types.SearchContext{
{Name: "ctx", Public: true},
})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
// With one context added, count should be 2
count, err = sc.CountSearchContexts(ctx, ListSearchContextsOptions{})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if count != 2 {
t.Fatalf("Expected count to be 2, got %d", count)
}
// Filtering by name should return 1
count, err = sc.CountSearchContexts(ctx, ListSearchContextsOptions{Name: "ctx"})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if count != 1 {
t.Fatalf("Expected count to be 1, got %d", count)
}
count, err = sc.CountSearchContexts(ctx, ListSearchContextsOptions{Name: "glob"})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if count != 1 {
t.Fatalf("Expected count to be 1, got %d", count)
}
}
func reverseSearchContextsSlice(s []*types.SearchContext) []*types.SearchContext {
copySlice := make([]*types.SearchContext, len(s))
copy(copySlice, s)
@ -783,18 +833,106 @@ func TestSearchContexts_OrderBy(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotSearchContexts, err := sc.ListSearchContexts(internalCtx, ListSearchContextsPageOptions{First: 6}, ListSearchContextsOptions{OrderBy: tt.orderBy, OrderByDescending: tt.descending})
gotSearchContexts, err := sc.ListSearchContexts(internalCtx, ListSearchContextsPageOptions{First: 7}, ListSearchContextsOptions{OrderBy: tt.orderBy, OrderByDescending: tt.descending})
if err != nil {
t.Fatal(err)
}
gotSearchContextNames := getSearchContextNames(gotSearchContexts)
if !reflect.DeepEqual(tt.wantSearchContextNames, gotSearchContextNames) {
t.Fatalf("wanted %+v search contexts, got %+v", tt.wantSearchContextNames, gotSearchContextNames)
wantSearchContextNames := []string{"global"}
wantSearchContextNames = append(wantSearchContextNames, tt.wantSearchContextNames...)
if !reflect.DeepEqual(wantSearchContextNames, gotSearchContextNames) {
t.Fatalf("wanted %+v search contexts, got %+v", wantSearchContextNames, gotSearchContextNames)
}
})
}
}
func TestSearchContexts_OrderByWithDefaultAndStarred(t *testing.T) {
logger := logtest.Scoped(t)
db := NewDB(logger, dbtest.NewDB(logger, t))
t.Parallel()
internalCtx := actor.WithInternalActor(context.Background())
u := db.Users()
o := db.Orgs()
om := db.OrgMembers()
sc := db.SearchContexts()
user1, err := u.Create(internalCtx, NewUser{Username: "u1", Password: "p"})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
err = u.SetIsSiteAdmin(internalCtx, user1.ID, false)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
displayName := "My Org"
org, err := o.Create(internalCtx, "myorg", &displayName)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
_, err = om.Create(internalCtx, org.ID, user1.ID)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
searchContexts, err := createSearchContexts(internalCtx, sc, []*types.SearchContext{
{Name: "A-instance-level", Public: true}, // Starred, returned 3rd
{Name: "B-instance-level", Public: false}, // Not returned, not public and not owned but this user or their org
{Name: "A-user-level", Public: true, NamespaceUserID: user1.ID}, // Default, returned 1st
{Name: "B-user-level", Public: false, NamespaceUserID: user1.ID}, // Starred, returned 2nd
{Name: "A-org-level", Public: true, NamespaceOrgID: org.ID}, // Returned 4th
{Name: "B-org-level", Public: false, NamespaceOrgID: org.ID}, // Returned 5th
})
if err != nil {
t.Fatal(err)
}
wantedSearchContexts := []*types.SearchContext{searchContexts[2], searchContexts[3], searchContexts[0], searchContexts[4], searchContexts[5]}
_, err = sc.UpdateSearchContextWithRepositoryRevisions(internalCtx, searchContexts[1], nil)
if err != nil {
t.Fatal(err)
}
_, err = sc.UpdateSearchContextWithRepositoryRevisions(internalCtx, searchContexts[3], nil)
if err != nil {
t.Fatal(err)
}
_, err = sc.UpdateSearchContextWithRepositoryRevisions(internalCtx, searchContexts[5], nil)
if err != nil {
t.Fatal(err)
}
// Set user1 has a default search context of searchContexts[2]
err = sc.SetUserDefaultSearchContextID(internalCtx, user1.ID, searchContexts[2].ID)
if err != nil {
t.Fatal(err)
}
// Set user1 as a star for searchContexts[0] and searchContexts[3]
err = sc.CreateSearchContextStarForUser(internalCtx, user1.ID, searchContexts[0].ID)
if err != nil {
t.Fatal(err)
}
err = sc.CreateSearchContextStarForUser(internalCtx, user1.ID, searchContexts[3].ID)
if err != nil {
t.Fatal(err)
}
// Use a different user to list the search contexts so that we can test that user's starred and default search contexts
ctx := actor.WithActor(internalCtx, actor.FromUser(user1.ID))
gotSearchContexts, err := sc.ListSearchContexts(ctx, ListSearchContextsPageOptions{First: 7}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
gotSearchContextNames := getSearchContextNames(gotSearchContexts)
wantSearchContextNames := append([]string{"global"}, getSearchContextNames(wantedSearchContexts)...)
if !reflect.DeepEqual(wantSearchContextNames, gotSearchContextNames) {
t.Fatalf("wanted %+v search contexts, got %+v", wantSearchContextNames, gotSearchContextNames)
}
}
func TestSearchContexts_GetAllRevisionsForRepos(t *testing.T) {
logger := logtest.Scoped(t)
db := NewDB(logger, dbtest.NewDB(logger, t))
@ -869,3 +1007,234 @@ func TestSearchContexts_GetAllRevisionsForRepos(t *testing.T) {
})
}
}
func getDefaultContext(searchContexts []*types.SearchContext) *types.SearchContext {
for _, c := range searchContexts {
if c.Default {
return c
}
}
return nil
}
func TestSearchContexts_DefaultContexts(t *testing.T) {
logger := logtest.Scoped(t)
db := NewDB(logger, dbtest.NewDB(logger, t))
t.Parallel()
internalCtx := actor.WithInternalActor(context.Background())
u := db.Users()
sc := db.SearchContexts()
user1, err := u.Create(internalCtx, NewUser{Username: "u1", Password: "p"})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
err = u.SetIsSiteAdmin(internalCtx, user1.ID, false)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
// Use a different user to list the search contexts so that we can test that user's default search contexts
userCtx := actor.WithActor(internalCtx, actor.FromUser(user1.ID))
searchContexts, err := createSearchContexts(userCtx, sc, []*types.SearchContext{
{Name: "A-user-level", Public: true, NamespaceUserID: user1.ID},
{Name: "B-user-level", Public: false, NamespaceUserID: user1.ID},
})
if err != nil {
t.Fatal(err)
}
// Global context should be the default
gotSearchContexts, err := sc.ListSearchContexts(userCtx, ListSearchContextsPageOptions{First: 3}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
defaultContext := getDefaultContext(gotSearchContexts)
if defaultContext == nil || defaultContext.Name != "global" {
t.Fatalf("Expected global context to be the default, got %+v", defaultContext)
}
// Set user1 has a default search context of searchContexts[1]
err = sc.SetUserDefaultSearchContextID(userCtx, user1.ID, searchContexts[1].ID)
if err != nil {
t.Fatal(err)
}
// B-user-level context should be the default
gotSearchContexts, err = sc.ListSearchContexts(userCtx, ListSearchContextsPageOptions{First: 3}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
defaultContext = getDefaultContext(gotSearchContexts)
if defaultContext == nil || defaultContext.Name != "B-user-level" {
t.Fatalf("Expected B-user-level context to be the default, got %+v", defaultContext)
}
// Set user1 has a default search context of searchContexts[0]
err = sc.SetUserDefaultSearchContextID(userCtx, user1.ID, searchContexts[0].ID)
if err != nil {
t.Fatal(err)
}
// A-user-level context should be the default
gotSearchContexts, err = sc.ListSearchContexts(userCtx, ListSearchContextsPageOptions{First: 3}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
defaultContext = getDefaultContext(gotSearchContexts)
if defaultContext == nil || defaultContext.Name != "A-user-level" {
t.Fatalf("Expected A-user-level context to be the default, got %+v", defaultContext)
}
// Set user1 default context back to global
err = sc.SetUserDefaultSearchContextID(userCtx, user1.ID, gotSearchContexts[0].ID)
if err != nil {
t.Fatal(err)
}
// Global context should be the default again
gotSearchContexts, err = sc.ListSearchContexts(userCtx, ListSearchContextsPageOptions{First: 3}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
defaultContext = getDefaultContext(gotSearchContexts)
if defaultContext == nil || defaultContext.Name != "global" {
t.Fatalf("Expected global context to be the default, got %+v", defaultContext)
}
}
func getStarredContexts(searchContexts []*types.SearchContext) []*types.SearchContext {
var starredContexts []*types.SearchContext
for _, c := range searchContexts {
if c.Starred {
starredContexts = append(starredContexts, c)
}
}
return starredContexts
}
func TestSearchContexts_StarringContexts(t *testing.T) {
logger := logtest.Scoped(t)
db := NewDB(logger, dbtest.NewDB(logger, t))
t.Parallel()
internalCtx := actor.WithInternalActor(context.Background())
u := db.Users()
sc := db.SearchContexts()
user1, err := u.Create(internalCtx, NewUser{Username: "u1", Password: "p"})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
err = u.SetIsSiteAdmin(internalCtx, user1.ID, false)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
// Use a different user to list the search contexts so that we can test that user's starred search contexts
userCtx := actor.WithActor(internalCtx, actor.FromUser(user1.ID))
searchContexts, err := createSearchContexts(userCtx, sc, []*types.SearchContext{
{Name: "A-user-level", Public: true, NamespaceUserID: user1.ID},
{Name: "B-user-level", Public: false, NamespaceUserID: user1.ID},
})
if err != nil {
t.Fatal(err)
}
// Create star for searchContexts[1]
err = sc.CreateSearchContextStarForUser(userCtx, user1.ID, searchContexts[1].ID)
if err != nil {
t.Fatal(err)
}
// B-user-level context should be starred
gotSearchContexts, err := sc.ListSearchContexts(userCtx, ListSearchContextsPageOptions{First: 3}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
starredContexts := getStarredContexts(gotSearchContexts)
if len(starredContexts) != 1 || starredContexts[0].Name != "B-user-level" {
t.Fatalf("Expected B-user-level context to be starred, got %+v", starredContexts)
}
// Try to star searchContexts[0] again, should be a no-op
err = sc.CreateSearchContextStarForUser(userCtx, user1.ID, searchContexts[1].ID)
if err != nil {
t.Fatal(err)
}
// B-user-level context should still be starred
gotSearchContexts, err = sc.ListSearchContexts(userCtx, ListSearchContextsPageOptions{First: 3}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
starredContexts = getStarredContexts(gotSearchContexts)
if len(starredContexts) != 1 || starredContexts[0].Name != "B-user-level" {
t.Fatalf("Expected B-user-level context to be starred, got %+v", starredContexts)
}
// Star searchContexts[0]
err = sc.CreateSearchContextStarForUser(userCtx, user1.ID, searchContexts[0].ID)
if err != nil {
t.Fatal(err)
}
// Both contexts should be starred
gotSearchContexts, err = sc.ListSearchContexts(userCtx, ListSearchContextsPageOptions{First: 3}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
starredContexts = getStarredContexts(gotSearchContexts)
if len(starredContexts) != 2 || starredContexts[0].Name != "A-user-level" || starredContexts[1].Name != "B-user-level" {
t.Fatalf("Expected both contexts to be starred, got %+v", starredContexts)
}
// Unstar searchContexts[0]
err = sc.DeleteSearchContextStarForUser(userCtx, user1.ID, searchContexts[0].ID)
if err != nil {
t.Fatal(err)
}
// Only B-user-level context should be starred
gotSearchContexts, err = sc.ListSearchContexts(userCtx, ListSearchContextsPageOptions{First: 3}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
starredContexts = getStarredContexts(gotSearchContexts)
if len(starredContexts) != 1 || starredContexts[0].Name != "B-user-level" {
t.Fatalf("Expected only B-user-level context to be starred, got %+v", starredContexts)
}
// Try to unstar searchContexts[0] again, should be a no-op
err = sc.DeleteSearchContextStarForUser(userCtx, user1.ID, searchContexts[0].ID)
if err != nil {
t.Fatal(err)
}
// Only B-user-level context should be starred
gotSearchContexts, err = sc.ListSearchContexts(userCtx, ListSearchContextsPageOptions{First: 3}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
starredContexts = getStarredContexts(gotSearchContexts)
if len(starredContexts) != 1 || starredContexts[0].Name != "B-user-level" {
t.Fatalf("Expected only B-user-level context to be starred, got %+v", starredContexts)
}
// Try to star the global context, should fail
err = sc.CreateSearchContextStarForUser(userCtx, user1.ID, 0)
if err == nil {
t.Fatal("Expected error, got nil")
}
// Only B-user-level context should be starred
gotSearchContexts, err = sc.ListSearchContexts(userCtx, ListSearchContextsPageOptions{First: 3}, ListSearchContextsOptions{OrderBy: SearchContextsOrderBySpec, OrderByDescending: false})
if err != nil {
t.Fatal(err)
}
starredContexts = getStarredContexts(gotSearchContexts)
if len(starredContexts) != 1 || starredContexts[0].Name != "B-user-level" {
t.Fatalf("Expected only B-user-level context to be starred, got %+v", starredContexts)
}
}

View File

@ -544,7 +544,7 @@ func GetRepositoryRevisions(ctx context.Context, db database.DB, searchContextID
}
func IsAutoDefinedSearchContext(searchContext *types.SearchContext) bool {
return searchContext.ID == 0
return searchContext.Autodefined
}
func IsInstanceLevelSearchContext(searchContext *types.SearchContext) bool {
@ -561,15 +561,15 @@ func IsGlobalSearchContext(searchContext *types.SearchContext) bool {
}
func GetUserSearchContext(userID int32, name string) *types.SearchContext {
return &types.SearchContext{Name: name, Public: true, Description: "All repositories you've added to Sourcegraph", NamespaceUserID: userID}
return &types.SearchContext{Name: name, Public: true, Description: "All repositories you've added to Sourcegraph", NamespaceUserID: userID, Autodefined: true}
}
func GetOrganizationSearchContext(orgID int32, name string, displayName string) *types.SearchContext {
return &types.SearchContext{Name: name, Public: false, Description: fmt.Sprintf("All repositories %s organization added to Sourcegraph", displayName), NamespaceOrgID: orgID}
return &types.SearchContext{Name: name, Public: false, Description: fmt.Sprintf("All repositories %s organization added to Sourcegraph", displayName), NamespaceOrgID: orgID, Autodefined: true}
}
func GetGlobalSearchContext() *types.SearchContext {
return &types.SearchContext{Name: GlobalSearchContextName, Public: true, Description: "All repositories on Sourcegraph"}
return &types.SearchContext{Name: GlobalSearchContextName, Public: true, Description: "All repositories on Sourcegraph", Autodefined: true}
}
func GetSearchContextSpec(searchContext *types.SearchContext) string {
@ -587,3 +587,15 @@ func GetSearchContextSpec(searchContext *types.SearchContext) string {
return searchContextSpecPrefix + namespaceName + "/" + searchContext.Name
}
}
func CreateSearchContextStarForUser(ctx context.Context, db database.DB, searchContext *types.SearchContext, userID int32) error {
return db.SearchContexts().CreateSearchContextStarForUser(ctx, userID, searchContext.ID)
}
func DeleteSearchContextStarForUser(ctx context.Context, db database.DB, searchContext *types.SearchContext, userID int32) error {
return db.SearchContexts().DeleteSearchContextStarForUser(ctx, userID, searchContext.ID)
}
func SetDefaultSearchContextForUser(ctx context.Context, db database.DB, searchContext *types.SearchContext, userID int32) error {
return db.SearchContexts().SetUserDefaultSearchContextID(ctx, userID, searchContext.ID)
}

View File

@ -196,8 +196,8 @@ func TestConstructingSearchContextSpecs(t *testing.T) {
wantSearchContextSpec string
}{
{name: "global search context", searchContext: GetGlobalSearchContext(), wantSearchContextSpec: "global"},
{name: "user auto-defined search context", searchContext: &types.SearchContext{Name: "user", NamespaceUserID: 1}, wantSearchContextSpec: "@user"},
{name: "org auto-defined search context", searchContext: &types.SearchContext{Name: "org", NamespaceOrgID: 1}, wantSearchContextSpec: "@org"},
{name: "user auto-defined search context", searchContext: &types.SearchContext{Name: "user", NamespaceUserID: 1, Autodefined: true}, wantSearchContextSpec: "@user"},
{name: "org auto-defined search context", searchContext: &types.SearchContext{Name: "org", NamespaceOrgID: 1, Autodefined: true}, wantSearchContextSpec: "@org"},
{name: "user namespaced search context", searchContext: &types.SearchContext{ID: 1, Name: "context", NamespaceUserID: 1, NamespaceUserName: "user"}, wantSearchContextSpec: "@user/context"},
{name: "org namespaced search context", searchContext: &types.SearchContext{ID: 1, Name: "context", NamespaceOrgID: 1, NamespaceOrgName: "org"}, wantSearchContextSpec: "@org/context"},
{name: "instance-level search context", searchContext: &types.SearchContext{ID: 1, Name: "instance-level-context"}, wantSearchContextSpec: "instance-level-context"},

View File

@ -1652,6 +1652,15 @@ type SearchContext struct {
// Query is the Sourcegraph query that defines this search context
// e.g. repo:^github\.com/org rev:bar archive:no f:sub/dir
Query string
// Whether the search context is auto-defined by Sourcegraph. Auto-defined search contexts are not editable by users.
Autodefined bool
// Whether the search context is the default for the user. If the user hasn't explicitly set a default or is not authenticated, the global search context is used.
Default bool
// Whether the user has starred the context. If the user is not authenticated, this field is always false.
Starred bool
}
// SearchContextRepositoryRevisions is a simple wrapper for a repository and its revisions

View File

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS search_context_stars;
DROP TABLE IF EXISTS search_context_default;

View File

@ -0,0 +1,2 @@
name: create_search_contexts_stars_defaults
parents: [1668603582]

View File

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS search_context_stars (
search_context_id bigint REFERENCES search_contexts(id) ON DELETE CASCADE DEFERRABLE,
user_id integer REFERENCES users(id) ON DELETE CASCADE DEFERRABLE,
created_at timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY (search_context_id, user_id)
);
COMMENT ON TABLE search_context_stars IS 'When a user stars a search context, a row is inserted into this table. If the user unstars the search context, the row is deleted. The global context is not in the database, and therefore cannot be starred.';
CREATE TABLE IF NOT EXISTS search_context_default (
user_id integer PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE DEFERRABLE,
search_context_id bigint NOT NULL REFERENCES search_contexts(id) ON DELETE CASCADE DEFERRABLE
);
COMMENT ON TABLE search_context_default IS 'When a user sets a search context as default, a row is inserted into this table. A user can only have one default search context. If the user has not set their default search context, it will fall back to `global`.';

View File

@ -3370,12 +3370,27 @@ CREATE SEQUENCE saved_searches_id_seq
ALTER SEQUENCE saved_searches_id_seq OWNED BY saved_searches.id;
CREATE TABLE search_context_default (
user_id integer NOT NULL,
search_context_id bigint NOT NULL
);
COMMENT ON TABLE search_context_default IS 'When a user sets a search context as default, a row is inserted into this table. A user can only have one default search context. If the user has not set their default search context, it will fall back to `global`.';
CREATE TABLE search_context_repos (
search_context_id bigint NOT NULL,
repo_id integer NOT NULL,
revision text NOT NULL
);
CREATE TABLE search_context_stars (
search_context_id bigint NOT NULL,
user_id integer NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
COMMENT ON TABLE search_context_stars IS 'When a user stars a search context, a row is inserted into this table. If the user unstars the search context, the row is deleted. The global context is not in the database, and therefore cannot be starred.';
CREATE TABLE search_contexts (
id bigint NOT NULL,
name citext NOT NULL,
@ -4204,9 +4219,15 @@ ALTER TABLE ONLY repo
ALTER TABLE ONLY saved_searches
ADD CONSTRAINT saved_searches_pkey PRIMARY KEY (id);
ALTER TABLE ONLY search_context_default
ADD CONSTRAINT search_context_default_pkey PRIMARY KEY (user_id);
ALTER TABLE ONLY search_context_repos
ADD CONSTRAINT search_context_repos_unique UNIQUE (repo_id, search_context_id, revision);
ALTER TABLE ONLY search_context_stars
ADD CONSTRAINT search_context_stars_pkey PRIMARY KEY (search_context_id, user_id);
ALTER TABLE ONLY search_contexts
ADD CONSTRAINT search_contexts_pkey PRIMARY KEY (id);
@ -5027,12 +5048,24 @@ ALTER TABLE ONLY saved_searches
ALTER TABLE ONLY saved_searches
ADD CONSTRAINT saved_searches_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
ALTER TABLE ONLY search_context_default
ADD CONSTRAINT search_context_default_search_context_id_fkey FOREIGN KEY (search_context_id) REFERENCES search_contexts(id) ON DELETE CASCADE DEFERRABLE;
ALTER TABLE ONLY search_context_default
ADD CONSTRAINT search_context_default_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE DEFERRABLE;
ALTER TABLE ONLY search_context_repos
ADD CONSTRAINT search_context_repos_repo_id_fk FOREIGN KEY (repo_id) REFERENCES repo(id) ON DELETE CASCADE;
ALTER TABLE ONLY search_context_repos
ADD CONSTRAINT search_context_repos_search_context_id_fk FOREIGN KEY (search_context_id) REFERENCES search_contexts(id) ON DELETE CASCADE;
ALTER TABLE ONLY search_context_stars
ADD CONSTRAINT search_context_stars_search_context_id_fkey FOREIGN KEY (search_context_id) REFERENCES search_contexts(id) ON DELETE CASCADE DEFERRABLE;
ALTER TABLE ONLY search_context_stars
ADD CONSTRAINT search_context_stars_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE DEFERRABLE;
ALTER TABLE ONLY search_contexts
ADD CONSTRAINT search_contexts_namespace_org_id_fk FOREIGN KEY (namespace_org_id) REFERENCES orgs(id) ON DELETE CASCADE;