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 Studio settings page #3379

Merged
merged 7 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1345,7 +1345,7 @@
{
"id": "dvc.views.studio",
"name": "Studio",
"when": "!dvc.studio.connected"
"when": "true"
},
{
"id": "dvc.views.experimentsColumnsTree",
Expand Down Expand Up @@ -1394,6 +1394,11 @@
"contents": "[$(plug) Connect](command:dvc.showConnect)",
"when": "!dvc.studio.connected"
},
{
"view": "dvc.views.studio",
"contents": "[$(settings-gear) Open Settings](command:dvc.showConnect)",
"when": "dvc.studio.connected"
},
{
"view": "dvc.views.welcome",
"contents": "New to the extension?\n[Show Walkthrough](command:dvc.getStarted)\n\nThe extension is currently unable to initialize.\n[Show Setup](command:dvc.showSetup)",
Expand Down
62 changes: 43 additions & 19 deletions extension/src/connect/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { commands, ExtensionContext, SecretStorage } from 'vscode'
import { commands, ExtensionContext, SecretStorage, workspace } from 'vscode'
import { validateTokenInput } from './inputBox'
import { STUDIO_ACCESS_TOKEN_KEY, isStudioAccessToken } from './token'
import { STUDIO_URL } from './webview/contract'
import { ConnectData, STUDIO_URL } from './webview/contract'
import { Resource } from '../resourceLocator'
import { ViewKey } from '../webview/constants'
import { MessageFromWebview, MessageFromWebviewType } from '../webview/contract'
Expand All @@ -12,15 +12,15 @@ import { Title } from '../vscode/title'
import { openUrl } from '../vscode/external'
import { ContextKey, setContextValue } from '../vscode/context'
import { RegisteredCommands } from '../commands/external'
import { Modal } from '../vscode/modal'
import { GLOBAL_WEBVIEW_DVCROOT } from '../webview/factory'
import { ConfigKey, getConfigValue } from '../vscode/config'
import { ConfigKey, getConfigValue, setConfigValue } from '../vscode/config'

export class Connect extends BaseRepository<undefined> {
export class Connect extends BaseRepository<ConnectData> {
public readonly viewKey = ViewKey.CONNECT

private readonly secrets: SecretStorage
private studioAccessToken: string | undefined = undefined
private studioIsConnected = false

constructor(context: ExtensionContext, webviewIcon: Resource) {
super(GLOBAL_WEBVIEW_DVCROOT, webviewIcon)
Expand All @@ -36,7 +36,7 @@ export class Connect extends BaseRepository<undefined> {
void this.getSecret(STUDIO_ACCESS_TOKEN_KEY).then(
async studioAccessToken => {
this.studioAccessToken = studioAccessToken
await this.setContext()
await this.updateIsStudioConnected()
this.deferred.resolve()
}
)
Expand All @@ -46,13 +46,24 @@ export class Connect extends BaseRepository<undefined> {
if (e.key !== STUDIO_ACCESS_TOKEN_KEY) {
return
}

this.studioAccessToken = await this.getSecret(STUDIO_ACCESS_TOKEN_KEY)
return this.setContext()
return this.updateIsStudioConnected()
})
)

this.dispose.track(
workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE)) {
this.sendWebviewMessage()
}
})
)
}

public sendInitialWebviewData(): void {}
public sendInitialWebviewData() {
return this.sendWebviewMessage()
}

