UNPKG

@craftercms/studio-ui

Version:

Services, components, models & utils to build CrafterCMS authoring extensions.

738 lines (736 loc) 29.2 kB
/* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ /* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import useSpreadState from '../../hooks/useSpreadState'; import useEnv from '../../hooks/useEnv'; import { createSite as createSiteFromMarketplace, fetchBlueprints as fetchMarketplaceBlueprintsService } from '../../services/marketplace'; import { setRequestForgeryToken, setSiteCookie } from '../../utils/auth'; import { create, exists, fetchBlueprints as fetchBuiltInBlueprints } from '../../services/sites'; import { getSystemLink } from '../../utils/system'; import Grid from '@mui/material/Grid'; import PluginCard from '../PluginCard'; import ConfirmDialog from '../ConfirmDialog'; import LoadingState from '../LoadingState'; import ApiResponseErrorState from '../ApiResponseErrorState'; import PluginDetailsView from '../PluginDetailsView'; import DialogHeader from '../DialogHeader'; import DialogBody from '../DialogBody'; import Divider from '@mui/material/Divider'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import SearchIcon from '@mui/icons-material/Search'; import FormControlLabel from '@mui/material/FormControlLabel'; import Checkbox from '@mui/material/Checkbox'; import SearchBar from '../SearchBar'; import Box from '@mui/material/Box'; import SignalWifiBadRounded from '@mui/icons-material/SignalWifiBadRounded'; import Button from '@mui/material/Button'; import EmptyState from '../EmptyState'; import BlueprintForm from './BlueprintForm'; import BlueprintReview from './BlueprintReview'; import DialogFooter from '../DialogFooter'; import PrimaryButton from '../PrimaryButton'; import { useStyles } from './styles'; import messages from './translations'; import { hasGlobalPermissions } from '../../services/users'; import SecondaryButton from '../SecondaryButton'; import useMount from '../../hooks/useMount'; import ContentCopyIcon from '@mui/icons-material/ContentCopyRounded'; import GitFilled from '../../icons/GitFilled'; import { previewSwitch } from '../../services/security'; const baseFormFields = ['siteName', 'siteId', 'description', 'gitBranch']; const gitFormFields = ['repoUrl', 'repoRemoteName', 'repoUsername', 'repoPassword', 'repoToken', 'repoKey']; export function CreateSiteDialogLoader(props) { const { title, subtitle, handleClose } = props; const { classes } = useStyles(); const { formatMessage } = useIntl(); return React.createElement( 'div', { className: classes.statePaper }, React.createElement(LoadingState, { title: title ?? formatMessage(messages.creatingSite), subtitle: subtitle ?? formatMessage(messages.pleaseWait), classes: { root: classes.loadingStateRoot, graphicRoot: classes.loadingStateGraphicRoot, graphic: classes.loadingStateGraphic } }), React.createElement( Box, { sx: { display: 'flex', alignItems: 'center', flexDirection: 'column' } }, React.createElement( SecondaryButton, { sx: { mb: 1 }, onClick: handleClose }, React.createElement(FormattedMessage, { id: 'words.close', defaultMessage: 'Close' }) ), React.createElement( Typography, { variant: 'body2', color: 'textSecondary' }, React.createElement(FormattedMessage, { defaultMessage: 'Project creation will continue in the background' }) ) ) ); } export function CreateSiteDialogContainer(props) { const { site, setSite, search, setSearch, handleClose, dialog, setDialog, disableEnforceFocus, onShowDuplicate } = props; const { classes, cx } = useStyles(); const [permissionsLookup, setPermissionsLookup] = useState({}); const hasListPluginPermission = permissionsLookup['list_plugins']; const [blueprints, setBlueprints] = useState(null); const [marketplace, setMarketplace] = useState(null); const [apiState, setApiState] = useSpreadState({ creatingSite: false, error: false, global: false, errorResponse: null, fetchingMarketplace: false, marketplaceError: false }); const finishRef = useRef(null); const { current: refts } = useRef({}); refts.setSite = setSite; const { formatMessage } = useIntl(); const { authoringBase, useBaseDomain } = useEnv(); const siteCreateSubscription = useRef(); const mounted = useRef(false); useMount(() => { setRequestForgeryToken(); mounted.current = true; return () => { mounted.current = false; }; }); const views = { 0: { title: formatMessage(messages.createSite) }, 1: { title: formatMessage(messages.createSite), subtitle: formatMessage(messages.nameAndDescription), btnText: formatMessage(messages.review) }, 2: { title: formatMessage(messages.finish), subtitle: formatMessage(messages.reviewSite), btnText: formatMessage(messages.createSite) } }; const fieldsErrorsLookup = useMemo(() => { let map = { siteName: !site.siteName || site.siteNameExist, siteId: !site.siteId || site.siteIdExist || site.invalidSiteId, description: false, gitBranch: false }; if (site.blueprint?.parameters) { site.blueprint.parameters.forEach((parameter) => { map[parameter.name] = parameter.required && !site.blueprintFields[parameter.name]; }); } if (site.blueprint?.id === 'GIT') { map['repoUrl'] = !site.repoUrl; map['repoRemoteName'] = false; const type = site.repoAuthentication; if (type === 'basic' || type === 'token') { map['repoUsername'] = !site.repoUsername; } if (type === 'basic') { map['repoPassword'] = !site.repoPassword; } if (type === 'token') { map['repoToken'] = !site.repoToken; } if (type === 'key') { map['repoKey'] = !site.repoKey; } } return map; }, [site]); const scrollToErrorInput = () => { const formFields = [ ...baseFormFields, ...(site.blueprint?.parameters?.map((parameter) => parameter.name) ?? []), ...(site.blueprint?.id === 'GIT' ? gitFormFields : []) ]; const firstFieldWithError = formFields.find((field) => fieldsErrorsLookup[field]); if (firstFieldWithError) { const element = document.querySelector(`[data-field-id="${firstFieldWithError}"]`); element?.scrollIntoView({ behavior: 'smooth' }); } }; function filterBlueprints(blueprints, searchKey) { searchKey = searchKey.toLowerCase(); return searchKey && blueprints ? blueprints.filter((blueprint) => blueprint.name.toLowerCase().includes(searchKey)) : blueprints; } const filteredMarketplace = filterBlueprints(marketplace, search.searchKey); const fetchMarketplaceBlueprints = useCallback(() => { setApiState({ fetchingMarketplace: true }); return fetchMarketplaceBlueprintsService({ showIncompatible: site.showIncompatible }).subscribe({ next: (plugins) => { setApiState({ marketplaceError: false, fetchingMarketplace: false }); setMarketplace(plugins); }, error: ({ response }) => { if (response) { setApiState({ creatingSite: false, error: true, marketplaceError: response.response, fetchingMarketplace: false }); } } }); }, [setApiState, site?.showIncompatible]); function handleCloseDetails() { setSite({ details: { blueprint: null, index: null } }); } function handleErrorBack() { setApiState({ error: false, global: false }); } function handleSearchClick() { setSearch({ ...search, searchSelected: !search.searchSelected, searchKey: '' }); } function handleOnSearchChange(searchKey) { setSearch({ ...search, searchKey }); } function handleBlueprintSelected(blueprint, view) { if (blueprint.id === 'GIT') { setSite({ selectedView: view, submitted: false, blueprint: blueprint, pushSite: false, createAsOrphan: false, details: { blueprint: null, index: null } }); } else if (blueprint.source === 'GIT') { setSite({ selectedView: view, submitted: false, blueprint: blueprint, pushSite: false, createAsOrphan: true, details: { blueprint: null, index: null } }); } else if (blueprint.id === 'DUPLICATE') { handleClose(); onShowDuplicate(); } else { setSite({ selectedView: view, submitted: false, blueprint: blueprint, createAsOrphan: true, details: { blueprint: null, index: null } }); } } function handleBack() { let back = site.selectedView - 1; setSite({ selectedView: back }); } function handleGoTo(step) { setSite({ selectedView: step }); } function handleFinish(e) { e && e.preventDefault(); if (site.selectedView === 1) { const isFormValid = validateForm(); if (isFormValid && !site.siteIdExist) { setSite({ selectedView: 2 }); } else { setSite({ submitted: true }); if (!isFormValid) { scrollToErrorInput(); } } } if (site.selectedView === 2) { setApiState({ creatingSite: true }); // it is a marketplace plugin if (site.blueprint.source === 'GIT') { const marketplaceParams = createMarketplaceParams(); createNewSiteFromMarketplace(marketplaceParams); } else { const blueprintParams = createParams(); createSite(blueprintParams); } } } function handleShowIncompatibleChange(e) { setMarketplace(null); setSite({ showIncompatible: e.target.checked }); } function checkAdditionalFields() { let valid = true; if (site.blueprint.parameters) { site.blueprint.parameters.forEach((parameter) => { if (parameter.required && !site.blueprintFields[parameter.name]) { valid = false; } }); } return valid; } function validateForm() { if (!site.siteId || site.siteIdExist || !site.siteName || site.siteNameExist || site.invalidSiteId) { return false; } else if (!site.repoUrl && site.blueprint.id === 'GIT') { return false; } else if (site.pushSite || site.blueprint.id === 'GIT') { if (!site.repoUrl) return false; else if (site.repoAuthentication === 'basic' && (!site.repoUsername || !site.repoPassword)) return false; else if (site.repoAuthentication === 'token' && (!site.repoUsername || !site.repoToken)) return false; else return !(site.repoAuthentication === 'key' && !site.repoKey); } else { return checkAdditionalFields(); } } function createMarketplaceParams() { const params = { siteId: site.siteId, name: site.siteName, description: site.description, blueprintId: site.blueprint.id, blueprintVersion: { major: site.blueprint.version.major, minor: site.blueprint.version.minor, patch: site.blueprint.version.patch } }; if (site.gitBranch) params.sandboxBranch = site.gitBranch; if (site.blueprintFields) params.siteParams = site.blueprintFields; return params; } function createParams() { if (site.blueprint) { const params = { siteId: site.siteId, singleBranch: false, createAsOrphan: site.createAsOrphan, siteName: site.siteName }; if (site.blueprint.id !== 'GIT') { params.blueprint = site.blueprint.id; } else { params.useRemote = true; } if (site.gitBranch) params.sandboxBranch = site.gitBranch; if (site.description) params.description = site.description; if (site.pushSite || site.blueprint.id === 'GIT') { params.authenticationType = site.repoAuthentication; if (site.repoRemoteName) params.remoteName = site.repoRemoteName; if (site.repoUrl) params.remoteUrl = site.repoUrl; if (site.gitBranch) { params.remoteBranch = site.gitBranch; } if (site.repoAuthentication === 'basic') { params.remoteUsername = site.repoUsername; params.remotePassword = site.repoPassword; } if (site.repoAuthentication === 'token') { params.remoteUsername = site.repoUsername; params.remoteToken = site.repoToken; } if (site.repoAuthentication === 'key') params.remotePrivateKey = site.repoKey; } if (Object.keys(site.blueprintFields).length) params.siteParams = site.blueprintFields; params.createOption = site.pushSite ? 'push' : 'clone'; return params; } } function createSite(site, fromMarketplace = false) { const next = () => { siteCreateSubscription.current = null; if (mounted.current === true) { setApiState({ creatingSite: false }); handleClose(); // Prop differs between regular site and marketplace site due to API versions 1 vs 2 differences setSiteCookie(site.siteId, useBaseDomain); previewSwitch().subscribe(() => { window.location.href = getSystemLink({ systemLinkId: 'preview', authoringBase, site: site.siteId, page: '/' }); }); } }; const error = ({ response }) => { if (response) { if (fromMarketplace) { setApiState({ creatingSite: false, error: true, errorResponse: response.response, global: true }); } else { // TODO: I'm wrapping the API response as a API2 response, change it when create site is on API2 const _response = { ...response, code: '', documentationUrl: '', remedialAction: '' }; setApiState({ creatingSite: false, error: true, errorResponse: _response, global: true }); } } }; if (fromMarketplace) { siteCreateSubscription.current = createSiteFromMarketplace(site).subscribe({ next, error }); } else { siteCreateSubscription.current = create(site).subscribe({ next, error }); } } function createNewSiteFromMarketplace(site) { createSite(site, true); } function checkNameExist(siteId) { if (siteId) { exists(siteId).subscribe( (exists) => { if (exists) { refts.setSite({ siteIdExist: exists, selectedView: 1 }); } else { refts.setSite({ siteIdExist: false }); } }, ({ response }) => { // TODO: I'm wrapping the API response as a API2 response, change it when create site is on API2 const _response = { ...response, code: '', documentationUrl: '', remedialAction: '' }; setApiState({ creatingSite: false, error: true, errorResponse: _response }); } ); } } function onDetails(blueprint, index) { setSite({ details: { blueprint: blueprint, index: index } }); } function renderBlueprints(list, isMarketplace = false) { return list.map((item) => { const isGitItem = item.id === 'GIT'; const isDuplicateItem = item.id === 'DUPLICATE'; const isGitOrDuplicateItem = isGitItem || isDuplicateItem; const disableCard = isDuplicateItem && !permissionsLookup['duplicate_site']; return React.createElement( Grid, { item: true, xs: 12, sm: 6, md: isGitOrDuplicateItem ? 6 : 4, lg: isGitOrDuplicateItem ? 6 : 3, key: item.id }, React.createElement(PluginCard, { plugin: item, onPluginSelected: handleBlueprintSelected, changeImageSlideInterval: 5000, isMarketplacePlugin: isMarketplace, onDetails: onDetails, disableCardActionClick: disableCard }) ); }); } function onConfirmOk() { handleClose(null, null); } function onConfirmCancel() { setDialog({ inProgress: false }); } useEffect(() => { let subscriptions = []; if (blueprints === null && !apiState.error) { subscriptions.push( fetchBuiltInBlueprints().subscribe({ next: (blueprints) => { setBlueprints([ { id: 'GIT', name: formatMessage(messages.gitBlueprintName), description: formatMessage(messages.gitBlueprintDescription), documentation: null, media: { screenshots: [ { description: '', title: formatMessage(messages.gitBlueprintName), icon: React.createElement(GitFilled, { fontSize: 'large', color: 'error' }) } ], videos: [] } }, { id: 'DUPLICATE', name: React.createElement(FormattedMessage, { defaultMessage: 'Duplicate Project' }), description: React.createElement(FormattedMessage, { defaultMessage: 'Create an exact copy of an existing Studio project.' }), documentation: null, media: { screenshots: [ { description: '', title: formatMessage(messages.gitBlueprintName), icon: React.createElement(ContentCopyIcon, { fontSize: 'large', color: 'success' }) } ], videos: [] } }, ...blueprints.map((bp) => bp.plugin) ]); }, error: ({ response }) => { if (response) { setApiState({ creatingSite: false, errorResponse: response.response }); } } }) ); } if (finishRef && finishRef.current && site.selectedView === 2) { finishRef.current.focus(); } return () => { subscriptions.forEach((sub) => sub.unsubscribe()); }; }, [apiState.error, blueprints, formatMessage, setApiState, site.selectedView]); useEffect(() => { let subscriptions = []; hasGlobalPermissions('list_plugins', 'duplicate_site').subscribe((permissions) => { setPermissionsLookup(permissions); if (permissions['list_plugins'] && marketplace === null && !apiState.error) { subscriptions.push(fetchMarketplaceBlueprints()); } }); return () => { subscriptions.forEach((sub) => sub.unsubscribe()); }; }, [apiState.error, fetchMarketplaceBlueprints, marketplace]); return React.createElement( React.Fragment, null, React.createElement(ConfirmDialog, { open: dialog.inProgress, onOk: onConfirmOk, onCancel: onConfirmCancel, body: formatMessage(messages.dialogCloseMessage), title: formatMessage(messages.dialogCloseTitle), disableEnforceFocus: disableEnforceFocus }), apiState.creatingSite || (apiState.error && apiState.global) || site.details.blueprint ? (apiState.creatingSite && React.createElement(CreateSiteDialogLoader, { handleClose: handleClose })) || (apiState.errorResponse && React.createElement(ApiResponseErrorState, { classes: { root: classes.errorPaperRoot }, error: apiState.errorResponse, onButtonClick: handleErrorBack })) || (site.details && React.createElement(PluginDetailsView, { plugin: site.details.blueprint, selectedImageSlideIndex: site.details.index, onBlueprintSelected: handleBlueprintSelected, onCloseDetails: handleCloseDetails, changeImageSlideInterval: 5000, isMarketplacePlugin: Boolean(site?.details.blueprint.url) })) : React.createElement( 'div', { className: classes.dialogContainer }, React.createElement(DialogHeader, { title: views[site.selectedView].title, subtitle: views[site.selectedView].subtitle, id: 'create-site-dialog', onCloseButtonClick: handleClose }), blueprints ? React.createElement( DialogBody, { classes: { root: classes.dialogContent } }, site.selectedView === 0 && React.createElement( 'div', { className: cx(classes.slide, classes.fadeIn) }, React.createElement( Grid, { container: true, spacing: 3, className: classes.containerGrid }, renderBlueprints(blueprints), hasListPluginPermission && React.createElement( React.Fragment, null, React.createElement( Grid, { item: true, xs: 12 }, React.createElement(Divider, { sx: { ml: -3, mr: -3 } }) ), React.createElement( Grid, { item: true, xs: 12, className: classes.marketplaceActions }, React.createElement( Typography, { color: 'text.secondary', variant: 'overline', sx: { mr: 2 } }, formatMessage(messages.publicMarketplaceBlueprints) ), React.createElement( IconButton, { size: 'small', onClick: handleSearchClick }, React.createElement(SearchIcon, null) ), React.createElement(FormControlLabel, { className: classes.showIncompatible, control: React.createElement(Checkbox, { checked: site.showIncompatible, onChange: (e) => handleShowIncompatibleChange(e), color: 'primary', className: classes.showIncompatibleCheckbox }), label: React.createElement( Typography, { className: classes.showIncompatibleInput }, formatMessage(messages.showIncompatible) ), labelPlacement: 'start' }) ), search.searchSelected && site.selectedView === 0 && React.createElement( Grid, { item: true, xs: 12 }, React.createElement( 'div', { className: classes.searchContainer }, React.createElement(SearchBar, { showActionButton: Boolean(search.searchKey), onChange: handleOnSearchChange, keyword: search.searchKey, autoFocus: true }) ) ), apiState.marketplaceError ? React.createElement( Box, { className: classes.marketplaceUnavailable }, React.createElement(SignalWifiBadRounded, { className: classes.marketplaceUnavailableIcon }), React.createElement( Typography, { variant: 'body1', color: 'text.secondary' }, formatMessage(messages.marketplaceUnavailable) ), React.createElement( Button, { variant: 'text', onClick: fetchMarketplaceBlueprints }, formatMessage(messages.retry) ) ) : apiState?.fetchingMarketplace ? React.createElement( Box, { sx: { width: '100%' } }, React.createElement(LoadingState, null) ) : !filteredMarketplace || filteredMarketplace?.length === 0 ? React.createElement(EmptyState, { title: formatMessage(messages.noMarketplaceBlueprints), subtitle: formatMessage(messages.changeQuery), classes: { root: classes.emptyStateRoot } }) : renderBlueprints(filteredMarketplace, true) ) ) ), site.selectedView === 1 && React.createElement( 'div', { className: cx(classes.slide, classes.fadeIn) }, site.blueprint && React.createElement(BlueprintForm, { inputs: site, setInputs: setSite, onCheckNameExist: checkNameExist, onSubmit: handleFinish, blueprint: site.blueprint, classes: { root: classes.blueprintFormRoot }, fieldsErrorsLookup: fieldsErrorsLookup }) ), site.selectedView === 2 && React.createElement( 'div', { className: cx(classes.slide, classes.fadeIn) }, site.blueprint && React.createElement(BlueprintReview, { onGoTo: handleGoTo, inputs: site, blueprint: site.blueprint }) ) ) : apiState.error ? React.createElement(ApiResponseErrorState, { classes: { root: classes.errorPaperRoot }, error: apiState.errorResponse }) : React.createElement('div', { className: classes.loading }, React.createElement(LoadingState, null)), site.selectedView !== 0 && React.createElement( DialogFooter, { classes: { root: classes.fadeIn } }, React.createElement(Button, { color: 'primary', variant: 'outlined', onClick: handleBack, children: formatMessage(messages.back) }), React.createElement(PrimaryButton, { ref: finishRef, onClick: handleFinish, children: views[site.selectedView].btnText }) ) ) ); } export default CreateSiteDialogContainer;