Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Automation UI #5622

Merged
merged 16 commits into from Sep 19, 2019
4 changes: 4 additions & 0 deletions cmd/frontend/internal/app/jscontext/jscontext.go
Expand Up @@ -78,6 +78,8 @@ type JSContext struct {
AuthProviders []authProviderInfo `json:"authProviders"`

Branding *schema.Branding `json:"branding"`

ExperimentalFeatures schema.ExperimentalFeatures `json:"experimentalFeatures"`
}

// NewJSContextFromRequest populates a JSContext struct from the HTTP
Expand Down Expand Up @@ -172,6 +174,8 @@ func NewJSContextFromRequest(req *http.Request) JSContext {
AuthProviders: authProviders,

Branding: conf.Branding(),

ExperimentalFeatures: conf.ExperimentalFeatures(),
}
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -75,6 +75,7 @@
"@storybook/components": "^5.1.3",
"@storybook/react": "^5.1.3",
"@storybook/theming": "^5.1.3",
"@testing-library/react-hooks": "^2.0.1",
"@types/babel__core": "7.1.2",
"@types/chai": "4.2.0",
"@types/chai-as-promised": "7.1.2",
Expand Down
8 changes: 8 additions & 0 deletions pkg/conf/computed.go
Expand Up @@ -318,3 +318,11 @@ func EventLoggingEnabled() bool {
}
return val == "enabled"
}

func ExperimentalFeatures() schema.ExperimentalFeatures {
val := Get().ExperimentalFeatures
if val == nil {
return schema.ExperimentalFeatures{}
}
return *val
}
1 change: 1 addition & 0 deletions schema/schema.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions schema/site.schema.json
Expand Up @@ -60,6 +60,12 @@
"type": "string",
"enum": ["enabled", "disabled"],
"default": "enabled"
},
"automation": {
"description": "Enables the experimental code automation features.",
"type": "string",
"enum": ["enabled", "disabled"],
"default": "disabled"
}
},
"group": "Experimental",
Expand Down
6 changes: 6 additions & 0 deletions schema/site_stringdata.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 21 additions & 18 deletions web/src/Layout.tsx
Expand Up @@ -92,6 +92,7 @@ export interface LayoutProps
) => Observable<GQL.ISearchResults | ErrorLike>

isSourcegraphDotCom: boolean
showCampaigns: boolean