public removeStudioAccessToken() {
return this.removeSecret(STUDIO_ACCESS_TOKEN_KEY)
Expand Down Expand Up @@ -84,6 +95,13 @@ export class Connect extends BaseRepository<undefined> {
return this.studioAccessToken
}

private sendWebviewMessage() {
void this.getWebview()?.show({
isStudioConnected: this.studioIsConnected,
shareLiveToStudio: getConfigValue(ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE)
})
}

private handleMessageFromWebview(message: MessageFromWebview) {
switch (message.type) {
case MessageFromWebviewType.OPEN_STUDIO:
Expand All @@ -94,6 +112,15 @@ export class Connect extends BaseRepository<undefined> {
return commands.executeCommand(
RegisteredCommands.ADD_STUDIO_ACCESS_TOKEN
)
case MessageFromWebviewType.REMOVE_STUDIO_TOKEN:
return commands.executeCommand(
RegisteredCommands.REMOVE_STUDIO_ACCESS_TOKEN
)
case MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE:
return setConfigValue(
ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE,
message.payload
)
default:
Logger.error(`Unexpected message: ${JSON.stringify(message)}`)
}
Expand All @@ -107,19 +134,16 @@ export class Connect extends BaseRepository<undefined> {
return openUrl(`${STUDIO_URL}/user/_/profile?section=accessToken`)
}

private setContext() {
private updateIsStudioConnected() {
const storedToken = this.getStudioAccessToken()
if (isStudioAccessToken(storedToken)) {
if (this.deferred.state === 'resolved') {
void Modal.showInformation(
'Studio is now connected. Use the "Share to Studio" command from an experiment\'s context menu to share experiments.'
)
}
this.webview?.dispose()
return setContextValue(ContextKey.STUDIO_CONNECTED, true)
}
const isConnected = isStudioAccessToken(storedToken)
return this.setStudioIsConnected(isConnected)
}

return setContextValue(ContextKey.STUDIO_CONNECTED, false)
private setStudioIsConnected(isConnected: boolean) {
this.studioIsConnected = isConnected
this.sendWebviewMessage()
return setContextValue(ContextKey.STUDIO_CONNECTED, isConnected)
}

private getSecret(key: string) {
Expand Down
5 changes: 5 additions & 0 deletions extension/src/connect/webview/contract.ts
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
export const STUDIO_URL = 'https://studio.iterative.ai'

export type ConnectData = {
isStudioConnected: boolean
shareLiveToStudio: boolean
}
26 changes: 25 additions & 1 deletion extension/src/test/suite/connect/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
ExtensionContext,
SecretStorage,
Uri,
WorkspaceConfiguration,
commands,
env,
window
window,
workspace
} from 'vscode'
import { Disposable } from '../../../extension'
import {
Expand All @@ -22,6 +24,7 @@ import { WEBVIEW_TEST_TIMEOUT } from '../timeouts'
import { ContextKey } from '../../../vscode/context'
import { STUDIO_ACCESS_TOKEN_KEY } from '../../../connect/token'
import { RegisteredCommands } from '../../../commands/external'
import { ConfigKey } from '../../../vscode/config'

suite('Connect Test Suite', () => {
const disposable = Disposable.fn()
Expand Down Expand Up @@ -154,6 +157,27 @@ suite('Connect Test Suite', () => {
)
}).timeout(WEBVIEW_TEST_TIMEOUT)

it('should handle a message to set dvc.studio.shareExperimentsLive', async () => {
const { connect, mockMessageReceived } = await buildConnect()
await connect.isReady()

const mockUpdate = stub()

stub(workspace, 'getConfiguration').returns({
update: mockUpdate
} as unknown as WorkspaceConfiguration)

mockMessageReceived.fire({
payload: false,
type: MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE
})

expect(mockUpdate).to.be.calledWithExactly(
ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE,
false
)
})

it('should be able to delete the Studio access token from secrets storage', async () => {
const mockDelete = stub()
stub(
Expand Down
3 changes: 2 additions & 1 deletion extension/src/test/suite/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { DvcExecutor } from '../../cli/dvc/executor'
import { GitReader } from '../../cli/git/reader'
import { SetupData } from '../../setup/webview/contract'
import { DvcViewer } from '../../cli/dvc/viewer'
import { ConnectData } from '../../connect/webview/contract'

export const mockDisposable = {
dispose: stub()
Expand Down Expand Up @@ -244,7 +245,7 @@ export const buildDependencies = (
}

export const getMessageReceivedEmitter = (
webview: BaseWebview<PlotsData | TableData | SetupData>
webview: BaseWebview<PlotsData | TableData | SetupData | ConnectData>
): EventEmitter<MessageFromWebview> => (webview as any).messageReceived

export const getInputBoxEvent = (mockInputValue: string) => {
Expand Down
10 changes: 9 additions & 1 deletion extension/src/webview/contract.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConnectData } from '../connect/webview/contract'
import { SortDefinition } from '../experiments/model/sortBy'
import { TableData } from '../experiments/webview/contract'
import {
Expand All @@ -8,7 +9,7 @@ import {
} from '../plots/webview/contract'
import { SetupData } from '../setup/webview/contract'

export type WebviewData = TableData | PlotsData | SetupData | {}
export type WebviewData = TableData | PlotsData | SetupData | ConnectData

export enum MessageFromWebviewType {
INITIALIZED = 'initialized',
Expand Down Expand Up @@ -49,11 +50,13 @@ export enum MessageFromWebviewType {
SELECT_PYTHON_INTERPRETER = 'select-python-interpreter',
SET_EXPERIMENTS_FOR_PLOTS = 'set-experiments-for-plots',
SET_EXPERIMENTS_AND_OPEN_PLOTS = 'set-experiments-and-open-plots',
SET_STUDIO_SHARE_EXPERIMENTS_LIVE = 'set-studio-share-experiments-live',
SHARE_EXPERIMENT_AS_BRANCH = 'share-experiment-as-branch',
SHARE_EXPERIMENT_AS_COMMIT = 'share-experiment-as-commit',
TOGGLE_METRIC = 'toggle-metric',
TOGGLE_PLOTS_SECTION = 'toggle-plots-section',
REMOVE_CUSTOM_PLOTS = 'remove-custom-plots',
REMOVE_STUDIO_TOKEN = 'remove-studio-token',
MODIFY_EXPERIMENT_PARAMS_AND_QUEUE = 'modify-experiment-params-and-queue',
MODIFY_EXPERIMENT_PARAMS_AND_RUN = 'modify-experiment-params-and-run',
MODIFY_EXPERIMENT_PARAMS_RESET_AND_RUN = 'modify-experiment-params-reset-and-run',
Expand Down Expand Up @@ -163,6 +166,7 @@ export type MessageFromWebview =
| {
type: MessageFromWebviewType.REMOVE_CUSTOM_PLOTS
}
| { type: MessageFromWebviewType.REMOVE_STUDIO_TOKEN }
| {
type: MessageFromWebviewType.REORDER_PLOTS_COMPARISON
payload: string[]
Expand Down Expand Up @@ -209,6 +213,10 @@ export type MessageFromWebview =
type: MessageFromWebviewType.SET_EXPERIMENTS_AND_OPEN_PLOTS
payload: string[]
}
| {
type: MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE
payload: boolean
}
| {
type: MessageFromWebviewType.SHARE_EXPERIMENT_AS_BRANCH
payload: string
Expand Down
102 changes: 69 additions & 33 deletions webview/src/connect/components/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { MessageFromWebviewType } from 'dvc/src/webview/contract'
import {
MessageFromWebviewType,
MessageToWebviewType
} from 'dvc/src/webview/contract'
import { ConnectData } from 'dvc/src/connect/webview/contract'
import { App } from './App'
import { vsCodeApi } from '../../shared/api'

Expand All @@ -18,47 +22,79 @@ afterEach(() => {
cleanup()
})

const renderApp = () => {
return render(<App />)
const setData = ({ isStudioConnected, shareLiveToStudio }: ConnectData) => {
fireEvent(
window,
new MessageEvent('message', {
data: {
data: {
isStudioConnected,
shareLiveToStudio
},
type: MessageToWebviewType.SET_DATA
}
})
)
}

const renderApp = (isStudioConnected = false, shareLiveToStudio = false) => {
render(<App />)
setData({ isStudioConnected, shareLiveToStudio })
}

describe('App', () => {
it('should show three buttons which walk the user through saving a token', async () => {
renderApp()
const buttons = await screen.findAllByRole('button')
expect(buttons).toHaveLength(3)
})
describe('Studio not connected', () => {
it('should show three buttons which walk the user through saving a token', async () => {
renderApp()
const buttons = await screen.findAllByRole('button')
expect(buttons).toHaveLength(3)
})

it('should show a button which opens Studio', () => {
renderApp()
mockPostMessage.mockClear()
const button = screen.getByText('Sign In')
fireEvent.click(button)
expect(mockPostMessage).toHaveBeenCalledTimes(1)
expect(mockPostMessage).toHaveBeenCalledWith({
type: MessageFromWebviewType.OPEN_STUDIO
it('should show a button which opens Studio', () => {
renderApp()
mockPostMessage.mockClear()
const button = screen.getByText('Sign In')
fireEvent.click(button)
expect(mockPostMessage).toHaveBeenCalledTimes(1)
expect(mockPostMessage).toHaveBeenCalledWith({
type: MessageFromWebviewType.OPEN_STUDIO
})
})

it("should show a button which opens the user's Studio profile", () => {
renderApp()
mockPostMessage.mockClear()
const button = screen.getByText('Get Token')
fireEvent.click(button)
expect(mockPostMessage).toHaveBeenCalledTimes(1)
expect(mockPostMessage).toHaveBeenCalledWith({
type: MessageFromWebviewType.OPEN_STUDIO_PROFILE
})
})
})

it("should show a button which opens the user's Studio profile", () => {
renderApp()
mockPostMessage.mockClear()
const button = screen.getByText('Get Token')
fireEvent.click(button)
expect(mockPostMessage).toHaveBeenCalledTimes(1)
expect(mockPostMessage).toHaveBeenCalledWith({
type: MessageFromWebviewType.OPEN_STUDIO_PROFILE
it("should show a button which allows the user's to save their Studio access token", () => {
renderApp()
mockPostMessage.mockClear()
const button = screen.getByText('Save')
fireEvent.click(button)
expect(mockPostMessage).toHaveBeenCalledTimes(1)
expect(mockPostMessage).toHaveBeenCalledWith({
type: MessageFromWebviewType.SAVE_STUDIO_TOKEN
})
})
})

it("should show a button which allows the user's to save their Studio access token", () => {
renderApp()
mockPostMessage.mockClear()
const button = screen.getByText('Save')
fireEvent.click(button)
expect(mockPostMessage).toHaveBeenCalledTimes(1)
expect(mockPostMessage).toHaveBeenCalledWith({
type: MessageFromWebviewType.SAVE_STUDIO_TOKEN
describe('Studio connected', () => {
it('should render a checkbox which can be used to update dvc.studio.shareExperimentsLive', () => {
const shareExperimentsLive = false
renderApp(true, shareExperimentsLive)
mockPostMessage.mockClear()
const checkbox = screen.getByRole('checkbox')
fireEvent.click(checkbox)
expect(mockPostMessage).toHaveBeenCalledWith({
payload: !shareExperimentsLive,
type: MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE
})
})
})
})
Loading