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 9 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 @@ -80,7 +80,7 @@ const SettingPage: React.FC<RouteComponentProps> = () => {
},
{ id: 'application', name: settingLabels.appSettings, url: getProjectLink('application') },
{ id: 'runtime', name: settingLabels.runtime, url: getProjectLink('runtime', projectId), disabled: !projectId },
// { id: 'extensions', name: settingLabels.extensions, url: getProjectLink('extensions') },
{ id: 'extensions', name: settingLabels.extensions, url: getProjectLink('extensions') },
{ id: 'about', name: settingLabels.about, url: getProjectLink('about') },
];

Expand Down
164 changes: 33 additions & 131 deletions Composer/packages/client/src/pages/setting/extensions/Extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,72 +3,48 @@

/** @jsx jsx */
import { jsx } from '@emotion/core';
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState } from 'react';
import { RouteComponentProps } from '@reach/router';
import {
DetailsList,
DetailsListLayoutMode,
SelectionMode,
IColumn,
CheckboxVisibility,
} from 'office-ui-fabric-react/lib/DetailsList';
import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList';
import { DefaultButton } from 'office-ui-fabric-react/lib/Button';
import formatMessage from 'format-message';
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
import axios from 'axios';
import { useRecoilValue } from 'recoil';
import { useRecoilValue, selector } from 'recoil';

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

import { InstallExtensionDialog } from './InstallExtensionDialog';

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

