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