mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 19:21:50 +00:00
saved searches & prompt library fixes (#63930)
- Add spacing at bottom of page - Fix viewerCanAdminister check to return `false` not an error when the user is not an administrator of a saved search or prompt - Improve display of prompt description ## Test plan In dotcom mode, try loading a prompt or saved search in incognito mode.
This commit is contained in:
parent
3c8b8e9e25
commit
2451aab9f8
@ -85,10 +85,10 @@ const Detail: FunctionComponent<TelemetryV2Props & { prompt: PromptFields }> = (
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
<LibraryItemVisibilityBadge item={prompt} className="mr-1" />
|
||||
<LibraryItemStatusBadge item={prompt} className="mr-1" />
|
||||
{prompt.description}
|
||||
{prompt.description ? ' — ' : ''}
|
||||
<LibraryItemVisibilityBadge item={prompt} />
|
||||
<LibraryItemStatusBadge item={prompt} className="ml-1" />{' '}
|
||||
<small>
|
||||
Last updated <Timestamp date={prompt.updatedAt} noAbout={true} />
|
||||
{prompt.updatedBy && (
|
||||
|
||||
@ -23,7 +23,7 @@ import {
|
||||
usePageSwitcherPagination,
|
||||
type PaginationKeys,
|
||||
} from '../components/FilteredConnection/hooks/usePageSwitcherPagination'
|
||||
import { ConnectionContainer, ConnectionForm } from '../components/FilteredConnection/ui'
|
||||
import { ConnectionForm } from '../components/FilteredConnection/ui'
|
||||
import { PromptsOrderBy, type PromptFields, type PromptsResult, type PromptsVariables } from '../graphql-operations'
|
||||
import { LibraryItemStatusBadge, LibraryItemVisibilityBadge } from '../library/itemBadges'
|
||||
import { useAffiliatedNamespaces } from '../namespaces/useAffiliatedNamespaces'
|
||||
@ -167,40 +167,36 @@ export const ListPage: FunctionComponent<TelemetryV2Props> = ({ telemetryRecorde
|
||||
return (
|
||||
<>
|
||||
<Container data-testid="prompts-list-page">
|
||||
<ConnectionContainer>
|
||||
<ConnectionForm
|
||||
hideSearch={false}
|
||||
showSearchFirst={true}
|
||||
inputClassName="mw-30"
|
||||
inputPlaceholder="Find a prompt..."
|
||||
inputAriaLabel=""
|
||||
inputValue={connectionState.query}
|
||||
onInputChange={event => {
|
||||
setConnectionState(prev => ({ ...prev, query: event.target.value }))
|
||||
}}
|
||||
autoFocus={false}
|
||||
filters={filters}
|
||||
onFilterSelect={(filter, value) =>
|
||||
setConnectionState(prev => ({ ...prev, [filter.id]: value }))
|
||||
}
|
||||
filterValues={connectionState}
|
||||
compact={false}
|
||||
formClassName="flex-gap-4 mb-4"
|
||||
/>
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<ErrorAlert error={error} className="mb-3" />
|
||||
) : !connection?.nodes || connection.nodes.length === 0 ? (
|
||||
<Text className="text-center text-muted mb-0">No prompts found.</Text>
|
||||
) : (
|
||||
<div className="list-group list-group-flush">
|
||||
{connection.nodes.map(prompt => (
|
||||
<PromptNode key={prompt.id} prompt={prompt} telemetryRecorder={telemetryRecorder} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ConnectionContainer>
|
||||
<ConnectionForm
|
||||
hideSearch={false}
|
||||
showSearchFirst={true}
|
||||
inputClassName="mw-30"
|
||||
inputPlaceholder="Find a prompt..."
|
||||
inputAriaLabel=""
|
||||
inputValue={connectionState.query}
|
||||
onInputChange={event => {
|
||||
setConnectionState(prev => ({ ...prev, query: event.target.value }))
|
||||
}}
|
||||
autoFocus={false}
|
||||
filters={filters}
|
||||
onFilterSelect={(filter, value) => setConnectionState(prev => ({ ...prev, [filter.id]: value }))}
|
||||
filterValues={connectionState}
|
||||
compact={false}
|
||||
formClassName="flex-gap-4 mb-4"
|
||||
/>
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<ErrorAlert error={error} className="mb-3" />
|
||||
) : !connection?.nodes || connection.nodes.length === 0 ? (
|
||||
<Text className="text-center text-muted mb-0">No prompts found.</Text>
|
||||
) : (
|
||||
<div className="list-group list-group-flush">
|
||||
{connection.nodes.map(prompt => (
|
||||
<PromptNode key={prompt.id} prompt={prompt} telemetryRecorder={telemetryRecorder} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
<PageSwitcher {...paginationProps} className="mt-4" totalCount={connection?.totalCount ?? null} />
|
||||
</>
|
||||
|
||||
@ -44,6 +44,7 @@ export const PromptPage: FunctionComponent<{
|
||||
</PageHeader.Heading>
|
||||
</PageHeader>
|
||||
{children}
|
||||
<div className="pb-4" />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,41 @@
|
||||
import AnimationPlayIcon from 'mdi-react/AnimationPlayIcon'
|
||||
import type { FunctionComponent } from 'react'
|
||||
|
||||
export const PromptIcon = AnimationPlayIcon
|
||||
/**
|
||||
* The icon for prompts in the Prompt Library, which is the Lucide `book-text` icon.
|
||||
*/
|
||||
export const PromptIcon: FunctionComponent<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
|
||||
<path d="M8 11h8" />
|
||||
<path d="M8 7h6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Lucide icon set license
|
||||
//
|
||||
// ISC License
|
||||
//
|
||||
// Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT).
|
||||
// All other copyright (c) for Lucide are held by Lucide Contributors 2022.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any purpose with or without
|
||||
// fee is hereby granted, provided that the above copyright notice and this permission notice appear
|
||||
// in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS
|
||||
// SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
|
||||
// AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
|
||||
// NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
|
||||
// OF THIS SOFTWARE.
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
usePageSwitcherPagination,
|
||||
type PaginationKeys,
|
||||
} from '../components/FilteredConnection/hooks/usePageSwitcherPagination'
|
||||
import { ConnectionContainer, ConnectionForm } from '../components/FilteredConnection/ui'
|
||||
import { ConnectionForm } from '../components/FilteredConnection/ui'
|
||||
import {
|
||||
SavedSearchesOrderBy,
|
||||
type SavedSearchFields,
|
||||
@ -199,45 +199,41 @@ export const ListPage: FunctionComponent<TelemetryV2Props> = ({ telemetryRecorde
|
||||
return (
|
||||
<>
|
||||
<Container data-testid="saved-searches-list-page">
|
||||
<ConnectionContainer>
|
||||
<ConnectionForm
|
||||
hideSearch={false}
|
||||
showSearchFirst={true}
|
||||
inputClassName="mw-30"
|
||||
inputPlaceholder="Find a saved search..."
|
||||
inputAriaLabel=""
|
||||
inputValue={connectionState.query}
|
||||
onInputChange={event => {
|
||||
setConnectionState(prev => ({ ...prev, query: event.target.value }))
|
||||
}}
|
||||
autoFocus={false}
|
||||
filters={filters}
|
||||
onFilterSelect={(filter, value) =>
|
||||
setConnectionState(prev => ({ ...prev, [filter.id]: value }))
|
||||
}
|
||||
filterValues={connectionState}
|
||||
compact={false}
|
||||
formClassName="flex-gap-4 mb-4"
|
||||
/>
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<ErrorAlert error={error} className="mb-3" />
|
||||
) : !connection?.nodes || connection.nodes.length === 0 ? (
|
||||
<Text className="text-center text-muted mb-0">No saved searches found.</Text>
|
||||
) : (
|
||||
<div className="list-group list-group-flush">
|
||||
{connection.nodes.map(savedSearch => (
|
||||
<SavedSearchNode
|
||||
key={savedSearch.id}
|
||||
patternType={searchPatternType}
|
||||
savedSearch={savedSearch}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ConnectionContainer>
|
||||
<ConnectionForm
|
||||
hideSearch={false}
|
||||
showSearchFirst={true}
|
||||
inputClassName="mw-30"
|
||||
inputPlaceholder="Find a saved search..."
|
||||
inputAriaLabel=""
|
||||
inputValue={connectionState.query}
|
||||
onInputChange={event => {
|
||||
setConnectionState(prev => ({ ...prev, query: event.target.value }))
|
||||
}}
|
||||
autoFocus={false}
|
||||
filters={filters}
|
||||
onFilterSelect={(filter, value) => setConnectionState(prev => ({ ...prev, [filter.id]: value }))}
|
||||
filterValues={connectionState}
|
||||
compact={false}
|
||||
formClassName="flex-gap-4 mb-4"
|
||||
/>
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<ErrorAlert error={error} className="mb-3" />
|
||||
) : !connection?.nodes || connection.nodes.length === 0 ? (
|
||||
<Text className="text-center text-muted mb-0">No saved searches found.</Text>
|
||||
) : (
|
||||
<div className="list-group list-group-flush">
|
||||
{connection.nodes.map(savedSearch => (
|
||||
<SavedSearchNode
|
||||
key={savedSearch.id}
|
||||
patternType={searchPatternType}
|
||||
savedSearch={savedSearch}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
<PageSwitcher {...paginationProps} className="mt-4" totalCount={connection?.totalCount ?? null} />
|
||||
</>
|
||||
|
||||
@ -7,7 +7,7 @@ import { PageHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import { Page } from '../components/Page'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { type SavedSearchFields } from '../graphql-operations'
|
||||
import type { SavedSearchFields } from '../graphql-operations'
|
||||
import { PageRoutes } from '../routes.constants'
|
||||
|
||||
import { urlToSavedSearchesList } from './ListPage'
|
||||
@ -47,6 +47,7 @@ export const SavedSearchPage: FunctionComponent<{
|
||||
</PageHeader.Heading>
|
||||
</PageHeader>
|
||||
{children}
|
||||
<div className="pb-4" />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@ -62,7 +62,7 @@ type PromptResolver interface {
|
||||
UpdatedBy(context.Context) (*UserResolver, error)
|
||||
UpdatedAt() gqlutil.DateTime
|
||||
URL() string
|
||||
ViewerCanAdminister(context.Context) (bool, error)
|
||||
ViewerCanAdminister(context.Context) bool
|
||||
}
|
||||
|
||||
type PromptDefinitionResolver struct {
|
||||
|
||||
@ -60,7 +60,7 @@ type SavedSearchResolver interface {
|
||||
UpdatedBy(context.Context) (*UserResolver, error)
|
||||
UpdatedAt() gqlutil.DateTime
|
||||
URL() string
|
||||
ViewerCanAdminister(context.Context) (bool, error)
|
||||
ViewerCanAdminister(context.Context) bool
|
||||
}
|
||||
|
||||
type SavedSearchesArgs struct {
|
||||
|
||||
@ -165,12 +165,12 @@ func (r *promptResolver) URL() string {
|
||||
return "/prompts/" + string(r.ID())
|
||||
}
|
||||
|
||||
func (r *promptResolver) ViewerCanAdminister(ctx context.Context) (bool, error) {
|
||||
func (r *promptResolver) ViewerCanAdminister(ctx context.Context) bool {
|
||||
// 🚨 SECURITY: If the visibility is public, then the user can see it, but they can only
|
||||
// administer it if they are authorized for the namespace (as an org member or their own user
|
||||
// account).
|
||||
err := graphqlbackend.CheckAuthorizedForNamespaceByIDs(ctx, r.db, r.s.Owner)
|
||||
return err == nil, err
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (r *Resolver) toPromptResolver(entry types.Prompt) *promptResolver {
|
||||
|
||||
@ -566,12 +566,7 @@ func TestPromptPermissions(t *testing.T) {
|
||||
t.Fatalf("got couldView %v (error %v), want %v", couldView, err, tt.viewerCanView)
|
||||
}
|
||||
if result != nil {
|
||||
gotCanAdminister, err := result.ViewerCanAdminister(ctx)
|
||||
if tt.opErrIs == nil {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorAs(t, err, &tt.opErrIs)
|
||||
}
|
||||
gotCanAdminister := result.ViewerCanAdminister(ctx)
|
||||
if gotCanAdminister != tt.viewerCanAdminister {
|
||||
t.Errorf("got %v, want %v", gotCanAdminister, tt.viewerCanAdminister)
|
||||
}
|
||||
|
||||
@ -158,12 +158,12 @@ func (r *savedSearchResolver) URL() string {
|
||||
return "/saved-searches/" + string(r.ID())
|
||||
}
|
||||
|
||||
func (r *savedSearchResolver) ViewerCanAdminister(ctx context.Context) (bool, error) {
|
||||
func (r *savedSearchResolver) ViewerCanAdminister(ctx context.Context) bool {
|
||||
// 🚨 SECURITY: If the visibility is public, then the user can see it, but they can only
|
||||
// administer it if they are authorized for the namespace (as an org member or their own user
|
||||
// account).
|
||||
err := graphqlbackend.CheckAuthorizedForNamespaceByIDs(ctx, r.db, r.s.Owner)
|
||||
return err == nil, err
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (r *Resolver) toSavedSearchResolver(entry types.SavedSearch) *savedSearchResolver {
|
||||
|
||||
@ -597,12 +597,7 @@ func TestSavedSearchPermissions(t *testing.T) {
|
||||
t.Fatalf("got couldView %v (error %v), want %v", couldView, err, tt.viewerCanView)
|
||||
}
|
||||
if result != nil {
|
||||
gotCanAdminister, err := result.ViewerCanAdminister(ctx)
|
||||
if tt.opErrIs == nil {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorAs(t, err, &tt.opErrIs)
|
||||
}
|
||||
gotCanAdminister := result.ViewerCanAdminister(ctx)
|
||||
if gotCanAdminister != tt.viewerCanAdminister {
|
||||
t.Errorf("got %v, want %v", gotCanAdminister, tt.viewerCanAdminister)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user