const Extensions: React.FC<RouteComponentProps> = () => {
const { fetchExtensions, toggleExtension, addExtension, removeExtension } = useRecoilValue(dispatcherState);
const extensions = useRecoilValue(extensionsState);
const extensions = useRecoilValue(remoteExtensionsState);
const [isAdding, setIsAdding] = useState(false);
const [showNewModal, setShowNewModal] = useState(false);
const [extensionName, setExtensionName] = useState<string | null>(null);
const [extensionVersion, setExtensionVersion] = useState<string | null>(null);
const [matchingExtensions, setMatchingExtensions] = useState<ExtensionConfig[]>([]);
const [selectedExtension, setSelectedExtension] = useState<any>();

useEffect(() => {
fetchExtensions();
}, []);

useEffect(() => {
if (extensionName !== null) {
const source = axios.CancelToken.source();

const timer = setTimeout(() => {
httpClient
.get(`/extensions/search?q=${extensionName}`, { cancelToken: source.token })
.then((res) => {
setMatchingExtensions(res.data);
})
.catch((err) => {
if (!axios.isCancel(err)) {
console.error(err);
}
});
}, 200);

return () => {
source.cancel('User interruption');
clearTimeout(timer);
};
}
}, [extensionName]);

const installedColumns: IColumn[] = [
{
key: 'name',
name: formatMessage('Name'),
minWidth: 100,
maxWidth: 150,
onRender: (item: ExtensionConfig) => {
return <span>{item.id}</span>;
return <span>{item.name}</span>;
},
},
{
Expand Down Expand Up @@ -109,53 +85,8 @@ const Extensions: React.FC<RouteComponentProps> = () => {
},
];

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

const toolbarItems: IToolbarItem[] = [
// TODO (toanzian / abrown): re-enable once remote extensions are supported
/*{
{
type: 'action',
text: formatMessage('Add'),
buttonProps: {
Expand All @@ -167,61 +98,32 @@ const Extensions: React.FC<RouteComponentProps> = () => {
},
},
align: 'left',
},*/
},
];

const submit = useCallback(() => {
if (selectedExtension && extensionVersion) {
addExtension(selectedExtension.id, extensionVersion);
const submit = async (selectedExtension) => {
if (selectedExtension) {
setIsAdding(true);
setShowNewModal(false);
setExtensionName(null);
setExtensionVersion(null);
setSelectedExtension(null);
await addExtension(selectedExtension.id);
setIsAdding(false);
}
}, [selectedExtension, extensionVersion]);
};

return (
<div>
<Toolbar toolbarItems={toolbarItems} />
<DetailsList
checkboxVisibility={CheckboxVisibility.hidden}
columns={installedColumns}
items={extensions}
layoutMode={DetailsListLayoutMode.justified}
selectionMode={SelectionMode.single}
/>
<Dialog
dialogContentProps={{
type: DialogType.close,
title: formatMessage('Add new extension'),
subText: formatMessage('Search for extensions'),
}}
hidden={!showNewModal}
minWidth="600px"
onDismiss={() => setShowNewModal(false)}
>
<div>
<TextField
label={formatMessage('Extension name')}
value={extensionName ?? ''}
onChange={(_e, val) => setExtensionName(val ?? null)}
/>
<DetailsList
checkboxVisibility={CheckboxVisibility.always}
columns={matchingColumns}
items={matchingExtensions}
layoutMode={DetailsListLayoutMode.justified}
selectionMode={SelectionMode.single}
onActiveItemChanged={(item) => setSelectedExtension(item)}
/>
</div>
<DialogFooter>
<DefaultButton onClick={() => setShowNewModal(false)}>Cancel</DefaultButton>
<PrimaryButton disabled={false} onClick={submit}>
{formatMessage('Add')}
</PrimaryButton>
</DialogFooter>
</Dialog>
{(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} />
a-b-r-o-w-n marked this conversation as resolved.
Show resolved Hide resolved
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/** @jsx jsx */
import { jsx } from '@emotion/core';
import React, { useState, useEffect } from 'react';
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import {
DetailsListLayoutMode,
SelectionMode,
IColumn,
CheckboxVisibility,
} from 'office-ui-fabric-react/lib/DetailsList';
import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList';
import axios from 'axios';
import formatMessage from 'format-message';

import httpClient from '../../../utils/httpUtil';

// TODO: extract to shared?
type ExtensionSearchResult = {
tonyanziano marked this conversation as resolved.
Show resolved Hide resolved
id: string;
keywords: string[];
version: string;
description: string;
url: string;
};

type InstallExtensionDialogProps = {
isOpen: boolean;
onDismiss: () => void;
onInstall: (selectedExtension: ExtensionSearchResult) => Promise<void>;
};

const InstallExtensionDialog: React.FC<InstallExtensionDialogProps> = (props) => {
const { isOpen, onDismiss, onInstall } = props;
const [searchQuery, setSearchQuery] = useState<string | null>(null);
const [matchingExtensions, setMatchingExtensions] = useState<ExtensionSearchResult[]>([]);
const [selectedExtension, setSelectedExtension] = useState<ExtensionSearchResult | null>(null);
const [isSearching, setIsSearching] = useState(false);

useEffect(() => {
if (searchQuery !== null) {
hatpick marked this conversation as resolved.
Show resolved Hide resolved
const source = axios.CancelToken.source();

const timer = setTimeout(() => {
setIsSearching(true);
httpClient
.get(`/extensions/search?q=${searchQuery}`, { cancelToken: source.token })
.then((res) => {
setMatchingExtensions(res.data);
setIsSearching(false);
})
.catch((err) => {
setIsSearching(false);
if (!axios.isCancel(err)) {
// TODO: abrown - what to do on error?
// eslint-disable-next-line no-console
console.error(err);
}
});
}, 200);

return () => {
source.cancel('User interruption');
clearTimeout(timer);
};
}
}, [searchQuery]);

const matchingColumns: 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">
View on npm
a-b-r-o-w-n marked this conversation as resolved.
Show resolved Hide resolved
</a>
) : null;
},
},
];

const onSubmit = async () => {
if (selectedExtension) {
await onInstall(selectedExtension);
setSearchQuery(null);
setMatchingExtensions([]);
setSelectedExtension(null);
}
};

return (
<Dialog
dialogContentProps={{
type: DialogType.close,
title: formatMessage('Add new extension'),
}}
hidden={!isOpen}
minWidth="800px"
onDismiss={onDismiss}
>
<div>
<TextField
a-b-r-o-w-n marked this conversation as resolved.
Show resolved Hide resolved
label={formatMessage('Search for extensions on npm')}
value={searchQuery ?? ''}
onChange={(_e, val) => setSearchQuery(val ?? null)}
/>
{(matchingExtensions.length > 0 || isSearching) && (
<ShimmeredDetailsList
checkboxVisibility={CheckboxVisibility.always}
columns={matchingColumns}
enableShimmer={isSearching}
items={matchingExtensions}
layoutMode={DetailsListLayoutMode.justified}
selectionMode={SelectionMode.single}
shimmerLines={5}
onActiveItemChanged={(item) => setSelectedExtension(item)}
/>
)}
</div>
<DialogFooter>
<DefaultButton onClick={onDismiss}>Cancel</DefaultButton>
<PrimaryButton disabled={false} onClick={onSubmit}>
{formatMessage('Add')}
</PrimaryButton>
</DialogFooter>
</Dialog>
);
};

export { InstallExtensionDialog };
Loading