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

feat: add extensions page in settings #4264

Merged
merged 24 commits into from
Sep 29, 2020
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4a6f8ba
re-enable extensions page
a-b-r-o-w-n Sep 23, 2020
7aa168e
support multiple extension pages
a-b-r-o-w-n Sep 23, 2020
dbfd671
revert to using bundleId for page config
a-b-r-o-w-n Sep 24, 2020
921d9ab
omit sensitve properties from extension apis
a-b-r-o-w-n Sep 24, 2020
504eb59
fix adding new extension to state
a-b-r-o-w-n Sep 24, 2020
9815cd5
add shimmer list when doing remote calls
a-b-r-o-w-n Sep 24, 2020
5b08eb5
show extension display name
a-b-r-o-w-n Sep 24, 2020
e5a1082
update search mechanics
a-b-r-o-w-n Sep 24, 2020
67e40ee
ensure remote extensions dir exists
a-b-r-o-w-n Sep 24, 2020
9af004d
fix api call to get plugin bundle
a-b-r-o-w-n Sep 24, 2020
9097571
spawn npm with shell on windows platforms
a-b-r-o-w-n Sep 24, 2020
999ae0a
Merge branch 'main' into abrown/extensions/list-ui
a-b-r-o-w-n Sep 25, 2020
191b405
improve searching UX
a-b-r-o-w-n Sep 25, 2020
03f489b
show message when no extensions or search results
a-b-r-o-w-n Sep 25, 2020
681e666
add description to extension db
a-b-r-o-w-n Sep 25, 2020
43a02a9
move uninstall action to toolbar
a-b-r-o-w-n Sep 29, 2020
f30b5ca
use ensureDir api from fs-extra
a-b-r-o-w-n Sep 29, 2020
33de4b3
memoize callback functions
a-b-r-o-w-n Sep 29, 2020
a6da5bc
Merge branch 'main' into abrown/extensions/list-ui
a-b-r-o-w-n Sep 29, 2020
70f455f
fix type error in test
a-b-r-o-w-n Sep 29, 2020
4fe78c3
fix failing tests
a-b-r-o-w-n Sep 29, 2020
b2957ba
Merge branch 'main' into abrown/extensions/list-ui
a-b-r-o-w-n Sep 29, 2020
fc593ea
localize some strings
a-b-r-o-w-n Sep 29, 2020
0032175
Merge branch 'main' into abrown/extensions/list-ui
cwhitten Sep 29, 2020
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
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const PluginHost: React.FC<PluginHostProps> = (props) => {
resolve();
};
// If plugin bundles end up being too large and block the client thread due to the load, enable the async flag on this call
injectScript(iframeDocument, pluginScriptId, `/api/plugins/${pluginName}/view/${pluginType}`, false, cb);
injectScript(iframeDocument, pluginScriptId, `/api/extensions/${pluginName}/view/${pluginType}`, false, cb);
});
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/** @jsx jsx */
import { jsx, css } from '@emotion/core';
import React from 'react';
import formatMessage from 'format-message';
import {
DetailsListLayoutMode,
SelectionMode,
IColumn,
CheckboxVisibility,
ConstrainMode,
} from 'office-ui-fabric-react/lib/DetailsList';
import { ScrollablePane } from 'office-ui-fabric-react/lib/ScrollablePane';
import { Sticky } from 'office-ui-fabric-react/lib/Sticky';
import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList';

// TODO: extract to shared?
export type ExtensionSearchResult = {
id: string;
keywords: string[];
version: string;
description: string;
url: string;
};

type ExtensionSearchResultsProps = {
results: ExtensionSearchResult[];
isSearching: boolean;
onSelect: (extension: ExtensionSearchResult) => void;
};

const containerStyles = css`
position: relative;
height: 400px;
`;

const noResultsStyles = css`
display: flex;
align-items: center;
justify-content: center;
`;

