@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
738 lines (736 loc) • 29.2 kB
JavaScript
/*
* 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;