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:
Quinn Slack 2024-07-19 01:41:47 -07:00 committed by GitHub
parent 3c8b8e9e25
commit 2451aab9f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 120 additions and 98 deletions

View File

@ -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 && (

View File

@ -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} />
</>

View File

@ -44,6 +44,7 @@ export const PromptPage: FunctionComponent<{
</PageHeader.Heading>
</PageHeader>
{children}
<div className="pb-4" />
</Page>
)
}

View File

@ -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.

View File

@ -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} />
</>

View File

@ -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>
)
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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)
}

View File

@ -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 {

View File

@ -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)
}