const ExtensionSearchResults: React.FC<ExtensionSearchResultsProps> = (props) => {
const { results, isSearching, onSelect } = props;

const searchColumns: IColumn[] = [
{
key: 'name',
name: formatMessage('Name'),
minWidth: 100,
maxWidth: 150,
onRender: (item: ExtensionSearchResult) => {
return <span>{item.id}</span>;
},
},
{
key: 'description',
name: formatMessage('Description'),
minWidth: 100,
maxWidth: 300,
isMultiline: true,
onRender: (item: ExtensionSearchResult) => {
return <div css={{ overflowWrap: 'break-word' }}>{item.description}</div>;
},
},
{
key: 'version',
name: formatMessage('Version'),
minWidth: 30,
maxWidth: 100,
onRender: (item: ExtensionSearchResult) => {
return <span>{item.version}</span>;
},
},
{
key: 'url',
name: formatMessage('Url'),
minWidth: 100,
maxWidth: 100,
onRender: (item: ExtensionSearchResult) => {
return item.url ? (
<a href={item.url} rel="noopener noreferrer" target="_blank">
{formatMessage('View on npm')}
</a>
) : null;
},
},
];

const noResultsFound = !isSearching && results.length === 0;

return (
<div css={containerStyles}>
<ScrollablePane>
<ShimmeredDetailsList
checkboxVisibility={CheckboxVisibility.always}
columns={searchColumns}
constrainMode={ConstrainMode.horizontalConstrained}
enableShimmer={isSearching}
items={noResultsFound ? [{}] : results}
layoutMode={DetailsListLayoutMode.justified}
selectionMode={SelectionMode.single}
shimmerLines={8}
onActiveItemChanged={(item) => onSelect(item)}
onRenderDetailsHeader={(headerProps, defaultRender) => {
if (defaultRender) {
return <Sticky>{defaultRender(headerProps)}</Sticky>;
}

return <div />;
}}
onRenderRow={(rowProps, defaultRender) => {
// there are no search results
if (!isSearching && results.length === 0) {
return (
<div css={noResultsStyles}>
<p>No search results</p>
a-b-r-o-w-n marked this conversation as resolved.
Show resolved Hide resolved
</div>
);
}

if (defaultRender) {
return defaultRender(rowProps);
}

return null;
}}
/>
</ScrollablePane>
</div>
);
};

export { ExtensionSearchResults };
173 changes: 128 additions & 45 deletions Composer/packages/client/src/pages/setting/extensions/Extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,57 @@
// Licensed under the MIT License.

/** @jsx jsx */
import { jsx } from '@emotion/core';
import React, { useEffect, useState } from 'react';
import { jsx, css } from '@emotion/core';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { RouteComponentProps } from '@reach/router';
import {
DetailsListLayoutMode,
Selection,
SelectionMode,
IColumn,
CheckboxVisibility,
ConstrainMode,
DetailsRow,
IDetailsRowStyles,
} from 'office-ui-fabric-react/lib/DetailsList';
import { Toggle } from 'office-ui-fabric-react/lib/Toggle';
import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList';
import { DefaultButton } from 'office-ui-fabric-react/lib/Button';
import formatMessage from 'format-message';
import { useRecoilValue, selector } from 'recoil';
import { NeutralColors } from '@uifabric/fluent-theme';

import { ExtensionConfig } from '../../../recoilModel/types';
import { Toolbar, IToolbarItem } from '../../../components/Toolbar';
import { dispatcherState, extensionsState } from '../../../recoilModel';

import { InstallExtensionDialog } from './InstallExtensionDialog';
import { ExtensionSearchResult } from './ExtensionSearchResults';

const remoteExtensionsState = selector({
key: 'remoteExtensions',
get: ({ get }) => get(extensionsState).filter((e) => !e.builtIn),
});

const noExtensionsStyles = css`
display: flex;
align-items: center;
justify-content: center;
`;

