Add pagination to saved searches pages (#45705)

* [Saved Searches] add pagination 
* [Search Page] remove old home panels
This commit is contained in:
Naman Kumar 2023-01-05 21:11:42 +05:30 committed by GitHub
parent 88ac63889e
commit 202a984759
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 902 additions and 5015 deletions

View File

@ -47,6 +47,7 @@ All notable changes to Sourcegraph are documented in this file.
- The extension registry no longer supports browsing, creating, or updating legacy extensions. Existing extensions may still be enabled or disabled in user settings and may be listed via the API. (The extension API was deprecated in 2022-09 but is still available if the `enableLegacyExtensions` site config experimental features flag is enabled.)
- User and organization auto-defined search contexts have been permanently removed along with the `autoDefinedSearchContexts` GraphQL query. The only auto-defined context now is the `global` context. [#46083](https://github.com/sourcegraph/sourcegraph/pull/46083)
- The settings `experimentalFeatures.showSearchContext`, `experimentalFeatures.showSearchNotebook`, and `experimentalFeatures.codeMonitoring` have been removed and these features are now permanently enabled when available. [#46086](https://github.com/sourcegraph/sourcegraph/pull/46086)
- The legacy panels on the homepage (recent searches, etc) which were turned off by default but could still be re-enabled by setting `experimentalFeatures.showEnterpriseHomePanels` to true, are permanently removed now. [#45705](https://github.com/sourcegraph/sourcegraph/pull/45705)
## 4.3.0

View File

@ -1,8 +1,8 @@
// @ts-check
const path = require('path')
const logger = require('signale')
const semver = require('semver')
const logger = require('signale')
/** @type {import('@babel/core').ConfigFunction} */
module.exports = api => {

View File

@ -14,9 +14,11 @@ import { HistorySidebarProps } from '../HistorySidebarView'
import styles from '../../search/SearchSidebarView.module.scss'
const savedSearchQuery = gql`
query SavedSearches {
savedSearches {
...SavedSearchFields
query SavedSearches($namespace: ID!) {
savedSearches(namespace: $namespace, first: 15) {
nodes {
...SavedSearchFields
}
}
}
fragment SavedSearchFields on SavedSearch {
@ -35,10 +37,10 @@ const savedSearchQuery = gql`
`
export const SavedSearchesSection: React.FunctionComponent<React.PropsWithChildren<HistorySidebarProps>> = ({
authenticatedUser,
platformContext,
extensionCoreAPI,
}) => {
const itemsToLoad = 15
const [collapsed, setCollapsed] = useState(false)
const savedSearchesResult = useObservable(
@ -47,7 +49,7 @@ export const SavedSearchesSection: React.FunctionComponent<React.PropsWithChildr
platformContext
.requestGraphQL<SavedSearchesResult, SavedSearchesVariables>({
request: savedSearchQuery,
variables: {},
variables: { namespace: authenticatedUser.id },
mightContainPrivateInfo: true,
})
.pipe(
@ -56,11 +58,11 @@ export const SavedSearchesSection: React.FunctionComponent<React.PropsWithChildr
return [null]
})
),
[platformContext]
[platformContext, authenticatedUser.id]
)
)
const savedSearches = savedSearchesResult?.data?.savedSearches
const savedSearches = savedSearchesResult?.data?.savedSearches?.nodes
if (!savedSearches || savedSearches.length === 0) {
return null
@ -97,21 +99,19 @@ export const SavedSearchesSection: React.FunctionComponent<React.PropsWithChildr
{!collapsed && savedSearches && (
<div className={classNames('p-1', styles.sidebarSectionList)}>
{savedSearches
.filter((search, index) => index < itemsToLoad)
.map(search => (
<div key={search.id}>
<small className={styles.sidebarSectionListItem}>
<Button
variant="link"
className="p-0 text-left text-decoration-none"
onClick={() => onSavedSearchClick(search.query)}
>
{search.description}
</Button>
</small>
</div>
))}
{savedSearches.map(search => (
<div key={search.id}>
<small className={styles.sidebarSectionListItem}>
<Button
variant="link"
className="p-0 text-left text-decoration-none"
onClick={() => onSavedSearchClick(search.query)}
>
{search.description}
</Button>
</small>
</div>
))}
</div>
)}
</div>

View File

@ -48,7 +48,7 @@ import type { RepoSettingsAreaRoute } from './repo/settings/RepoSettingsArea'
import type { RepoSettingsSideBarGroup } from './repo/settings/RepoSettingsSidebar'
import type { LayoutRouteComponentProps, LayoutRouteProps } from './routes'
import { EnterprisePageRoutes, PageRoutes } from './routes.constants'
import { HomePanelsProps, parseSearchURLQuery, SearchAggregationProps, SearchStreamingProps } from './search'
import { parseSearchURLQuery, SearchAggregationProps, SearchStreamingProps } from './search'
import { NotepadContainer } from './search/Notepad'
import type { SiteAdminAreaRoute } from './site-admin/SiteAdminArea'
import type { SiteAdminSideBarGroups } from './site-admin/SiteAdminSidebar'
@ -69,7 +69,6 @@ export interface LayoutProps
ExtensionsControllerProps,
TelemetryProps,
SearchContextProps,
HomePanelsProps,
SearchStreamingProps,
CodeIntelligenceProps,
BatchChangesProps,

View File

@ -39,6 +39,7 @@ export interface UsePaginatedConnectionResult<TData> extends PaginationProps {
connection?: PaginatedConnection<TData>
loading: boolean
error?: ApolloError
refetch: () => any
}
interface UsePaginatedConnectionConfig<TResult> {
@ -55,7 +56,7 @@ interface UsePaginatedConnectionConfig<TResult> {
interface UsePaginatedConnectionParameters<TResult, TVariables extends PaginatedConnectionQueryArguments, TData> {
query: string
variables: Omit<TVariables, 'first' | 'last' | 'before' | 'after'>
getConnection: (result: GraphQLResult<TResult>) => PaginatedConnection<TData>
getConnection: (result: GraphQLResult<TResult>) => PaginatedConnection<TData> | undefined
options?: UsePaginatedConnectionConfig<TResult>
}
@ -135,6 +136,7 @@ export const usePageSwitcherPagination = <TResult, TVariables extends PaginatedC
connection,
loading,
error,
refetch,
hasNextPage: connection?.pageInfo?.hasNextPage ?? null,
hasPreviousPage: connection?.pageInfo?.hasPreviousPage ?? null,
goToNextPage,

View File

@ -4,12 +4,6 @@ import { mergeSettings } from '@sourcegraph/shared/src/settings/settings'
import { testUserID, sharedGraphQlResults } from '@sourcegraph/shared/src/testing/integration/graphQlResults'
import { WebGraphQlOperations } from '../graphql-operations'
import {
collaboratorsPayload,
recentFilesPayload,
recentSearchesPayload,
savedSearchesPayload,
} from '../search/panels/utils'
import { builtinAuthProvider, siteGQLID, siteID } from './jscontext'
@ -123,8 +117,12 @@ export const commonWebGraphQlResults: Partial<WebGraphQlOperations & SharedGraph
},
},
}),
savedSearches: () => ({
savedSearches: [],
SavedSearches: () => ({
savedSearches: {
nodes: [],
totalCount: 0,
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
},
}),
LogEvents: () => ({
logEvents: {
@ -157,16 +155,6 @@ export const commonWebGraphQlResults: Partial<WebGraphQlOperations & SharedGraph
OrgFeatureFlagOverrides: () => ({
organizationFeatureFlagOverrides: [],
}),
HomePanelsQuery: () => ({
node: {
__typename: 'User',
recentlySearchedRepositoriesLogs: recentSearchesPayload(),
recentSearchesLogs: recentSearchesPayload(),
recentFilesLogs: recentFilesPayload(),
collaborators: collaboratorsPayload(),
},
savedSearches: savedSearchesPayload(),
}),
SearchHistoryEventLogsQuery: () => ({
currentUser: {
__typename: 'User',

View File

@ -495,18 +495,28 @@ describe('Search', () => {
test('is styled correctly, with saved searches', async () => {
testContext.overrideGraphQL({
...commonSearchGraphQLResults,
savedSearches: () => ({
savedSearches: [
{
description: 'Demo',
id: 'U2F2ZWRTZWFyY2g6NQ==',
namespace: { __typename: 'User', id: 'user123', namespaceName: 'test' },
notify: false,
notifySlack: false,
query: 'context:global Batch Change patternType:literal',
slackWebhookURL: null,
SavedSearches: () => ({
savedSearches: {
nodes: [
{
__typename: 'SavedSearch',
description: 'Demo',
id: 'U2F2ZWRTZWFyY2g6NQ==',
namespace: { __typename: 'User', id: 'user123', namespaceName: 'test' },
notify: false,
notifySlack: false,
query: 'context:global Batch Change patternType:literal',
slackWebhookURL: null,
},
],
totalCount: 1,
pageInfo: {
startCursor: 'U2F2ZWRTZWFyY2g6NQ==',
endCursor: 'U2F2ZWRTZWFyY2g6NQ==',
hasNextPage: false,
hasPreviousPage: false,
},
],
},
}),
})

View File

@ -5,18 +5,29 @@ import { VisuallyHidden } from '@reach/visually-hidden'
import classNames from 'classnames'
import { RouteComponentProps, useLocation } from 'react-router'
import { Subject, Subscription } from 'rxjs'
import { catchError, map, mapTo, startWith, switchMap } from 'rxjs/operators'
import { catchError, mapTo, switchMap } from 'rxjs/operators'
import { useCallbackRef } from 'use-callback-ref'
import { asError, ErrorLike, isErrorLike, logger } from '@sourcegraph/common'
import { logger } from '@sourcegraph/common'
import { SearchPatternTypeProps } from '@sourcegraph/shared/src/search'
import { buildSearchURLQuery } from '@sourcegraph/shared/src/util/url'
import { Container, PageHeader, LoadingSpinner, Button, Link, Icon, Tooltip, ErrorAlert } from '@sourcegraph/wildcard'
import {
Container,
PageHeader,
LoadingSpinner,
Button,
Link,
Icon,
Tooltip,
ErrorAlert,
PageSwitcher,
} from '@sourcegraph/wildcard'
import { usePageSwitcherPagination } from '../components/FilteredConnection/hooks/usePageSwitcherPagination'
import { PageTitle } from '../components/PageTitle'
import { SavedSearchFields } from '../graphql-operations'
import { SavedSearchFields, SavedSearchesResult, SavedSearchesVariables } from '../graphql-operations'
import { NamespaceProps } from '../namespaces'
import { deleteSavedSearch, fetchSavedSearches } from '../search/backend'
import { deleteSavedSearch, savedSearchesQuery } from '../search/backend'
import { useNavbarQueryState } from '../stores'
import { eventLogger } from '../tracking/eventLogger'
@ -121,93 +132,90 @@ class SavedSearchNode extends React.PureComponent<NodeProps, NodeState> {
}
}
interface State {
savedSearchesOrError?: SavedSearchFields[] | ErrorLike
}
interface Props extends RouteComponentProps, NamespaceProps {}
export class SavedSearchListPage extends React.Component<Props, State> {
public subscriptions = new Subscription()
private refreshRequests = new Subject<void>()
public state: State = {}
public componentDidMount(): void {
this.subscriptions.add(
this.refreshRequests
.pipe(
startWith(undefined),
switchMap(() => fetchSavedSearches().pipe(catchError(error => [asError(error)]))),
map(savedSearchesOrError => ({ savedSearchesOrError }))
)
.subscribe(newState => this.setState(newState as State))
)
export const SavedSearchListPage: React.FunctionComponent<Props> = props => {
React.useEffect(() => {
eventLogger.logViewEvent('SavedSearchListPage')
}
}, [])
public render(): JSX.Element | null {
return (
<div className={styles.savedSearchListPage} data-testid="saved-searches-list-page">
<PageHeader
description="Manage notifications and alerts for specific search queries."
actions={
<Button
to={`${this.props.match.path}/add`}
className="test-add-saved-search-button"
variant="primary"
as={Link}
>
<Icon aria-hidden={true} svgPath={mdiPlus} /> Add saved search
</Button>
}
className="mb-3"
>
<PageTitle title="Saved searches" />
<PageHeader.Heading as="h3" styleAs="h2">
<PageHeader.Breadcrumb>Saved searches</PageHeader.Breadcrumb>
</PageHeader.Heading>
</PageHeader>
<SavedSearchListPageContent onDelete={this.onDelete} {...this.props} {...this.state} />
</div>
)
}
const { connection, loading, error, refetch, ...paginationProps } = usePageSwitcherPagination<
SavedSearchesResult,
SavedSearchesVariables,
SavedSearchFields
>({
query: savedSearchesQuery,
variables: { namespace: props.namespace.id },
getConnection: ({ data }) => data?.savedSearches || undefined,
})
private onDelete = (): void => {
this.refreshRequests.next()
}
return (
<div className={styles.savedSearchListPage} data-testid="saved-searches-list-page">
<PageHeader
description="Manage notifications and alerts for specific search queries."
actions={
<Button
to={`${props.match.path}/add`}
className="test-add-saved-search-button"
variant="primary"
as={Link}
>
<Icon aria-hidden={true} svgPath={mdiPlus} /> Add saved search
</Button>
}
className="mb-3"
>
<PageTitle title="Saved searches" />
<PageHeader.Heading as="h3" styleAs="h2">
<PageHeader.Breadcrumb>Saved searches</PageHeader.Breadcrumb>
</PageHeader.Heading>
</PageHeader>
<SavedSearchListPageContent
{...props}
onDelete={() => refetch()}
savedSearches={connection?.nodes || []}
error={error}
loading={loading}
/>
<PageSwitcher {...paginationProps} className="mt-4" totalCount={connection?.totalCount || 0} />
</div>
)
}
interface SavedSearchListPageContentProps extends Props, State {
interface SavedSearchListPageContentProps extends Props {
onDelete: () => void
savedSearches: SavedSearchFields[]
error: unknown
loading: boolean
}
const SavedSearchListPageContent: React.FunctionComponent<React.PropsWithChildren<SavedSearchListPageContentProps>> = ({
namespace,
savedSearchesOrError,
savedSearches,
error,
loading,
...props
}) => {
const location = useLocation<{ description?: string }>()
const searchPatternType = useNavbarQueryState(state => state.searchPatternType)
const callbackReference = useCallbackRef<HTMLAnchorElement>(null, ref => ref?.focus())
if (savedSearchesOrError === undefined) {
if (loading) {
return <LoadingSpinner />
}
if (isErrorLike(savedSearchesOrError)) {
return <ErrorAlert className="mb-3" error={savedSearchesOrError} />
if (error) {
return <ErrorAlert className="mb-3" error={error} />
}
const namespaceSavedSearches = savedSearchesOrError.filter(search => namespace.id === search.namespace.id)
if (namespaceSavedSearches.length === 0) {
if (savedSearches.length === 0) {
return <Container className="text-center text-muted">You haven't created a saved search yet.</Container>
}
return (
<Container>
<div className="list-group list-group-flush">
{namespaceSavedSearches.map(search => (
{savedSearches.map(search => (
<SavedSearchNode
key={search.id}
linkRef={location.state?.description === search.description ? callbackReference : null}

View File

@ -17,7 +17,6 @@ import {
Scalars,
SavedSearchFields,
ReposByQueryResult,
savedSearchesResult,
SavedSearchResult,
} from '../graphql-operations'
@ -62,23 +61,23 @@ const savedSearchFragment = gql`
}
`
export function fetchSavedSearches(): Observable<SavedSearchFields[]> {
return queryGraphQL<savedSearchesResult>(gql`
query savedSearches {
savedSearches {
export const savedSearchesQuery = gql`
query SavedSearches($namespace: ID!, $first: Int, $last: Int, $after: String, $before: String) {
savedSearches(namespace: $namespace, first: $first, last: $last, after: $after, before: $before) {
nodes {
...SavedSearchFields
}
}
${savedSearchFragment}
`).pipe(
map(({ data, errors }) => {
if (!data || !data.savedSearches) {
throw createAggregateError(errors)
totalCount
pageInfo {
hasNextPage
hasPreviousPage
endCursor
startCursor
}
return data.savedSearches
})
)
}
}
}
${savedSearchFragment}
`
export function fetchSavedSearch(id: Scalars['ID']): Observable<SavedSearchFields> {
return queryGraphQL<SavedSearchResult>(

View File

@ -1,10 +1,7 @@
import { DecoratorFn, Meta, Story } from '@storybook/react'
import { parseISO } from 'date-fns'
import { createMemoryHistory } from 'history'
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 {
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
@ -16,19 +13,6 @@ import { WebStory } from '../../components/WebStory'
import { MockedFeatureFlagsProvider } from '../../featureFlags/MockedFeatureFlagsProvider'
import { useExperimentalFeatures } from '../../stores'
import { ThemePreference } from '../../theme'
import {
HOME_PANELS_QUERY,
RECENTLY_SEARCHED_REPOSITORIES_TO_LOAD,
RECENT_FILES_TO_LOAD,
RECENT_SEARCHES_TO_LOAD,
} from '../panels/HomePanels'
import {
authUser,
collaboratorsPayload,
recentFilesPayload,
recentSearchesPayload,
savedSearchesPayload,
} from '../panels/utils'
import { SearchPage, SearchPageProps } from './SearchPage'
@ -45,14 +29,13 @@ const defaultProps = (props: ThemeProps): SearchPageProps => ({
telemetryService: NOOP_TELEMETRY_SERVICE,
themePreference: ThemePreference.Light,
onThemePreferenceChange: () => undefined,
authenticatedUser: authUser,
authenticatedUser: null,
globbing: false,
platformContext: {} as any,
searchContextsEnabled: true,
selectedSearchContextSpec: '',
setSelectedSearchContextSpec: () => {},
isLightTheme: props.isLightTheme,
now: () => parseISO('2020-09-16T23:15:01Z'),
fetchSearchContexts: mockFetchSearchContexts,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
})
@ -60,7 +43,7 @@ const defaultProps = (props: ThemeProps): SearchPageProps => ({
window.context.allowSignup = true
const decorator: DecoratorFn = Story => {
useExperimentalFeatures.setState({ showSearchContext: false, showEnterpriseHomePanels: false })
useExperimentalFeatures.setState({ showSearchContext: false })
return <Story />
}
@ -77,60 +60,16 @@ const config: Meta = {
}
export default config
function getMocks({
enableSavedSearches,
enableCollaborators,
}: {
enableSavedSearches: boolean
enableCollaborators: boolean
}) {
return [
{
request: {
query: getDocumentNode(HOME_PANELS_QUERY),
variables: {
userId: '0',
firstRecentlySearchedRepositories: RECENTLY_SEARCHED_REPOSITORIES_TO_LOAD,
firstRecentSearches: RECENT_SEARCHES_TO_LOAD,
firstRecentFiles: RECENT_FILES_TO_LOAD,
enableSavedSearches,
enableCollaborators,
},
},
result: {
data: {
node: {
__typename: 'User',
recentlySearchedRepositoriesLogs: recentSearchesPayload(),
recentSearchesLogs: recentSearchesPayload(),
recentFilesLogs: recentFilesPayload(),
collaborators: enableCollaborators ? collaboratorsPayload() : undefined,
},
savedSearches: enableSavedSearches ? savedSearchesPayload() : undefined,
},
},
},
]
}
export const CloudAuthedHome: Story = () => (
<WebStory>
{webProps => (
<MockedTestProvider
mocks={getMocks({
enableSavedSearches: false,
enableCollaborators: false,
})}
>
<SearchPage {...defaultProps(webProps)} isSourcegraphDotCom={true} />
</MockedTestProvider>
)}
</WebStory>
<WebStory>{webProps => <SearchPage {...defaultProps(webProps)} isSourcegraphDotCom={true} />}</WebStory>
)
CloudAuthedHome.storyName = 'Cloud authenticated home'
export const ServerHome: Story = () => <WebStory>{webProps => <SearchPage {...defaultProps(webProps)} />}</WebStory>
ServerHome.storyName = 'Server home'
export const CloudMarketingHome: Story = () => (
<WebStory>
{webProps => (
@ -142,20 +81,3 @@ export const CloudMarketingHome: Story = () => (
)
CloudMarketingHome.storyName = 'Cloud marketing home'
export const ServerHome: Story = () => (
<WebStory>
{webProps => (
<MockedTestProvider
mocks={getMocks({
enableSavedSearches: true,
enableCollaborators: false,
})}
>
<SearchPage {...defaultProps(webProps)} />
</MockedTestProvider>
)}
</WebStory>
)
ServerHome.storyName = 'Server home'

View File

@ -1,191 +0,0 @@
import { cleanup } from '@testing-library/react'
import { createMemoryHistory } from 'history'
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 {
mockFetchSearchContexts,
mockGetUserSearchContextNamespaces,
} from '@sourcegraph/shared/src/testing/searchContexts/testHelpers'
import { extensionsController } from '@sourcegraph/shared/src/testing/searchTestHelpers'
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
import { SourcegraphContext } from '../../jscontext'
import { useExperimentalFeatures } from '../../stores'
import { ThemePreference } from '../../theme'
import {
HOME_PANELS_QUERY,
RECENTLY_SEARCHED_REPOSITORIES_TO_LOAD,
RECENT_FILES_TO_LOAD,
RECENT_SEARCHES_TO_LOAD,
} from '../panels/HomePanels'
import {
authUser,
collaboratorsPayload,
recentFilesPayload,
recentSearchesPayload,
savedSearchesPayload,
} from '../panels/utils'
import { SearchPage, SearchPageProps } from './SearchPage'
// Mock the Monaco input box to make this a shallow test
jest.mock('./SearchPageInput', () => ({
SearchPageInput: () => null,
}))
// Uses import.meta.url, which is a SyntaxError when used outside of ES Modules (Jest runs tests as
// CommonJS).
function getMocks({
enableSavedSearches,
enableCollaborators,
}: {
enableSavedSearches: boolean
enableCollaborators: boolean
}) {
return [
{
request: {
query: getDocumentNode(HOME_PANELS_QUERY),
variables: {
userId: '0',
firstRecentlySearchedRepositories: RECENTLY_SEARCHED_REPOSITORIES_TO_LOAD,
firstRecentSearches: RECENT_SEARCHES_TO_LOAD,
firstRecentFiles: RECENT_FILES_TO_LOAD,
enableSavedSearches,
enableCollaborators,
},
},
result: {
data: {
node: {
__typename: 'User',
recentlySearchedRepositoriesLogs: recentSearchesPayload(),
recentSearchesLogs: recentSearchesPayload(),
recentFilesLogs: recentFilesPayload(),
collaborators: enableCollaborators ? collaboratorsPayload() : undefined,
},
savedSearches: enableSavedSearches ? savedSearchesPayload() : undefined,
},
},
},
]
}
describe('SearchPage', () => {
afterAll(cleanup)
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
window.context = {} as SourcegraphContext & Mocha.SuiteFunction
})
let container: HTMLElement
const history = createMemoryHistory()
const defaultProps: SearchPageProps = {
isSourcegraphDotCom: false,
settingsCascade: {
final: null,
subjects: null,
},
location: history.location,
history,
extensionsController,
telemetryService: NOOP_TELEMETRY_SERVICE,
themePreference: ThemePreference.Light,
onThemePreferenceChange: () => undefined,
authenticatedUser: authUser,
globbing: false,
platformContext: {} as any,
searchContextsEnabled: true,
selectedSearchContextSpec: '',
setSelectedSearchContextSpec: () => {},
isLightTheme: true,
fetchSearchContexts: mockFetchSearchContexts,
getUserSearchContextNamespaces: mockGetUserSearchContextNamespaces,
}
it('should show home panels if not on Sourcegraph.com, logged in and showEnterpriseHomePanels enabled', () => {
useExperimentalFeatures.setState({ showEnterpriseHomePanels: true })
container = renderWithBrandedContext(
<MockedTestProvider
mocks={getMocks({
enableSavedSearches: false,
enableCollaborators: false,
})}
>
<SearchPage {...defaultProps} />
</MockedTestProvider>
).container
const homePanels = container.querySelector('[data-testid="home-panels"]')
expect(homePanels).toBeVisible()
})
it('should not show home panels if on Sourcegraph.com and showEnterpriseHomePanels disabled', () => {
container = renderWithBrandedContext(
<MockedTestProvider
mocks={getMocks({
enableSavedSearches: false,
enableCollaborators: false,
})}
>
<SearchPage {...defaultProps} isSourcegraphDotCom={true} />
</MockedTestProvider>
).container
const homePanels = container.querySelector('[data-testid="home-panels"]')
expect(homePanels).not.toBeInTheDocument()
})
it('should not show home panels if on Sourcegraph.com and showEnterpriseHomePanels enabled with user logged out', () => {
useExperimentalFeatures.setState({ showEnterpriseHomePanels: true })
container = renderWithBrandedContext(
<MockedTestProvider
mocks={getMocks({
enableSavedSearches: false,
enableCollaborators: false,
})}
>
<SearchPage {...defaultProps} isSourcegraphDotCom={true} authenticatedUser={null} />
</MockedTestProvider>
).container
const homePanels = container.querySelector('[data-testid="home-panels"]')
expect(homePanels).not.toBeInTheDocument()
})
it('should not show home panels if showEnterpriseHomePanels disabled', () => {
container = renderWithBrandedContext(
<MockedTestProvider
mocks={getMocks({
enableSavedSearches: false,
enableCollaborators: false,
})}
>
<SearchPage {...defaultProps} />
</MockedTestProvider>
).container
const homePanels = container.querySelector('[data-testid="home-panels"]')
expect(homePanels).not.toBeInTheDocument()
})
it('should show home panels if showEnterpriseHomePanels enabled and not on Sourcegraph.com', () => {
useExperimentalFeatures.setState({ showEnterpriseHomePanels: true })
container = renderWithBrandedContext(
<MockedTestProvider
mocks={getMocks({
enableSavedSearches: false,
enableCollaborators: false,
})}
>
<SearchPage {...defaultProps} />
</MockedTestProvider>
).container
const homePanels = container.querySelector('[data-testid="home-panels"]')
expect(homePanels).toBeVisible()
})
})

View File

@ -13,7 +13,6 @@ import { ThemeProps } from '@sourcegraph/shared/src/theme'
import { buildCloudTrialURL } from '@sourcegraph/shared/src/util/url'
import { Link, Tooltip, useWindowSize, VIEWPORT_SM } from '@sourcegraph/wildcard'
import { HomePanelsProps } from '..'
import { AuthenticatedUser } from '../../auth'
import { BrandLogo } from '../../components/branding/BrandLogo'
import { CodeInsightsProps } from '../../insights/types'
@ -21,7 +20,6 @@ import { AddCodeHostWidget, useShouldShowAddCodeHostWidget } from '../../onboard
import { useExperimentalFeatures } from '../../stores'
import { ThemePreferenceProps } from '../../theme'
import { eventLogger } from '../../tracking/eventLogger'
import { HomePanels } from '../panels/HomePanels'
import { QueryExamplesHomepage } from './QueryExamplesHomepage'
import { SearchPageFooter } from './SearchPageFooter'
@ -37,7 +35,6 @@ export interface SearchPageProps
ExtensionsControllerProps<'extHostAPI' | 'executeCommand'>,
PlatformContextProps<'settings' | 'sourcegraphURL' | 'updateSettings' | 'requestGraphQL'>,
SearchContextInputProps,
HomePanelsProps,
CodeInsightsProps {
authenticatedUser: AuthenticatedUser | null
location: H.Location
@ -53,7 +50,6 @@ export interface SearchPageProps
* The search page
*/
export const SearchPage: React.FunctionComponent<React.PropsWithChildren<SearchPageProps>> = props => {
const showEnterpriseHomePanels = useExperimentalFeatures(features => features.showEnterpriseHomePanels ?? false)
const homepageUserInvitation = useExperimentalFeatures(features => features.homepageUserInvitation) ?? false
const showCollaborators = window.context.allowSignup && homepageUserInvitation && props.isSourcegraphDotCom
const { width } = useWindowSize()
@ -112,22 +108,16 @@ export const SearchPage: React.FunctionComponent<React.PropsWithChildren<SearchP
[styles.panelsContainerWithCollaborators]: showCollaborators,
})}
>
<>
{showEnterpriseHomePanels && !!props.authenticatedUser && !props.isSourcegraphDotCom && (
<HomePanels showCollaborators={showCollaborators} {...props} />
)}
{((!showEnterpriseHomePanels && !!props.authenticatedUser) || props.isSourcegraphDotCom) && (
<QueryExamplesHomepage
selectedSearchContextSpec={props.selectedSearchContextSpec}
telemetryService={props.telemetryService}
queryState={queryState}
setQueryState={setQueryState}
isSourcegraphDotCom={props.isSourcegraphDotCom}
authenticatedUser={props.authenticatedUser}
/>
)}
</>
{(!!props.authenticatedUser || props.isSourcegraphDotCom) && (
<QueryExamplesHomepage
selectedSearchContextSpec={props.selectedSearchContextSpec}
telemetryService={props.telemetryService}
queryState={queryState}
setQueryState={setQueryState}
isSourcegraphDotCom={props.isSourcegraphDotCom}
authenticatedUser={props.authenticatedUser}
/>
)}
</div>
<SearchPageFooter {...props} />

View File

@ -205,11 +205,6 @@ export function literalSearchCompatibility({ queryInput, patternTypeInput }: Que
}
}
export interface HomePanelsProps {
/** Function that returns current time (for stability in visual tests). */
now?: () => Date
}
export interface SearchStreamingProps {
streamSearch: (
queryObservable: Observable<string>,

View File

@ -1,78 +0,0 @@
.avatar {
width: 2.5rem;
height: 2.5rem;
min-width: 2.5rem;
min-height: 2.5rem;
}
.invite-button {
height: 1.875rem;
position: relative;
}
.button {
text-align: left;
margin-left: 0.125rem;
margin-right: 0.125rem;
padding-top: 0.25rem;
padding-right: 0.25rem;
}
.button:disabled {
color: var(--body-color) !important;
cursor: pointer;
}
.invite-button-overlay {
width: 100%;
position: absolute;
top: 0;
left: 0;
background-color: var(--body-bg);
opacity: 0;
transition: opacity 60ms ease-in-out;
}
.button:focus-within .invite-button-overlay {
opacity: 1;
}
.invite-button-link {
padding: 0;
font-weight: normal;
}
.content {
width: calc(100% - 3.5rem);
white-space: nowrap;
}
.clip-text {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.invitebox:hover {
.invite-button-overlay {
opacity: 1;
}
}
.info {
font-size: 0.75rem;
}
.info-box {
font-size: 0.85rem;
}
.info-header {
font-size: 1rem;
}
.panel {
height: calc(100% - 1rem);
}

View File

@ -1,46 +0,0 @@
import { Story, Meta } from '@storybook/react'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { H2 } from '@sourcegraph/wildcard'
import { WebStory } from '../../components/WebStory'
import { CollaboratorsPanel } from './CollaboratorsPanel'
import { collaboratorsPayload, authUser } from './utils'
const config: Meta = {
title: 'web/search/panels/CollaboratorsPanel',
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/Og1zVk7BbZ7SWTXM5WsWA5/Account-Setups-OKR-explorations?node-id=188%3A17448',
},
chromatic: { disableSnapshot: false },
},
}
export default config
const props = {
authenticatedUser: authUser,
collaboratorsFragment: { collaborators: collaboratorsPayload() },
telemetryService: NOOP_TELEMETRY_SERVICE,
}
export const CollaboratorsPanelStory: Story = () => (
<WebStory>
{() => (
<div style={{ maxWidth: '32rem' }}>
<H2>Populated</H2>
<CollaboratorsPanel {...props} />
<H2>Loading</H2>
<CollaboratorsPanel {...props} collaboratorsFragment={null} />
<H2>Empty</H2>
<CollaboratorsPanel {...props} collaboratorsFragment={{ collaborators: [] }} />
</div>
)}
</WebStory>
)
CollaboratorsPanelStory.storyName = 'CollaboratorsPanel'

View File

@ -1,271 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { mdiEmailCheck, mdiEmail, mdiInformationOutline } from '@mdi/js'
import classNames from 'classnames'
import { ErrorLike, isErrorLike } from '@sourcegraph/common'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Button, Card, CardBody, Link, LoadingSpinner, Icon, H2, Text, ErrorAlert } from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../../auth'
import { CopyableText } from '../../components/CopyableText'
import { CollaboratorsFragment, Maybe } from '../../graphql-operations'
import { eventLogger } from '../../tracking/eventLogger'
import { UserAvatar } from '../../user/UserAvatar'
import { LoadingPanelView } from './LoadingPanelView'
import { PanelContainer } from './PanelContainer'
import { useInviteEmailToSourcegraph } from './useInviteEmailToSourcegraph'
import styles from './CollaboratorsPanel.module.scss'
interface Props extends TelemetryProps {
className?: string
authenticatedUser: AuthenticatedUser | null
collaboratorsFragment: CollaboratorsFragment | null
}
const emailEnabled = window.context?.emailEnabled ?? false
export interface InvitableCollaborator {
email: string
displayName: string
name: string
avatarURL: Maybe<string>
}
export const CollaboratorsPanel: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
className,
authenticatedUser,
collaboratorsFragment,
}) => {
const inviteEmailToSourcegraph = useInviteEmailToSourcegraph()
const collaborators = collaboratorsFragment?.collaborators ?? null
const filteredCollaborators = useMemo(() => collaborators?.slice(0, 6), [collaborators])
const [inviteError, setInviteError] = useState<ErrorLike | null>(null)
const [loadingInvites, setLoadingInvites] = useState<Set<string>>(new Set<string>())
const [successfulInvites, setSuccessfulInvites] = useState<Set<string>>(new Set<string>())
const isSiteAdmin = authenticatedUser?.siteAdmin ?? false
useEffect(() => {
if (!Array.isArray(collaborators)) {
return
}
// When Email is not set up we might find some people to invite but won't show that to the user.
if (!emailEnabled) {
return
}
const loggerPayload = {
discovered: collaborators.length,
}
eventLogger.log('HomepageInvitationsDiscoveredCollaborators', loggerPayload, loggerPayload)
}, [collaborators])
const invitePerson = useCallback(
async (person: InvitableCollaborator): Promise<void> => {
if (loadingInvites.has(person.email) || successfulInvites.has(person.email)) {
return
}
setLoadingInvites(set => new Set(set).add(person.email))
try {
await inviteEmailToSourcegraph({ variables: { email: person.email } })
setLoadingInvites(set => {
const removed = new Set(set)
removed.delete(person.email)
return removed
})
setSuccessfulInvites(set => new Set(set).add(person.email))
eventLogger.log('HomepageInvitationsSentEmailInvite')
} catch (error) {
setInviteError(error)
}
},
[inviteEmailToSourcegraph, loadingInvites, successfulInvites]
)
const loadingDisplay = <LoadingPanelView text="Loading colleagues" />
const contentDisplay =
filteredCollaborators?.length === 0 || !emailEnabled ? (
<CollaboratorsPanelNullState username={authenticatedUser?.username || ''} isSiteAdmin={isSiteAdmin} />
) : (
<div className={classNames('row', 'pt-1')}>
{isErrorLike(inviteError) && <ErrorAlert error={inviteError} />}
<CollaboratorsPanelInfo isSiteAdmin={isSiteAdmin} />
{filteredCollaborators?.map((person: InvitableCollaborator) => (
<div
className={classNames('d-flex', 'align-items-center', 'col-lg-6', 'mt-1', styles.invitebox)}
key={person.email}
>
<Button
variant="icon"
key={person.email}
disabled={loadingInvites.has(person.email) || successfulInvites.has(person.email)}
className={classNames('w-100', styles.button)}
onClick={() => invitePerson(person)}
>
<UserAvatar size={40} className={classNames(styles.avatar, 'mr-3')} user={person} />
<div className={styles.content}>
<strong className={styles.clipText}>{person.displayName}</strong>
<div className={styles.inviteButton}>
{loadingInvites.has(person.email) ? (
<span className=" ml-auto mr-3">
<LoadingSpinner className="mr-1" />
Inviting...
</span>
) : successfulInvites.has(person.email) ? (
<span className="text-success ml-auto mr-3">
<Icon aria-hidden={true} className="mr-1" svgPath={mdiEmailCheck} />
Invited
</span>
) : (
<>
<div className={classNames('text-muted', styles.clipText)}>
{person.email}
</div>
<div className={classNames('text-primary', styles.inviteButtonOverlay)}>
<Icon aria-hidden={true} className="mr-1" svgPath={mdiEmail} />
Invite to Sourcegraph
</div>
</>
)}
</div>
</div>
</Button>
</div>
))}
</div>
)
return (
<PanelContainer
className={classNames(className, styles.panel)}
title="Invite your colleagues"
insideTabPanel={true}
state={collaborators === null ? 'loading' : 'populated'}
loadingContent={loadingDisplay}
populatedContent={contentDisplay}
/>
)
}
const CollaboratorsPanelNullState: React.FunctionComponent<
React.PropsWithChildren<{ username: string; isSiteAdmin: boolean }>
> = ({ username, isSiteAdmin }) => {
const inviteURL = `${window.context.externalURL}/sign-up?invitedBy=${username}`
useEffect(() => {
const loggerPayload = {
// The third type, `config-disabled`, is emitted in <HomePanels />
type: emailEnabled ? 'email-not-configured' : 'no-collaborators',
}
eventLogger.log('HomepageInvitationsViewEmpty', loggerPayload, loggerPayload)
}, [])
return (
<div
className={classNames(
'd-flex',
'align-items-center',
'flex-column',
'justify-content-center',
'col-lg-12',
'h-100'
)}
>
{emailEnabled ? (
<div className="text-muted text-center">No collaborators found in sampled repositories.</div>
) : isSiteAdmin ? (
<div className="text-muted text-center">
This server is not configured to send emails. <Link to="/help/admin/config/email">Learn more</Link>
</div>
) : null}
<div className="text-muted mt-3 text-center">
You can invite people to Sourcegraph with this direct link:
</div>
<CopyableText
className="mt-3"
text={inviteURL}
flex={true}
size={inviteURL.length}
onCopy={() => eventLogger.log('HomepageInvitationsCopiedInviteLink')}
/>
</div>
)
}
const CollaboratorsPanelInfo: React.FunctionComponent<React.PropsWithChildren<{ isSiteAdmin: boolean }>> = ({
isSiteAdmin,
}) => {
const [infoShown, setInfoShown] = useState<boolean>(false)
if (infoShown) {
return (
<div className="col-12 mt-2 mb-2 position-relative">
<Card>
<CardBody>
<div className={classNames('d-flex', 'align-content-start', 'mb-2')}>
<H2 className={classNames(styles.infoBox, 'mb-0')}>
<Icon aria-hidden={true} className="mr-2 text-muted" svgPath={mdiInformationOutline} />
What is this?
</H2>
<div className="flex-grow-1" />
<Button
variant="icon"
onClick={() => setInfoShown(false)}
aria-label="Close info box"
aria-expanded="true"
>
<span aria-hidden="true">×</span>
</Button>
</div>
{isSiteAdmin ? (
<>
<Text className={styles.infoBox}>
This feature enables Sourcegraph users to invite collaborators we discover through
your Git repository commit history. The invitee will receive a link to Sourcegraph,
but no special permissions are granted.
</Text>
<Text className={classNames(styles.infoBox, 'mb-0')}>
If you wish to disable this feature, see{' '}
<Link to="/help/admin/config/user_invitations">this documentation</Link>.
</Text>
</>
) : (
<Text className={classNames(styles.infoBox, 'mb-0')}>
These collaborators were found via your repositories Git commit history. The invitee
will receive a link to Sourcegraph, but no special permissions are granted.
</Text>
)}
</CardBody>
</Card>
</div>
)
}
return (
<div className={classNames('col-12', 'd-flex', 'mt-2', 'mb-1')}>
<div className={classNames('text-muted', styles.info)}>Collaborators from your repositories</div>
<div className="flex-grow-1" />
<div>
<Icon aria-hidden={true} className="mr-1 text-muted" svgPath={mdiInformationOutline} />
<Button
variant="link"
className={classNames(styles.info, 'p-0')}
onClick={() => setInfoShown(true)}
aria-haspopup="true"
aria-expanded="false"
>
What is this?
</Button>
</div>
</div>
)
}

View File

@ -1,5 +0,0 @@
.icon {
width: 3rem;
height: 3rem;
object-fit: contain;
}

View File

@ -1,40 +0,0 @@
import { DecoratorFn, Meta, Story } from '@storybook/react'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { WebStory } from '../../components/WebStory'
import { CommunitySearchContextsPanel } from './CommunitySearchContextPanel'
const decorator: DecoratorFn = story => <div style={{ width: '800px' }}>{story()}</div>
const config: Meta = {
title: 'web/search/panels/CommunitySearchContextPanel',
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/zCGglxWBFm8Fv5DwdcHdAQ/Repository-group-home-page-panel-14393?node-id=1%3A159',
},
chromatic: { viewports: [800] },
},
decorators: [decorator],
}
export default config
const props = {
authenticatedUser: null,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
export const CommunitySearchContextPanelStory: Story = () => (
<WebStory>
{() => (
<div style={{ maxWidth: '32rem' }}>
<CommunitySearchContextsPanel {...props} />
</div>
)}
</WebStory>
)
CommunitySearchContextPanelStory.storyName = 'CommunitySearchContextsPanel'

View File

@ -1,15 +0,0 @@
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
import { CommunitySearchContextsPanel } from './CommunitySearchContextPanel'
describe('CommunitySearchContextPanel', () => {
test('renders correctly', () => {
const props = {
authenticatedUser: null,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
expect(renderWithBrandedContext(<CommunitySearchContextsPanel {...props} />).asFragment()).toMatchSnapshot()
})
})

View File

@ -1,61 +0,0 @@
import React, { useCallback, useEffect } from 'react'
import classNames from 'classnames'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Link } from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../../auth'
import { communitySearchContextsList } from '../../communitySearchContexts/HomepageConfig'
import { PanelContainer } from './PanelContainer'
import styles from './CommunitySearchContextPanel.module.scss'
interface Props extends TelemetryProps {
className?: string
authenticatedUser: AuthenticatedUser | null
insideTabPanel?: boolean
}
export const CommunitySearchContextsPanel: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
className,
telemetryService,
insideTabPanel,
}) => {
useEffect(() => {
telemetryService.log('HomepageContextsPanelViewed')
}, [telemetryService])
const logContextClicked = useCallback(
() => telemetryService.log('CommunitySearchContextsPanelCommunitySearchContextClicked'),
[telemetryService]
)
const populatedContent = (
<div className="mt-2 row">
{communitySearchContextsList.map(communitySearchContext => (
<div
className="d-flex align-items-center mb-4 col-xl-6 col-lg-12 col-sm-6"
key={communitySearchContext.spec}
>
<img className={classNames('mr-4', styles.icon)} src={communitySearchContext.homepageIcon} alt="" />
<div className="d-flex flex-column">
<Link to={communitySearchContext.url} onClick={logContextClicked} className="mb-1">
{communitySearchContext.title}
</Link>
</div>
</div>
))}
</div>
)
return (
<PanelContainer
insideTabPanel={insideTabPanel}
className={classNames(className, 'community-search-context-panel')}
title="Community search contexts"
state="populated"
populatedContent={populatedContent}
/>
)
}

View File

@ -1,9 +0,0 @@
.empty-container {
display: flex;
flex-direction: column;
text-align: center;
padding: 3rem;
justify-content: center;
background: var(--color-bg-2);
height: 100%;
}

View File

@ -1,14 +0,0 @@
import * as React from 'react'
import classNames from 'classnames'
import styles from './EmptyPanelContainer.module.scss'
interface EmptyPanelContainerProps {
className?: string
}
export const EmptyPanelContainer: React.FunctionComponent<React.PropsWithChildren<EmptyPanelContainerProps>> = ({
children,
className,
}) => <div className={classNames(className, styles.emptyContainer)}>{children}</div>

View File

@ -1,3 +0,0 @@
.footer {
background: var(--color-bg-2);
}

View File

@ -1,14 +0,0 @@
import * as React from 'react'
import classNames from 'classnames'
import styles from './FooterPanel.module.scss'
interface FooterPanelProps {
className?: string
}
export const FooterPanel: React.FunctionComponent<React.PropsWithChildren<FooterPanelProps>> = ({
children,
className,
}) => <div className={classNames(className, styles.footer)}>{children}</div>

View File

@ -1,20 +0,0 @@
.home-panels {
margin-top: 1rem;
max-width: 90vw;
}
.panel {
height: 15rem;
}
.tabs {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
}
.tab-panels {
overflow-y: auto;
overflow-x: hidden;
}

View File

@ -1,215 +0,0 @@
import * as React from 'react'
import { useEffect, useCallback } from 'react'
import { ApolloQueryResult, gql } from '@apollo/client'
import classNames from 'classnames'
import { useQuery } from '@sourcegraph/http-client'
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary/useTemporarySetting'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Tabs, Tab, TabList, TabPanel, TabPanels } from '@sourcegraph/wildcard'
import { HomePanelsProps } from '..'
import { AuthenticatedUser } from '../../auth'
import { CollaboratorsFragment, HomePanelsQueryResult, HomePanelsQueryVariables } from '../../graphql-operations'
import { GettingStartedTour } from '../../tour/GettingStartedTour'
import { eventLogger } from '../../tracking/eventLogger'
import { CollaboratorsPanel } from './CollaboratorsPanel'
import { CommunitySearchContextsPanel } from './CommunitySearchContextPanel'
import {
collaboratorsFragment,
recentlySearchedRepositoriesFragment,
recentFilesFragment,
recentSearchesPanelFragment,
savedSearchesPanelFragment,
} from './PanelFragments'
import { RecentFilesPanel } from './RecentFilesPanel'
import { RecentSearchesPanel } from './RecentSearchesPanel'
import { RepositoriesPanel } from './RepositoriesPanel'
import { SavedSearchesPanel } from './SavedSearchesPanel'
import styles from './HomePanels.module.scss'
interface Props extends TelemetryProps, HomePanelsProps {
authenticatedUser: AuthenticatedUser | null
isSourcegraphDotCom: boolean
showCollaborators: boolean
}
const INVITES_TAB_KEY = 'homepage.userInvites.tab'
// Use a larger page size because not every search may have a `repo:` filter, and `repo:` filters could often
// be duplicated. Therefore, we fetch more searches to populate this panel.
export const RECENTLY_SEARCHED_REPOSITORIES_TO_LOAD = 50
export const RECENT_SEARCHES_TO_LOAD = 20
export const RECENT_FILES_TO_LOAD = 20
export type HomePanelsFetchMore = (
fetchMoreOptions: Partial<HomePanelsQueryVariables>
) => Promise<ApolloQueryResult<HomePanelsQueryResult>>
export const HOME_PANELS_QUERY = gql`
query HomePanelsQuery(
$userId: ID!
$firstRecentlySearchedRepositories: Int!
$firstRecentSearches: Int!
$firstRecentFiles: Int!
$enableSavedSearches: Boolean!
$enableCollaborators: Boolean!
) {
node(id: $userId) {
__typename
...RecentlySearchedRepositoriesFragment
...RecentSearchesPanelFragment
...RecentFilesFragment
...CollaboratorsFragment
}
...SavedSearchesPanelFragment
}
${recentlySearchedRepositoriesFragment}
${recentSearchesPanelFragment}
${savedSearchesPanelFragment}
${recentFilesFragment}
${collaboratorsFragment}
`
export const HomePanels: React.FunctionComponent<React.PropsWithChildren<Props>> = (props: Props) => {
const userId = props.authenticatedUser?.id || ''
const showCollaborators = props.showCollaborators
const showSavedSearches = !props.isSourcegraphDotCom
const { data, fetchMore: rawFetchMore } = useQuery<HomePanelsQueryResult, HomePanelsQueryVariables>(
HOME_PANELS_QUERY,
{
variables: {
userId,
firstRecentlySearchedRepositories: RECENTLY_SEARCHED_REPOSITORIES_TO_LOAD,
firstRecentSearches: RECENT_SEARCHES_TO_LOAD,
firstRecentFiles: RECENT_FILES_TO_LOAD,
enableSavedSearches: showSavedSearches,
enableCollaborators: showCollaborators,
},
}
)
const fetchMore: HomePanelsFetchMore = useCallback(
(variables: Partial<HomePanelsQueryVariables>) =>
rawFetchMore({
variables: {
userId,
firstRecentlySearchedRepositories: 0,
firstRecentSearches: 0,
firstRecentFiles: 0,
enableSavedSearches: false,
enableCollaborators: false,
...variables,
},
}),
[rawFetchMore, userId]
)
useEffect(() => {
if (props.showCollaborators === true) {
return
}
const loggerPayload = {
// The other types are emitted in <CollaboratorsPanel />
type: 'config-disabled',
}
eventLogger.log('HomepageInvitationsViewEmpty', loggerPayload, loggerPayload)
}, [props.showCollaborators])
const node = data?.node ?? null
if (node !== null && node.__typename !== 'User') {
return null
}
return (
<div className={classNames('container', styles.homePanels)} data-testid="home-panels">
<div className="row">
<GettingStartedTour
isSourcegraphDotCom={props.isSourcegraphDotCom}
telemetryService={props.telemetryService}
isAuthenticated={true}
className="w-100"
variant="horizontal"
/>
</div>
<div className="row">
<RepositoriesPanel
{...props}
className={classNames('col-lg-4', styles.panel)}
recentlySearchedRepositories={node}
fetchMore={fetchMore}
/>
<RecentSearchesPanel
{...props}
recentSearches={node}
className={classNames('col-lg-8', styles.panel)}
fetchMore={fetchMore}
/>
</div>
<div className="row">
<RecentFilesPanel
{...props}
className={classNames('col-lg-7', styles.panel)}
recentFilesFragment={node}
fetchMore={fetchMore}
/>
{showCollaborators ? (
<CollaboratorsTabPanel {...props} data={data} collaboratorsFragment={node} />
) : showSavedSearches ? (
<SavedSearchesPanel
{...props}
className={classNames('col-lg-5', styles.panel)}
savedSearchesFragment={data ?? null}
/>
) : (
<CommunitySearchContextsPanel {...props} className={classNames('col-lg-5', styles.panel)} />
)}
</div>
</div>
)
}
interface CollaboratorsTabPanelProps extends Props {
data: undefined | HomePanelsQueryResult
collaboratorsFragment: null | CollaboratorsFragment
}
const CollaboratorsTabPanel: React.FunctionComponent<React.PropsWithChildren<CollaboratorsTabPanelProps>> = ({
data,
collaboratorsFragment,
...props
}) => {
const [persistedTabIndex, setPersistedTabIndex] = useTemporarySetting(INVITES_TAB_KEY, 1)
if (persistedTabIndex === undefined) {
return null
}
return (
<div className={classNames('col-lg-5', styles.panel)}>
<Tabs defaultIndex={persistedTabIndex} onChange={setPersistedTabIndex} className={styles.tabs}>
<TabList>
<Tab>{props.isSourcegraphDotCom ? 'Community search contexts' : 'Saved searches'}</Tab>
<Tab>Invite colleagues</Tab>
</TabList>
<TabPanels className={classNames('h-100', styles.tabPanels)}>
<TabPanel className="h-100">
{props.isSourcegraphDotCom ? (
<CommunitySearchContextsPanel {...props} insideTabPanel={true} />
) : (
<SavedSearchesPanel {...props} insideTabPanel={true} savedSearchesFragment={data ?? null} />
)}
</TabPanel>
<TabPanel className="h-100">
<CollaboratorsPanel {...props} collaboratorsFragment={collaboratorsFragment} />
</TabPanel>
</TabPanels>
</Tabs>
</div>
)
}

View File

@ -1,3 +0,0 @@
.loading-container {
background: none !important;
}

View File

@ -1,18 +0,0 @@
import * as React from 'react'
import classNames from 'classnames'
import { LoadingSpinner } from '@sourcegraph/wildcard'
import { EmptyPanelContainer } from './EmptyPanelContainer'
import styles from './LoadingPanelView.module.scss'
export const LoadingPanelView: React.FunctionComponent<React.PropsWithChildren<{ text: string }>> = ({ text }) => (
<EmptyPanelContainer
className={classNames('d-flex justify-content-center align-items-center', styles.loadingContainer)}
>
<LoadingSpinner />
<span className="text-muted">{text}</span>
</EmptyPanelContainer>
)

View File

@ -1,25 +0,0 @@
.panel-container {
margin-bottom: 1rem;
}
.header {
min-height: 2rem;
&-text {
margin-bottom: 0.125rem;
flex-grow: 1;
align-self: flex-end;
font-weight: 400;
}
}
.header-inside-tab-panel {
position: absolute;
top: 0;
right: 0;
}
.content {
overflow-y: auto;
overflow-x: hidden;
}

View File

@ -1,32 +0,0 @@
import { render } from '@testing-library/react'
import { PanelContainer } from './PanelContainer'
describe('PanelContainer', () => {
const defaultProps = {
title: 'Test Panel',
state: 'loading',
loadingContent: <div>Loading</div>,
populatedContent: <div>Content</div>,
emptyContent: <div>Empty</div>,
}
test('loading state', () => {
expect(render(<PanelContainer {...defaultProps} state="loading" />).asFragment()).toMatchSnapshot()
})
test('empty state', () => {
expect(render(<PanelContainer {...defaultProps} state="empty" />).asFragment).toMatchSnapshot()
})
test('content state', () => {
expect(render(<PanelContainer {...defaultProps} state="populated" />).asFragment).toMatchSnapshot()
})
test('with action buttons', () => {
const actionButtons = <button type="button">Button</button>
expect(
render(<PanelContainer {...defaultProps} state="populated" actionButtons={actionButtons} />).asFragment()
).toMatchSnapshot()
})
})

View File

@ -1,51 +0,0 @@
import * as React from 'react'
import classNames from 'classnames'
import { H2, H4 } from '@sourcegraph/wildcard'
import styles from './PanelContainer.module.scss'
interface Props {
title: string
state: 'loading' | 'populated' | 'empty'
// the content displayed when state is 'loading'
loadingContent?: JSX.Element
// the content displayed when state is 'empty'
emptyContent?: JSX.Element
// the content displayed when state is 'populated'
populatedContent: JSX.Element
actionButtons?: JSX.Element
className?: string
insideTabPanel?: boolean
}
export const PanelContainer: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
title,
state,
loadingContent = <></>,
emptyContent = <></>,
populatedContent,
actionButtons,
className,
insideTabPanel,
}) => (
<div className={classNames(className, styles.panelContainer, 'd-flex', 'flex-column')}>
{insideTabPanel !== true ? (
<div className={classNames('d-flex border-bottom', styles.header)}>
<H4 as={H2} className={styles.headerText}>
{title}
</H4>
{actionButtons}
</div>
) : (
<div className={classNames(styles.header, styles.headerInsideTabPanel)}>{actionButtons}</div>
)}
<div className={classNames('h-100', styles.content)}>
{state === 'loading' && loadingContent}
{state === 'populated' && populatedContent}
{state === 'empty' && emptyContent}
</div>
</div>
)

View File

@ -1,87 +0,0 @@
import { gql } from '@sourcegraph/http-client'
// We define all fragments for the different homepage panels in this file to
// avoid creating circular imports in Jest, see:
//
// - https://blackdeerdev.com/graphqlerror-syntax-error-unexpected-name-undefined/
// - https://spectrum.chat/apollo/general/fragments-not-working-cross-files-in-mutation~c4e90568-f89a-458f-9810-0730846fc5f0
export const collaboratorsFragment = gql`
fragment CollaboratorsFragment on User {
collaborators: invitableCollaborators @include(if: $enableCollaborators) {
name
email
displayName
avatarURL
}
}
`
export const recentFilesFragment = gql`
fragment RecentFilesFragment on User {
recentFilesLogs: eventLogs(first: $firstRecentFiles, eventName: "ViewBlob") {
nodes {
argument
timestamp
url
}
pageInfo {
hasNextPage
}
totalCount
}
}
`
export const recentSearchesPanelFragment = gql`
fragment RecentSearchesPanelFragment on User {
recentSearchesLogs: eventLogs(first: $firstRecentSearches, eventName: "SearchResultsQueried") {
nodes {
argument
timestamp
url
}
pageInfo {
hasNextPage
}
totalCount
}
}
`
export const recentlySearchedRepositoriesFragment = gql`
fragment RecentlySearchedRepositoriesFragment on User {
recentlySearchedRepositoriesLogs: eventLogs(
first: $firstRecentlySearchedRepositories
eventName: "SearchResultsQueried"
) {
nodes {
argument
timestamp
url
}
pageInfo {
hasNextPage
}
totalCount
}
}
`
export const savedSearchesPanelFragment = gql`
fragment SavedSearchesPanelFragment on Query {
savedSearches @include(if: $enableSavedSearches) {
id
description
notify
notifySlack
query
namespace {
__typename
id
namespaceName
}
slackWebhookURL
}
}
`

View File

@ -1,62 +0,0 @@
import { Story, DecoratorFn, Meta } from '@storybook/react'
import { noop } from 'lodash'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { H2 } from '@sourcegraph/wildcard'
import { WebStory } from '../../components/WebStory'
import { RecentFilesPanel } from './RecentFilesPanel'
import { recentFilesPayload } from './utils'
const decorator: DecoratorFn = story => <div style={{ width: '800px' }}>{story()}</div>
const config: Meta = {
title: 'web/search/panels/RecentFilesPanel',
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/sPRyyv3nt5h0284nqEuAXE/12192-Sourcegraph-server-page-v1?node-id=255%3A3',
},
chromatic: { viewports: [800], disableSnapshot: false },
},
decorators: [decorator],
}
export default config
const emptyRecentFiles = {
totalCount: 0,
nodes: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
}
const props = {
authenticatedUser: null,
recentFilesFragment: { recentFilesLogs: recentFilesPayload() },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
export const RecentFilesPanelStory: Story = () => (
<WebStory>
{() => (
<div style={{ maxWidth: '32rem' }}>
<H2>Populated</H2>
<RecentFilesPanel {...props} />
<H2>Loading</H2>
<RecentFilesPanel {...props} recentFilesFragment={null} />
<H2>Empty</H2>
<RecentFilesPanel {...props} recentFilesFragment={{ recentFilesLogs: emptyRecentFiles }} />
</div>
)}
</WebStory>
)
RecentFilesPanelStory.storyName = 'RecentFilesPanel'

View File

@ -1,157 +0,0 @@
import { screen } from '@testing-library/react'
import { noop } from 'rxjs'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
import { RecentFilesPanel } from './RecentFilesPanel'
describe('RecentFilesPanel', () => {
test('duplicate files are only shown once', () => {
const recentFiles = {
nodes: [
{
argument: '{"filePath": "go.mod", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T22:55:30Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/go.mod',
},
{
argument: '{"filePath": ".eslintrc.js", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T22:55:18Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/.eslintrc.js',
},
{
argument: '{"filePath": "go.mod", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T22:55:06Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/go.mod',
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
totalCount: 3,
}
const props = {
authenticatedUser: null,
recentFilesFragment: { recentFilesLogs: recentFiles },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
renderWithBrandedContext(<RecentFilesPanel {...props} />)
const listItems = screen.getAllByTestId('recent-files-item')
expect(listItems).toHaveLength(2)
expect(listItems[0]).toHaveTextContent('ghe.sgdev.org/sourcegraph/gorilla-mux go.mod')
expect(listItems[1]).toHaveTextContent('github.com/sourcegraph/sourcegraph .eslintrc.js')
})
test('files with missing data can extract it from the URL if available', () => {
const recentFiles = {
nodes: [
{
argument: '{"filePath": ".eslintrc.js", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T22:55:18Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/.eslintrc.js',
},
{
argument: '{}',
timestamp: '2020-09-10T22:55:06Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/go.mod',
},
{
argument: '{}',
timestamp: '2020-09-10T22:55:06Z',
url: 'https://sourcegraph.test:3443/bitbucket.sgdev.org/SOURCEGRAPH/jsonrpc2',
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
totalCount: 2,
}
const props = {
authenticatedUser: null,
recentFilesFragment: { recentFilesLogs: recentFiles },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
renderWithBrandedContext(<RecentFilesPanel {...props} />)
const listItems = screen.getAllByTestId('recent-files-item')
expect(listItems).toHaveLength(2)
expect(listItems[0]).toHaveTextContent('github.com/sourcegraph/sourcegraph .eslintrc.js')
expect(listItems[1]).toHaveTextContent('ghe.sgdev.org/sourcegraph/gorilla-mux go.mod')
})
test('Show More button shown when more items can be loaded', () => {
const recentFiles = {
nodes: [
{
argument: '{"filePath": ".eslintrc.js", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T22:55:18Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/.eslintrc.js',
},
{
argument: '{}',
timestamp: '2020-09-10T22:55:06Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/go.mod',
},
],
pageInfo: {
endCursor: null,
hasNextPage: true,
},
totalCount: 2,
}
const props = {
authenticatedUser: null,
recentFilesFragment: { recentFilesLogs: recentFiles },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
renderWithBrandedContext(<RecentFilesPanel {...props} />)
expect(screen.getByTestId('recent-files-panel-show-more')).toBeInTheDocument()
})
test('Show More button not shown when more items cannot be loaded', () => {
const recentFiles = {
nodes: [
{
argument: '{"filePath": ".eslintrc.js", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T22:55:18Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/.eslintrc.js',
},
{
argument: '{}',
timestamp: '2020-09-10T22:55:06Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/go.mod',
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
totalCount: 2,
}
const props = {
authenticatedUser: null,
recentFilesFragment: { recentFilesLogs: recentFiles },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
renderWithBrandedContext(<RecentFilesPanel {...props} />)
expect(screen.queryByTestId('recent-files-panel-show-more')).not.toBeInTheDocument()
})
})

View File

@ -1,248 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react'
import { mdiFileCode } from '@mdi/js'
import { VisuallyHidden } from '@reach/visually-hidden'
import classNames from 'classnames'
import { gql } from '@sourcegraph/http-client'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Link, Icon, useFocusOnLoadedMore } from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../../auth'
import { RecentFilesFragment } from '../../graphql-operations'
import { EventLogResult } from '../backend'
import { EmptyPanelContainer } from './EmptyPanelContainer'
import { HomePanelsFetchMore, RECENT_FILES_TO_LOAD } from './HomePanels'
import { LoadingPanelView } from './LoadingPanelView'
import { PanelContainer } from './PanelContainer'
import { ShowMoreButton } from './ShowMoreButton'
import { useComputeResults } from './useComputeResults'
import styles from './RecentSearchesPanel.module.scss'
interface Props extends TelemetryProps {
className?: string
authenticatedUser: AuthenticatedUser | null
recentFilesFragment: RecentFilesFragment | null
fetchMore: HomePanelsFetchMore
}
export const recentFilesFragment = gql`
fragment RecentFilesFragment on User {
recentFilesLogs: eventLogs(first: $firstRecentFiles, eventName: "ViewBlob") {
nodes {
argument
timestamp
url
}
pageInfo {
hasNextPage
}
totalCount
}
}
`
export const RecentFilesPanel: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
className,
recentFilesFragment,
telemetryService,
fetchMore,
authenticatedUser,
}) => {
const [recentFiles, setRecentFiles] = useState<null | RecentFilesFragment['recentFilesLogs']>(
recentFilesFragment?.recentFilesLogs ?? null
)
useEffect(
() => setRecentFiles(recentFilesFragment?.recentFilesLogs ?? null),
[recentFilesFragment?.recentFilesLogs]
)
const [itemsToLoad, setItemsToLoad] = useState(RECENT_FILES_TO_LOAD)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [processedResults, setProcessedResults] = useState<RecentFile[] | null>(null)
const getItemRef = useFocusOnLoadedMore(processedResults?.length ?? 0)
// Only update processed results when results are valid to prevent
// flashing loading screen when "Show more" button is clicked
useEffect(() => {
if (recentFiles) {
setProcessedResults(processRecentFiles(recentFiles))
}
}, [recentFiles])
useEffect(() => {
// Only log the first load (when items to load is equal to the page size)
if (processedResults && itemsToLoad === RECENT_FILES_TO_LOAD) {
telemetryService.log(
'RecentFilesPanelLoaded',
{ empty: processedResults.length === 0 },
{ empty: processedResults.length === 0 }
)
}
}, [processedResults, telemetryService, itemsToLoad])
const logFileClicked = useCallback(() => telemetryService.log('RecentFilesPanelFileClicked'), [telemetryService])
const loadingDisplay = <LoadingPanelView text="Loading recent files" />
const emptyDisplay = (
<EmptyPanelContainer className="align-items-center text-muted">
<Icon className="mb-2" svgPath={mdiFileCode} inline={false} aria-hidden={true} height="2rem" width="2rem" />
<small className="mb-2">This panel will display your most recently viewed files.</small>
</EmptyPanelContainer>
)
async function loadMoreItems(): Promise<void> {
telemetryService.log('RecentFilesPanelShowMoreClicked')
const newItemsToLoad = itemsToLoad + RECENT_FILES_TO_LOAD
setItemsToLoad(newItemsToLoad)
setIsLoadingMore(true)
const { data } = await fetchMore({
firstRecentFiles: newItemsToLoad,
})
setIsLoadingMore(false)
if (data === undefined) {
return
}
const node = data.node
if (node === null || node.__typename !== 'User') {
return
}
setRecentFiles(node.recentFilesLogs)
}
const { isLoading: computeLoading, results: computeResults } = useComputeResults(authenticatedUser, '$repo $path')
const renderComputeResults = computeResults.size > 0
const contentDisplay = (
<>
<table className={classNames('mt-2', styles.resultsTable)}>
<thead>
<tr className={styles.resultsTableRow}>
<th>
<small>File</small>
</th>
</tr>
</thead>
<tbody>
{renderComputeResults
? [...computeResults].map((file, index) => (
<tr key={index} className={classNames('text-monospace d-block', styles.resultsTableRow)}>
<td>
<small>
<Link
to={`/${file.split(' ')[0]}/-/blob/${file.split(' ')[1].trim()}`}
onClick={logFileClicked}
data-testid="recent-files-item"
>
{file}
</Link>
</small>
</td>
</tr>
))
: processedResults?.map((recentFile, index) => (
<tr key={index} className={styles.resultsTableRow}>
<td>
<small>
<Link
to={recentFile.url}
ref={getItemRef(index)}
onClick={logFileClicked}
data-testid="recent-files-item"
>
{recentFile.repoName} {recentFile.filePath}
</Link>
</small>
</td>
</tr>
))}
</tbody>
</table>
{!renderComputeResults && (
<>
{isLoadingMore && <VisuallyHidden aria-live="polite">Loading more recent files</VisuallyHidden>}
{recentFiles?.pageInfo.hasNextPage && (
<div>
<ShowMoreButton onClick={loadMoreItems} dataTestid="recent-files-panel-show-more" />
</div>
)}
</>
)}
</>
)
// Wait for both the search event logs and the git history to be loaded
const isLoading = computeLoading || !processedResults
// If neither search event logs or git history have items, then display the empty display
const isEmpty = processedResults?.length === 0 && computeResults.size === 0
return (
<PanelContainer
className={classNames(className, 'recent-files-panel')}
title="Recent files"
state={isLoading ? 'loading' : isEmpty ? 'empty' : 'populated'}
loadingContent={loadingDisplay}
populatedContent={contentDisplay}
emptyContent={emptyDisplay}
/>
)
}
interface RecentFile {
repoName: string
filePath: string
timestamp: string
url: string
}
function processRecentFiles(eventLogResult?: EventLogResult): RecentFile[] | null {
if (!eventLogResult) {
return null
}
const recentFiles: RecentFile[] = []
for (const node of eventLogResult.nodes) {
if (node.argument && node.url) {
const parsedArguments = JSON.parse(node.argument)
let repoName = parsedArguments?.repoName as string
let filePath = parsedArguments?.filePath as string
if (!repoName || !filePath) {
;({ repoName, filePath } = extractFileInfoFromUrl(node.url))
}
if (
filePath &&
repoName &&
!recentFiles.some(file => file.repoName === repoName && file.filePath === filePath) // Don't show the same file twice
) {
const parsedUrl = new URL(node.url)
recentFiles.push({
url: parsedUrl.pathname + parsedUrl.search, // Strip domain from URL so clicking on it doesn't reload page
repoName,
filePath,
timestamp: node.timestamp,
})
}
}
}
return recentFiles
}
function extractFileInfoFromUrl(url: string): { repoName: string; filePath: string } {
const parsedUrl = new URL(url)
// Remove first character as it's a '/'
const [repoName, filePath] = parsedUrl.pathname.slice(1).split('/-/blob/')
if (!repoName || !filePath) {
return { repoName: '', filePath: '' }
}
return { repoName, filePath }
}

View File

@ -1,27 +0,0 @@
.results-table {
width: 100%;
&-row {
border-bottom: solid 4px transparent;
}
&-date-col {
width: 7rem;
}
}
.examples-list {
list-style: none;
margin: 0;
padding: 0;
align-self: center;
text-align: left;
&-item {
margin-bottom: 0.25rem;
}
}
.recent-query {
word-break: break-word;
}

View File

@ -1,64 +0,0 @@
import { Meta, DecoratorFn, Story } from '@storybook/react'
import { parseISO } from 'date-fns'
import { noop } from 'lodash'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { H2 } from '@sourcegraph/wildcard'
import { WebStory } from '../../components/WebStory'
import { RecentSearchesPanel } from './RecentSearchesPanel'
import { recentSearchesPayload } from './utils'
const decorator: DecoratorFn = story => <div style={{ width: '800px' }}>{story()}</div>
const config: Meta = {
title: 'web/search/panels/RecentSearchesPanel',
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/sPRyyv3nt5h0284nqEuAXE/12192-Sourcegraph-server-page-v1?node-id=255%3A3',
},
chromatic: { viewports: [800], disableSnapshot: false },
},
decorators: [decorator],
}
export default config
const emptyRecentSearches = {
totalCount: 0,
nodes: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
}
const props = {
authenticatedUser: null,
recentSearches: { recentSearchesLogs: recentSearchesPayload() },
now: () => parseISO('2020-09-16T23:15:01Z'),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
export const RecentSearchesPanelStory: Story = () => (
<WebStory>
{() => (
<div style={{ maxWidth: '32rem' }}>
<H2>Populated</H2>
<RecentSearchesPanel {...props} />
<H2>Loading</H2>
<RecentSearchesPanel {...props} recentSearches={null} />
<H2>Empty</H2>
<RecentSearchesPanel {...props} recentSearches={{ recentSearchesLogs: emptyRecentSearches }} />
</div>
)}
</WebStory>
)
RecentSearchesPanelStory.storyName = 'RecentSearchesPanel'

View File

@ -1,225 +0,0 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { noop } from 'rxjs'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo/mockedTestProvider'
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
import { RecentSearchesPanel } from './RecentSearchesPanel'
describe('RecentSearchesPanel', () => {
test('consecutive identical searches are correctly merged when rendered', () => {
const recentSearches = {
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
totalCount: 3,
}
const props = {
authenticatedUser: null,
telemetryService: NOOP_TELEMETRY_SERVICE,
recentSearches: { recentSearchesLogs: recentSearches },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
}
expect(
renderWithBrandedContext(
<MockedTestProvider>
<RecentSearchesPanel {...props} />
</MockedTestProvider>
).asFragment()
).toMatchSnapshot()
})
test('searches with no argument are skipped', () => {
const recentSearches = {
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=test&patternType=literal',
},
{
argument: '{}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
totalCount: 3,
}
const props = {
authenticatedUser: null,
recentSearches: { recentSearchesLogs: recentSearches },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
expect(renderWithBrandedContext(<RecentSearchesPanel {...props} />).asFragment()).toMatchSnapshot()
})
test('Show More button is shown if more pages are available', () => {
const recentSearches = {
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
],
pageInfo: {
endCursor: null,
hasNextPage: true,
},
totalCount: 6,
}
const props = {
authenticatedUser: null,
recentSearches: { recentSearchesLogs: recentSearches },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
expect(renderWithBrandedContext(<RecentSearchesPanel {...props} />).asFragment()).toMatchSnapshot()
})
test('Show More button loads more items', () => {
const recentSearches1 = {
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
],
pageInfo: {
endCursor: null,
hasNextPage: true,
},
totalCount: 6,
}
const recentSearches2 = {
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
totalCount: 6,
}
const props = {
className: '',
authenticatedUser: null,
recentSearches: { recentSearchesLogs: recentSearches1 },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: (() => ({ recentSearchesLogs: recentSearches2 })) as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
const { asFragment } = renderWithBrandedContext(<RecentSearchesPanel {...props} />)
userEvent.click(screen.getByRole('button', { name: /Show more/ }))
expect(asFragment()).toMatchSnapshot()
})
})

View File

@ -1,252 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { gql } from '@apollo/client'
import { VisuallyHidden } from '@reach/visually-hidden'
import classNames from 'classnames'
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
import { SyntaxHighlightedSearchQuery } from '@sourcegraph/search-ui'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { buildSearchURLQuery } from '@sourcegraph/shared/src/util/url'
import { Link, useFocusOnLoadedMore } from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../../auth'
import { RecentSearchesPanelFragment, SearchPatternType } from '../../graphql-operations'
import { EventLogResult } from '../backend'
import { EmptyPanelContainer } from './EmptyPanelContainer'
import { HomePanelsFetchMore, RECENT_SEARCHES_TO_LOAD } from './HomePanels'
import { LoadingPanelView } from './LoadingPanelView'
import { PanelContainer } from './PanelContainer'
import { ShowMoreButton } from './ShowMoreButton'
import styles from './RecentSearchesPanel.module.scss'
interface RecentSearch {
count: number
searchText: string
timestamp: string
url: string
}
interface Props extends TelemetryProps {
className?: string
authenticatedUser: AuthenticatedUser | null
recentSearches: RecentSearchesPanelFragment | null
/** Function that returns current time (for stability in visual tests). */
now?: () => Date
fetchMore: HomePanelsFetchMore
}
export const recentSearchesPanelFragment = gql`
fragment RecentSearchesPanelFragment on User {
recentSearchesLogs: eventLogs(first: $firstRecentSearches, eventName: "SearchResultsQueried") {
nodes {
argument
timestamp
url
}
pageInfo {
hasNextPage
}
totalCount
}
}
`
export const RecentSearchesPanel: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
className,
now,
telemetryService,
recentSearches,
fetchMore,
}) => {
const [searchEventLogs, setSearchEventLogs] = useState<null | RecentSearchesPanelFragment['recentSearchesLogs']>(
recentSearches?.recentSearchesLogs ?? null
)
useEffect(
() => setSearchEventLogs(recentSearches?.recentSearchesLogs ?? null),
[recentSearches?.recentSearchesLogs]
)
const [itemsToLoad, setItemsToLoad] = useState(RECENT_SEARCHES_TO_LOAD)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const processedResults = useMemo(
() => (searchEventLogs === null ? null : processRecentSearches(searchEventLogs)),
[searchEventLogs]
)
const getItemRef = useFocusOnLoadedMore(processedResults?.length ?? 0)
useEffect(() => {
// Only log the first load (when items to load is equal to the page size)
if (processedResults && itemsToLoad === RECENT_SEARCHES_TO_LOAD) {
telemetryService.log(
'RecentSearchesPanelLoaded',
{ empty: processedResults.length === 0 },
{ empty: processedResults.length === 0 }
)
}
}, [processedResults, telemetryService, itemsToLoad])
const logSearchClicked = useCallback(
() => telemetryService.log('RecentSearchesPanelSearchClicked'),
[telemetryService]
)
const loadingDisplay = <LoadingPanelView text="Loading recent searches" />
const emptyDisplay = (
<EmptyPanelContainer className="text-muted">
<small className="mb-2">
Your recent searches will be displayed here. Here are a few searches to get you started:
</small>
<ul className={styles.examplesList}>
<li className={styles.examplesListItem}>
<small>
<Link
to={
'/search?' +
buildSearchURLQuery(
'lang:c if(:[eval_match]) { :[statement_match] }',
SearchPatternType.structural,
false
)
}
className="text-monospace"
>
<SyntaxHighlightedSearchQuery query="lang:c if(:[eval_match]) { :[statement_match] }" />
</Link>
</small>
</li>
<li className={styles.examplesListItem}>
<small>
<Link
to={
'/search?' +
buildSearchURLQuery(
'lang:java type:diff after:"1 week ago"',
SearchPatternType.standard,
false
)
}
className="text-monospace"
>
<SyntaxHighlightedSearchQuery query='lang:java type:diff after:"1 week ago"' />
</Link>
</small>
</li>
<li className={styles.examplesListItem}>
<small>
<Link
to={'/search?' + buildSearchURLQuery('lang:java', SearchPatternType.standard, false)}
className="text-monospace"
>
<SyntaxHighlightedSearchQuery query="lang:java" />
</Link>
</small>
</li>
</ul>
</EmptyPanelContainer>
)
async function loadMoreItems(): Promise<void> {
telemetryService.log('RecentSearchesPanelShowMoreClicked')
const newItemsToLoad = itemsToLoad + RECENT_SEARCHES_TO_LOAD
setItemsToLoad(newItemsToLoad)
setIsLoadingMore(true)
const { data } = await fetchMore({
firstRecentSearches: newItemsToLoad,
})
setIsLoadingMore(false)
if (data === undefined) {
return
}
const node = data.node
if (node === null || node.__typename !== 'User') {
return
}
setSearchEventLogs(node.recentSearchesLogs)
}
const contentDisplay = (
<>
<table className={classNames('mt-2', styles.resultsTable)}>
<thead>
<tr className={styles.resultsTableRow}>
<th>
<small>Search</small>
</th>
<th>
<small>Date</small>
</th>
</tr>
</thead>
<tbody>
{processedResults?.map((recentSearch, index) => (
<tr key={index} className={styles.resultsTableRow}>
<td>
<small className={styles.recentQuery}>
<Link to={recentSearch.url} onClick={logSearchClicked} ref={getItemRef(index)}>
<SyntaxHighlightedSearchQuery query={recentSearch.searchText} />
</Link>
</small>
</td>
<td className={styles.resultsTableDateCol}>
<Timestamp noAbout={true} date={recentSearch.timestamp} now={now} strict={true} />
</td>
</tr>
))}
</tbody>
</table>
{isLoadingMore && <VisuallyHidden aria-live="polite">Loading more recent searches</VisuallyHidden>}
{searchEventLogs?.pageInfo.hasNextPage && (
<ShowMoreButton className="test-repositories-panel-show-more" onClick={loadMoreItems} />
)}
</>
)
return (
<PanelContainer
className={classNames(className, 'recent-searches-panel')}
title="Recent searches"
state={processedResults ? (processedResults.length > 0 ? 'populated' : 'empty') : 'loading'}
loadingContent={loadingDisplay}
populatedContent={contentDisplay}
emptyContent={emptyDisplay}
/>
)
}
function processRecentSearches(eventLogResult?: EventLogResult): RecentSearch[] | null {
if (!eventLogResult) {
return null
}
const recentSearches: RecentSearch[] = []
for (const node of eventLogResult.nodes) {
if (node.argument && node.url) {
const parsedArguments = JSON.parse(node.argument)
const searchText: string | undefined = parsedArguments?.code_search?.query_data?.combined
if (searchText) {
if (recentSearches.length > 0 && recentSearches[recentSearches.length - 1].searchText === searchText) {
recentSearches[recentSearches.length - 1].count += 1
} else {
const parsedUrl = new URL(node.url)
recentSearches.push({
count: 1,
url: parsedUrl.pathname + parsedUrl.search, // Strip domain from URL so clicking on it doesn't reload page
searchText,
timestamp: node.timestamp,
})
}
}
}
}
return recentSearches
}

View File

@ -1,65 +0,0 @@
import { Meta, DecoratorFn, Story } from '@storybook/react'
import { noop } from 'lodash'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { H2 } from '@sourcegraph/wildcard'
import { WebStory } from '../../components/WebStory'
import { RepositoriesPanel } from './RepositoriesPanel'
import { recentSearchesPayload } from './utils'
const decorator: DecoratorFn = story => <div style={{ width: '800px' }}>{story()}</div>
const config: Meta = {
title: 'web/search/panels/RepositoriesPanel',
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/sPRyyv3nt5h0284nqEuAXE/12192-Sourcegraph-server-page-v1?node-id=255%3A3',
},
chromatic: { viewports: [800], disableSnapshot: false },
},
decorators: [decorator],
}
export default config
const emptyRecentSearches = {
totalCount: 0,
nodes: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
}
const props = {
authenticatedUser: null,
recentlySearchedRepositories: { recentlySearchedRepositoriesLogs: recentSearchesPayload() },
telemetryService: NOOP_TELEMETRY_SERVICE,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
}
export const RepositoriesPanelStory: Story = () => (
<WebStory>
{() => (
<div style={{ maxWidth: '32rem' }}>
<H2>Populated</H2>
<RepositoriesPanel {...props} />
<H2>Loading</H2>
<RepositoriesPanel {...props} recentlySearchedRepositories={null} />
<H2>Empty</H2>
<RepositoriesPanel
{...props}
recentlySearchedRepositories={{ recentlySearchedRepositoriesLogs: emptyRecentSearches }}
/>
</div>
)}
</WebStory>
)
RepositoriesPanelStory.storyName = 'RepositoriesPanel'

View File

@ -1,213 +0,0 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { noop } from 'rxjs'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
import { RepositoriesPanel } from './RepositoriesPanel'
describe('RepositoriesPanel', () => {
test('Both r: and repo: filters are tracked', () => {
const recentSearches = {
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "repo:test"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=repo:test&patternType=literal',
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
totalCount: 3,
}
const props = {
authenticatedUser: null,
telemetryService: NOOP_TELEMETRY_SERVICE,
recentlySearchedRepositories: { recentlySearchedRepositoriesLogs: recentSearches },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
}
expect(renderWithBrandedContext(<RepositoriesPanel {...props} />).asFragment()).toMatchSnapshot()
})
test('consecutive searches with identical repo filters are correctly merged when rendered', () => {
const recentSearches = {
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=r:test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph+test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
totalCount: 3,
}
const props = {
authenticatedUser: null,
recentlySearchedRepositories: { recentlySearchedRepositoriesLogs: recentSearches },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
expect(renderWithBrandedContext(<RepositoriesPanel {...props} />).asFragment()).toMatchSnapshot()
})
test('Show More button is shown if more pages are available', () => {
const recentSearches = {
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=r:test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:test-two&patternType=literal',
},
],
pageInfo: {
endCursor: null,
hasNextPage: true,
},
totalCount: 6,
}
const props = {
authenticatedUser: null,
recentlySearchedRepositories: { recentlySearchedRepositoriesLogs: recentSearches },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: noop as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
expect(renderWithBrandedContext(<RepositoriesPanel {...props} />).asFragment()).toMatchSnapshot()
})
test('Show More button loads more items', () => {
const recentSearches1 = {
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=r:test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:test-two&patternType=literal',
},
],
pageInfo: {
endCursor: null,
hasNextPage: true,
},
totalCount: 6,
}
const recentSearches2 = {
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=r:test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:test-two&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=r:test-three&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:r:test-four&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:test-five&patternType=literal',
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
totalCount: 6,
}
const props = {
className: '',
authenticatedUser: null,
recentlySearchedRepositories: { recentlySearchedRepositoriesLogs: recentSearches1 },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any
fetchMore: (() => ({ recentSearchesLogs: recentSearches2 })) as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
}
const { asFragment } = renderWithBrandedContext(<RepositoriesPanel {...props} />)
userEvent.click(screen.getByRole('button', { name: /Show more/ }))
expect(asFragment()).toMatchSnapshot()
})
})

View File

@ -1,239 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react'
import { gql } from '@apollo/client'
import VisuallyHidden from '@reach/visually-hidden'
import classNames from 'classnames'
import { SyntaxHighlightedSearchQuery } from '@sourcegraph/search-ui'
import { scanSearchQuery } from '@sourcegraph/shared/src/search/query/scanner'
import { isRepoFilter } from '@sourcegraph/shared/src/search/query/validate'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Link, Text, useFocusOnLoadedMore } from '@sourcegraph/wildcard'
import { parseSearchURLQuery } from '..'
import { AuthenticatedUser } from '../../auth'
import { RecentlySearchedRepositoriesFragment } from '../../graphql-operations'
import { EventLogResult } from '../backend'
import { EmptyPanelContainer } from './EmptyPanelContainer'
import { HomePanelsFetchMore, RECENTLY_SEARCHED_REPOSITORIES_TO_LOAD } from './HomePanels'
import { LoadingPanelView } from './LoadingPanelView'
import { PanelContainer } from './PanelContainer'
import { ShowMoreButton } from './ShowMoreButton'
import { useComputeResults } from './useComputeResults'
import styles from './RecentSearchesPanel.module.scss'
interface Props extends TelemetryProps {
className?: string
authenticatedUser: AuthenticatedUser | null
recentlySearchedRepositories: RecentlySearchedRepositoriesFragment | null
fetchMore: HomePanelsFetchMore
}
export const recentlySearchedRepositoriesFragment = gql`
fragment RecentlySearchedRepositoriesFragment on User {
recentlySearchedRepositoriesLogs: eventLogs(
first: $firstRecentlySearchedRepositories
eventName: "SearchResultsQueried"
) {
nodes {
argument
timestamp
url
}
pageInfo {
hasNextPage
}
totalCount
}
}
`
export const RepositoriesPanel: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
className,
telemetryService,
recentlySearchedRepositories,
fetchMore,
authenticatedUser,
}) => {
const [recentlySearchedRepos, setRecentlySearchedRepos] = useState<
null | RecentlySearchedRepositoriesFragment['recentlySearchedRepositoriesLogs']
>(recentlySearchedRepositories?.recentlySearchedRepositoriesLogs ?? null)
useEffect(
() => setRecentlySearchedRepos(recentlySearchedRepositories?.recentlySearchedRepositoriesLogs ?? null),
[recentlySearchedRepositories?.recentlySearchedRepositoriesLogs]
)
const [itemsToLoad, setItemsToLoad] = useState(RECENTLY_SEARCHED_REPOSITORIES_TO_LOAD)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [repoFilterValues, setRepoFilterValues] = useState<string[] | null>(null)
const getItemRef = useFocusOnLoadedMore(repoFilterValues?.length ?? 0)
useEffect(() => {
if (recentlySearchedRepos) {
setRepoFilterValues(processRepositories(recentlySearchedRepos))
}
}, [recentlySearchedRepos])
useEffect(() => {
// Only log the first load (when items to load is equal to the page size)
if (repoFilterValues && itemsToLoad === RECENTLY_SEARCHED_REPOSITORIES_TO_LOAD) {
telemetryService.log(
'RepositoriesPanelLoaded',
{ empty: repoFilterValues.length === 0 },
{ empty: repoFilterValues.length === 0 }
)
}
}, [repoFilterValues, telemetryService, itemsToLoad])
const logRepoClicked = useCallback(
() => telemetryService.log('RepositoriesPanelRepoFilterClicked'),
[telemetryService]
)
const loadingDisplay = <LoadingPanelView text="Loading recently searched repositories" />
const emptyDisplay = (
<EmptyPanelContainer className="text-muted">
<small className="mb-2">
<Text className="mb-1">Recently searched repositories will be displayed here.</Text>
<Text className="mb-1">
Search in repositories with the <strong>repo:</strong> filter:
</Text>
<Text className="mb-1">
<SyntaxHighlightedSearchQuery query="repo:sourcegraph/sourcegraph" />
</Text>
<Text className="mb-1">Add the code host to scope to a single repository:</Text>
<Text className="mb-1">
<SyntaxHighlightedSearchQuery query="repo:^git\.local/my/repo$" />
</Text>
</small>
</EmptyPanelContainer>
)
async function loadMoreItems(): Promise<void> {
telemetryService.log('RepositoriesPanelShowMoreClicked')
const newItemsToLoad = itemsToLoad + RECENTLY_SEARCHED_REPOSITORIES_TO_LOAD
setItemsToLoad(newItemsToLoad)
setIsLoadingMore(true)
const { data } = await fetchMore({
firstRecentlySearchedRepositories: newItemsToLoad,
})
setIsLoadingMore(false)
if (data === undefined) {
return
}
const node = data.node
if (node === null || node.__typename !== 'User') {
return
}
setRecentlySearchedRepos(node.recentlySearchedRepositoriesLogs)
}
const { isLoading: computeLoading, results: computeResults } = useComputeResults(authenticatedUser, '$repo')
const renderComputeResults = computeResults.size > 0
const contentDisplay = (
<>
<table className={classNames('mt-2', styles.resultsTable)}>
<thead>
<tr className={styles.resultsTableRow}>
<th>
<small>Search</small>
</th>
</tr>
</thead>
<tbody>
{renderComputeResults
? [...computeResults].map((repoFilterValue, index) => (
<tr
key={index}
className={classNames('text-monospace text-break', styles.resultsTableRow)}
>
<td>
<small>
<Link
to={`/search?q=repo:${repoFilterValue}`}
ref={getItemRef(index)}
onClick={logRepoClicked}
>
<SyntaxHighlightedSearchQuery query={`repo:${repoFilterValue}`} />
</Link>
</small>
</td>
</tr>
))
: repoFilterValues?.map((repoFilterValue, index) => (
<tr
key="index"
className={classNames('text-monospace text-break', styles.resultsTableRow)}
>
<td>
<small>
<Link
to={`/search?q=repo:${repoFilterValue}`}
ref={getItemRef(index)}
onClick={logRepoClicked}
>
<SyntaxHighlightedSearchQuery query={`repo:${repoFilterValue}`} />
</Link>
</small>
</td>
</tr>
))}
</tbody>
</table>
{!renderComputeResults && (
<>
{isLoadingMore && <VisuallyHidden aria-live="polite">Loading more repositories</VisuallyHidden>}
{recentlySearchedRepos?.pageInfo.hasNextPage && (
<ShowMoreButton className="test-repositories-panel-show-more" onClick={loadMoreItems} />
)}
</>
)}
</>
)
// Wait for both the search event logs and the git history to be loaded
const isLoading = computeLoading || !repoFilterValues
// If neither search event logs or git history have items, then display the empty display
const isEmpty = repoFilterValues?.length === 0 && computeResults.size === 0
return (
<PanelContainer
className={classNames(className, 'repositories-panel')}
title="Repositories"
state={isLoading ? 'loading' : isEmpty ? 'empty' : 'populated'}
loadingContent={loadingDisplay}
populatedContent={contentDisplay}
emptyContent={emptyDisplay}
/>
)
}
function processRepositories(eventLogResult: EventLogResult): string[] | null {
if (!eventLogResult) {
return null
}
const recentlySearchedRepos: string[] = []
for (const node of eventLogResult.nodes) {
if (node.url) {
const url = new URL(node.url)
const queryFromURL = parseSearchURLQuery(url.search)
const scannedQuery = scanSearchQuery(queryFromURL || '')
if (scannedQuery.type === 'success') {
for (const token of scannedQuery.term) {
if (isRepoFilter(token) && token.value && !recentlySearchedRepos.includes(token.value.value)) {
recentlySearchedRepos.push(token.value.value)
}
}
}
}
}
return recentlySearchedRepos
}

View File

@ -1,49 +0,0 @@
import { Meta, Story } from '@storybook/react'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { H2 } from '@sourcegraph/wildcard'
import { WebStory } from '../../components/WebStory'
import { SearchPatternType } from '../../graphql-operations'
import { SavedSearchesPanel } from './SavedSearchesPanel'
import { savedSearchesPayload, authUser } from './utils'
const config: Meta = {
title: 'web/search/panels/SavedSearchesPanel',
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/sPRyyv3nt5h0284nqEuAXE/12192-Sourcegraph-server-page-v1?node-id=255%3A3',
},
chromatic: { disableSnapshot: false },
},
}
export default config
const props = {
authenticatedUser: authUser,
patternType: SearchPatternType.standard,
savedSearchesFragment: { savedSearches: savedSearchesPayload() },
telemetryService: NOOP_TELEMETRY_SERVICE,
}
export const SavedSearchesPanelStory: Story = () => (
<WebStory>
{() => (
<div style={{ maxWidth: '32rem' }}>
<H2>Populated</H2>
<SavedSearchesPanel {...props} />
<H2>Loading</H2>
<SavedSearchesPanel {...props} savedSearchesFragment={null} />
<H2>Empty</H2>
<SavedSearchesPanel {...props} savedSearchesFragment={{ savedSearches: [] }} />
</div>
)}
</WebStory>
)
SavedSearchesPanelStory.storyName = 'SavedSearchesPanel'

View File

@ -1,36 +0,0 @@
import { cleanup, fireEvent } from '@testing-library/react'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
import { SearchPatternType } from '../../graphql-operations'
import { SavedSearchesPanel } from './SavedSearchesPanel'
import { savedSearchesPayload, authUser } from './utils'
describe('SavedSearchesPanel', () => {
afterAll(cleanup)
let container: HTMLElement
const defaultProps = {
patternType: SearchPatternType.standard,
authenticatedUser: authUser,
savedSearchesFragment: { savedSearches: savedSearchesPayload() },
telemetryService: NOOP_TELEMETRY_SERVICE,
}
it('should show correct mode and number of entries when clicking on "my searches" and "all searches" buttons', () => {
container = renderWithBrandedContext(<SavedSearchesPanel {...defaultProps} />).container
let savedSearchEntries = container.querySelectorAll('.test-saved-search-entry')
expect(savedSearchEntries.length).toBe(2)
const mySearchesButton = container.querySelector('.test-saved-search-panel-my-searches')!
fireEvent.click(mySearchesButton)
savedSearchEntries = container.querySelectorAll('.test-saved-search-entry')
expect(savedSearchEntries.length).toBe(1)
const allSearchesButton = container.querySelector('.test-saved-search-panel-all-searches')!
fireEvent.click(allSearchesButton)
savedSearchEntries = container.querySelectorAll('.test-saved-search-entry')
expect(savedSearchEntries.length).toBe(2)
})
})

View File

@ -1,231 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react'
import { gql } from '@apollo/client'
import { mdiPlus, mdiPencilOutline } from '@mdi/js'
import classNames from 'classnames'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import {
Button,
ButtonGroup,
ButtonLink,
Link,
Menu,
MenuButton,
MenuList,
MenuItem,
Icon,
} from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../../auth'
import { SavedSearchesPanelFragment } from '../../graphql-operations'
import { buildSearchURLQueryFromQueryState } from '../../stores'
import { EmptyPanelContainer } from './EmptyPanelContainer'
import { FooterPanel } from './FooterPanel'
import { LoadingPanelView } from './LoadingPanelView'
import { PanelContainer } from './PanelContainer'
interface Props extends TelemetryProps {
className?: string
authenticatedUser: AuthenticatedUser | null
savedSearchesFragment: SavedSearchesPanelFragment | null
insideTabPanel?: boolean
}
export const savedSearchesPanelFragment = gql`
fragment SavedSearchesPanelFragment on Query {
savedSearches @include(if: $enableSavedSearches) {
id
description
notify
notifySlack
query
namespace {
__typename
id
namespaceName
}
slackWebhookURL
}
}
`
export const SavedSearchesPanel: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
authenticatedUser,
className,
telemetryService,
insideTabPanel,
savedSearchesFragment,
}) => {
const savedSearches = savedSearchesFragment?.savedSearches ?? null
const [showAllSearches, setShowAllSearches] = useState(true)
useEffect(() => {
// Only log the first load (when items to load is equal to the page size)
if (savedSearches) {
telemetryService.log(
'SavedSearchesPanelLoaded',
{ empty: savedSearches.length === 0, showAllSearches },
{ empty: savedSearches.length === 0, showAllSearches }
)
}
}, [savedSearches, telemetryService, showAllSearches])
const logEvent = useCallback(
(event: string, props?: any) => (): void => telemetryService.log(event, props),
[telemetryService]
)
const emptyDisplay = (
<EmptyPanelContainer className="text-muted">
<small>
Use saved searches to alert you to uses of a favorite API, or changes to code you need to monitor.
</small>
{authenticatedUser && (
<ButtonLink
to={`/users/${authenticatedUser.username}/searches/add`}
onClick={logEvent('SavedSearchesPanelCreateButtonClicked', { source: 'empty view' })}
className="mt-2 align-self-center"
variant="secondary"
as={Link}
>
<Icon aria-hidden={true} svgPath={mdiPlus} />
Create a saved search
</ButtonLink>
)}
</EmptyPanelContainer>
)
const loadingDisplay = <LoadingPanelView text="Loading saved searches" />
const contentDisplay = (
<div className="d-flex flex-column h-100 justify-content-between">
<table className="w-100 mt-2">
<thead className="pb-1">
<tr>
<th>
<small>Search</small>
</th>
<th className="text-right">
<small>Edit</small>
</th>
</tr>
</thead>
<tbody>
{savedSearches
?.filter(search => (showAllSearches ? true : search.namespace.id === authenticatedUser?.id))
.map(search => (
<tr key={search.id} className="text-monospace test-saved-search-entry">
<td className="pb-2">
<small>
<Link
to={'/search?' + buildSearchURLQueryFromQueryState({ query: search.query })}
className="p-0"
onClick={logEvent('SavedSearchesPanelSearchClicked')}
>
{search.description}
</Link>
</small>
</td>
<td className="text-right align-top pb-2">
{authenticatedUser &&
(search.namespace.__typename === 'User' ? (
<Link
to={`/users/${search.namespace.namespaceName}/searches/${search.id}`}
onClick={logEvent('SavedSearchesPanelEditClicked')}
aria-label={`Edit saved search ${search.description}`}
>
<Icon role="img" aria-hidden={true} svgPath={mdiPencilOutline} />
</Link>
) : (
<Link
to={`/organizations/${search.namespace.namespaceName}/searches/${search.id}`}
onClick={logEvent('SavedSearchesPanelEditClicked')}
aria-label={`Edit saved search ${search.description}`}
>
<Icon role="img" aria-hidden={true} svgPath={mdiPencilOutline} />
</Link>
))}
</td>
</tr>
))}
</tbody>
</table>
{authenticatedUser && (
<FooterPanel className="p-1 mt-3">
<small>
{/*
a11y-ignore
Rule: "color-contrast" (Elements must have sufficient color contrast)
*/}
<Link
to={`/users/${authenticatedUser.username}/searches`}
className="text-left a11y-ignore"
onClick={logEvent('SavedSearchesPanelViewAllClicked')}
>
View saved searches
</Link>
</small>
</FooterPanel>
)}
</div>
)
const actionButtons = (
<>
<ButtonGroup className="d-none d-sm-block d-lg-none d-xl-block" role="tablist">
<Button
onClick={() => setShowAllSearches(false)}
className="test-saved-search-panel-my-searches"
outline={showAllSearches}
aria-selected={showAllSearches}
variant="secondary"
size="sm"
role="tab"
>
My searches
</Button>
<Button
onClick={() => setShowAllSearches(true)}
className="test-saved-search-panel-all-searches"
outline={!showAllSearches}
aria-selected={!showAllSearches}
variant="secondary"
size="sm"
role="tab"
>
All searches
</Button>
</ButtonGroup>
<Menu>
<MenuButton
variant="icon"
outline={true}
className="d-block d-sm-none d-lg-block d-xl-none p-0"
size="lg"
aria-label="Filter saved searches"
>
...
</MenuButton>
<MenuList>
<MenuItem onSelect={() => setShowAllSearches(false)}>My searches</MenuItem>
<MenuItem onSelect={() => setShowAllSearches(true)}>All searches</MenuItem>
</MenuList>
</Menu>
</>
)
return (
<PanelContainer
insideTabPanel={insideTabPanel}
title="Saved searches"
className={classNames(className, { 'h-100': insideTabPanel })}
state={savedSearches ? (savedSearches.length > 0 ? 'populated' : 'empty') : 'loading'}
loadingContent={loadingDisplay}
populatedContent={contentDisplay}
emptyContent={emptyDisplay}
actionButtons={actionButtons}
/>
)
}

View File

@ -1,17 +0,0 @@
import * as React from 'react'
import { Button } from '@sourcegraph/wildcard'
export const ShowMoreButton: React.FunctionComponent<
React.PropsWithChildren<{
onClick: () => void
className?: string
dataTestid?: string
}>
> = ({ onClick, className, dataTestid }) => (
<div className="text-center py-3">
<Button className={className} onClick={onClick} data-testid={dataTestid} variant="link">
Show more
</Button>
</div>
)

View File

@ -1,160 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CommunitySearchContextPanel renders correctly 1`] = `
<DocumentFragment>
<div
class="community-search-context-panel panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Community search contexts
</h2>
</div>
<div
class="h-100 content"
>
<div
class="mt-2 row"
>
<div
class="d-flex align-items-center mb-4 col-xl-6 col-lg-12 col-sm-6"
>
<img
alt=""
class="mr-4 icon"
src="https://raw.githubusercontent.com/cncf/artwork/master/other/cncf/icon/color/cncf-icon-color.png"
/>
<div
class="d-flex flex-column"
>
<a
class="anchorLink mb-1"
href="/cncf"
>
Cloud Native Computing Foundation (CNCF)
</a>
</div>
</div>
<div
class="d-flex align-items-center mb-4 col-xl-6 col-lg-12 col-sm-6"
>
<img
alt=""
class="mr-4 icon"
src="https://avatars.githubusercontent.com/u/56493103?s=200&v=4"
/>
<div
class="d-flex flex-column"
>
<a
class="anchorLink mb-1"
href="/temporal"
>
Temporal
</a>
</div>
</div>
<div
class="d-flex align-items-center mb-4 col-xl-6 col-lg-12 col-sm-6"
>
<img
alt=""
class="mr-4 icon"
src="https://raw.githubusercontent.com/o3de/artwork/19b89e72e15824f20204a8977a007f53d5fcd5b8/o3de/03_O3DE%20Application%20Icon/SVG/O3DE-Circle-Icon.svg"
/>
<div
class="d-flex flex-column"
>
<a
class="anchorLink mb-1"
href="/o3de"
>
O3DE
</a>
</div>
</div>
<div
class="d-flex align-items-center mb-4 col-xl-6 col-lg-12 col-sm-6"
>
<img
alt=""
class="mr-4 icon"
src="https://avatars.githubusercontent.com/u/4969009?s=200&v=4"
/>
<div
class="d-flex flex-column"
>
<a
class="anchorLink mb-1"
href="/stackstorm"
>
StackStorm
</a>
</div>
</div>
<div
class="d-flex align-items-center mb-4 col-xl-6 col-lg-12 col-sm-6"
>
<img
alt=""
class="mr-4 icon"
src="https://code.benco.io/icon-collection/logos/kubernetes.svg"
/>
<div
class="d-flex flex-column"
>
<a
class="anchorLink mb-1"
href="/kubernetes"
>
Kubernetes
</a>
</div>
</div>
<div
class="d-flex align-items-center mb-4 col-xl-6 col-lg-12 col-sm-6"
>
<img
alt=""
class="mr-4 icon"
src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/Icons8_flat_graduation_cap.svg/120px-Icons8_flat_graduation_cap.svg.png"
/>
<div
class="d-flex flex-column"
>
<a
class="anchorLink mb-1"
href="/stanford"
>
Stanford University
</a>
</div>
</div>
<div
class="d-flex align-items-center mb-4 col-xl-6 col-lg-12 col-sm-6"
>
<img
alt=""
class="mr-4 icon"
src="https://raw.githubusercontent.com/sourcegraph-community/julia-context/main/julia.svg"
/>
<div
class="d-flex flex-column"
>
<a
class="anchorLink mb-1"
href="/julia"
>
Julia
</a>
</div>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View File

@ -1,60 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PanelContainer content state 1`] = `[Function]`;
exports[`PanelContainer empty state 1`] = `[Function]`;
exports[`PanelContainer loading state 1`] = `
<DocumentFragment>
<div
class="panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Test Panel
</h2>
</div>
<div
class="h-100 content"
>
<div>
Loading
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`PanelContainer with action buttons 1`] = `
<DocumentFragment>
<div
class="panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Test Panel
</h2>
<button
type="button"
>
Button
</button>
</div>
<div
class="h-100 content"
>
<div>
Content
</div>
</div>
</div>
</DocumentFragment>
`;

View File

@ -1,507 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RecentSearchesPanel Show More button is shown if more pages are available 1`] = `
<DocumentFragment>
<div
class="recent-searches-panel panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Recent searches
</h2>
</div>
<div
class="h-100 content"
>
<table
class="mt-2 resultsTable"
>
<thead>
<tr
class="resultsTableRow"
>
<th>
<small>
Search
</small>
</th>
<th>
<small>
Date
</small>
</th>
</tr>
</thead>
<tbody>
<tr
class="resultsTableRow"
>
<td>
<small
class="recentQuery"
>
<a
class="anchorLink"
href="/search?q=test&patternType=literal"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-query-text"
>
test
</span>
</span>
</a>
</small>
</td>
<td
class="resultsTableDateCol"
>
<span
class="timestamp"
>
in 15 years
</span>
</td>
</tr>
<tr
class="resultsTableRow"
>
<td>
<small
class="recentQuery"
>
<a
class="anchorLink"
href="/search?q=r:sourcegraph&patternType=literal"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
r
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
sourcegraph
</span>
</span>
</a>
</small>
</td>
<td
class="resultsTableDateCol"
>
<span
class="timestamp"
>
in 15 years
</span>
</td>
</tr>
</tbody>
</table>
<div
class="text-center py-3"
>
<button
class="btn btnLink test-repositories-panel-show-more"
type="button"
>
Show more
</button>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`RecentSearchesPanel Show More button loads more items 1`] = `
<DocumentFragment>
<div
class="recent-searches-panel panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Recent searches
</h2>
</div>
<div
class="h-100 content"
>
<table
class="mt-2 resultsTable"
>
<thead>
<tr
class="resultsTableRow"
>
<th>
<small>
Search
</small>
</th>
<th>
<small>
Date
</small>
</th>
</tr>
</thead>
<tbody>
<tr
class="resultsTableRow"
>
<td>
<small
class="recentQuery"
>
<a
class="anchorLink"
href="/search?q=test&patternType=literal"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-query-text"
>
test
</span>
</span>
</a>
</small>
</td>
<td
class="resultsTableDateCol"
>
<span
class="timestamp"
>
in 15 years
</span>
</td>
</tr>
<tr
class="resultsTableRow"
>
<td>
<small
class="recentQuery"
>
<a
class="anchorLink"
href="/search?q=r:sourcegraph&patternType=literal"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
r
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
sourcegraph
</span>
</span>
</a>
</small>
</td>
<td
class="resultsTableDateCol"
>
<span
class="timestamp"
>
in 15 years
</span>
</td>
</tr>
</tbody>
</table>
<span
aria-live="polite"
style="border: 0px; height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; word-wrap: normal;"
>
Loading more recent searches
</span>
<div
class="text-center py-3"
>
<button
class="btn btnLink test-repositories-panel-show-more"
type="button"
>
Show more
</button>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`RecentSearchesPanel consecutive identical searches are correctly merged when rendered 1`] = `
<DocumentFragment>
<div
class="recent-searches-panel panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Recent searches
</h2>
</div>
<div
class="h-100 content"
>
<table
class="mt-2 resultsTable"
>
<thead>
<tr
class="resultsTableRow"
>
<th>
<small>
Search
</small>
</th>
<th>
<small>
Date
</small>
</th>
</tr>
</thead>
<tbody>
<tr
class="resultsTableRow"
>
<td>
<small
class="recentQuery"
>
<a
class="anchorLink"
href="/search?q=test&patternType=literal"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-query-text"
>
test
</span>
</span>
</a>
</small>
</td>
<td
class="resultsTableDateCol"
>
<span
class="timestamp"
>
in 15 years
</span>
</td>
</tr>
<tr
class="resultsTableRow"
>
<td>
<small
class="recentQuery"
>
<a
class="anchorLink"
href="/search?q=r:sourcegraph&patternType=literal"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
r
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
sourcegraph
</span>
</span>
</a>
</small>
</td>
<td
class="resultsTableDateCol"
>
<span
class="timestamp"
>
in 15 years
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</DocumentFragment>
`;
exports[`RecentSearchesPanel searches with no argument are skipped 1`] = `
<DocumentFragment>
<div
class="recent-searches-panel panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Recent searches
</h2>
</div>
<div
class="h-100 content"
>
<table
class="mt-2 resultsTable"
>
<thead>
<tr
class="resultsTableRow"
>
<th>
<small>
Search
</small>
</th>
<th>
<small>
Date
</small>
</th>
</tr>
</thead>
<tbody>
<tr
class="resultsTableRow"
>
<td>
<small
class="recentQuery"
>
<a
class="anchorLink"
href="/search?q=test&patternType=literal"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-query-text"
>
test
</span>
</span>
</a>
</small>
</td>
<td
class="resultsTableDateCol"
>
<span
class="timestamp"
>
in 15 years
</span>
</td>
</tr>
<tr
class="resultsTableRow"
>
<td>
<small
class="recentQuery"
>
<a
class="anchorLink"
href="/search?q=r:sourcegraph&patternType=literal"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
r
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
sourcegraph
</span>
</span>
</a>
</small>
</td>
<td
class="resultsTableDateCol"
>
<span
class="timestamp"
>
in 15 years
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</DocumentFragment>
`;

