From dabe34d267adf3cd0c07326d21ebd4afe042db4f Mon Sep 17 00:00:00 2001 From: Quinn Slack Date: Sat, 22 Sep 2018 12:53:09 -0700 Subject: [PATCH] feat: improve UX of extension add/enable/remove Simplifies what a user needs to know/do to use extensions, specifically the add/remove/enable/disable operations. The set of possible states for an extension remain the same, but the UI actions exposed to the user are much simpler and only address the common cases. To enable/disable/add/remove extensions for everyone (in global settings) or for organization members (in organization settings), you must edit those settings manually. That is acceptable (for now, at least) and makes the UI much simpler. The previous approach is best described as "show the user the set of all possible changes to settings related to this extension". The new approach is "make it easy for the user to start or stop using an extension, and to know why an extension is enabled if it's enabled". See https://sourcegraph.slack.com/archives/CCLF4R6EM/p1537634102000100?thread_ts=1537605588.000100&cid=CCLF4R6EM (internal link) for more context. --- src/context.ts | 2 +- src/extensions/ExtensionAddButton.tsx | 100 +++++ .../ExtensionConfigureButton.test.tsx | 35 -- src/extensions/ExtensionConfigureButton.tsx | 416 ------------------ .../ExtensionConfigureButtonDropdown.tsx | 260 +++++++++++ src/extensions/ExtensionEnablementToggle.tsx | 127 ------ .../ExtensionPrimaryActionButton.tsx | 88 ++++ src/extensions/extension.ts | 18 + src/extensions/manager/ExtensionCard.tsx | 82 +--- 9 files changed, 475 insertions(+), 653 deletions(-) create mode 100644 src/extensions/ExtensionAddButton.tsx delete mode 100644 src/extensions/ExtensionConfigureButton.test.tsx delete mode 100644 src/extensions/ExtensionConfigureButton.tsx create mode 100644 src/extensions/ExtensionConfigureButtonDropdown.tsx delete mode 100644 src/extensions/ExtensionEnablementToggle.tsx create mode 100644 src/extensions/ExtensionPrimaryActionButton.tsx diff --git a/src/context.ts b/src/context.ts index 6d8d062..0bb429b 100644 --- a/src/context.ts +++ b/src/context.ts @@ -53,7 +53,7 @@ export interface Context { * content. */ readonly icons: Record< - 'Loader' | 'Warning' | 'Menu' | 'CaretDown', + 'Loader' | 'Warning' | 'Info' | 'Menu' | 'CaretDown' | 'Add' | 'Settings', React.ComponentType<{ className: 'icon-inline' | string onClick?: () => void diff --git a/src/extensions/ExtensionAddButton.tsx b/src/extensions/ExtensionAddButton.tsx new file mode 100644 index 0000000..c7905a1 --- /dev/null +++ b/src/extensions/ExtensionAddButton.tsx @@ -0,0 +1,100 @@ +import * as React from 'react' +import { from, Subject, Subscription } from 'rxjs' +import { catchError, map, mapTo, startWith, switchMap, tap } from 'rxjs/operators' +import { ExtensionsProps } from '../context' +import { asError, ErrorLike, isErrorLike } from '../errors' +import { ConfigurationSubject, ConfiguredSubjectOrError, Settings } from '../settings' +import { ConfiguredExtension } from './extension' + +const LOADING: 'loading' = 'loading' + +interface Props extends ExtensionsProps { + /** The extension that this button adds. */ + extension: ConfiguredExtension + + /** The configuration subject that this button adds the extension for. */ + subject: ConfiguredSubjectOrError + + disabled?: boolean + + className?: string + + /** + * Called to confirm the primary action. If the callback returns false, the action is not + * performed. + */ + confirm?: () => boolean + + /** Called when the component performs an update that requires the parent component to refresh data. */ + onUpdate: () => void +} + +interface State { + /** The operation's status: null when done or not started, 'loading', or an error. */ + operationResultOrError: typeof LOADING | null | ErrorLike +} + +/** An button to add an extension. */ +export class ExtensionAddButton extends React.PureComponent< + Props, + State +> { + public state: State = { operationResultOrError: null } + + private clicks = new Subject() + private subscriptions = new Subscription() + + public componentDidMount(): void { + this.subscriptions.add( + this.clicks + .pipe( + switchMap(() => + from(this.addExtensionForSubject(this.props.extension, this.props.subject)).pipe( + mapTo(null), + catchError(error => [asError(error) as ErrorLike]), + map(c => ({ operationResultOrError: c } as State)), + tap(() => this.props.onUpdate()), + startWith({ operationResultOrError: LOADING }) + ) + ) + ) + .subscribe(stateUpdate => this.setState(stateUpdate), error => console.error(error)) + ) + } + + public componentWillUnmount(): void { + this.subscriptions.unsubscribe() + } + + public render(): JSX.Element | null { + return ( + + ) + } + + private onClick: React.MouseEventHandler = () => { + if (!this.props.confirm || this.props.confirm()) { + this.clicks.next() + } + } + + private addExtensionForSubject = ( + extension: ConfiguredExtension, + subject: ConfiguredSubjectOrError + ) => + this.props.extensions.context.updateExtensionSettings(subject.subject.id, { + extensionID: extension.id, + enabled: true, + }) +} diff --git a/src/extensions/ExtensionConfigureButton.test.tsx b/src/extensions/ExtensionConfigureButton.test.tsx deleted file mode 100644 index af796aa..0000000 --- a/src/extensions/ExtensionConfigureButton.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'assert' -import { ConfigurationSubject, ConfiguredSubject, Settings } from '../settings' -import { filterItems } from './ExtensionConfigureButton' - -const FIXTURE_CONFIGURATION_SUBJECT: ConfigurationSubject = { - id: '', - __typename: 'User', - username: 'n', - displayName: 'n', - viewerCanAdminister: true, - settingsURL: 'a', -} - -describe('filterItems', () => - it('filters to added only', () => - assert.deepStrictEqual( - filterItems( - 'a', - [ - { subject: { ...FIXTURE_CONFIGURATION_SUBJECT, id: '1' }, settings: { extensions: { a: true } } }, - { subject: { ...FIXTURE_CONFIGURATION_SUBJECT, id: '2' }, settings: { extensions: { a: false } } }, - { subject: { ...FIXTURE_CONFIGURATION_SUBJECT, id: '3' }, settings: { extensions: { b: true } } }, - { subject: { ...FIXTURE_CONFIGURATION_SUBJECT, id: '4' }, settings: null }, - { subject: { ...FIXTURE_CONFIGURATION_SUBJECT, id: '4' }, settings: {} }, - ] as ConfiguredSubject[], - { added: true } - ), - [ - { - subject: { ...FIXTURE_CONFIGURATION_SUBJECT, id: '1' }, - settings: { extensions: { a: true } } as Settings, - }, - { subject: { ...FIXTURE_CONFIGURATION_SUBJECT, id: '2' }, settings: { extensions: { a: false } } }, - ] as ConfiguredSubject[] - ))) diff --git a/src/extensions/ExtensionConfigureButton.tsx b/src/extensions/ExtensionConfigureButton.tsx deleted file mode 100644 index 2f58834..0000000 --- a/src/extensions/ExtensionConfigureButton.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import * as React from 'react' -import { Link } from 'react-router-dom' -import { ButtonDropdown, DropdownMenu, DropdownToggle } from 'reactstrap' -import DropdownItem from 'reactstrap/lib/DropdownItem' -import { from, Subject, Subscription } from 'rxjs' -import { catchError, map, mapTo, startWith, switchMap, tap } from 'rxjs/operators' -import { ExtensionsProps } from '../context' -import { asError, ErrorLike, isErrorLike } from '../errors' -import { - ConfigurationCascadeProps, - ConfigurationSubject, - ConfiguredSubjectOrError, - Settings, - SUBJECT_TYPE_ORDER, - subjectLabel, - subjectTypeHeader, -} from '../settings' -import { ConfiguredExtension, isExtensionAdded, isExtensionEnabled } from './extension' - -interface ExtensionConfiguredSubject { - extension: ConfiguredExtension - subject: ConfiguredSubjectOrError -} - -/** A dropdown menu item for a extension-subject item that links to the subject's settings. */ -export class ExtensionConfiguredSubjectItemForConfigure< - S extends ConfigurationSubject, - C extends Settings -> extends React.PureComponent< - { - item: ExtensionConfiguredSubject - onUpdate: () => void - onComplete: () => void - } & ExtensionsProps -> { - public render(): JSX.Element | null { - return ( - - {subjectLabel(this.props.item.subject.subject)} - {isExtensionAdded(this.props.item.subject.settings, this.props.item.extension.id) && - !isErrorLike(this.props.item.subject.settings) && - !isExtensionEnabled(this.props.item.subject.settings, this.props.item.extension.id) && ( - Disabled - )} - - ) - } -} - -const LOADING: 'loading' = 'loading' - -interface ExtensionConfiguredSubjectItemForAddState { - /** The add operation's status: null when done or not started, 'loading', or an error. */ - addOrError: typeof LOADING | null | ErrorLike -} - -/** A dropdown menu item for a extension-subject item that adds the extension to the subject's settings. */ -export class ExtensionConfiguredSubjectItemForAdd< - S extends ConfigurationSubject, - C extends Settings -> extends React.PureComponent< - { - item: ExtensionConfiguredSubject - confirm?: () => boolean - onUpdate: () => void - onComplete: () => void - } & ExtensionsProps, - ExtensionConfiguredSubjectItemForAddState -> { - public state: ExtensionConfiguredSubjectItemForAddState = { addOrError: null } - - private addClicks = new Subject() - private subscriptions = new Subscription() - - public componentDidMount(): void { - this.subscriptions.add( - this.addClicks - .pipe( - switchMap(() => - from( - this.props.extensions.context.updateExtensionSettings(this.props.item.subject.subject.id, { - extensionID: this.props.item.extension.id, - enabled: true, - }) - ).pipe( - mapTo(null), - tap(() => this.props.onComplete()), - catchError(error => [asError(error) as ErrorLike]), - map(c => ({ addOrError: c } as ExtensionConfiguredSubjectItemForAddState)), - tap(() => this.props.onUpdate()), - startWith({ addOrError: LOADING }) - ) - ) - ) - .subscribe(stateUpdate => this.setState(stateUpdate), error => console.error(error)) - ) - } - - public componentWillUnmount(): void { - this.subscriptions.unsubscribe() - } - - public render(): JSX.Element | null { - const isAdded = isExtensionAdded(this.props.item.subject.settings, this.props.item.extension.id) - return ( - - {subjectLabel(this.props.item.subject.subject)} -
- {isErrorLike(this.state.addOrError) && ( - - Error - - )} - {isAdded && Already added} -
-
- ) - } - - private onClick: React.MouseEventHandler = () => { - if (!this.props.confirm || this.props.confirm()) { - this.addClicks.next() - } - } -} - -interface ExtensionConfiguredSubjectItemForRemoveState { - /** The remove operation's status: null when done or not started, 'loading', or an error. */ - removeOrError: typeof LOADING | null | ErrorLike -} - -/** A dropdown menu item for a extension-subject item that removes the extension from the subject's settings. */ -export class ExtensionConfiguredSubjectItemForRemove< - S extends ConfigurationSubject, - C extends Settings -> extends React.PureComponent< - { - item: ExtensionConfiguredSubject - confirm?: () => boolean - onUpdate: () => void - onComplete: () => void - } & ExtensionsProps, - ExtensionConfiguredSubjectItemForRemoveState -> { - public state: ExtensionConfiguredSubjectItemForRemoveState = { removeOrError: null } - - private removeClicks = new Subject() - private subscriptions = new Subscription() - - public componentDidMount(): void { - this.subscriptions.add( - this.removeClicks - .pipe( - switchMap(() => - from( - this.props.extensions.context.updateExtensionSettings(this.props.item.subject.subject.id, { - extensionID: this.props.item.extension.id, - remove: true, - }) - ).pipe( - mapTo(null), - tap(() => this.props.onComplete()), - catchError(error => [asError(error) as ErrorLike]), - map(c => ({ removeOrError: c } as ExtensionConfiguredSubjectItemForRemoveState)), - tap(() => this.props.onUpdate()), - startWith({ removeOrError: LOADING }) - ) - ) - ) - .subscribe(stateUpdate => this.setState(stateUpdate), error => console.error(error)) - ) - } - - public componentWillUnmount(): void { - this.subscriptions.unsubscribe() - } - - public render(): JSX.Element | null { - return ( - - {subjectLabel(this.props.item.subject.subject)} -
- {isErrorLike(this.state.removeOrError) && ( - - Error - - )} -
-
- ) - } - - private onClick: React.MouseEventHandler = () => { - if (!this.props.confirm || this.props.confirm()) { - this.removeClicks.next() - } - } -} - -class ExtensionConfigurationSubjectsDropdownItems< - S extends ConfigurationSubject, - C extends Settings -> extends React.PureComponent< - { - items: ExtensionConfiguredSubject[] - - /** - * Closes the dropdown menu. This is necessary because the menu must remain open after the user selects an - * item that starts an operation. If it immediately closed, then the component's componentWillUnmount would - * be called and the in-progress operation would be canceled (i.e., the HTTP request would be canceled, - * probably before it reached the server). Also, if the operation failed, the user would not get any - * feedback about the error (because it is shown in the menu item). - */ - onComplete: () => void - } & Pick, 'header' | 'itemComponent' | 'confirm' | 'onUpdate'> & - ExtensionsProps -> { - public render(): JSX.Element | null { - const { header, items, itemComponent: Item, ...props } = this.props - - const itemsByType = new Map< - ExtensionConfiguredSubject['subject']['subject']['__typename'], - ExtensionConfiguredSubject[] - >() - for (const item of items) { - let typeItems = itemsByType.get(item.subject.subject.__typename) - if (!typeItems) { - typeItems = [] - itemsByType.set(item.subject.subject.__typename, typeItems) - } - typeItems.push(item) - } - let needsDivider = false - return ( - <> - {header && ( - <> - {header} - - - )} - {SUBJECT_TYPE_ORDER.map((nodeType, i) => { - const items = itemsByType.get(nodeType) - if (!items) { - return null - } - const neededDivider = needsDivider - needsDivider = items.length > 0 - const headerLabel = subjectTypeHeader(nodeType) - return ( - - {neededDivider && } - {headerLabel && {headerLabel}} - {items.map((item, i) => ( - - ))} - - ) - })} - - ) - } -} - -interface ExtensionConfigurationSubjectsFilter { - added?: boolean - notAdded?: boolean - onlyIfViewerCanAdminister?: boolean -} - -export const ALL_CAN_ADMINISTER: ExtensionConfigurationSubjectsFilter = { - added: true, - notAdded: true, - onlyIfViewerCanAdminister: true, -} - -export const ADDED_AND_CAN_ADMINISTER: ExtensionConfigurationSubjectsFilter = { - added: true, - notAdded: false, - onlyIfViewerCanAdminister: true, -} - -export function filterItems( - extensionID: string, - items: ConfiguredSubjectOrError[], - filter: ExtensionConfigurationSubjectsFilter -): ConfiguredSubjectOrError[] { - return items.filter(item => { - const isAdded = isExtensionAdded(item.settings, extensionID) - if (isAdded && !filter.added) { - return false - } - if (!isAdded && !filter.notAdded) { - return false - } - if (!item.subject.viewerCanAdminister && filter.onlyIfViewerCanAdminister) { - return false - } - return true - }) -} - -interface Props - extends ConfigurationCascadeProps, - ExtensionsProps { - /** The extension that this element is for. */ - extension: ConfiguredExtension - - /** Class name applied to the button element. */ - buttonClassName?: string - - /* The button label. */ - children: React.ReactFragment - - /** The dropdown menu header. */ - header?: React.ReactFragment - - /** Only show items matching the filter. */ - itemFilter: ExtensionConfigurationSubjectsFilter - - /** Renders the subject dropdown item. */ - itemComponent: React.ComponentType< - { - item: ExtensionConfiguredSubject - onUpdate: () => void - onComplete: () => void - } & ExtensionsProps - > - - /** Whether to show the caret on the dropdown toggle. */ - caret?: boolean - - /** - * Called to confirm the primary action. If the callback returns false, the action is not - * performed. - */ - confirm?: () => boolean - - /** Called when the component performs an update that requires the parent component to refresh data. */ - onUpdate: () => void -} - -interface State { - dropdownOpen: boolean -} - -/** - * Displays a button with a dropdown menu listing the extension configuration subjects of an extension. - */ -export class ExtensionConfigureButton extends React.PureComponent< - Props, - State -> { - public state: State = { - dropdownOpen: false, - } - - public render(): JSX.Element | null { - if (!this.props.configurationCascade.subjects) { - return null - } - if (isErrorLike(this.props.configurationCascade.subjects)) { - // TODO: Show error. - return null - } - const configurableSubjects = filterItems( - this.props.extension.id, - this.props.configurationCascade.subjects, - this.props.itemFilter - ) - return ( - - - {this.props.children} - - - ({ - subject, - extension: this.props.extension, - }))} - confirm={this.props.confirm} - onUpdate={this.props.onUpdate} - onComplete={this.onComplete} - extensions={this.props.extensions} - /> - - - ) - } - - private toggle = () => { - this.setState(prevState => ({ dropdownOpen: !prevState.dropdownOpen })) - } - - private onComplete = () => this.setState({ dropdownOpen: false }) -} diff --git a/src/extensions/ExtensionConfigureButtonDropdown.tsx b/src/extensions/ExtensionConfigureButtonDropdown.tsx new file mode 100644 index 0000000..833fd21 --- /dev/null +++ b/src/extensions/ExtensionConfigureButtonDropdown.tsx @@ -0,0 +1,260 @@ +import * as React from 'react' +import { ButtonDropdown, DropdownMenu, DropdownToggle } from 'reactstrap' +import DropdownItem from 'reactstrap/lib/DropdownItem' +import { from, Subject, Subscribable, Subscription } from 'rxjs' +import { catchError, map, mapTo, startWith, switchMap, tap } from 'rxjs/operators' +import { ExtensionsProps } from '../context' +import { asError, ErrorLike, isErrorLike } from '../errors' +import { + ConfigurationCascadeProps, + ConfigurationSubject, + ConfiguredSubjectOrError, + Settings, + subjectLabel, +} from '../settings' +import { ConfiguredExtension, isExtensionAdded, isExtensionEnabled } from './extension' + +const LOADING: 'loading' = 'loading' + +interface ExtensionConfigureDropdownItemState { + /** The operation's status: null when done or not started, 'loading', or an error. */ + operationResultOrError: typeof LOADING | null | ErrorLike +} + +/** An item in the {@link ExtensionConfigureButton} dropdown menu. */ +export class ExtensionConfigureDropdownItem< + S extends ConfigurationSubject, + C extends Settings +> extends React.PureComponent< + { + /** The extension that this button is for. */ + extension: ConfiguredExtension + + /** The configuration subject that this item modifies extension settings for. */ + subject: ConfiguredSubjectOrError + + disabled?: boolean + confirm?: () => boolean + operation: ( + extension: ConfiguredExtension, + subject: ConfiguredSubjectOrError + ) => Subscribable + onUpdate: () => void + onComplete: () => void + } & ExtensionsProps, + ExtensionConfigureDropdownItemState +> { + public state: ExtensionConfigureDropdownItemState = { operationResultOrError: null } + + private clicks = new Subject() + private subscriptions = new Subscription() + + public componentDidMount(): void { + this.subscriptions.add( + this.clicks + .pipe( + switchMap(() => + from(this.props.operation(this.props.extension, this.props.subject)).pipe( + mapTo(null), + tap(() => this.props.onComplete()), + catchError(error => [asError(error) as ErrorLike]), + map(c => ({ operationResultOrError: c } as ExtensionConfigureDropdownItemState)), + tap(() => this.props.onUpdate()), + startWith({ operationResultOrError: LOADING }) + ) + ) + ) + .subscribe(stateUpdate => this.setState(stateUpdate), error => console.error(error)) + ) + } + + public componentWillUnmount(): void { + this.subscriptions.unsubscribe() + } + + public render(): JSX.Element | null { + return ( + + {this.props.children} +
+ {isErrorLike(this.state.operationResultOrError) && ( + + Error + + )} +
+
+ ) + } + + private onClick: React.MouseEventHandler = () => { + if (!this.props.confirm || this.props.confirm()) { + this.clicks.next() + } + } +} + +interface Props + extends ConfigurationCascadeProps, + ExtensionsProps { + /** The extension that this dropdown is for. */ + extension: ConfiguredExtension + + /** The configuration subject that this dropdown modifies extension settings for. */ + subject: ConfiguredSubjectOrError + + /** Class name applied to the button element. */ + buttonClassName?: string + + /* The button label. */ + children: React.ReactFragment + + /** Whether to show the caret on the dropdown toggle. */ + caret?: boolean + + /** + * Called to confirm the primary action. If the callback returns false, the action is not + * performed. + */ + confirm?: () => boolean + + /** Called when the component performs an update that requires the parent component to refresh data. */ + onUpdate: () => void +} + +interface State { + dropdownOpen: boolean +} + +/** + * Displays a button with a dropdown menu for enabling/disabling the extension. + * + * For simplicity, the menu is only intended to expose the most common extension configuration actions for the + * current user. For example, it does not expose actions to configure the extension for all users (in global + * settings) or for an organization's members. To make those changes, the user needs to manually edit global or + * organization settings. + */ +export class ExtensionConfigureButtonDropdown< + S extends ConfigurationSubject, + C extends Settings +> extends React.PureComponent, State> { + public state: State = { + dropdownOpen: false, + } + + public render(): JSX.Element | null { + // Configuration subjects other than this.props.subject for which the extension is added in settings. + const otherSubjectsWithExtensionAdded = + this.props.configurationCascade.subjects && !isErrorLike(this.props.configurationCascade.subjects) + ? this.props.configurationCascade.subjects + .filter(a => a.subject.id !== this.props.subject.subject.id) + .filter(subject => isExtensionAdded(subject.settings, this.props.extension.id)) + : [] + + return ( + + + {this.props.children} + + + + User settings ({subjectLabel(this.props.subject.subject)} + ): + + + Enable extension + + + Disable extension + + {// Hide "Remove extension" button when the extension is present in other lower-precedence + // subjects' settings, because in that case, removing the extension from user settings + // would just fall back to the lower-precedence settings, which would be unexpected to the + // user. To handle these cases, the user must manually edit settings. + otherSubjectsWithExtensionAdded.length === 0 ? ( + + Remove extension + + ) : ( + <> + + subject.__typename === 'Org') + .map(({ subject }) => subjectLabel(subject)) + .join(', ')} + > + + {otherSubjectsWithExtensionAdded.some(({ subject }) => subject.__typename === 'Site') + ? 'Default: enabled for everyone' + : 'Default: enabled for organization'} + + + )} + + + ) + } + + private toggle = () => { + this.setState(prevState => ({ dropdownOpen: !prevState.dropdownOpen })) + } + + private enableExtensionForSubject = ( + extension: ConfiguredExtension, + subject: ConfiguredSubjectOrError + ) => + this.props.extensions.context.updateExtensionSettings(subject.subject.id, { + extensionID: extension.id, + enabled: true, + }) + + private disableExtensionForSubject = ( + extension: ConfiguredExtension, + subject: ConfiguredSubjectOrError + ) => + this.props.extensions.context.updateExtensionSettings(subject.subject.id, { + extensionID: extension.id, + enabled: false, + }) + + private removeExtensionForSubject = ( + extension: ConfiguredExtension, + subject: ConfiguredSubjectOrError + ) => + this.props.extensions.context.updateExtensionSettings(subject.subject.id, { + extensionID: extension.id, + remove: true, + }) + + private onComplete = () => this.setState({ dropdownOpen: false }) +} diff --git a/src/extensions/ExtensionEnablementToggle.tsx b/src/extensions/ExtensionEnablementToggle.tsx deleted file mode 100644 index b52132b..0000000 --- a/src/extensions/ExtensionEnablementToggle.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import * as React from 'react' -import { combineLatest, Subject, Subscription, from } from 'rxjs' -import { catchError, distinctUntilChanged, map, mapTo, startWith, switchMap, tap } from 'rxjs/operators' -import { ExtensionsProps } from '../context' -import { asError, ErrorLike, isErrorLike } from '../errors' -import { ConfigurationCascadeProps, ConfigurationSubject, Settings } from '../settings' -import { Toggle } from '../ui/generic/Toggle' -import { ConfiguredExtension, isExtensionEnabled } from './extension' - -interface Props - extends ConfigurationCascadeProps, - ExtensionsProps { - extension: ConfiguredExtension - - /** The subject whose settings are edited when the user toggles enablement using this component. */ - subject: Pick - - /** - * Called when this component results in the extension's enablement state being changed. - */ - onChange: (enabled: boolean) => void - - className?: string - tabIndex?: number -} - -const LOADING: 'loading' = 'loading' - -interface State { - /** The toggle operation's status: null when not started, true when done, 'loading', or an error. */ - toggleOrError: null | typeof LOADING | true | ErrorLike -} - -/** - * Enables and disables the extension and displays the enablement state. - */ -export class ExtensionEnablementToggle extends React.PureComponent< - Props, - State -> { - public state: State = { toggleOrError: null } - - private componentUpdates = new Subject>() - private toggles = new Subject() - private subscriptions = new Subscription() - - public componentDidMount(): void { - const extensionChanges = this.componentUpdates.pipe( - map(({ extension }) => extension), - distinctUntilChanged((a, b) => a.id === b.id) - ) - - const enablementChanges = combineLatest( - extensionChanges, - this.componentUpdates.pipe( - map(({ configurationCascade }) => configurationCascade && configurationCascade.merged) - ) - ).pipe(map(([extension, settings]) => isExtensionEnabled(settings, extension.id))) - - // Reset toggleOrError compensation for stale enablement after we receive the new post-update value. - this.subscriptions.add(enablementChanges.subscribe(() => this.setState({ toggleOrError: null }))) - - this.subscriptions.add( - this.toggles - .pipe( - switchMap(enabled => - from( - this.props.extensions.context.updateExtensionSettings(this.props.subject.id, { - extensionID: this.props.extension.id, - enabled, - }) - ).pipe( - mapTo(true), - catchError(error => [asError(error) as ErrorLike]), - map(c => ({ toggleOrError: c } as State)), - tap(() => { - if (this.props.onChange) { - this.props.onChange(enabled) - } - }), - startWith({ toggleOrError: LOADING }) - ) - ) - ) - .subscribe(stateUpdate => this.setState(stateUpdate), error => console.error(error)) - ) - } - - public componentWillReceiveProps(props: Props): void { - this.componentUpdates.next(props) - } - - public componentWillUnmount(): void { - this.subscriptions.unsubscribe() - } - - public render(): JSX.Element | null { - if (this.props.extension === null) { - return null - } - - // Invert extension enablement if we changed the value but the change hasn't yet been synced to the server. - const unadjustedIsEnabled = isExtensionEnabled(this.props.configurationCascade.merged, this.props.extension.id) - const isEnabled = this.state.toggleOrError === LOADING ? !unadjustedIsEnabled : unadjustedIsEnabled - - return ( -
- {isErrorLike(this.state.toggleOrError) && ( - - - - )} - -
- ) - } - - private onChange = (value: boolean) => { - this.toggles.next(value) - } -} diff --git a/src/extensions/ExtensionPrimaryActionButton.tsx b/src/extensions/ExtensionPrimaryActionButton.tsx new file mode 100644 index 0000000..cc2680b --- /dev/null +++ b/src/extensions/ExtensionPrimaryActionButton.tsx @@ -0,0 +1,88 @@ +import * as React from 'react' +import { ExtensionsProps } from '../context' +import { isErrorLike } from '../errors' +import { ConfigurationCascadeProps, ConfigurationSubject, Settings } from '../settings' +import { ConfiguredExtension, confirmAddExtension, isExtensionAdded } from './extension' +import { ExtensionAddButton } from './ExtensionAddButton' +import { ExtensionConfigureButtonDropdown } from './ExtensionConfigureButtonDropdown' + +interface Props + extends ConfigurationCascadeProps, + ExtensionsProps { + /** The extension that this element is for. */ + extension: ConfiguredExtension + + disabled?: boolean + + /** Class name applied to this element. */ + className?: string + + /** Class name applied to this element when it is an "Add" button. */ + addClassName?: string + + /** Called when the component performs an update that requires the parent component to refresh data. */ + onUpdate: () => void +} + +/** + * Displays the primary action for an extension. + * + * - "Add" if the extension is not yet added and can be added. + * - "Configure (icon)" dropdown menu in all other cases. + */ +export class ExtensionPrimaryActionButton< + S extends ConfigurationSubject, + C extends Settings +> extends React.PureComponent> { + public render(): JSX.Element | null { + if (this.props.configurationCascade.subjects === null) { + return null + } + if (isErrorLike(this.props.configurationCascade.subjects)) { + // TODO: Show error. + return null + } + + // Only operate on the current user's extension configuration, for simplicity. + const userSubject = this.props.configurationCascade.subjects.find( + ({ subject }) => subject.__typename === 'User' + ) + if (!userSubject || !userSubject.subject.viewerCanAdminister) { + return null + } + + if ( + this.props.configurationCascade.subjects.every(s => !isExtensionAdded(s.settings, this.props.extension.id)) + ) { + return ( + + Add + + ) + } + return ( +
+ + + +
+ ) + } + + public confirm = () => confirmAddExtension(this.props.extension.id, this.props.extension.manifest) +} diff --git a/src/extensions/extension.ts b/src/extensions/extension.ts index 0a2d57d..7d93f99 100644 --- a/src/extensions/extension.ts +++ b/src/extensions/extension.ts @@ -36,3 +36,21 @@ export function isExtensionEnabled(settings: Settings | ErrorLike | null, extens export function isExtensionAdded(settings: Settings | ErrorLike | null, extensionID: string): boolean { return !!settings && !isErrorLike(settings) && !!settings.extensions && extensionID in settings.extensions } + +/** + * Shows a modal confirmation prompt to the user confirming whether to add an extension. + */ +export function confirmAddExtension(extensionID: string, extensionManifest?: ConfiguredExtension['manifest']): boolean { + // Either `"title" (id)` (if there is a title in the manifest) or else just `id`. It is + // important to show the ID because it indicates who the publisher is and allows + // disambiguation from other similarly titled extensions. + let displayName: string + if (extensionManifest && !isErrorLike(extensionManifest) && extensionManifest.title) { + displayName = `${JSON.stringify(extensionManifest.title)} (${extensionID})` + } else { + displayName = extensionID + } + return confirm( + `Add Sourcegraph extension ${displayName}?\n\nIt can:\n- Read repositories and files you view using Sourcegraph\n- Read and change your Sourcegraph settings` + ) +} diff --git a/src/extensions/manager/ExtensionCard.tsx b/src/extensions/manager/ExtensionCard.tsx index 73c2729..fee672b 100644 --- a/src/extensions/manager/ExtensionCard.tsx +++ b/src/extensions/manager/ExtensionCard.tsx @@ -6,14 +6,7 @@ import { ConfigurationCascadeProps, ConfigurationSubject, Settings } from '../.. import { LinkOrSpan } from '../../ui/generic/LinkOrSpan' import { ConfiguredExtension, isExtensionAdded, isExtensionEnabled } from '../extension' import { ExtensionConfigurationState } from '../ExtensionConfigurationState' -import { - ADDED_AND_CAN_ADMINISTER, - ALL_CAN_ADMINISTER, - ExtensionConfigureButton, - ExtensionConfiguredSubjectItemForAdd, - ExtensionConfiguredSubjectItemForRemove, -} from '../ExtensionConfigureButton' -import { ExtensionEnablementToggle } from '../ExtensionEnablementToggle' +import { ExtensionPrimaryActionButton } from '../ExtensionPrimaryActionButton' interface Props extends ConfigurationCascadeProps, @@ -73,57 +66,13 @@ export class ExtensionCard e
  • {props.subject && (props.subject.viewerCanAdminister ? ( - <> - {isExtensionAdded(props.configurationCascade.merged, node.id) && - !isExtensionEnabled(props.configurationCascade.merged, node.id) && ( -
  • - - Remove - -
  • - )} - {isExtensionAdded(props.configurationCascade.merged, node.id) && - props.subject.viewerCanAdminister && ( -
  • - -
  • - )} - {!isExtensionAdded(props.configurationCascade.merged, node.id) && ( -
  • - - Add - -
  • - )} - + ) : (
  • e ) } - - private confirmAdd = (): boolean => { - // Either `"title" (id)` (if there is a title in the manifest) or else just `id`. It is - // important to show the ID because it indicates who the publisher is and allows - // disambiguation from other similarly titled extensions. - let displayName: string - if (this.props.node.manifest && !isErrorLike(this.props.node.manifest) && this.props.node.manifest.title) { - displayName = `${JSON.stringify(this.props.node.manifest.title)} (${this.props.node.id})` - } else { - displayName = this.props.node.id - } - return confirm( - `Add Sourcegraph extension ${displayName}?\n\nIt can:\n- Read repositories and files you view using Sourcegraph\n- Read and change your Sourcegraph settings` - ) - } }