const Extensions: React.FC<RouteComponentProps> = () => {
const { fetchExtensions, toggleExtension, addExtension, removeExtension } = useRecoilValue(dispatcherState);
const extensions = useRecoilValue(remoteExtensionsState);
const [isAdding, setIsAdding] = useState(false);
// if a string, its the id of the extension being updated
const [isUpdating, setIsUpdating] = useState<string | boolean>(false);
const [showNewModal, setShowNewModal] = useState(false);
const [selectedExtensions, setSelectedExtensions] = useState<ExtensionConfig[]>([]);
const selection = useRef(
new Selection({
onSelectionChanged: () => {
setSelectedExtensions(selection.getSelection() as ExtensionConfig[]);
},
})
).current;

useEffect(() => {
fetchExtensions();
Expand All @@ -42,44 +63,47 @@ const Extensions: React.FC<RouteComponentProps> = () => {
key: 'name',
name: formatMessage('Name'),
minWidth: 100,
maxWidth: 150,
onRender: (item: ExtensionConfig) => {
return <span>{item.name}</span>;
},
maxWidth: 250,
isResizable: true,
fieldName: 'name',
},
{
key: 'description',
name: formatMessage('Description'),
minWidth: 150,
maxWidth: 500,
isResizable: true,
isCollapsible: true,
isMultiline: true,
fieldName: 'description',
},
{
key: 'version',
name: formatMessage('Version'),
minWidth: 30,
minWidth: 100,
maxWidth: 100,
onRender: (item: ExtensionConfig) => {
return <span>{item.version}</span>;
},
isResizable: true,
fieldName: 'version',
},
{
key: 'enabled',
name: formatMessage('Enabled'),
minWidth: 30,
maxWidth: 150,
onRender: (item: ExtensionConfig) => {
const text = item.enabled ? formatMessage('Disable') : formatMessage('Enable');
return (
<DefaultButton disabled={item.builtIn} onClick={() => toggleExtension(item.id, !item.enabled)}>
{text}
</DefaultButton>
);
},
},
{
key: 'remove',
name: formatMessage('Remove'),
minWidth: 30,
minWidth: 100,
maxWidth: 150,
isResizable: true,
onRender: (item: ExtensionConfig) => {
return (
<DefaultButton disabled={item.builtIn} onClick={() => removeExtension(item.id)}>
{formatMessage('Remove')}
</DefaultButton>
<Toggle
ariaLabel={formatMessage('Toggle extension')}
checked={item.enabled}
styles={{ root: { marginBottom: 0 } }}
onChange={async () => {
const timeout = setTimeout(() => setIsUpdating(item.id), 200);
await toggleExtension(item.id, !item.enabled);
clearTimeout(timeout);
setIsUpdating(false);
}}
/>
);
},
},
Expand All @@ -99,31 +123,90 @@ const Extensions: React.FC<RouteComponentProps> = () => {
},
align: 'left',
},
{
type: 'action',
text: formatMessage('Uninstall'),
buttonProps: {
iconProps: {
iconName: 'Trash',
},
onClick: async () => {
const names = selectedExtensions.map((e) => e.name).join('\n');
const message = formatMessage('Are you sure you want to uninstall these extensions?');
if (confirm(`${message}\n\n${names}`)) {
for (const ext of selectedExtensions) {
const timeout = setTimeout(() => setIsUpdating(ext.id), 200);
await removeExtension(ext.id);
clearTimeout(timeout);
setIsUpdating(false);
}
}
},
},
disabled: selectedExtensions.length === 0,
align: 'left',
},
];

const submit = async (selectedExtension) => {
const submit = useCallback(async (selectedExtension?: ExtensionSearchResult) => {
if (selectedExtension) {
setIsAdding(true);
setIsUpdating(true);
setShowNewModal(false);
await addExtension(selectedExtension.id);
setIsAdding(false);
setIsUpdating(false);
}
}, []);

const shownItems = () => {
if (extensions.length === 0) {
// render no installed message
return [{}];
} else if (isUpdating === true) {
// extension is being added, render a shimmer row at end of list
return [...extensions, null];
} else if (typeof isUpdating === 'string') {
// extension is being removed or updated, show shimmer for that row
return extensions.map((e) => (e.id === isUpdating ? null : e));
} else {
return extensions;
}
};

const dismissInstallDialog = useCallback(() => setShowNewModal(false), []);

return (
<div>
<div style={{ maxWidth: '100%' }}>
<Toolbar toolbarItems={toolbarItems} />
{(isAdding || extensions.length > 0) && (
<ShimmeredDetailsList
checkboxVisibility={CheckboxVisibility.hidden}
columns={installedColumns}
items={isAdding ? [...extensions, null] : extensions}
layoutMode={DetailsListLayoutMode.justified}
selectionMode={SelectionMode.single}
shimmerLines={1}
/>
)}
<InstallExtensionDialog isOpen={showNewModal} onDismiss={() => setShowNewModal(false)} onInstall={submit} />
<ShimmeredDetailsList
checkboxVisibility={CheckboxVisibility.onHover}
columns={installedColumns}
constrainMode={ConstrainMode.horizontalConstrained}
items={shownItems()}
layoutMode={DetailsListLayoutMode.justified}
selection={selection}
selectionMode={SelectionMode.multiple}
onRenderRow={(rowProps, defaultRender) => {
if (extensions.length === 0) {
return (
<div css={noExtensionsStyles}>
<p>No extensions installed</p>
a-b-r-o-w-n marked this conversation as resolved.
Show resolved Hide resolved
</div>
);
}

if (defaultRender && rowProps) {
const customStyles: Partial<IDetailsRowStyles> = {
root: {
color: rowProps?.item?.enabled ? undefined : NeutralColors.gray90,
},
};
return <DetailsRow {...rowProps} styles={customStyles} />;
}

return null;
}}
/>
<InstallExtensionDialog isOpen={showNewModal} onDismiss={dismissInstallDialog} onInstall={submit} />
</div>
);
};
Expand Down
Loading