View File

@ -1,503 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RepositoriesPanel Both r: and repo: filters are tracked 1`] = `
<DocumentFragment>
<div
class="repositories-panel panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Repositories
</h2>
</div>
<div
class="h-100 content"
>
<table
class="mt-2 resultsTable"
>
<thead>
<tr
class="resultsTableRow"
>
<th>
<small>
Search
</small>
</th>
</tr>
</thead>
<tbody>
<tr
class="text-monospace text-break resultsTableRow"
>
<td>
<small>
<a
class="anchorLink"
href="/search?q=repo:sourcegraph"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
repo
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
sourcegraph
</span>
</span>
</a>
</small>
</td>
</tr>
<tr
class="text-monospace text-break resultsTableRow"
>
<td>
<small>
<a
class="anchorLink"
href="/search?q=repo:test"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
repo
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
test
</span>
</span>
</a>
</small>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</DocumentFragment>
`;
exports[`RepositoriesPanel Show More button is shown if more pages are available 1`] = `
<DocumentFragment>
<div
class="repositories-panel panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Repositories
</h2>
</div>
<div
class="h-100 content"
>
<table
class="mt-2 resultsTable"
>
<thead>
<tr
class="resultsTableRow"
>
<th>
<small>
Search
</small>
</th>
</tr>
</thead>
<tbody>
<tr
class="text-monospace text-break resultsTableRow"
>
<td>
<small>
<a
class="anchorLink"
href="/search?q=repo:test"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
repo
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
test
</span>
</span>
</a>
</small>
</td>
</tr>
<tr
class="text-monospace text-break resultsTableRow"
>
<td>
<small>
<a
class="anchorLink"
href="/search?q=repo:sourcegraph"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
repo
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
sourcegraph
</span>
</span>
</a>
</small>
</td>
</tr>
<tr
class="text-monospace text-break resultsTableRow"
>
<td>
<small>
<a
class="anchorLink"
href="/search?q=repo:test-two"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
repo
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
test-two
</span>
</span>
</a>
</small>
</td>
</tr>
</tbody>
</table>
<div
class="text-center py-3"
>
<button
class="btn btnLink test-repositories-panel-show-more"
type="button"
>
Show more
</button>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`RepositoriesPanel Show More button loads more items 1`] = `
<DocumentFragment>
<div
class="repositories-panel panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Repositories
</h2>
</div>
<div
class="h-100 content"
>
<table
class="mt-2 resultsTable"
>
<thead>
<tr
class="resultsTableRow"
>
<th>
<small>
Search
</small>
</th>
</tr>
</thead>
<tbody>
<tr
class="text-monospace text-break resultsTableRow"
>
<td>
<small>
<a
class="anchorLink"
href="/search?q=repo:test"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
repo
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
test
</span>
</span>
</a>
</small>
</td>
</tr>
<tr
class="text-monospace text-break resultsTableRow"
>
<td>
<small>
<a
class="anchorLink"
href="/search?q=repo:sourcegraph"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
repo
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
sourcegraph
</span>
</span>
</a>
</small>
</td>
</tr>
<tr
class="text-monospace text-break resultsTableRow"
>
<td>
<small>
<a
class="anchorLink"
href="/search?q=repo:test-two"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
repo
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
test-two
</span>
</span>
</a>
</small>
</td>
</tr>
</tbody>
</table>
<span
aria-live="polite"
style="border: 0px; height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; word-wrap: normal;"
>
Loading more repositories
</span>
<div
class="text-center py-3"
>
<button
class="btn btnLink test-repositories-panel-show-more"
type="button"
>
Show more
</button>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`RepositoriesPanel consecutive searches with identical repo filters are correctly merged when rendered 1`] = `
<DocumentFragment>
<div
class="repositories-panel panelContainer d-flex flex-column"
>
<div
class="d-flex border-bottom header"
>
<h2
class="h2 h4 headerText"
>
Repositories
</h2>
</div>
<div
class="h-100 content"
>
<table
class="mt-2 resultsTable"
>
<thead>
<tr
class="resultsTableRow"
>
<th>
<small>
Search
</small>
</th>
</tr>
</thead>
<tbody>
<tr
class="text-monospace text-break resultsTableRow"
>
<td>
<small>
<a
class="anchorLink"
href="/search?q=repo:test"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
repo
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
test
</span>
</span>
</a>
</small>
</td>
</tr>
<tr
class="text-monospace text-break resultsTableRow"
>
<td>
<small>
<a
class="anchorLink"
href="/search?q=repo:sourcegraph"
>
<span
class="text-monospace search-query-link"
>
<span
class="search-filter-keyword"
>
repo
</span>
<span
class="search-filter-separator"
>
:
</span>
<span
class="search-query-text"
>
sourcegraph
</span>
</span>
</a>
</small>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</DocumentFragment>
`;

View File

@ -1,49 +0,0 @@
import { useMemo } from 'react'
import { of } from 'rxjs'
import { streamComputeQuery } from '@sourcegraph/shared/src/search/stream'
import { useObservable } from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../../auth'
import { useExperimentalFeatures } from '../../stores'
export type ComputeParseResult = [{ kind: string; value: string }]
export function useComputeResults(
authenticatedUser: AuthenticatedUser | null,
computeOutput: string
): { isLoading: boolean; results: Set<string> } {
const checkHomePanelsFeatureFlag = useExperimentalFeatures(features => features.homePanelsComputeSuggestions)
const gitRecentFiles = useObservable(
useMemo(
() =>
checkHomePanelsFeatureFlag && authenticatedUser
? streamComputeQuery(
`content:output((.|\n)* -> ${computeOutput}) author:${authenticatedUser.email} type:diff after:"1 year ago" count:all`
)
: of([]),
[authenticatedUser, checkHomePanelsFeatureFlag, computeOutput]
)
)
const gitSet = useMemo(() => {
let gitRepositoryParsedString: ComputeParseResult[] = []
if (gitRecentFiles) {
gitRepositoryParsedString = gitRecentFiles.map(value => JSON.parse(value) as ComputeParseResult)
}
const gitReposList = gitRepositoryParsedString?.flat()
const gitSet = new Set<string>()
if (gitReposList) {
for (const git of gitReposList) {
if (git.value) {
gitSet.add(git.value)
}
}
}
return gitSet
}, [gitRecentFiles])
return { isLoading: gitRecentFiles === undefined, results: gitSet }
}

View File

@ -1,31 +0,0 @@
import { gql, MutationFunctionOptions, FetchResult } from '@apollo/client'
import { useMutation } from '@sourcegraph/http-client'
import { Exact, InviteEmailToSourcegraphResult, InviteEmailToSourcegraphVariables } from '../../graphql-operations'
const INVITE_EMAIL_TO_SOURCEGRAPH = gql`
mutation InviteEmailToSourcegraph($email: String!) {
inviteEmailToSourcegraph(email: $email) {
alwaysNil
}
}
`
type UseInviteEmailToSourcegraphResult = (
options?:
| MutationFunctionOptions<
InviteEmailToSourcegraphResult,
Exact<{
email: string
}>
>
| undefined
) => Promise<FetchResult<InviteEmailToSourcegraphResult>>
export const useInviteEmailToSourcegraph = (): UseInviteEmailToSourcegraphResult => {
const [inviteEmailToSourcegraph] = useMutation<InviteEmailToSourcegraphResult, InviteEmailToSourcegraphVariables>(
INVITE_EMAIL_TO_SOURCEGRAPH
)
return inviteEmailToSourcegraph
}

View File

@ -1,345 +0,0 @@
import { AuthenticatedUser } from '../../auth'
import { SavedSearchesPanelFragment } from '../../graphql-operations'
import { EventLogResult } from '../backend'
import { InvitableCollaborator } from './CollaboratorsPanel'
type SavedSearchPanelFields = NonNullable<SavedSearchesPanelFragment['savedSearches']>[number]
type NameSpaceOrg = Extract<SavedSearchPanelFields['namespace'], { __typename: 'Org' }>
type NameSpaceUser = Extract<SavedSearchPanelFields['namespace'], { __typename: 'User' }>
export const authUser: AuthenticatedUser & NameSpaceUser = {
__typename: 'User',
id: '0',
email: 'alice@sourcegraph.com',
username: 'alice',
avatarURL: null,
session: { canSignOut: true },
displayName: null,
url: '',
settingsURL: '#',
siteAdmin: true,
organizations: {
nodes: [
{ id: '0', settingsURL: '#', displayName: 'Acme Corp' },
{ id: '1', settingsURL: '#', displayName: 'Beta Inc' },
] as AuthenticatedUser['organizations']['nodes'],
},
tags: [],
viewerCanAdminister: true,
databaseID: 0,
tosAccepted: true,
searchable: true,
namespaceName: 'alice',
emails: [],
latestSettings: null,
}
const org: NameSpaceOrg = {
__typename: 'Org',
id: '1',
namespaceName: 'test-org',
}
export const savedSearchesPayload = (): SavedSearchPanelFields[] => [
{
__typename: 'SavedSearch',
id: 'test',
description: 'test',
query: 'test',
notify: false,
notifySlack: false,
namespace: authUser,
slackWebhookURL: null,
},
{
__typename: 'SavedSearch',
id: 'test-org',
description: 'org test',
query: 'org test',
notify: false,
notifySlack: false,
namespace: org,
slackWebhookURL: null,
},
]
export const recentSearchesPayload = (): EventLogResult => ({
nodes: [
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test and spec"}}}',
timestamp: '2020-09-08T17:36:52Z',
url: 'https://sourcegraph.test:3443/search?q=test%20and%20spec&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 5, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 1, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "^test"}}}',
timestamp: '2020-09-08T17:26:05Z',
url: 'https://sourcegraph.test:3443/search?q=%5Etest&patternType=regexp',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 5, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 1, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "^test"}}}',
timestamp: '2020-09-08T17:20:11Z',
url: 'https://sourcegraph.test:3443/search?q=%5Etest&patternType=regexp',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 5, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 1, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "^test"}}}',
timestamp: '2020-09-08T17:20:05Z',
url: 'https://sourcegraph.test:3443/search?q=%5Etest&patternType=regexp',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 26, "space": 2, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 3, "count_non_default": 1}, "field_lang": {"count": 1, "count_alias": 0, "count_negated": 0}, "field_default": {"count": 2, "count_regexp": 0, "count_literal": 2, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "lang:cpp try {:[my_match]}"}}}',
timestamp: '2020-09-08T17:12:53Z',
url: 'https://sourcegraph.test:3443/search?q=lang:cpp+try+%7B:%5Bmy_match%5D%7D&patternType=structural&onboardingTour=true',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 26, "space": 2, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 3, "count_non_default": 1}, "field_lang": {"count": 1, "count_alias": 0, "count_negated": 0}, "field_default": {"count": 2, "count_regexp": 0, "count_literal": 2, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "lang:cpp try {:[my_match]}"}}}',
timestamp: '2020-09-08T17:11:46Z',
url: 'https://sourcegraph.test:3443/search?q=lang:cpp+try+%7B:%5Bmy_match%5D%7D&patternType=structural&onboardingTour=true',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 86, "space": 4, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 4, "count_non_default": 3}, "field_lang": {"count": 1, "count_alias": 0, "count_negated": 0}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 0, "value_regexp": 1, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_type": {"count": 1, "value_diff": 0, "value_file": 0, "value_commit": 1, "value_symbol": 0}, "field_default": {"count": 2, "count_regexp": 0, "count_literal": 1, "count_pattern": 1, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "repo:^github\\\\.com/sourcegraph/sourcegraph$ PanelContainer lang:typescript type:commit"}}}',
timestamp: '2020-09-04T20:31:57Z',
url: 'https://sourcegraph.test:3443/search?q=repo:%5Egithub%5C.com/sourcegraph/sourcegraph%24+PanelContainer+lang:typescript++type:commit&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 86, "space": 4, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 4, "count_non_default": 3}, "field_lang": {"count": 1, "count_alias": 0, "count_negated": 0}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 0, "value_regexp": 1, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_type": {"count": 1, "value_diff": 0, "value_file": 0, "value_commit": 1, "value_symbol": 0}, "field_default": {"count": 2, "count_regexp": 0, "count_literal": 1, "count_pattern": 1, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "repo:^github\\\\.com/sourcegraph/sourcegraph$ PanelContainer lang:typescript type:commit"}}}',
timestamp: '2020-09-04T20:27:02Z',
url: 'https://sourcegraph.test:3443/search?q=repo:%5Egithub%5C.com/sourcegraph/sourcegraph%24+PanelContainer+lang:typescript++type:commit&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 86, "space": 4, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 4, "count_non_default": 3}, "field_lang": {"count": 1, "count_alias": 0, "count_negated": 0}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 0, "value_regexp": 1, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_type": {"count": 1, "value_diff": 0, "value_file": 0, "value_commit": 1, "value_symbol": 0}, "field_default": {"count": 2, "count_regexp": 0, "count_literal": 1, "count_pattern": 1, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "repo:^github\\\\.com/sourcegraph/sourcegraph$ PanelContainer lang:typescript type:commit"}}}',
timestamp: '2020-09-04T20:24:56Z',
url: 'https://sourcegraph.test:3443/search?q=repo:%5Egithub%5C.com/sourcegraph/sourcegraph%24+PanelContainer+lang:typescript++type:commit&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 74, "space": 3, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 3, "count_non_default": 2}, "field_lang": {"count": 1, "count_alias": 0, "count_negated": 0}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 0, "value_regexp": 1, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 2, "count_regexp": 0, "count_literal": 1, "count_pattern": 1, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "repo:^github\\\\.com/sourcegraph/sourcegraph$ PanelContainer lang:typescript "}}}',
timestamp: '2020-09-04T20:23:44Z',
url: 'https://sourcegraph.test:3443/search?q=repo:%5Egithub%5C.com/sourcegraph/sourcegraph%24+PanelContainer+lang:typescript+&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 57, "space": 1, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 2, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 0, "value_regexp": 1, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 2, "count_regexp": 0, "count_literal": 1, "count_pattern": 1, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "repo:^github\\\\.com/sourcegraph/sourcegraph$ PanelContainer"}}}',
timestamp: '2020-09-04T20:23:38Z',
url: 'https://sourcegraph.test:3443/search?q=repo:%5Egithub%5C.com/sourcegraph/sourcegraph%24+PanelContainer&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 43, "space": 1, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 0, "value_regexp": 1, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 0, "count_pattern": 1, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "repo:^github\\\\.com/sourcegraph/sourcegraph$ "}}}',
timestamp: '2020-09-04T20:23:30Z',
url: 'https://sourcegraph.test:3443/search?q=repo:%5Egithub%5C.com/sourcegraph/sourcegraph%24+&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 28, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 0, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "repo:sourcegraph/sourcegraph"}}}',
timestamp: '2020-09-04T20:23:23Z',
url: 'https://sourcegraph.test:3443/search?q=repo:sourcegraph/sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-04T20:23:09Z',
url: 'https://sourcegraph.test:3443/search?q=test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T20:23:08Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-04T20:23:07Z',
url: 'https://sourcegraph.test:3443/search?q=test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T20:23:06Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 4, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 0}, "field_default": {"count": 1, "count_regexp": 0, "count_literal": 1, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "test"}}}',
timestamp: '2020-09-04T20:23:06Z',
url: 'https://sourcegraph.test:3443/search?q=test&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:39Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
{
argument:
'{"mode": "plain", "code_search": {"query_data": {"empty": false, "query": {"chars": {"count": 13, "space": 0, "non_ascii": 0, "double_quote": 0, "single_quote": 0}, "fields": {"count": 1, "count_non_default": 1}, "field_repo": {"count": 1, "value_glob": 0, "value_pipe": 0, "count_alias": 1, "value_regexp": 0, "count_negated": 0, "value_at_sign": 0, "value_rev_star": 0, "value_rev_caret": 0, "value_rev_colon": 0}, "field_default": {"count": 0, "count_regexp": 0, "count_literal": 0, "count_pattern": 0, "count_double_quote": 0, "count_single_quote": 0}}, "combined": "r:sourcegraph"}}}',
timestamp: '2020-09-04T18:44:30Z',
url: 'https://sourcegraph.test:3443/search?q=r:sourcegraph&patternType=literal',
},
],
pageInfo: {
hasNextPage: true,
},
totalCount: 436,
})
export const recentFilesPayload = (): EventLogResult => ({
nodes: [
{
argument: '{"filePath": "web/src/tree/Tree.tsx", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T23:07:55Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/web/src/tree/Tree.tsx',
},
{
argument: '{"filePath": "web/src/tree/TreeRoot.tsx", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T23:07:55Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/web/src/tree/TreeRoot.tsx',
},
{
argument:
'{"filePath": "web/src/tree/SingleChildTreeLayer.tsx", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T23:07:54Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/web/src/tree/SingleChildTreeLayer.tsx',
},
{
argument: '{"filePath": "web/src/tree/Directory.tsx", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T23:07:54Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/web/src/tree/Directory.tsx',
},
{
argument:
'{"filePath": "web/src/site/FreeUsersExceededAlert.tsx", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T23:07:51Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/web/src/site/FreeUsersExceededAlert.tsx',
},
{
argument:
'{"filePath": "web/src/site/DockerForMacAlert.scss", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T23:07:50Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/web/src/site/DockerForMacAlert.scss',
},
{
argument: '{"filePath": "web/jest.config.js", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T23:07:45Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/web/jest.config.js',
},
{
argument: '{"filePath": "go.mod", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T22:55:30Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/go.mod',
},
{
argument: '{"filePath": ".eslintrc.js", "repoName": "github.com/sourcegraph/sourcegraph"}',
timestamp: '2020-09-10T22:55:18Z',
url: 'https://sourcegraph.test:3443/github.com/sourcegraph/sourcegraph/-/blob/.eslintrc.js',
},
{
argument: '{"filePath": "go.mod", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T22:55:06Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/go.mod',
},
{
argument: '{"filePath": "go.mod", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T22:54:54Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/go.mod',
},
{
argument: '{"filePath": "go.mod", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T22:54:50Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/go.mod',
},
{
argument: '{"filePath": "AUTHORS", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T21:21:23Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/AUTHORS',
},
{
argument: '{"filePath": "LICENSE", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T21:21:23Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/LICENSE',
},
{
argument: '{"filePath": "README.md", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T21:21:22Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/README.md',
},
{
argument:
'{"filePath": "example_authentication_middleware_test.go", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T21:21:21Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/example_authentication_middleware_test.go',
},
{
argument:
'{"filePath": "example_cors_method_middleware_test.go", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T21:21:20Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/example_cors_method_middleware_test.go',
},
{
argument: '{"filePath": "example_route_test.go", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T21:21:19Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/example_route_test.go',
},
{
argument: '{"filePath": "go.mod", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T21:21:16Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/go.mod',
},
{
argument: '{"filePath": "mux_test.go", "repoName": "ghe.sgdev.org/sourcegraph/gorilla-mux"}',
timestamp: '2020-09-10T21:21:03Z',
url: 'https://sourcegraph.test:3443/ghe.sgdev.org/sourcegraph/gorilla-mux/-/blob/mux_test.go',
},
],
totalCount: 500,
pageInfo: { hasNextPage: true },
})
export const collaboratorsPayload: () => InvitableCollaborator[] = () => [
{
email: 'hello@philippspiess.com',
displayName: 'Philipp Spiess',
name: 'Philipp Spiess',
avatarURL: 'https://avatars.githubusercontent.com/u/458591?v=4',
},
{
email: 'hello@philippspiess.com',
displayName: 'Philipp Spiess',
name: 'Philipp Spiess',
avatarURL: 'https://avatars.githubusercontent.com/u/458591?v=4',
},
{
email: 'hello@philippspiess.com',
displayName: 'Philipp Spiess',
name: 'Philipp Spiess',
avatarURL: 'https://avatars.githubusercontent.com/u/458591?v=4',
},
{
email: 'hello@nicolasdular.com',
displayName: 'Nicolas Dular',
name: 'Nicolas Dular',
avatarURL: 'https://avatars.githubusercontent.com/u/890544?v=4',
},
{
email: 'mario.telesklav@gmx.at',
displayName: 'Mario Telesklav',
name: 'Mario Telesklav',
avatarURL: 'https://avatars.githubusercontent.com/u/3846403?v=4',
},
{
email: 'gluastoned@gmail.com',
displayName: 'Gregor Steiner',
name: 'Gregor Steiner',
avatarURL: 'https://avatars.githubusercontent.com/u/173158?v=4',
},
]

View File

@ -6,7 +6,6 @@ import { SettingsCascadeOrError } from '@sourcegraph/shared/src/settings/setting
const defaultSettings: SettingsExperimentalFeatures = {
codeMonitoring: true,
showEnterpriseHomePanels: false,
/**
* Whether we show the multiline editor at /search/console
*/

View File

@ -6,6 +6,7 @@ import (
"github.com/graph-gophers/graphql-go"
"github.com/graph-gophers/graphql-go/relay"
"github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend/graphqlutil"
"github.com/sourcegraph/sourcegraph/internal/actor"
"github.com/sourcegraph/sourcegraph/internal/auth"
"github.com/sourcegraph/sourcegraph/internal/database"
@ -111,20 +112,80 @@ func (r *schemaResolver) toSavedSearchResolver(entry types.SavedSearch) *savedSe
return &savedSearchResolver{db: r.db, s: entry}
}
func (r *schemaResolver) SavedSearches(ctx context.Context) ([]*savedSearchResolver, error) {
a := actor.FromContext(ctx)
if !a.IsAuthenticated() {
return nil, errors.New("no currently authenticated user")
type savedSearchesArgs struct {
graphqlutil.ConnectionResolverArgs
Namespace graphql.ID
}
func (r *schemaResolver) SavedSearches(ctx context.Context, args savedSearchesArgs) (*graphqlutil.ConnectionResolver[savedSearchResolver], error) {
var userID, orgID int32
if err := UnmarshalNamespaceID(args.Namespace, &userID, &orgID); err != nil {
return nil, err
}
allSavedSearches, err := r.db.SavedSearches().ListSavedSearchesByUserID(ctx, a.UID)
if userID != 0 {
if err := auth.CheckSiteAdminOrSameUser(ctx, r.db, userID); err != nil {
return nil, err
}
} else if orgID != 0 {
if err := auth.CheckOrgAccessOrSiteAdmin(ctx, r.db, orgID); err != nil {
return nil, err
}
} else {
return nil, errors.New("User or Organisation namespace must be provided.")
}
connectionStore := &savedSearchesConnectionStore{
db: r.db,
userID: &userID,
orgID: &orgID,
}
return graphqlutil.NewConnectionResolver[savedSearchResolver](connectionStore, &args.ConnectionResolverArgs, nil)
}
type savedSearchesConnectionStore struct {
db database.DB
userID *int32
orgID *int32
}
func (s *savedSearchesConnectionStore) MarshalCursor(node *savedSearchResolver) (*string, error) {
cursor := string(node.ID())
return &cursor, nil
}
func (s *savedSearchesConnectionStore) UnmarshalCursor(cursor string) (*int, error) {
nodeID, err := unmarshalSavedSearchID(graphql.ID(cursor))
if err != nil {
return nil, err
}
id := int(nodeID)
return &id, nil
}
func (s *savedSearchesConnectionStore) ComputeTotal(ctx context.Context) (*int32, error) {
count, err := s.db.SavedSearches().CountSavedSearchesByOrgOrUser(ctx, s.userID, s.orgID)
if err != nil {
return nil, err
}
total := int32(count)
return &total, nil
}
func (s *savedSearchesConnectionStore) ComputeNodes(ctx context.Context, args *database.PaginationArgs) ([]*savedSearchResolver, error) {
allSavedSearches, err := s.db.SavedSearches().ListSavedSearchesByOrgOrUser(ctx, s.userID, s.orgID, args)
if err != nil {
return nil, err
}
var savedSearches []*savedSearchResolver
for _, savedSearch := range allSavedSearches {
savedSearches = append(savedSearches, r.toSavedSearchResolver(*savedSearch))
savedSearches = append(savedSearches, &savedSearchResolver{db: s.db, s: *savedSearch})
}
return savedSearches, nil

View File

@ -9,6 +9,7 @@ import (
"github.com/graph-gophers/graphql-go"
"github.com/stretchr/testify/require"
"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"
@ -21,30 +22,156 @@ func TestSavedSearches(t *testing.T) {
key := int32(1)
users := database.NewMockUserStore()
users.GetByCurrentAuthUserFunc.SetDefaultReturn(&types.User{SiteAdmin: true, ID: key}, nil)
users.GetByIDFunc.SetDefaultReturn(&types.User{SiteAdmin: true, ID: key}, nil)
ss := database.NewMockSavedSearchStore()
ss.ListSavedSearchesByUserIDFunc.SetDefaultHook(func(_ context.Context, userID int32) ([]*types.SavedSearch, error) {
return []*types.SavedSearch{{ID: key, Description: "test query", Query: "test type:diff patternType:regexp", UserID: &userID, OrgID: nil}}, nil
ss.ListSavedSearchesByOrgOrUserFunc.SetDefaultHook(func(_ context.Context, userID, orgId *int32, paginationArgs *database.PaginationArgs) ([]*types.SavedSearch, error) {
return []*types.SavedSearch{{ID: key, Description: "test query", Query: "test type:diff patternType:regexp", UserID: userID, OrgID: nil}}, nil
})
ss.CountSavedSearchesByOrgOrUserFunc.SetDefaultHook(func(_ context.Context, userID, orgId *int32) (int, error) {
return 1, nil
})
db := database.NewMockDB()
db.UsersFunc.SetDefaultReturn(users)
db.SavedSearchesFunc.SetDefaultReturn(ss)
savedSearches, err := newSchemaResolver(db, gitserver.NewClient(db)).SavedSearches(actor.WithActor(context.Background(), actor.FromUser(key)))
args := savedSearchesArgs{
ConnectionResolverArgs: graphqlutil.ConnectionResolverArgs{First: &key},
Namespace: MarshalUserID(key),
}
resolver, err := newSchemaResolver(db, gitserver.NewClient(db)).SavedSearches(actor.WithActor(context.Background(), actor.FromUser(key)), args)
if err != nil {
t.Fatal(err)
}
want := []*savedSearchResolver{{db, types.SavedSearch{
ID: key,
Description: "test query",
Query: "test type:diff patternType:regexp",
UserID: &key,
OrgID: nil,
nodes, err := resolver.Nodes(context.Background())
if err != nil {
t.Fatal(err)
}
wantNodes := []*savedSearchResolver{{db, types.SavedSearch{
ID: key,
Description: "test query",
Query: "test type:diff patternType:regexp",
UserID: &key,
OrgID: nil,
SlackWebhookURL: nil,
}}}
if !reflect.DeepEqual(savedSearches, want) {
t.Errorf("got %v+, want %v+", savedSearches[0], want[0])
if !reflect.DeepEqual(nodes, wantNodes) {
t.Errorf("got %v+, want %v+", nodes[0], wantNodes[0])
}
}
func TestSavedSearchesForSameUser(t *testing.T) {
key := int32(1)
users := database.NewMockUserStore()
users.GetByIDFunc.SetDefaultReturn(&types.User{SiteAdmin: false, ID: key}, nil)
ss := database.NewMockSavedSearchStore()
ss.ListSavedSearchesByOrgOrUserFunc.SetDefaultHook(func(_ context.Context, userID, orgId *int32, paginationArgs *database.PaginationArgs) ([]*types.SavedSearch, error) {
return []*types.SavedSearch{{ID: key, Description: "test query", Query: "test type:diff patternType:regexp", UserID: userID, OrgID: nil}}, nil
})
ss.CountSavedSearchesByOrgOrUserFunc.SetDefaultHook(func(_ context.Context, userID, orgId *int32) (int, error) {
return 1, nil
})
db := database.NewMockDB()
db.UsersFunc.SetDefaultReturn(users)
db.SavedSearchesFunc.SetDefaultReturn(ss)
args := savedSearchesArgs{
ConnectionResolverArgs: graphqlutil.ConnectionResolverArgs{First: &key},
Namespace: MarshalUserID(key),
}
resolver, err := newSchemaResolver(db, gitserver.NewClient(db)).SavedSearches(actor.WithActor(context.Background(), actor.FromUser(key)), args)
if err != nil {
t.Fatal(err)
}
nodes, err := resolver.Nodes(context.Background())
if err != nil {
t.Fatal(err)
}
wantNodes := []*savedSearchResolver{{db, types.SavedSearch{
ID: key,
Description: "test query",
Query: "test type:diff patternType:regexp",
UserID: &key,
OrgID: nil,
SlackWebhookURL: nil,
}}}
if !reflect.DeepEqual(nodes, wantNodes) {
t.Errorf("got %v+, want %v+", nodes[0], wantNodes[0])
}
}
func TestSavedSearchesForDifferentUser(t *testing.T) {
key := int32(1)
userID := int32(2)
users := database.NewMockUserStore()
users.GetByIDFunc.SetDefaultReturn(&types.User{SiteAdmin: false, ID: userID}, nil)
ss := database.NewMockSavedSearchStore()
ss.ListSavedSearchesByOrgOrUserFunc.SetDefaultHook(func(_ context.Context, userID, orgId *int32, paginationArgs *database.PaginationArgs) ([]*types.SavedSearch, error) {
return []*types.SavedSearch{{ID: key, Description: "test query", Query: "test type:diff patternType:regexp", UserID: userID, OrgID: nil}}, nil
})
ss.CountSavedSearchesByOrgOrUserFunc.SetDefaultHook(func(_ context.Context, userID, orgId *int32) (int, error) {
return 1, nil
})
db := database.NewMockDB()
db.UsersFunc.SetDefaultReturn(users)
db.SavedSearchesFunc.SetDefaultReturn(ss)
args := savedSearchesArgs{
ConnectionResolverArgs: graphqlutil.ConnectionResolverArgs{First: &key},
Namespace: MarshalUserID(key),
}
_, err := newSchemaResolver(db, gitserver.NewClient(db)).SavedSearches(actor.WithActor(context.Background(), actor.FromUser(userID)), args)
if err == nil {
t.Error("got nil, want error to be returned for accessing saved searches of different user by non site admin.")
}
}
func TestSavedSearchesForDifferentOrg(t *testing.T) {
key := int32(1)
users := database.NewMockUserStore()
users.GetByIDFunc.SetDefaultReturn(&types.User{SiteAdmin: false, ID: key}, nil)
users.GetByCurrentAuthUserFunc.SetDefaultReturn(&types.User{SiteAdmin: false, ID: key}, nil)
om := database.NewMockOrgMemberStore()
om.GetByOrgIDAndUserIDFunc.SetDefaultHook(func(ctx context.Context, oid, uid int32) (*types.OrgMembership, error) {
return nil, nil
})
ss := database.NewMockSavedSearchStore()
ss.ListSavedSearchesByOrgOrUserFunc.SetDefaultHook(func(_ context.Context, userID, orgId *int32, paginationArgs *database.PaginationArgs) ([]*types.SavedSearch, error) {
return []*types.SavedSearch{{ID: key, Description: "test query", Query: "test type:diff patternType:regexp", UserID: nil, OrgID: &key}}, nil
})
ss.CountSavedSearchesByOrgOrUserFunc.SetDefaultHook(func(_ context.Context, userID, orgId *int32) (int, error) {
return 1, nil
})
db := database.NewMockDB()
db.UsersFunc.SetDefaultReturn(users)
db.OrgMembersFunc.SetDefaultReturn(om)
db.SavedSearchesFunc.SetDefaultReturn(ss)
args := savedSearchesArgs{
ConnectionResolverArgs: graphqlutil.ConnectionResolverArgs{First: &key},
Namespace: MarshalOrgID(key),
}
if _, err := newSchemaResolver(db, gitserver.NewClient(db)).SavedSearches(actor.WithActor(context.Background(), actor.FromUser(key)), args); err != auth.ErrNotAnOrgMember {
t.Errorf("got %v+, want %v+", err, auth.ErrNotAnOrgMember)
}
}

View File

@ -1465,9 +1465,30 @@ type Query {
query: String = ""
): Search
"""
All saved searches configured for the current user, merged from all configurations.
List of saved searches based on namespace
"""
savedSearches: [SavedSearch!]!
savedSearches(
"""
The namespace to list the saved searches for.
"""
namespace: ID!
"""
The limit argument for forward pagination.
"""
first: Int
"""
The limit argument for backward pagination.
"""
last: Int
"""
The cursor argument for forward pagination.
"""
after: String
"""
The cursor argument for backward pagination.
"""
before: String
): SavedSearchesConnection!
"""
(experimental) Return the parse tree of a search query.
"""
@ -2516,6 +2537,26 @@ type SearchAlert {
proposedQueries: [SearchQueryDescription!]
}
"""
A paginated connection for saved search queries, defined in settings.
"""
type SavedSearchesConnection implements Connection {
"""
A list of saved searches.
"""
nodes: [SavedSearch!]!
"""
The total number of saved searches in the connection.
"""
totalCount: Int!
"""
Pagination information.
"""
pageInfo: ConnectionPageInfo!
}
"""
A saved search query, defined in settings.
"""
@ -8317,3 +8358,38 @@ type SlowRequest {
"""
filepath: String
}
"""
An object with totalCount and PageInfo.
"""
interface Connection {
"""
The total count of items in the connection.
"""
totalCount: Int!
"""
The pagination info for the connection.
"""
pageInfo: ConnectionPageInfo!
}
"""
Pagination information.
"""
type ConnectionPageInfo {
"""
When paginating forwards, the cursor to continue.
"""
endCursor: String
"""
When paginating forwards, are there more items?
"""
hasNextPage: Boolean!
"""
When paginating backward, the cursor to continue.
"""
startCursor: String
"""
When paginating backward, are there more items?
"""
hasPreviousPage: Boolean!
}

View File

@ -8,6 +8,7 @@
* [`code-intel`](code-intel/index.md)
* [`config`](config/index.md)
* [`debug`](debug.md)
* [`extensions`](extensions/index.md)
* [`extsvc`](extsvc/index.md)
* [`login`](login.md)
* [`lsif`](lsif.md)
@ -19,4 +20,4 @@
* [`users`](users/index.md)
* [`validate`](validate.md)
* [`version`](version.md)

View File

@ -3,7 +3,10 @@
## Subcommands
* [`add-kvp`](add-kvp.md)
* [`delete`](delete.md)
* [`delete-kvp`](delete-kvp.md)
* [`get`](get.md)
* [`list`](list.md)
* [`update-kvp`](update-kvp.md)

View File

@ -34770,6 +34770,10 @@ func (c RepoStoreWithFuncCall) Results() []interface{} {
// github.com/sourcegraph/sourcegraph/internal/database) used for unit
// testing.
type MockSavedSearchStore struct {
// CountSavedSearchesByOrgOrUserFunc is an instance of a mock function
// object controlling the behavior of the method
// CountSavedSearchesByOrgOrUser.
CountSavedSearchesByOrgOrUserFunc *SavedSearchStoreCountSavedSearchesByOrgOrUserFunc
// CreateFunc is an instance of a mock function object controlling the
// behavior of the method Create.
CreateFunc *SavedSearchStoreCreateFunc
@ -34791,6 +34795,10 @@ type MockSavedSearchStore struct {
// ListSavedSearchesByOrgIDFunc is an instance of a mock function object
// controlling the behavior of the method ListSavedSearchesByOrgID.
ListSavedSearchesByOrgIDFunc *SavedSearchStoreListSavedSearchesByOrgIDFunc
// ListSavedSearchesByOrgOrUserFunc is an instance of a mock function
// object controlling the behavior of the method
// ListSavedSearchesByOrgOrUser.
ListSavedSearchesByOrgOrUserFunc *SavedSearchStoreListSavedSearchesByOrgOrUserFunc
// ListSavedSearchesByUserIDFunc is an instance of a mock function
// object controlling the behavior of the method
// ListSavedSearchesByUserID.
@ -34811,6 +34819,11 @@ type MockSavedSearchStore struct {
// overwritten.
func NewMockSavedSearchStore() *MockSavedSearchStore {
return &MockSavedSearchStore{
CountSavedSearchesByOrgOrUserFunc: &SavedSearchStoreCountSavedSearchesByOrgOrUserFunc{
defaultHook: func(context.Context, *int32, *int32) (r0 int, r1 error) {
return
},
},
CreateFunc: &SavedSearchStoreCreateFunc{
defaultHook: func(context.Context, *types.SavedSearch) (r0 *types.SavedSearch, r1 error) {
return
@ -34846,6 +34859,11 @@ func NewMockSavedSearchStore() *MockSavedSearchStore {
return
},
},
ListSavedSearchesByOrgOrUserFunc: &SavedSearchStoreListSavedSearchesByOrgOrUserFunc{
defaultHook: func(context.Context, *int32, *int32, *PaginationArgs) (r0 []*types.SavedSearch, r1 error) {
return
},
},
ListSavedSearchesByUserIDFunc: &SavedSearchStoreListSavedSearchesByUserIDFunc{
defaultHook: func(context.Context, int32) (r0 []*types.SavedSearch, r1 error) {
return
@ -34873,6 +34891,11 @@ func NewMockSavedSearchStore() *MockSavedSearchStore {
// interface. All methods panic on invocation, unless overwritten.
func NewStrictMockSavedSearchStore() *MockSavedSearchStore {
return &MockSavedSearchStore{
CountSavedSearchesByOrgOrUserFunc: &SavedSearchStoreCountSavedSearchesByOrgOrUserFunc{
defaultHook: func(context.Context, *int32, *int32) (int, error) {
panic("unexpected invocation of MockSavedSearchStore.CountSavedSearchesByOrgOrUser")
},
},
CreateFunc: &SavedSearchStoreCreateFunc{
defaultHook: func(context.Context, *types.SavedSearch) (*types.SavedSearch, error) {
panic("unexpected invocation of MockSavedSearchStore.Create")
@ -34908,6 +34931,11 @@ func NewStrictMockSavedSearchStore() *MockSavedSearchStore {
panic("unexpected invocation of MockSavedSearchStore.ListSavedSearchesByOrgID")
},
},
ListSavedSearchesByOrgOrUserFunc: &SavedSearchStoreListSavedSearchesByOrgOrUserFunc{
defaultHook: func(context.Context, *int32, *int32, *PaginationArgs) ([]*types.SavedSearch, error) {
panic("unexpected invocation of MockSavedSearchStore.ListSavedSearchesByOrgOrUser")
},
},
ListSavedSearchesByUserIDFunc: &SavedSearchStoreListSavedSearchesByUserIDFunc{
defaultHook: func(context.Context, int32) ([]*types.SavedSearch, error) {
panic("unexpected invocation of MockSavedSearchStore.ListSavedSearchesByUserID")
@ -34936,6 +34964,9 @@ func NewStrictMockSavedSearchStore() *MockSavedSearchStore {
// implementation, unless overwritten.
func NewMockSavedSearchStoreFrom(i SavedSearchStore) *MockSavedSearchStore {
return &MockSavedSearchStore{
CountSavedSearchesByOrgOrUserFunc: &SavedSearchStoreCountSavedSearchesByOrgOrUserFunc{
defaultHook: i.CountSavedSearchesByOrgOrUser,
},
CreateFunc: &SavedSearchStoreCreateFunc{
defaultHook: i.Create,
},
@ -34957,6 +34988,9 @@ func NewMockSavedSearchStoreFrom(i SavedSearchStore) *MockSavedSearchStore {
ListSavedSearchesByOrgIDFunc: &SavedSearchStoreListSavedSearchesByOrgIDFunc{
defaultHook: i.ListSavedSearchesByOrgID,
},
ListSavedSearchesByOrgOrUserFunc: &SavedSearchStoreListSavedSearchesByOrgOrUserFunc{
defaultHook: i.ListSavedSearchesByOrgOrUser,
},
ListSavedSearchesByUserIDFunc: &SavedSearchStoreListSavedSearchesByUserIDFunc{
defaultHook: i.ListSavedSearchesByUserID,
},
@ -34972,6 +35006,121 @@ func NewMockSavedSearchStoreFrom(i SavedSearchStore) *MockSavedSearchStore {
}
}
// SavedSearchStoreCountSavedSearchesByOrgOrUserFunc describes the behavior
// when the CountSavedSearchesByOrgOrUser method of the parent
// MockSavedSearchStore instance is invoked.
type SavedSearchStoreCountSavedSearchesByOrgOrUserFunc struct {
defaultHook func(context.Context, *int32, *int32) (int, error)
hooks []func(context.Context, *int32, *int32) (int, error)
history []SavedSearchStoreCountSavedSearchesByOrgOrUserFuncCall
mutex sync.Mutex
}
// CountSavedSearchesByOrgOrUser delegates to the next hook function in the
// queue and stores the parameter and result values of this invocation.
func (m *MockSavedSearchStore) CountSavedSearchesByOrgOrUser(v0 context.Context, v1 *int32, v2 *int32) (int, error) {
r0, r1 := m.CountSavedSearchesByOrgOrUserFunc.nextHook()(v0, v1, v2)
m.CountSavedSearchesByOrgOrUserFunc.appendCall(SavedSearchStoreCountSavedSearchesByOrgOrUserFuncCall{v0, v1, v2, r0, r1})
return r0, r1
}
// SetDefaultHook sets function that is called when the
// CountSavedSearchesByOrgOrUser method of the parent MockSavedSearchStore
// instance is invoked and the hook queue is empty.
func (f *SavedSearchStoreCountSavedSearchesByOrgOrUserFunc) SetDefaultHook(hook func(context.Context, *int32, *int32) (int, error)) {
f.defaultHook = hook
}
// PushHook adds a function to the end of hook queue. Each invocation of the
// CountSavedSearchesByOrgOrUser method of the parent MockSavedSearchStore
// 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 *SavedSearchStoreCountSavedSearchesByOrgOrUserFunc) PushHook(hook func(context.Context, *int32, *int32) (int, 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 *SavedSearchStoreCountSavedSearchesByOrgOrUserFunc) SetDefaultReturn(r0 int, r1 error) {
f.SetDefaultHook(func(context.Context, *int32, *int32) (int, error) {
return r0, r1
})
}
// PushReturn calls PushHook with a function that returns the given values.
func (f *SavedSearchStoreCountSavedSearchesByOrgOrUserFunc) PushReturn(r0 int, r1 error) {
f.PushHook(func(context.Context, *int32, *int32) (int, error) {
return r0, r1
})
}
func (f *SavedSearchStoreCountSavedSearchesByOrgOrUserFunc) nextHook() func(context.Context, *int32, *int32) (int, 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 *SavedSearchStoreCountSavedSearchesByOrgOrUserFunc) appendCall(r0 SavedSearchStoreCountSavedSearchesByOrgOrUserFuncCall) {
f.mutex.Lock()
f.history = append(f.history, r0)
f.mutex.Unlock()
}
// History returns a sequence of
// SavedSearchStoreCountSavedSearchesByOrgOrUserFuncCall objects describing
// the invocations of this function.
func (f *SavedSearchStoreCountSavedSearchesByOrgOrUserFunc) History() []SavedSearchStoreCountSavedSearchesByOrgOrUserFuncCall {
f.mutex.Lock()
history := make([]SavedSearchStoreCountSavedSearchesByOrgOrUserFuncCall, len(f.history))
copy(history, f.history)
f.mutex.Unlock()
return history
}
// SavedSearchStoreCountSavedSearchesByOrgOrUserFuncCall is an object that
// describes an invocation of method CountSavedSearchesByOrgOrUser on an
// instance of MockSavedSearchStore.
type SavedSearchStoreCountSavedSearchesByOrgOrUserFuncCall 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 *int32
// Result0 is the value of the 1st result returned from this method
// invocation.
Result0 int
// Result1 is the value of the 2nd result returned from this method
// invocation.
Result1 error
}
// Args returns an interface slice containing the arguments of this
// invocation.
func (c SavedSearchStoreCountSavedSearchesByOrgOrUserFuncCall) Args() []interface{} {
return []interface{}{c.Arg0, c.Arg1, c.Arg2}
}
// Results returns an interface slice containing the results of this
// invocation.
func (c SavedSearchStoreCountSavedSearchesByOrgOrUserFuncCall) Results() []interface{} {
return []interface{}{c.Result0, c.Result1}
}
// SavedSearchStoreCreateFunc describes the behavior when the Create method
// of the parent MockSavedSearchStore instance is invoked.
type SavedSearchStoreCreateFunc struct {
@ -35714,6 +35863,124 @@ func (c SavedSearchStoreListSavedSearchesByOrgIDFuncCall) Results() []interface{
return []interface{}{c.Result0, c.Result1}
}
// SavedSearchStoreListSavedSearchesByOrgOrUserFunc describes the behavior
// when the ListSavedSearchesByOrgOrUser method of the parent
// MockSavedSearchStore instance is invoked.
type SavedSearchStoreListSavedSearchesByOrgOrUserFunc struct {
defaultHook func(context.Context, *int32, *int32, *PaginationArgs) ([]*types.SavedSearch, error)
hooks []func(context.Context, *int32, *int32, *PaginationArgs) ([]*types.SavedSearch, error)
history []SavedSearchStoreListSavedSearchesByOrgOrUserFuncCall
mutex sync.Mutex
}
// ListSavedSearchesByOrgOrUser delegates to the next hook function in the
// queue and stores the parameter and result values of this invocation.
func (m *MockSavedSearchStore) ListSavedSearchesByOrgOrUser(v0 context.Context, v1 *int32, v2 *int32, v3 *PaginationArgs) ([]*types.SavedSearch, error) {
r0, r1 := m.ListSavedSearchesByOrgOrUserFunc.nextHook()(v0, v1, v2, v3)
m.ListSavedSearchesByOrgOrUserFunc.appendCall(SavedSearchStoreListSavedSearchesByOrgOrUserFuncCall{v0, v1, v2, v3, r0, r1})
return r0, r1
}
// SetDefaultHook sets function that is called when the
// ListSavedSearchesByOrgOrUser method of the parent MockSavedSearchStore
// instance is invoked and the hook queue is empty.
func (f *SavedSearchStoreListSavedSearchesByOrgOrUserFunc) SetDefaultHook(hook func(context.Context, *int32, *int32, *PaginationArgs) ([]*types.SavedSearch, error)) {
f.defaultHook = hook
}
// PushHook adds a function to the end of hook queue. Each invocation of the
// ListSavedSearchesByOrgOrUser method of the parent MockSavedSearchStore
// 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 *SavedSearchStoreListSavedSearchesByOrgOrUserFunc) PushHook(hook func(context.Context, *int32, *int32, *PaginationArgs) ([]*types.SavedSearch, 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 *SavedSearchStoreListSavedSearchesByOrgOrUserFunc) SetDefaultReturn(r0 []*types.SavedSearch, r1 error) {
f.SetDefaultHook(func(context.Context, *int32, *int32, *PaginationArgs) ([]*types.SavedSearch, error) {
return r0, r1
})
}
// PushReturn calls PushHook with a function that returns the given values.
func (f *SavedSearchStoreListSavedSearchesByOrgOrUserFunc) PushReturn(r0 []*types.SavedSearch, r1 error) {
f.PushHook(func(context.Context, *int32, *int32, *PaginationArgs) ([]*types.SavedSearch, error) {
return r0, r1
})
}
func (f *SavedSearchStoreListSavedSearchesByOrgOrUserFunc) nextHook() func(context.Context, *int32, *int32, *PaginationArgs) ([]*types.SavedSearch, 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 *SavedSearchStoreListSavedSearchesByOrgOrUserFunc) appendCall(r0 SavedSearchStoreListSavedSearchesByOrgOrUserFuncCall) {
f.mutex.Lock()
f.history = append(f.history, r0)
f.mutex.Unlock()
}
// History returns a sequence of
// SavedSearchStoreListSavedSearchesByOrgOrUserFuncCall objects describing
// the invocations of this function.
func (f *SavedSearchStoreListSavedSearchesByOrgOrUserFunc) History() []SavedSearchStoreListSavedSearchesByOrgOrUserFuncCall {
f.mutex.Lock()
history := make([]SavedSearchStoreListSavedSearchesByOrgOrUserFuncCall, len(f.history))
copy(history, f.history)
f.mutex.Unlock()
return history
}
// SavedSearchStoreListSavedSearchesByOrgOrUserFuncCall is an object that
// describes an invocation of method ListSavedSearchesByOrgOrUser on an
// instance of MockSavedSearchStore.
type SavedSearchStoreListSavedSearchesByOrgOrUserFuncCall 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 *int32
// Arg3 is the value of the 4th argument passed to this method
// invocation.
Arg3 *PaginationArgs
// Result0 is the value of the 1st result returned from this method
// invocation.
Result0 []*types.SavedSearch
// Result1 is the value of the 2nd result returned from this method
// invocation.
Result1 error
}
// Args returns an interface slice containing the arguments of this
// invocation.
func (c SavedSearchStoreListSavedSearchesByOrgOrUserFuncCall) Args() []interface{} {
return []interface{}{c.Arg0, c.Arg1, c.Arg2, c.Arg3}
}
// Results returns an interface slice containing the results of this
// invocation.
func (c SavedSearchStoreListSavedSearchesByOrgOrUserFuncCall) Results() []interface{} {
return []interface{}{c.Result0, c.Result1}
}
// SavedSearchStoreListSavedSearchesByUserIDFunc describes the behavior when
// the ListSavedSearchesByUserID method of the parent MockSavedSearchStore
// instance is invoked.

View File

@ -9,6 +9,7 @@ import (
"github.com/sourcegraph/sourcegraph/internal/api"
"github.com/sourcegraph/sourcegraph/internal/database/basestore"
"github.com/sourcegraph/sourcegraph/internal/database/dbutil"
"github.com/sourcegraph/sourcegraph/internal/trace"
"github.com/sourcegraph/sourcegraph/internal/types"
"github.com/sourcegraph/sourcegraph/lib/errors"
@ -22,6 +23,8 @@ type SavedSearchStore interface {
ListAll(context.Context) ([]api.SavedQuerySpecAndConfig, error)
ListSavedSearchesByOrgID(ctx context.Context, orgID int32) ([]*types.SavedSearch, error)
ListSavedSearchesByUserID(ctx context.Context, userID int32) ([]*types.SavedSearch, error)
ListSavedSearchesByOrgOrUser(ctx context.Context, userID, orgID *int32, paginationArgs *PaginationArgs) ([]*types.SavedSearch, error)
CountSavedSearchesByOrgOrUser(ctx context.Context, userID, orgID *int32) (int, error)
Transact(context.Context) (SavedSearchStore, error)
Update(context.Context, *types.SavedSearch) (*types.SavedSearch, error)
With(basestore.ShareableStore) SavedSearchStore
@ -239,6 +242,76 @@ func (s *savedSearchStore) ListSavedSearchesByOrgID(ctx context.Context, orgID i
return savedSearches, nil
}
// ListSavedSearchesByOrgOrUser lists all the saved searches associated with an
// organization for the user.
//
// 🚨 SECURITY: This method does NOT verify the user's identity or that the
// user is an admin. It is the callers responsibility to ensure only admins or
// members of the specified organization can access the returned saved
// searches.
func (s *savedSearchStore) ListSavedSearchesByOrgOrUser(ctx context.Context, userID, orgID *int32, paginationArgs *PaginationArgs) ([]*types.SavedSearch, error) {
p, err := paginationArgs.SQL()
if err != nil {
return nil, err
}
var where []*sqlf.Query
if userID != nil && *userID != 0 {
where = append(where, sqlf.Sprintf("user_id = %v", *userID))
} else if orgID != nil && *orgID != 0 {
where = append(where, sqlf.Sprintf("org_id = %v", *orgID))
} else {
return nil, errors.New("userID or orgID must be provided.")
}
if p.Where != nil {
where = append(where, p.Where)
}
query := sqlf.Sprintf(listSavedSearchesQueryFmtStr, sqlf.Sprintf("WHERE %v", sqlf.Join(where, " AND ")))
query = p.AppendOrderToQuery(query)
query = p.AppendLimitToQuery(query)
return scanSavedSearches(s.Query(ctx, query))
}
const listSavedSearchesQueryFmtStr = `
SELECT
id,
description,
query,
notify_owner,
notify_slack,
user_id,
org_id,
slack_webhook_url
FROM saved_searches %v
`
var scanSavedSearches = basestore.NewSliceScanner(scanSavedSearch)
func scanSavedSearch(s dbutil.Scanner) (*types.SavedSearch, error) {
var ss types.SavedSearch
if err := s.Scan(&ss.ID, &ss.Description, &ss.Query, &ss.Notify, &ss.NotifySlack, &ss.UserID, &ss.OrgID, &ss.SlackWebhookURL); err != nil {
return nil, errors.Wrap(err, "Scan")
}
return &ss, nil
}
// CountSavedSearchesByOrgOrUser counts all the saved searches associated with an
// organization for the user.
//
// 🚨 SECURITY: This method does NOT verify the user's identity or that the
// user is an admin. It is the callers responsibility to ensure only admins or
// members of the specified organization can access the returned saved
// searches.
func (s *savedSearchStore) CountSavedSearchesByOrgOrUser(ctx context.Context, userID, orgID *int32) (int, error) {
query := sqlf.Sprintf(`SELECT COUNT(*) FROM saved_searches WHERE user_id=%v OR org_id=%v`, userID, orgID)
count, _, err := basestore.ScanFirstInt(s.Query(ctx, query))
return count, err
}
// Create creates a new saved search with the specified parameters. The ID
// field must be zero, or an error will be returned.
//

View File

@ -73,7 +73,7 @@ const config = {
],
globalSetup: path.join(__dirname, 'client/shared/dev/jestGlobalSetup.js'),
globals: {
Uint8Array: Uint8Array,
Uint8Array,
},
}

View File

@ -2022,8 +2022,16 @@ type SettingsExperimentalFeatures struct {
BatchChangesExecution *bool `json:"batchChangesExecution,omitempty"`
// ClientSearchResultRanking description: How to rank search results in the client
ClientSearchResultRanking *string `json:"clientSearchResultRanking,omitempty"`
// CodeInsights description: Enables code insights on directory pages.
CodeInsights *bool `json:"codeInsights,omitempty"`
// CodeInsightsAllRepos description: DEPRECATED: Enables the experimental ability to run an insight over all repositories on the instance.
CodeInsightsAllRepos *bool `json:"codeInsightsAllRepos,omitempty"`
// CodeInsightsCompute description: Enables Compute powered Code Insights
CodeInsightsCompute *bool `json:"codeInsightsCompute,omitempty"`
// CodeInsightsGqlApi description: DEPRECATED: Enables gql api instead of using setting cascade as a main storage fro code insights entities
CodeInsightsGqlApi *bool `json:"codeInsightsGqlApi,omitempty"`
// CodeInsightsLandingPage description: DEPRECATED: Enables code insights landing page layout.
CodeInsightsLandingPage *bool `json:"codeInsightsLandingPage,omitempty"`
// CodeInsightsRepoUI description: Specifies which (code insight repo) editor to use for repo query UI
CodeInsightsRepoUI *string `json:"codeInsightsRepoUI,omitempty"`
CodeIntelRepositoryBadge *CodeIntelRepositoryBadge `json:"codeIntelRepositoryBadge,omitempty"`
@ -2031,6 +2039,8 @@ type SettingsExperimentalFeatures struct {
CodeMonitoringWebHooks *bool `json:"codeMonitoringWebHooks,omitempty"`
// CodeNavigation description: What kind of experimental code navigation UX to enable. The most recommended option is 'selection-driven'.
CodeNavigation *string `json:"codeNavigation,omitempty"`
// CopyQueryButton description: DEPRECATED: This feature is now permanently enabled. Enables displaying the copy query button in the search bar when hovering over the global navigation bar.
CopyQueryButton *bool `json:"copyQueryButton,omitempty"`
// Editor description: Specifies which (code) editor to use for query and text input
Editor *string `json:"editor,omitempty"`
// EnableCodeMirrorFileView description: Uses CodeMirror to display files. In this first iteration not all features of the current file view are available.
@ -2063,8 +2073,6 @@ type SettingsExperimentalFeatures struct {
FuzzyFinderSymbols *bool `json:"fuzzyFinderSymbols,omitempty"`
// GoCodeCheckerTemplates description: Shows a panel with code insights templates for go code checker results.
GoCodeCheckerTemplates *bool `json:"goCodeCheckerTemplates,omitempty"`
// HomePanelsComputeSuggestions description: Enable the home panels compute suggestions feature.
HomePanelsComputeSuggestions bool `json:"homePanelsComputeSuggestions,omitempty"`
// HomepageUserInvitation description: Shows a panel to invite collaborators to Sourcegraph on home page.
HomepageUserInvitation *bool `json:"homepageUserInvitation,omitempty"`
// PreloadGoToDefinition description: Preload definitions for available tokens in the visible viewport.
@ -2077,12 +2085,22 @@ type SettingsExperimentalFeatures struct {
SearchQueryInput *string `json:"searchQueryInput,omitempty"`
// SearchResultsAggregations description: Display aggregations for your search results on the search screen.
SearchResultsAggregations *bool `json:"searchResultsAggregations,omitempty"`
// SearchStats description: Enables a button on the search results page that shows language statistics about the results for a search query.
SearchStats *bool `json:"searchStats,omitempty"`
// SearchStreaming description: DEPRECATED: This feature is now permanently enabled. Enables streaming search support.
SearchStreaming *bool `json:"searchStreaming,omitempty"`
// ShowCodeMonitoringLogs description: Shows code monitoring logs tab.
ShowCodeMonitoringLogs *bool `json:"showCodeMonitoringLogs,omitempty"`
// ShowEnterpriseHomePanels description: Enabled the homepage panels in the Enterprise homepage
ShowEnterpriseHomePanels *bool `json:"showEnterpriseHomePanels,omitempty"`
// ShowMultilineSearchConsole description: Enables the multiline search console at search/console
ShowMultilineSearchConsole *bool `json:"showMultilineSearchConsole,omitempty"`
// ShowOnboardingTour description: REMOVED.
ShowOnboardingTour *bool `json:"showOnboardingTour,omitempty"`
// ShowRepogroupHomepage description: Enables the repository group homepage
ShowRepogroupHomepage *bool `json:"showRepogroupHomepage,omitempty"`
// ShowSearchContext description: Enables the search context dropdown.
ShowSearchContext *bool `json:"showSearchContext,omitempty"`
// ShowSearchContextManagement description: REMOVED.
ShowSearchContextManagement *bool `json:"showSearchContextManagement,omitempty"`
// SymbolKindTags description: Show the initial letter of the symbol kind instead of icons.
SymbolKindTags bool `json:"symbolKindTags,omitempty"`
Additional map[string]any `json:"-"` // additionalProperties not explicitly defined in the schema
@ -2121,11 +2139,16 @@ func (v *SettingsExperimentalFeatures) UnmarshalJSON(data []byte) error {
delete(m, "applySearchQuerySuggestionOnEnter")
delete(m, "batchChangesExecution")
delete(m, "clientSearchResultRanking")
delete(m, "codeInsights")
delete(m, "codeInsightsAllRepos")
delete(m, "codeInsightsCompute")
delete(m, "codeInsightsGqlApi")
delete(m, "codeInsightsLandingPage")
delete(m, "codeInsightsRepoUI")
delete(m, "codeIntelRepositoryBadge")
delete(m, "codeMonitoringWebHooks")
delete(m, "codeNavigation")
delete(m, "copyQueryButton")
delete(m, "editor")
delete(m, "enableCodeMirrorFileView")
delete(m, "enableGoImportsSearchQueryTransform")
@ -2142,16 +2165,20 @@ func (v *SettingsExperimentalFeatures) UnmarshalJSON(data []byte) error {
delete(m, "fuzzyFinderRepositories")
delete(m, "fuzzyFinderSymbols")
delete(m, "goCodeCheckerTemplates")
delete(m, "homePanelsComputeSuggestions")
delete(m, "homepageUserInvitation")
delete(m, "preloadGoToDefinition")
delete(m, "proactiveSearchResultsAggregations")
delete(m, "searchContextsQuery")
delete(m, "searchQueryInput")
delete(m, "searchResultsAggregations")
delete(m, "searchStats")
delete(m, "searchStreaming")
delete(m, "showCodeMonitoringLogs")
delete(m, "showEnterpriseHomePanels")
delete(m, "showMultilineSearchConsole")
delete(m, "showOnboardingTour")
delete(m, "showRepogroupHomepage")
delete(m, "showSearchContext")
delete(m, "showSearchContextManagement")
delete(m, "symbolKindTags")
if len(m) > 0 {
v.Additional = make(map[string]any, len(m))

View File

@ -13,11 +13,40 @@
"type": "object",
"additionalProperties": true,
"properties": {
"homePanelsComputeSuggestions": {
"codeInsights": {
"description": "Enables code insights on directory pages.",
"type": "boolean",
"default": false,
"deprecated": false,
"description": "Enable the home panels compute suggestions feature."
"!go": {
"pointer": true
}
},
"codeInsightsGqlApi": {
"deprecated": true,
"description": "DEPRECATED: Enables gql api instead of using setting cascade as a main storage fro code insights entities",
"type": "boolean",
"default": false,
"!go": {
"pointer": true
}
},
"codeInsightsAllRepos": {
"deprecated": true,
"description": "DEPRECATED: Enables the experimental ability to run an insight over all repositories on the instance.",
"type": "boolean",
"default": false,
"!go": {
"pointer": true
}
},
"codeInsightsLandingPage": {
"deprecated": true,
"description": "DEPRECATED: Enables code insights landing page layout.",
"type": "boolean",
"default": false,
"!go": {
"pointer": true
}
},
"codeInsightsRepoUI": {
"description": "Specifies which (code insight repo) editor to use for repo query UI",
@ -36,8 +65,56 @@
"pointer": true
}
},
"showEnterpriseHomePanels": {
"description": "Enabled the homepage panels in the Enterprise homepage",
"searchStats": {
"description": "Enables a button on the search results page that shows language statistics about the results for a search query.",
"type": "boolean",
"default": false,
"!go": {
"pointer": true
}
},
"searchStreaming": {
"description": "DEPRECATED: This feature is now permanently enabled. Enables streaming search support.",
"type": "boolean",
"default": false,
"!go": {
"pointer": true
}
},
"copyQueryButton": {
"description": "DEPRECATED: This feature is now permanently enabled. Enables displaying the copy query button in the search bar when hovering over the global navigation bar.",
"type": "boolean",
"default": false,
"!go": {
"pointer": true
}
},
"showRepogroupHomepage": {
"description": "Enables the repository group homepage ",
"type": "boolean",
"default": false,
"!go": {
"pointer": true
}
},
"showOnboardingTour": {
"description": "REMOVED.",
"type": "boolean",
"default": true,
"!go": {
"pointer": true
}
},
"showSearchContext": {
"description": "Enables the search context dropdown.",
"type": "boolean",
"default": false,
"!go": {
"pointer": true
}
},
"showSearchContextManagement": {
"description": "REMOVED.",
"type": "boolean",
"default": false,
"!go": {