children?: never
}
Expand Down Expand Up @@ -127,26 +128,28 @@ export const Layout: React.FunctionComponent<LayoutProps> = props => {
<Suspense fallback={<LoadingSpinner className="icon-inline m-2" />}>
<Switch>
{/* eslint-disable react/jsx-no-bind */}
{props.routes.map(({ render, ...route }) => {
{props.routes.map(({ render, condition = () => true, ...route }) => {
const isFullWidth = !route.forceNarrowWidth
return (
<Route
{...route}
key="hardcoded-key" // see https://github.com/ReactTraining/react-router/issues/4578#issuecomment-334489490
component={undefined}
render={routeComponentProps => (
<div
className={[
'layout__app-router-container',
`layout__app-router-container--${
isFullWidth ? 'full-width' : 'restricted'
}`,
].join(' ')}
>
{render({ ...props, ...routeComponentProps })}
</div>
)}
/>
condition(props) && (
<Route
{...route}
key="hardcoded-key" // see https://github.com/ReactTraining/react-router/issues/4578#issuecomment-334489490
component={undefined}
render={routeComponentProps => (
<div
className={[
'layout__app-router-container',
`layout__app-router-container--${
isFullWidth ? 'full-width' : 'restricted'
}`,
].join(' ')}
>
{render({ ...props, ...routeComponentProps })}
</div>
)}
/>
)
)
})}
{/* eslint-enable react/jsx-no-bind */}
Expand Down
8 changes: 8 additions & 0 deletions web/src/SourcegraphWebApp.tsx
Expand Up @@ -70,6 +70,7 @@ export interface SourcegraphWebAppProps extends KeyboardShortcutsProps {
repoRevContainerRoutes: readonly RepoRevContainerRoute[]
repoHeaderActionButtons: readonly RepoHeaderActionButton[]
routes: readonly LayoutRouteProps[]
showCampaigns: boolean
}

interface SourcegraphWebAppState extends SettingsCascadeProps {
Expand Down Expand Up @@ -255,6 +256,13 @@ class ColdSourcegraphWebApp extends React.Component<SourcegraphWebAppProps, Sour
authenticatedUser={authenticatedUser}
viewerSubject={this.state.viewerSubject}
settingsCascade={this.state.settingsCascade}
showCampaigns={
this.props.showCampaigns &&
window.context.experimentalFeatures.automation === 'enabled' &&
!window.context.sourcegraphDotComMode &&
!!authenticatedUser &&
authenticatedUser.siteAdmin
}
// Theme
isLightTheme={this.isLightTheme()}
themePreference={this.state.themePreference}
Expand Down
22 changes: 22 additions & 0 deletions web/src/components/LinkWithIconOnlyTooltip.tsx
@@ -0,0 +1,22 @@
import React from 'react'
import { Link } from 'react-router-dom'

/**
* A link that shows a tooltipped icon on narrow screens and a non-tooltipped icon label on wider
* screens.
*
* The tooltip is hidden on wider screens because it is redundant with the label text.
*/
export const LinkWithIconOnlyTooltip: React.FunctionComponent<{
to: string
text: string
tooltip?: string
icon: React.ComponentType<{ className?: string }>
className?: string
}> = ({ to, text, tooltip = text, icon: Icon, className = '' }) => (
<Link to={to} className={`${className} d-flex align-items-center`}>
<Icon className="icon-inline d-lg-none" data-tooltip={tooltip} />
<Icon className="icon-inline d-none d-lg-inline-block" />
<span className="d-none d-lg-inline-block ml-1">{text}</span>
</Link>
)
111 changes: 111 additions & 0 deletions web/src/enterprise/campaigns/detail/CampaignDetails.tsx
@@ -0,0 +1,111 @@
import { LoadingSpinner } from '@sourcegraph/react-loading-spinner'
import AlertCircleIcon from 'mdi-react/AlertCircleIcon'
import React from 'react'
import * as GQL from '../../../../../shared/src/graphql/schema'
import { HeroPage } from '../../../components/HeroPage'
import { PageTitle } from '../../../components/PageTitle'
import { useCampaignByID } from './useCampaignByID'
import { UserAvatar } from '../../../user/UserAvatar'
import { Timestamp } from '../../../components/time/Timestamp'
import { CampaignsIcon } from '../icons'
import { ChangesetList } from './changesets/ChangesetList'
import {
changesetStatusColorClasses,
changesetReviewStateColors,
changesetStageLabels,
} from './changesets/presentation'
import { Link } from '../../../../../shared/src/components/Link'
import { groupBy } from 'lodash'

interface Props {
/** The campaign ID. */
campaignID: GQL.ID
}

const changesetStages: (GQL.ChangesetState | GQL.ChangesetReviewState)[] = [
GQL.ChangesetState.MERGED,
GQL.ChangesetState.CLOSED,
GQL.ChangesetReviewState.APPROVED,
GQL.ChangesetReviewState.CHANGES_REQUESTED,
GQL.ChangesetReviewState.PENDING,
]
const changesetStageColors: Record<GQL.ChangesetReviewState | GQL.ChangesetState, string> = {
...changesetReviewStateColors,
...changesetStatusColorClasses,
}

/**
* The area for a single campaign.
*/
export const CampaignDetails: React.FunctionComponent<Props> = ({ campaignID }) => {
const campaign = useCampaignByID(campaignID)

if (campaign === undefined) {
return <LoadingSpinner className="icon-inline mx-auto my-4" />
}
if (campaign === null) {
return <HeroPage icon={AlertCircleIcon} title="Campaign not found" />
}

const changeSetCount = campaign.changesets.nodes.length

const changesetsByStage = groupBy(campaign.changesets.nodes, changeset =>
// For open changesets, group by review state
changeset.state !== GQL.ChangesetState.OPEN ? changeset.state : changeset.reviewState
)

return (
<>
<PageTitle title={campaign.name} />
<h2>
<CampaignsIcon className="icon-inline" /> {campaign.namespace.namespaceName}
<span className="text-muted d-inline-block mx-2">/</span>
{campaign.name}
</h2>
<div className="card mb-3">
<div className="card-header">
<strong>
<UserAvatar user={campaign.author} className="icon-inline" /> {campaign.author.username}
</strong>{' '}
started <Timestamp date={campaign.createdAt} />
</div>
<div className="card-body">{campaign.description}</div>
</div>
<h3>
Changesets <span className="badge badge-secondary badge-pill">{campaign.changesets.nodes.length}</span>
</h3>
{changeSetCount > 0 && (
<div>
<div className="progress rounded mb-2">
{changesetStages.map(stage => {
const changesetsInStage = changesetsByStage[stage] || []
const count = changesetsInStage.length
return (
count > 0 && (
<div
// Needed for dynamic width
// eslint-disable-next-line react/forbid-dom-props
style={{ width: (count / changeSetCount) * 100 + '%' }}
className={`progress-bar bg-${changesetStageColors[stage]}`}
role="progressbar"
aria-valuemin={0}
aria-valuenow={count}
aria-valuemax={changeSetCount}
key={stage}
>
{count} {changesetStageLabels[stage]}
</div>
)
)
})}
</div>
</div>
)}
<ChangesetList changesets={campaign.changesets.nodes} />
<p className="mt-2">
Use the <Link to="/api/console">GraphQL API</Link> to add changesets to this campaign (
<code>createChangeset</code> and <code>addChangesetToCampaign</code>)
</p>
</>
)
}
52 changes: 52 additions & 0 deletions web/src/enterprise/campaigns/detail/changesets/ChangesetList.tsx
@@ -0,0 +1,52 @@
import { IChangeset } from '../../../../../../shared/src/graphql/schema'
import React from 'react'
import SourcePullIcon from 'mdi-react/SourcePullIcon'
import {
changesetStatusColorClasses,
changesetReviewStateColors,
changesetReviewStateIcons,
changesetStageLabels,
} from './presentation'
import { Link } from '../../../../../../shared/src/components/Link'

interface Props {
changesets: IChangeset[]
}

export const ChangesetList: React.FunctionComponent<Props> = ({ changesets }) => (
<ul className="list-group">
{changesets.map(changeset => {
const ReviewStateIcon = changesetReviewStateIcons[changeset.reviewState]
return (
<li key={changeset.id} className="list-group-item d-flex pl-1 align-items-center">
<div className="flex-shrink-0 flex-grow-0 m-1">
<SourcePullIcon
className={`text-${changesetStatusColorClasses[changeset.state]}`}
data-tooltip={changesetStageLabels[changeset.state]}
/>
</div>
<div className="flex-shrink-0 flex-grow-0 m-1">
<ReviewStateIcon
className={`text-${changesetReviewStateColors[changeset.reviewState]}`}
data-tooltip={changesetStageLabels[changeset.reviewState]}
/>
</div>
<div className="flex-fill overflow-hidden m-1">
<h4 className="m-0">
<Link
to={changeset.repository.url}
className="text-muted"
target="_blank"
rel="noopener noreferrer"
>
{changeset.repository.name}
</Link>{' '}
<Link to={changeset.externalURL.url}>{changeset.title}</Link>
</h4>
<div className="text-truncate w-100">{changeset.body}</div>
</div>
</li>
)
})}
</ul>
)
32 changes: 32 additions & 0 deletions web/src/enterprise/campaigns/detail/changesets/presentation.ts
@@ -0,0 +1,32 @@
import { ChangesetState, ChangesetReviewState } from '../../../../../../shared/src/graphql/schema'
import { MdiReactIconComponentType } from 'mdi-react'
import AccountCheckIcon from 'mdi-react/AccountCheckIcon'
import AccountAlertIcon from 'mdi-react/AccountAlertIcon'
import AccountQuestionIcon from 'mdi-react/AccountQuestionIcon'

export const changesetStatusColorClasses: Record<ChangesetState, string> = {
[ChangesetState.OPEN]: 'success',
[ChangesetState.CLOSED]: 'danger',
[ChangesetState.MERGED]: 'purple',
}

export const changesetReviewStateColors: Record<ChangesetReviewState, string> = {
[ChangesetReviewState.APPROVED]: 'success',
[ChangesetReviewState.CHANGES_REQUESTED]: 'danger',
[ChangesetReviewState.PENDING]: 'warning',
}

export const changesetReviewStateIcons: Record<ChangesetReviewState, MdiReactIconComponentType> = {
[ChangesetReviewState.APPROVED]: AccountCheckIcon,
[ChangesetReviewState.CHANGES_REQUESTED]: AccountAlertIcon,
[ChangesetReviewState.PENDING]: AccountQuestionIcon,
}

export const changesetStageLabels: Record<ChangesetReviewState | ChangesetState, string> = {
[ChangesetState.OPEN]: 'open',
[ChangesetState.CLOSED]: 'closed',
[ChangesetState.MERGED]: 'merged',
[ChangesetReviewState.APPROVED]: 'approved',
[ChangesetReviewState.CHANGES_REQUESTED]: 'changes requested',
[ChangesetReviewState.PENDING]: 'pending review',
}