From 1e8364830387fa6be7204a1895a7ec4359d8c593 Mon Sep 17 00:00:00 2001 From: Jan Philip Schellenberg Date: Thu, 15 Apr 2021 15:32:31 +0200 Subject: [PATCH] feat(library-management): add h5p-library-management to settings (#1416) * fix(settings): overhaul settings layout * test(lint): make ESLint happy * fix(settings): add locales for settings * fix(settings): remove deprecated components&hide unimplemented features * test(lint): make eslint happy * feat(library-management): add first experimental draft * fix(settings): scrollable main-area * fix(settings): highlight the current selected listitem * fix(settings): rework general listitem * feat(library-management): add library details * fix(library-management): display errors * refactor(style): prettier style * fix(settings): main content overlaps with list * fix(settings): color of upload-button to primary-color * fix(settings): remove deleted libraries from list * fix(settings): display tooltip why library can not be deleted * test(lint): make ESLint happy * fix(settings): plural locale * fix(library-settings): make header position relative * fix(library-management): increase margin between delete&details button * fix(settings): hide account-option for now * fix(settings): make linter happy Co-authored-by: Sebastian Rettig --- .../services/LibraryAdministrationService.ts | 113 +++++ client/src/views/Settings.tsx | 41 +- .../components/Settings/LibraryManagement.tsx | 421 ++++++++++++++++-- .../Settings/LibraryManagementDetails.tsx | 277 ++++++++++++ locales/lumi/en.json | 43 +- 5 files changed, 842 insertions(+), 53 deletions(-) create mode 100644 client/src/services/LibraryAdministrationService.ts create mode 100644 client/src/views/components/Settings/LibraryManagementDetails.tsx diff --git a/client/src/services/LibraryAdministrationService.ts b/client/src/services/LibraryAdministrationService.ts new file mode 100644 index 000000000..5e7bad66a --- /dev/null +++ b/client/src/services/LibraryAdministrationService.ts @@ -0,0 +1,113 @@ +import type { + IInstalledLibrary, + ILibraryAdministrationOverviewItem +} from '@lumieducation/h5p-server'; + +/** + * The data model used to display the library list. + */ +export interface ILibraryViewModel extends ILibraryAdministrationOverviewItem { + details?: IInstalledLibrary & { + dependentsCount: number; + instancesAsDependencyCount: number; + instancesCount: number; + isAddon: boolean; + }; + isDeleting?: boolean; + isShowingDetails?: boolean; +} + +/** + * + */ +export class LibraryAdministrationService { + constructor(private baseUrl: string) {} + + public async deleteLibrary(library: ILibraryViewModel): Promise { + const response = await fetch( + `${this.baseUrl}/${library.machineName}-${library.majorVersion}.${library.minorVersion}`, + { + method: 'DELETE' + } + ); + + if (response.ok) { + return; + } + throw new Error( + `Could not delete library: ${response.status} - ${response.text}` + ); + } + + public async getLibraries(): Promise { + const response = await fetch(this.baseUrl); + if (response.ok) { + return response.json(); + } + throw new Error( + `Could not get library list: ${response.status} - ${response.statusText}` + ); + } + + public async getLibrary( + library: ILibraryViewModel + ): Promise< + IInstalledLibrary & { + dependentsCount: number; + instancesAsDependencyCount: number; + instancesCount: number; + isAddon: boolean; + } + > { + const response = await fetch( + `${this.baseUrl}/${library.machineName}-${library.majorVersion}.${library.minorVersion}` + ); + if (response.ok) { + return response.json(); + } + throw new Error( + `Could not get library details: ${response.status} - ${response.statusText}` + ); + } + + public async patchLibrary( + library: ILibraryViewModel, + changes: Partial + ): Promise { + const response = await fetch( + `${this.baseUrl}/${library.machineName}-${library.majorVersion}.${library.minorVersion}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json;charset=UTF-8' + }, + body: JSON.stringify(changes) + } + ); + if (response.ok) { + return { ...library, ...changes }; + } + throw new Error( + `Could not patch library: ${response.status} - ${response.statusText}` + ); + } + + public async postPackage( + file: File + ): Promise<{ installed: number; updated: number }> { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(this.baseUrl, { + method: 'POST', + body: formData + }); + if (response.ok) { + const result = await response.json(); + return { installed: result.installed, updated: result.updated }; + } + throw new Error( + `Could not upload package with libraries: ${response.status} - ${response.statusText}` + ); + } +} diff --git a/client/src/views/Settings.tsx b/client/src/views/Settings.tsx index 1a01e5d5a..2bd255db0 100644 --- a/client/src/views/Settings.tsx +++ b/client/src/views/Settings.tsx @@ -25,8 +25,9 @@ import ListItemText from '@material-ui/core/ListItemText'; import CloseIcon from '@material-ui/icons/Close'; import SettingsIcon from '@material-ui/icons/Settings'; +import LibraryBooksIcon from '@material-ui/icons/LibraryBooks'; // import LibraryBooksIcon from '@material-ui/icons/LibraryBooks'; -import AccountBoxIcon from '@material-ui/icons/AccountBox'; +// import AccountBoxIcon from '@material-ui/icons/AccountBox'; import UpdateIcon from '@material-ui/icons/Update'; import SettingsList from './components/Settings/GeneralSettingsList'; @@ -49,7 +50,7 @@ const useStyles = makeStyles((theme: Theme) => }, root: { display: 'flex', - marginLeft: '100px' + paddingLeft: drawerWidth }, heading: { fontSize: theme.typography.pxToRem(15), @@ -72,6 +73,7 @@ const useStyles = makeStyles((theme: Theme) => }, drawer: { width: drawerWidth, + marginRight: '20px', flexShrink: 0 }, drawerPaper: { @@ -138,7 +140,8 @@ export default function FullScreenDialog() { - {/* setSection('h5p-libraries')} - style={{ - backgroundColor: - section === 'general' - ? '#EFEFEF' - : '#FFFFFF', - color: '#3498db' - }} + key="h5p-library-administration" + onClick={() => + setSection('h5p-library-administration') + } + className={classnames({ + [classes.selected]: + section === 'h5p-library-administration' + })} > - */} - setSection('account')} @@ -210,7 +213,7 @@ export default function FullScreenDialog() { - + */} @@ -226,9 +229,9 @@ export default function FullScreenDialog() { case 'updates': return ; - case 'h5p-libraries': + case 'h5p-library-administration': return ( - + ); case 'account': diff --git a/client/src/views/components/Settings/LibraryManagement.tsx b/client/src/views/components/Settings/LibraryManagement.tsx index f0fe021c6..fead5bc7a 100644 --- a/client/src/views/components/Settings/LibraryManagement.tsx +++ b/client/src/views/components/Settings/LibraryManagement.tsx @@ -1,52 +1,407 @@ import React from 'react'; -import { useTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; -import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; import ListItemText from '@material-ui/core/ListItemText'; import ListSubheader from '@material-ui/core/ListSubheader'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import Tooltip from '@material-ui/core/Tooltip'; + +import i18next from 'i18next'; import Button from '@material-ui/core/Button'; +import ButtonGroup from '@material-ui/core/ButtonGroup'; + +import LibraryDetails from './LibraryManagementDetails'; + +import H5PAvatar from '../H5PAvatar'; + +// The .js references are necessary for requireJs to work in the browser. +// import LibraryDetails from './LibraryDetailsComponent'; +import { + ILibraryViewModel, + LibraryAdministrationService +} from '../../../services/LibraryAdministrationService'; +import { actions } from '../../../state'; +import { NotificationTypes } from '../../../state/Notifications/NotificationsTypes'; +/** + * The components displays a list with the currently installed libraries. It + * offers basic administration functions like deleting libraries, showing more + * details of an installed library and uploading new libraries. + * + * It uses Bootstrap 4 to layout the component. You can override or replace the + * render() method to customize looks. + */ +export class LibraryAdmin extends React.Component< + { endpointUrl: string; notify: typeof actions.notifications.notify }, + { + isUploading: boolean; + libraries?: ILibraryViewModel[] | null; + message: { + text: string; + type: 'primary' | 'success' | 'danger'; + } | null; + } +> { + protected librariesService: LibraryAdministrationService; + + /** + * @param endpointUrl the URL of the REST library administration endpoint. + */ + constructor(props: { + endpointUrl: string; + notify: typeof actions.notifications.notify; + }) { + super(props); + + this.state = { + isUploading: false, + libraries: null, + message: null + }; + this.librariesService = new LibraryAdministrationService( + props.endpointUrl + ); + } + + public async componentDidMount(): Promise { + return this.updateList(); + } -import EmailIcon from '@material-ui/icons/Email'; + protected closeDetails(library: ILibraryViewModel): void { + this.updateLibraryState(library, { isShowingDetails: false }); + } -const useStyles = makeStyles((theme: Theme) => - createStyles({ - root: { - width: '100%', - backgroundColor: theme.palette.background.paper + protected async deleteLibrary(library: ILibraryViewModel): Promise { + const newState = this.updateLibraryState(library, { + isDeleting: true + }); + const { libraries } = this.state; + + try { + await this.librariesService.deleteLibrary(library); + const libraryIndex = libraries?.indexOf(library); + if (libraryIndex === undefined) { + throw new Error('Could not find old entry in list'); + } + this.setState({ + libraries: libraries + ?.slice(0, libraryIndex) + .concat(libraries?.slice(libraryIndex + 1)) + }); + this.displayMessage( + i18next.t( + `settings.h5p-library-administration.notifications.delete.success`, + { + title: library.title, + version: `${library.majorVersion}.${library.minorVersion}` + } + ), + 'success' + ); + await this.updateList(); + } catch { + this.displayMessage( + i18next.t( + 'settings.h5p-library-administration.notifications.delete.error', + { + title: library.title, + version: `${library.majorVersion}.${library.minorVersion}` + } + ), + 'error' + ); + this.updateLibraryState(newState, { isDeleting: false }); + await this.updateList(); } - }) -); + } -export default function SettingsLibraryManagement() { - const classes = useStyles(); + protected async fileSelected(files: FileList | null): Promise { + if (!files || !files[0]) { + return; + } + try { + this.setState({ isUploading: true }); + const { + installed, + updated + } = await this.librariesService.postPackage(files[0]); + if (installed + updated === 0) { + this.displayMessage( + i18next.t( + 'settings.h5p-library-administration.notifications.upload.success-no-update' + ), + 'success' + ); + return; + } + this.displayMessage( + i18next.t( + 'settings.h5p-library-administration.notifications.upload.success-with-update', + { installed, updated } + ), + 'success' + ); + } catch { + this.displayMessage( + i18next.t( + 'settings.h5p-library-administration.notifications.upload.error' + ), + 'error' + ); + return; + } finally { + this.setState({ isUploading: false }); + } + this.setState({ libraries: null }); + try { + const libraries = await this.librariesService.getLibraries(); + this.setState({ libraries }); + } catch (error) { + this.props.notify(error.message, 'error'); + } + } - const { t } = useTranslation(); + protected async showDetails(library: ILibraryViewModel): Promise { + const newState = this.updateLibraryState(library, { + isShowingDetails: true + }); - return ( - {t('settings.libraries.label')} + if (!library.details) { + try { + const details = await this.librariesService.getLibrary(library); + this.updateLibraryState(newState, { + details + }); + } catch { + this.displayMessage( + i18next.t( + 'settings.h5p-library-administration.notifications.get.error', + { + title: library.title, + version: `${library.majorVersion}.${library.minorVersion}` + } + ), + 'error' + ); } - className={classes.root} - > - - - - - - - - - - + } + } + + public async updateList(): Promise { + try { + const libraries = await this.librariesService.getLibraries(); + this.setState({ libraries }); + } catch (error) { + this.props.notify(error.message, 'error'); + } + } + + protected displayMessage(text: string, type: NotificationTypes): void { + this.props.notify(text, type); + // this.setState({ + // message: { + // text, + // type + // } + // }); + } + + protected updateLibraryState( + library: ILibraryViewModel, + changes: Partial + ): ILibraryViewModel { + const { libraries } = this.state; + + if (!libraries) { + return { + ...library, + ...changes + }; + } + const libraryIndex = libraries.indexOf(library); + const newState = { + ...library, + ...changes + }; + this.setState({ + libraries: [ + ...libraries.slice(0, libraryIndex), + newState, + ...libraries.slice(libraryIndex + 1) + ] + }); + return newState; + } + + public render(): React.ReactNode { + return ( +
+
+
+ +
+
+ {this.state.message ? ( +
+ {this.state.message.text} +
+ ) : null} + {this.state.libraries === null ? ( +
+ +
+ ) : ( + + {i18next.t( + 'settings.h5p-library-administration.header' + )} + + } + // className={classes.root} + > + {this.state.libraries?.map((info) => ( + + + + + + + + {info.canBeDeleted ? ( + + ) : ( + +
+ +
+
+ )} + + this.showDetails(info) + } + /> +
+ {/* */} +
+
+ ))} +
+ )} +
+ ); + } +} + +function mapStateToProps(state: any, ownProps: any): any { + return {}; +} + +function mapDispatchToProps(dispatch: any): any { + return bindActionCreators( + { + notify: actions.notifications.notify + }, + dispatch ); } + +export default connect(mapStateToProps, mapDispatchToProps)(LibraryAdmin); diff --git a/client/src/views/components/Settings/LibraryManagementDetails.tsx b/client/src/views/components/Settings/LibraryManagementDetails.tsx new file mode 100644 index 000000000..03103ec18 --- /dev/null +++ b/client/src/views/components/Settings/LibraryManagementDetails.tsx @@ -0,0 +1,277 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import Button from '@material-ui/core/Button'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableCell from '@material-ui/core/TableCell'; +import TableContainer from '@material-ui/core/TableContainer'; +import TableRow from '@material-ui/core/TableRow'; +import Paper from '@material-ui/core/Paper'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import CircularProgress from '@material-ui/core/CircularProgress'; + +import CheckIcon from '@material-ui/icons/Check'; +import CrossIcon from '@material-ui/icons/Close'; + +import type { IInstalledLibrary } from '@lumieducation/h5p-server'; + +const yesNo = (value: undefined | boolean | 0 | 1) => + value ? : ; + +export interface SimpleDialogProps { + open: boolean; + // selectedValue: string; + onClose: () => void; + details?: IInstalledLibrary & { + dependentsCount: number; + instancesAsDependencyCount: number; + instancesCount: number; + isAddon: boolean; + }; +} + +function LibraryDetailDialog(props: SimpleDialogProps) { + const { t } = useTranslation(); + const { onClose, open } = props; + + const handleClose = () => { + onClose(); + }; + + return ( + + + {t( + 'settings.h5p-library-administration.library-details.header' + )} + +
+ {props.details === undefined ? ( +
+ +
+ ) : ( +
+
+ + + + + + {t( + 'settings.h5p-library-administration.library-details.author' + )} + {' '} + + {props.details.author || '-'} + + + + + {t( + 'settings.h5p-library-administration.library-details.description' + )} + {' '} + + {props.details.description || + '-'} + + + + + {t( + 'settings.h5p-library-administration.library-details.license' + )} + {' '} + + {props.details.license || '-'} + + + + + {t( + 'settings.h5p-library-administration.library-details.standalone' + )} + {' '} + + {yesNo(props.details.runnable)} + + + + + {t( + 'settings.h5p-library-administration.library-details.restricted' + )} + {' '} + + {yesNo( + props.details.restricted + )} + + + + + {t( + 'settings.h5p-library-administration.library-details.fullscreen' + )} + {' '} + + {yesNo( + props.details.fullscreen + )} + + + + + {t( + 'settings.h5p-library-administration.library-details.addon' + )} + {' '} + + {yesNo(props.details.isAddon)} + + + + + {t( + 'settings.h5p-library-administration.library-details.embedTypes' + )} + {' '} + + {props.details.embedTypes?.join( + ' ' + ) || '-'} + + + + + {t( + 'settings.h5p-library-administration.library-details.dependentsCount' + )} + {' '} + + {props.details.dependentsCount} + + + + + {t( + 'settings.h5p-library-administration.library-details.instancesCount' + )} + {' '} + + {props.details.instancesCount} + + + + + {t( + 'settings.h5p-library-administration.library-details.instancesAsDependencyCount' + )} + {' '} + + { + props.details + .instancesAsDependencyCount + } + + + +
+
+
+
+ )} +
+ + + +
+ ); +} + +export default function LibraryManagementDetailsDialog(props: { + details?: IInstalledLibrary & { + dependentsCount: number; + instancesAsDependencyCount: number; + instancesCount: number; + isAddon: boolean; + }; + showDetails: () => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = React.useState(false); + + const handleClickOpen = () => { + props.showDetails(); + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + return ( +
+ + +
+ ); +} diff --git a/locales/lumi/en.json b/locales/lumi/en.json index c161166e3..e92b985a3 100644 --- a/locales/lumi/en.json +++ b/locales/lumi/en.json @@ -1,4 +1,10 @@ { + "dialog": { + "close": "Close" + }, + "general": { + "delete": "Delete" + }, "auth": { "set_email": "Set Email", "email": "Email", @@ -74,7 +80,7 @@ "menu": { "general": "General", "updates": "Updates", - "h5p-libraries": "H5P Libraries", + "h5p-library-administration": "H5P Libraries", "account": "Account" }, "general": { @@ -90,6 +96,41 @@ "change": "Change Email", "not-set": "Email is not set" } + }, + "h5p-library-administration": { + "header": "Installed H5P libraries", + "notifications": { + "upload": { + "success-no-update": "Upload successful, but no libraries were installed or updated. The content type is probably already installed on the system.", + "success-with-update": "Successfully installed {{installed}} and updated {{updated}} libraries.", + "error": "Error while uploading package." + }, + "delete": { + "success": "Successfully deleted library {{title}} ({{version}}).", + "error": "Error deleting library {{title}} ({{version}})." + }, + "get": { + "error": "Error getting detailed information about library {{library.title}} ({{version}})." + } + }, + "upload_and_install": "Upload and install Library", + "details": "Details", + "can-not-be-deleted": "This library cannot be deleted, because it is used in {{count}} other library", + "can-not-be-deleted_plural": "This library cannot be deleted, because it is used in {{count}} other libraries", + "library-details": { + "header": "Library Details", + "author": "Author", + "description": "Description", + "license": "License", + "standalone": "Standalone content type", + "restricted": "Restricted (can only be used with privilege)", + "fullscreen": "Supports fullscreen", + "addon": "Addon", + "embedTypes": "Allowed embed types", + "dependentsCount": "Number of libraries that use the library", + "instancesCount": "Objects created with this library as the main content type", + "instancesAsDependencyCount": "Objects in which this library is used by another library" + } } }, "analytics": {