widget.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import { VDomRenderer, ToolbarButtonComponent } from '@jupyterlab/apputils';
  4. import { ServiceManager } from '@jupyterlab/services';
  5. import { Message } from '@lumino/messaging';
  6. import { Button, InputGroup, Collapse } from '@jupyterlab/ui-components';
  7. import * as React from 'react';
  8. import ReactPaginate from 'react-paginate';
  9. import { ListModel, IEntry, Action } from './model';
  10. import { isJupyterOrg } from './query';
  11. // TODO: Replace pagination with lazy loading of lower search results
  12. /**
  13. * Search bar VDOM component.
  14. */
  15. export class SearchBar extends React.Component<
  16. SearchBar.IProperties,
  17. SearchBar.IState
  18. > {
  19. constructor(props: SearchBar.IProperties) {
  20. super(props);
  21. this.state = {
  22. value: ''
  23. };
  24. }
  25. /**
  26. * Render the list view using the virtual DOM.
  27. */
  28. render(): React.ReactNode {
  29. return (
  30. <div className="jp-extensionmanager-search-bar">
  31. <InputGroup
  32. className="jp-extensionmanager-search-wrapper"
  33. type="text"
  34. placeholder={this.props.placeholder}
  35. onChange={this.handleChange}
  36. value={this.state.value}
  37. rightIcon="search"
  38. />
  39. </div>
  40. );
  41. }
  42. /**
  43. * Handler for search input changes.
  44. */
  45. handleChange = (e: React.FormEvent<HTMLElement>) => {
  46. let target = e.target as HTMLInputElement;
  47. this.setState({
  48. value: target.value
  49. });
  50. };
  51. }
  52. /**
  53. * The namespace for search bar statics.
  54. */
  55. export namespace SearchBar {
  56. /**
  57. * React properties for search bar component.
  58. */
  59. export interface IProperties {
  60. /**
  61. * The placeholder string to use in the search bar input field when empty.
  62. */
  63. placeholder: string;
  64. }
  65. /**
  66. * React state for search bar component.
  67. */
  68. export interface IState {
  69. /**
  70. * The value of the search bar input field.
  71. */
  72. value: string;
  73. }
  74. }
  75. /**
  76. * Create a build prompt as a react element.
  77. *
  78. * @param props Configuration of the build prompt.
  79. */
  80. function BuildPrompt(props: BuildPrompt.IProperties): React.ReactElement<any> {
  81. return (
  82. <div className="jp-extensionmanager-buildprompt">
  83. <div className="jp-extensionmanager-buildmessage">
  84. A build is needed to include the latest changes
  85. </div>
  86. <Button onClick={props.performBuild} minimal small>
  87. Rebuild
  88. </Button>
  89. <Button onClick={props.ignoreBuild} minimal small>
  90. Ignore
  91. </Button>
  92. </div>
  93. );
  94. }
  95. /**
  96. * The namespace for build prompt statics.
  97. */
  98. namespace BuildPrompt {
  99. /**
  100. * Properties for build prompt react component.
  101. */
  102. export interface IProperties {
  103. /**
  104. * Callback for when a build is requested.
  105. */
  106. performBuild: () => void;
  107. /**
  108. * Callback for when a build notice is dismissed.
  109. */
  110. ignoreBuild: () => void;
  111. }
  112. }
  113. /**
  114. * VDOM for visualizing an extension entry.
  115. */
  116. function ListEntry(props: ListEntry.IProperties): React.ReactElement<any> {
  117. const { entry } = props;
  118. const flagClasses = [];
  119. if (entry.status && ['ok', 'warning', 'error'].indexOf(entry.status) !== -1) {
  120. flagClasses.push(`jp-extensionmanager-entry-${entry.status}`);
  121. }
  122. let title = entry.name;
  123. if (isJupyterOrg(entry.name)) {
  124. flagClasses.push(`jp-extensionmanager-entry-mod-whitelisted`);
  125. title = `${entry.name} (Developed by Project Jupyter)`;
  126. }
  127. return (
  128. <li
  129. className={`jp-extensionmanager-entry ${flagClasses.join(' ')}`}
  130. title={title}
  131. >
  132. <div className="jp-extensionmanager-entry-title">
  133. <div className="jp-extensionmanager-entry-name">
  134. <a href={entry.url} target="_blank" rel="noopener">
  135. {entry.name}
  136. </a>
  137. </div>
  138. <div className="jp-extensionmanager-entry-jupyter-org" />
  139. </div>
  140. <div className="jp-extensionmanager-entry-content">
  141. <div className="jp-extensionmanager-entry-description">
  142. {entry.description}
  143. </div>
  144. <div className="jp-extensionmanager-entry-buttons">
  145. {!entry.installed && (
  146. <Button
  147. onClick={() => props.performAction('install', entry)}
  148. minimal
  149. small
  150. >
  151. Install
  152. </Button>
  153. )}
  154. {ListModel.entryHasUpdate(entry) && (
  155. <Button
  156. onClick={() => props.performAction('install', entry)}
  157. minimal
  158. small
  159. >
  160. Update
  161. </Button>
  162. )}
  163. {entry.installed && (
  164. <Button
  165. onClick={() => props.performAction('uninstall', entry)}
  166. minimal
  167. small
  168. >
  169. Uninstall
  170. </Button>
  171. )}
  172. {entry.enabled && (
  173. <Button
  174. onClick={() => props.performAction('disable', entry)}
  175. minimal
  176. small
  177. >
  178. Disable
  179. </Button>
  180. )}
  181. {entry.installed && !entry.enabled && (
  182. <Button
  183. onClick={() => props.performAction('enable', entry)}
  184. minimal
  185. small
  186. >
  187. Enable
  188. </Button>
  189. )}
  190. </div>
  191. </div>
  192. </li>
  193. );
  194. }
  195. /**
  196. * The namespace for extension entry statics.
  197. */
  198. export namespace ListEntry {
  199. export interface IProperties {
  200. /**
  201. * The entry to visualize.
  202. */
  203. entry: IEntry;
  204. /**
  205. * Callback to use for performing an action on the entry.
  206. */
  207. performAction: (action: Action, entry: IEntry) => void;
  208. }
  209. }
  210. /**
  211. * List view widget for extensions
  212. */
  213. export function ListView(props: ListView.IProperties): React.ReactElement<any> {
  214. const entryViews = [];
  215. for (let entry of props.entries) {
  216. entryViews.push(
  217. <ListEntry
  218. entry={entry}
  219. key={entry.name}
  220. performAction={props.performAction}
  221. />
  222. );
  223. }
  224. let pagination;
  225. if (props.numPages > 1) {
  226. pagination = (
  227. <div className="jp-extensionmanager-pagination">
  228. <ReactPaginate
  229. previousLabel={'<'}
  230. nextLabel={'>'}
  231. breakLabel={<a href="">...</a>}
  232. breakClassName={'break-me'}
  233. pageCount={props.numPages}
  234. marginPagesDisplayed={2}
  235. pageRangeDisplayed={5}
  236. onPageChange={(data: { selected: number }) =>
  237. props.onPage(data.selected)
  238. }
  239. containerClassName={'pagination'}
  240. activeClassName={'active'}
  241. />
  242. </div>
  243. );
  244. }
  245. const listview = (
  246. <ul className="jp-extensionmanager-listview">{entryViews}</ul>
  247. );
  248. return (
  249. <div className="jp-extensionmanager-listview-wrapper">
  250. {entryViews.length > 0 ? (
  251. listview
  252. ) : (
  253. <div key="message" className="jp-extensionmanager-listview-message">
  254. No entries
  255. </div>
  256. )}
  257. {pagination}
  258. </div>
  259. );
  260. }
  261. /**
  262. * The namespace for list view widget statics.
  263. */
  264. export namespace ListView {
  265. export interface IProperties {
  266. /**
  267. * The extension entries to display.
  268. */
  269. entries: ReadonlyArray<IEntry>;
  270. /**
  271. * The number of pages that can be viewed via pagination.
  272. */
  273. numPages: number;
  274. /**
  275. * The callback to use for changing the page
  276. */
  277. onPage: (page: number) => void;
  278. /**
  279. * Callback to use for performing an action on an entry.
  280. */
  281. performAction: (action: Action, entry: IEntry) => void;
  282. }
  283. }
  284. function ErrorMessage(props: ErrorMessage.IProperties) {
  285. return (
  286. <div key="error-msg" className="jp-extensionmanager-error">
  287. {props.children}
  288. </div>
  289. );
  290. }
  291. namespace ErrorMessage {
  292. export interface IProperties {
  293. children: React.ReactNode;
  294. }
  295. }
  296. /**
  297. *
  298. */
  299. export class CollapsibleSection extends React.Component<
  300. CollapsibleSection.IProperties,
  301. CollapsibleSection.IState
  302. > {
  303. constructor(props: CollapsibleSection.IProperties) {
  304. super(props);
  305. this.state = {
  306. isOpen: props.isOpen || true
  307. };
  308. }
  309. /**
  310. * Render the collapsible section using the virtual DOM.
  311. */
  312. render(): React.ReactNode {
  313. return (
  314. <>
  315. <header>
  316. <ToolbarButtonComponent
  317. iconClassName={
  318. this.state.isOpen
  319. ? 'jp-extensionmanager-expandIcon'
  320. : 'jp-extensionmanager-collapseIcon'
  321. }
  322. onClick={() => {
  323. this.handleCollapse();
  324. }}
  325. />
  326. <span className="jp-extensionmanager-headerText">
  327. {this.props.header}
  328. </span>
  329. {this.props.headerElements}
  330. </header>
  331. <Collapse isOpen={this.state.isOpen}>{this.props.children}</Collapse>
  332. </>
  333. );
  334. }
  335. /**
  336. * Handler for search input changes.
  337. */
  338. handleCollapse() {
  339. this.setState(
  340. {
  341. isOpen: !this.state.isOpen
  342. },
  343. () => {
  344. if (this.props.onCollapse) {
  345. this.props.onCollapse(this.state.isOpen);
  346. }
  347. }
  348. );
  349. }
  350. }
  351. /**
  352. * The namespace for collapsible section statics.
  353. */
  354. export namespace CollapsibleSection {
  355. /**
  356. * React properties for collapsible section component.
  357. */
  358. export interface IProperties {
  359. /**
  360. * The header string for section list.
  361. */
  362. header: string;
  363. /**
  364. * Whether the view will be expanded or collapsed initially, defaults to open.
  365. */
  366. isOpen?: boolean;
  367. /**
  368. * Handle collapse event.
  369. */
  370. onCollapse?: (isOpen: boolean) => void;
  371. /**
  372. * Any additional elements to add to the header.
  373. */
  374. headerElements?: React.ReactNode;
  375. /**
  376. * If given, this will be diplayed instead of the children.
  377. */
  378. errorMessage?: string | null;
  379. }
  380. /**
  381. * React state for collapsible section component.
  382. */
  383. export interface IState {
  384. /**
  385. * Whether the section is expanded or collapsed.
  386. */
  387. isOpen: boolean;
  388. }
  389. }
  390. /**
  391. * The main view for the discovery extension.
  392. */
  393. export class ExtensionView extends VDomRenderer<ListModel> {
  394. constructor(serviceManager: ServiceManager) {
  395. super(new ListModel(serviceManager));
  396. this.addClass('jp-extensionmanager-view');
  397. }
  398. /**
  399. * The search input node.
  400. */
  401. get inputNode(): HTMLInputElement {
  402. return this.node.querySelector(
  403. '.jp-extensionmanager-search-wrapper input'
  404. ) as HTMLInputElement;
  405. }
  406. /**
  407. * Render the extension view using the virtual DOM.
  408. */
  409. protected render(): React.ReactElement<any>[] {
  410. const model = this.model!;
  411. let pages = Math.ceil(model.totalEntries / model.pagination);
  412. let elements = [<SearchBar key="searchbar" placeholder="SEARCH" />];
  413. if (model.promptBuild) {
  414. elements.push(
  415. <BuildPrompt
  416. key="buildpromt"
  417. performBuild={() => {
  418. model.performBuild();
  419. }}
  420. ignoreBuild={() => {
  421. model.ignoreBuildRecommendation();
  422. }}
  423. />
  424. );
  425. }
  426. // Indicator element for pending actions:
  427. elements.push(
  428. <div
  429. key="pending"
  430. className={`jp-extensionmanager-pending ${
  431. model.hasPendingActions() ? 'jp-mod-hasPending' : ''
  432. }`}
  433. />
  434. );
  435. const content = [];
  436. if (!model.initialized) {
  437. void model.initialize();
  438. content.push(
  439. <div key="loading-placeholder" className="jp-extensionmanager-loader">
  440. Updating extensions list
  441. </div>
  442. );
  443. } else if (model.serverConnectionError !== null) {
  444. content.push(
  445. <ErrorMessage key="error-msg">
  446. <p>
  447. Error communicating with server extension. Consult the documentation
  448. for how to ensure that it is enabled.
  449. </p>
  450. <p>Reason given:</p>
  451. <pre>{model.serverConnectionError}</pre>
  452. </ErrorMessage>
  453. );
  454. } else if (model.serverRequirementsError !== null) {
  455. content.push(
  456. <ErrorMessage key="server-requirements-error">
  457. <p>
  458. The server has some missing requirements for installing extensions.
  459. </p>
  460. <p>Details:</p>
  461. <pre>{model.serverRequirementsError}</pre>
  462. </ErrorMessage>
  463. );
  464. } else {
  465. // List installed and discovery sections
  466. let installedContent = [];
  467. if (model.installedError !== null) {
  468. installedContent.push(
  469. <ErrorMessage key="install-error">
  470. {`Error querying installed extensions${
  471. model.installedError ? `: ${model.installedError}` : '.'
  472. }`}
  473. </ErrorMessage>
  474. );
  475. } else {
  476. installedContent.push(
  477. <ListView
  478. key="installed-items"
  479. entries={model.installed}
  480. numPages={1}
  481. onPage={value => {
  482. /* no-op */
  483. }}
  484. performAction={this.onAction.bind(this)}
  485. />
  486. );
  487. }
  488. content.push(
  489. <CollapsibleSection
  490. key="installed-section"
  491. isOpen={true}
  492. header="Installed"
  493. headerElements={
  494. <ToolbarButtonComponent
  495. key="refresh-button"
  496. className="jp-extensionmanager-refresh"
  497. iconClassName="jp-RefreshIcon"
  498. onClick={() => {
  499. model.refreshInstalled();
  500. }}
  501. tooltip="Refresh extension list"
  502. />
  503. }
  504. >
  505. {installedContent}
  506. </CollapsibleSection>
  507. );
  508. let searchContent = [];
  509. if (model.searchError !== null) {
  510. searchContent.push(
  511. <ErrorMessage key="search-error">
  512. {`Error searching for extensions${
  513. model.searchError ? `: ${model.searchError}` : '.'
  514. }`}
  515. </ErrorMessage>
  516. );
  517. } else {
  518. searchContent.push(
  519. <ListView
  520. key="search-items"
  521. // Filter out installed extensions:
  522. entries={model.searchResult.filter(
  523. entry => model.installed.indexOf(entry) === -1
  524. )}
  525. numPages={pages}
  526. onPage={value => {
  527. this.onPage(value);
  528. }}
  529. performAction={this.onAction.bind(this)}
  530. />
  531. );
  532. }
  533. content.push(
  534. <CollapsibleSection
  535. key="search-section"
  536. isOpen={false}
  537. header={model.query ? 'Search Results' : 'Discover'}
  538. onCollapse={(isOpen: boolean) => {
  539. if (isOpen && model.query === null) {
  540. model.query = '';
  541. }
  542. }}
  543. >
  544. {searchContent}
  545. </CollapsibleSection>
  546. );
  547. }
  548. elements.push(
  549. <div key="content" className="jp-extensionmanager-content">
  550. {content}
  551. </div>
  552. );
  553. return elements;
  554. }
  555. /**
  556. * Callback handler for the user specifies a new search query.
  557. *
  558. * @param value The new query.
  559. */
  560. onSearch(value: string) {
  561. this.model!.query = value;
  562. }
  563. /**
  564. * Callback handler for the user changes the page of the search result pagination.
  565. *
  566. * @param value The pagination page number.
  567. */
  568. onPage(value: number) {
  569. this.model!.page = value;
  570. }
  571. /**
  572. * Callback handler for when the user wants to perform an action on an extension.
  573. *
  574. * @param action The action to perform.
  575. * @param entry The entry to perform the action on.
  576. */
  577. onAction(action: Action, entry: IEntry) {
  578. switch (action) {
  579. case 'install':
  580. return this.model!.install(entry);
  581. case 'uninstall':
  582. return this.model!.uninstall(entry);
  583. case 'enable':
  584. return this.model!.enable(entry);
  585. case 'disable':
  586. return this.model!.disable(entry);
  587. default:
  588. throw new Error(`Invalid action: ${action}`);
  589. }
  590. }
  591. /**
  592. * Handle the DOM events for the command palette.
  593. *
  594. * @param event - The DOM event sent to the command palette.
  595. *
  596. * #### Notes
  597. * This method implements the DOM `EventListener` interface and is
  598. * called in response to events on the command palette's DOM node.
  599. * It should not be called directly by user code.
  600. */
  601. handleEvent(event: Event): void {
  602. switch (event.type) {
  603. case 'input':
  604. this.onSearch(this.inputNode.value);
  605. break;
  606. case 'focus':
  607. case 'blur':
  608. this._toggleFocused();
  609. break;
  610. default:
  611. break;
  612. }
  613. }
  614. /**
  615. * A message handler invoked on a `'before-attach'` message.
  616. */
  617. protected onBeforeAttach(msg: Message): void {
  618. this.node.addEventListener('input', this);
  619. this.node.addEventListener('focus', this, true);
  620. this.node.addEventListener('blur', this, true);
  621. }
  622. /**
  623. * A message handler invoked on an `'after-detach'` message.
  624. */
  625. protected onAfterDetach(msg: Message): void {
  626. this.node.removeEventListener('input', this);
  627. this.node.removeEventListener('focus', this, true);
  628. this.node.removeEventListener('blur', this, true);
  629. }
  630. /**
  631. * A message handler invoked on an `'activate-request'` message.
  632. */
  633. protected onActivateRequest(msg: Message): void {
  634. if (this.isAttached) {
  635. let input = this.inputNode;
  636. input.focus();
  637. input.select();
  638. }
  639. }
  640. /**
  641. * Toggle the focused modifier based on the input node focus state.
  642. */
  643. private _toggleFocused(): void {
  644. let focused = document.activeElement === this.inputNode;
  645. this.toggleClass('p-mod-focused', focused);
  646. }
  647. }