diff --git a/assets/src/edit-story/components/dialog/index.js b/assets/src/edit-story/components/dialog/index.js index 14d725b8d3ad..92db82fe9be9 100644 --- a/assets/src/edit-story/components/dialog/index.js +++ b/assets/src/edit-story/components/dialog/index.js @@ -26,6 +26,8 @@ import { rgba } from 'polished'; */ import Modal from '../modal'; +export const TRANSITION_DURATION = 300; + // Shadow styles ported from @material-ui/Dialog const DialogBox = styled.div` border-radius: 4px; @@ -40,7 +42,8 @@ const DialogBox = styled.div` 0px 24px 38px 3px ${({ theme }) => rgba(theme.colors.bg.black, 0.14)}, 0px 9px 46px 8px ${({ theme }) => rgba(theme.colors.bg.black, 0.12)}; color: ${({ theme }) => rgba(theme.colors.bg.black, 0.87)}; - transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: box-shadow ${TRANSITION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1) + 0ms; background-color: ${({ theme }) => theme.colors.fg.white}; `; diff --git a/assets/src/edit-story/components/header/buttons.js b/assets/src/edit-story/components/header/buttons.js deleted file mode 100644 index 1e535e0f2b24..000000000000 --- a/assets/src/edit-story/components/header/buttons.js +++ /dev/null @@ -1,354 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * External dependencies - */ -import styled from 'styled-components'; -import { useCallback, useState, useEffect } from 'react'; - -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import { trackEvent } from '../../../tracking'; -import addQueryArgs from '../../utils/addQueryArgs'; -import { useStory, useLocalMedia, useConfig, useHistory } from '../../app'; -import useRefreshPostEditURL from '../../utils/useRefreshPostEditURL'; -import { Outline, Primary } from '../button'; -import CircularProgress from '../circularProgress'; -import escapeHTML from '../../utils/escapeHTML'; -import { useGlobalKeyDownEffect } from '../keyboard'; -import PreviewErrorDialog from './previewErrorDialog'; -import PostPublishDialog from './postPublishDialog'; - -const PREVIEW_TARGET = 'story-preview'; - -const ButtonList = styled.nav` - display: flex; - justify-content: flex-end; - padding: 1em; - height: 100%; -`; - -const List = styled.div` - display: flex; -`; - -const Space = styled.div` - width: 6px; -`; - -function PreviewButton() { - const { isSaving, link, status, autoSave, saveStory } = useStory( - ({ - state: { - meta: { isSaving }, - story: { link, status }, - }, - actions: { autoSave, saveStory }, - }) => ({ isSaving, link, status, autoSave, saveStory }) - ); - const { isUploading } = useLocalMedia((state) => ({ - isUploading: state.state.isUploading, - })); - const { previewLink: autoSaveLink } = useConfig(); - - const [previewLinkToOpenViaDialog, setPreviewLinkToOpenViaDialog] = useState( - null - ); - const isDraft = 'draft' === status; - - /** - * Open a preview of the story in current window. - */ - const openPreviewLink = useCallback(() => { - trackEvent('editor', 'preview_story'); - - // Display the actual link in case of a draft. - const previewLink = isDraft - ? addQueryArgs(link, { preview: 'true' }) - : autoSaveLink; - - // Start a about:blank popup with waiting message until we complete - // the saving operation. That way we will not bust the popup timeout. - let popup; - try { - popup = window.open('about:blank', PREVIEW_TARGET); - if (popup) { - popup.document.write(''); - popup.document.write(''); - popup.document.write( - escapeHTML(__('Generating the preview…', 'web-stories')) - ); - popup.document.write(''); - popup.document.write(''); - // Output "waiting" message. - popup.document.write( - escapeHTML(__('Please wait. Generating the preview…', 'web-stories')) - ); - // Force redirect to the preview URL after 5 seconds. The saving tab - // might get frozen by the browser. - popup.document.write( - `` - ); - } - } catch (e) { - // Ignore errors. Anything can happen with a popup. The errors - // will be resolved after the story is saved. - } - - // Save story directly if draft, otherwise, use auto-save. - const updateFunc = isDraft ? saveStory : autoSave; - updateFunc() - .then((update) => { - if (popup && !popup.closed) { - if (popup.location.href) { - // Auto-save sends an updated preview link, use that instead if available. - const updatedPreviewLink = update?.preview_link ?? previewLink; - popup.location.replace(updatedPreviewLink); - } - } - }) - .catch(() => { - setPreviewLinkToOpenViaDialog(previewLink); - }); - }, [autoSave, autoSaveLink, isDraft, link, saveStory]); - - const openPreviewLinkSync = useCallback( - (evt) => { - setPreviewLinkToOpenViaDialog(null); - // Ensure that this method is as safe as possible and pass the random - // target in case the normal target is not openable. - window.open(previewLinkToOpenViaDialog, PREVIEW_TARGET + Math.random()); - evt.preventDefault(); - }, - [previewLinkToOpenViaDialog] - ); - - const onDialogClose = useCallback( - () => setPreviewLinkToOpenViaDialog(null), - [] - ); - - return ( - <> - - {__('Preview', 'web-stories')} - - - - ); -} - -function Publish() { - const { isSaving, date, storyId, saveStory } = useStory( - ({ - state: { - meta: { isSaving }, - story: { date, storyId }, - }, - actions: { saveStory }, - }) => ({ isSaving, date, storyId, saveStory }) - ); - const { isUploading } = useLocalMedia((state) => ({ - isUploading: state.state.isUploading, - })); - const { capabilities } = useConfig(); - - const refreshPostEditURL = useRefreshPostEditURL(storyId); - const hasFutureDate = Date.now() < Date.parse(date); - - const handlePublish = useCallback(() => { - if (hasFutureDate) { - trackEvent('editor', 'schedule_story'); - } else { - trackEvent('editor', 'publish_story'); - } - - saveStory({ status: 'publish' }); - refreshPostEditURL(); - }, [refreshPostEditURL, saveStory, hasFutureDate]); - - const text = hasFutureDate - ? __('Schedule', 'web-stories') - : __('Publish', 'web-stories'); - - return ( - - {text} - - ); -} - -function SwitchToDraft() { - const { isSaving, saveStory } = useStory( - ({ - state: { - meta: { isSaving }, - }, - actions: { saveStory }, - }) => ({ isSaving, saveStory }) - ); - const { isUploading } = useLocalMedia((state) => ({ - isUploading: state.state.isUploading, - })); - - const handleUnPublish = useCallback(() => saveStory({ status: 'draft' }), [ - saveStory, - ]); - - return ( - - {__('Switch to Draft', 'web-stories')} - - ); -} - -function Update() { - const { isSaving, status, saveStory } = useStory( - ({ - state: { - meta: { isSaving }, - story: { status }, - }, - actions: { saveStory }, - }) => ({ isSaving, status, saveStory }) - ); - const { isUploading } = useLocalMedia((state) => ({ - isUploading: state.state.isUploading, - })); - const { - state: { hasNewChanges }, - } = useHistory(); - - useGlobalKeyDownEffect( - { key: ['mod+s'] }, - (event) => { - event.preventDefault(); - if (isSaving) { - return; - } - saveStory(); - }, - [saveStory, isSaving] - ); - - let text; - switch (status) { - case 'publish': - case 'private': - text = __('Update', 'web-stories'); - break; - case 'future': - text = __('Schedule', 'web-stories'); - break; - default: - text = __('Save draft', 'web-stories'); - return ( - saveStory({ status: 'draft' })} - isDisabled={isSaving || isUploading || !hasNewChanges} - > - {text} - - ); - } - - return ( - saveStory()} isDisabled={isSaving || isUploading}> - {text} - - ); -} - -function Loading() { - const { isSaving } = useStory((state) => ({ - isSaving: state.state.meta.isSaving, - })); - return ( - <> - {isSaving && } - - - ); -} - -function Buttons() { - const { status, storyId, link, isFreshlyPublished } = useStory( - ({ - state: { - story: { status, storyId, link }, - meta: { isFreshlyPublished }, - }, - }) => ({ - status, - storyId, - link, - isFreshlyPublished, - }) - ); - const [showDialog, setShowDialog] = useState(isFreshlyPublished); - useEffect(() => { - setShowDialog(isFreshlyPublished); - }, [isFreshlyPublished]); - - const isDraft = 'draft' === status; - - const confirmURL = addQueryArgs('post-new.php', { - ['from-web-story']: storyId, - }); - - return ( - <> - - - - {isDraft && } - {!isDraft && } - - - - {isDraft && } - {!isDraft && } - - - - setShowDialog(false)} - confirmURL={confirmURL} - storyURL={link} - /> - - ); -} -export default Buttons; diff --git a/assets/src/edit-story/components/header/buttons/index.js b/assets/src/edit-story/components/header/buttons/index.js new file mode 100644 index 000000000000..306b21e132ae --- /dev/null +++ b/assets/src/edit-story/components/header/buttons/index.js @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import styled from 'styled-components'; +import { useState, useEffect } from 'react'; + +/** + * Internal dependencies + */ +import addQueryArgs from '../../../utils/addQueryArgs'; +import { useStory } from '../../../app'; +import CircularProgress from '../../circularProgress'; +import PostPublishDialog from '../postPublishDialog'; +import Preview from './preview'; +import SwitchToDraft from './switchToDraft'; +import Update from './update'; +import Publish from './publish'; + +const ButtonList = styled.nav` + display: flex; + justify-content: flex-end; + padding: 1em; + height: 100%; +`; + +const List = styled.div` + display: flex; +`; + +const Space = styled.div` + width: 6px; +`; + +function Loading() { + const { isSaving } = useStory((state) => ({ + isSaving: state.state.meta.isSaving, + })); + return ( + <> + {isSaving && } + + + ); +} + +function Buttons() { + const { status, storyId, link, isFreshlyPublished } = useStory( + ({ + state: { + story: { status, storyId, link }, + meta: { isFreshlyPublished }, + }, + }) => ({ + status, + storyId, + link, + isFreshlyPublished, + }) + ); + const [showDialog, setShowDialog] = useState(false); + useEffect(() => setShowDialog(Boolean(isFreshlyPublished)), [ + isFreshlyPublished, + ]); + + const isDraft = 'draft' === status; + + const confirmURL = addQueryArgs('post-new.php', { + ['from-web-story']: storyId, + }); + + return ( + <> + + + + {isDraft && } + {!isDraft && } + + + + {isDraft && } + {!isDraft && } + + + + setShowDialog(false)} + confirmURL={confirmURL} + storyURL={link} + /> + + ); +} +export default Buttons; diff --git a/assets/src/edit-story/components/header/buttons/preview.js b/assets/src/edit-story/components/header/buttons/preview.js new file mode 100644 index 000000000000..20d0912b9cdc --- /dev/null +++ b/assets/src/edit-story/components/header/buttons/preview.js @@ -0,0 +1,147 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { useCallback, useState } from 'react'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { trackEvent } from '../../../../tracking'; +import addQueryArgs from '../../../utils/addQueryArgs'; +import { useStory, useLocalMedia, useConfig } from '../../../app'; +import { Outline } from '../../button'; +import escapeHTML from '../../../utils/escapeHTML'; +import PreviewErrorDialog from '../previewErrorDialog'; + +const PREVIEW_TARGET = 'story-preview'; + +function Preview() { + const { isSaving, link, status, autoSave, saveStory } = useStory( + ({ + state: { + meta: { isSaving }, + story: { link, status }, + }, + actions: { autoSave, saveStory }, + }) => ({ isSaving, link, status, autoSave, saveStory }) + ); + const { isUploading } = useLocalMedia((state) => ({ + isUploading: state.state.isUploading, + })); + const { previewLink: autoSaveLink } = useConfig(); + + const [previewLinkToOpenViaDialog, setPreviewLinkToOpenViaDialog] = useState( + null + ); + const isDraft = 'draft' === status; + + /** + * Open a preview of the story in current window. + */ + const openPreviewLink = useCallback(() => { + trackEvent('editor', 'preview_story'); + + // Display the actual link in case of a draft. + const previewLink = isDraft + ? addQueryArgs(link, { preview: 'true' }) + : autoSaveLink; + + // Start a about:blank popup with waiting message until we complete + // the saving operation. That way we will not bust the popup timeout. + let popup; + try { + popup = window.open('about:blank', PREVIEW_TARGET); + if (popup) { + popup.document.write(''); + popup.document.write(''); + popup.document.write( + escapeHTML(__('Generating the preview…', 'web-stories')) + ); + popup.document.write(''); + popup.document.write(''); + // Output "waiting" message. + popup.document.write( + escapeHTML(__('Please wait. Generating the preview…', 'web-stories')) + ); + // Force redirect to the preview URL after 5 seconds. The saving tab + // might get frozen by the browser. + popup.document.write( + `` + ); + } + } catch (e) { + // Ignore errors. Anything can happen with a popup. The errors + // will be resolved after the story is saved. + } + + // Save story directly if draft, otherwise, use auto-save. + const updateFunc = isDraft ? saveStory : autoSave; + updateFunc() + .then((update) => { + if (popup && !popup.closed) { + if (popup.location.href) { + // Auto-save sends an updated preview link, use that instead if available. + const updatedPreviewLink = update?.preview_link ?? previewLink; + popup.location.replace(updatedPreviewLink); + } + } + }) + .catch(() => setPreviewLinkToOpenViaDialog(previewLink)); + }, [autoSave, autoSaveLink, isDraft, link, saveStory]); + + const openPreviewLinkSync = useCallback( + (evt) => { + setPreviewLinkToOpenViaDialog(null); + // Ensure that this method is as safe as possible and pass the random + // target in case the normal target is not openable. + window.open(previewLinkToOpenViaDialog, PREVIEW_TARGET + Math.random()); + evt.preventDefault(); + }, + [previewLinkToOpenViaDialog] + ); + + const onDialogClose = useCallback( + () => setPreviewLinkToOpenViaDialog(null), + [] + ); + + return ( + <> + + {__('Preview', 'web-stories')} + + + + ); +} + +export default Preview; diff --git a/assets/src/edit-story/components/header/buttons/publish.js b/assets/src/edit-story/components/header/buttons/publish.js new file mode 100644 index 000000000000..173f802f37fe --- /dev/null +++ b/assets/src/edit-story/components/header/buttons/publish.js @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { useCallback, useState } from 'react'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { trackEvent } from '../../../../tracking'; +import { TRANSITION_DURATION } from '../../dialog'; +import { useStory, useLocalMedia, useConfig } from '../../../app'; +import useRefreshPostEditURL from '../../../utils/useRefreshPostEditURL'; +import { Primary } from '../../button'; +import TitleMissingDialog from '../titleMissingDialog'; +import useHeader from '../use'; + +function Publish() { + const { isSaving, date, storyId, saveStory, title } = useStory( + ({ + state: { + meta: { isSaving }, + story: { date, storyId, title }, + }, + actions: { saveStory }, + }) => ({ isSaving, date, storyId, saveStory, title }) + ); + const { isUploading } = useLocalMedia((state) => ({ + isUploading: state.state.isUploading, + })); + const { titleInput } = useHeader(); + const [showDialog, setShowDialog] = useState(false); + const { capabilities } = useConfig(); + + const refreshPostEditURL = useRefreshPostEditURL(storyId); + const hasFutureDate = Date.now() < Date.parse(date); + + const publish = useCallback(() => { + setShowDialog(false); + if (hasFutureDate) { + trackEvent('editor', 'schedule_story'); + } else { + trackEvent('editor', 'publish_story'); + } + + saveStory({ status: 'publish' }); + refreshPostEditURL(); + }, [refreshPostEditURL, saveStory, hasFutureDate]); + + const handlePublish = useCallback(() => { + if (!title) { + setShowDialog(true); + return; + } + + publish(); + }, [title, publish]); + + const fixTitle = useCallback(() => { + setShowDialog(false); + // Focus title input when dialog is closed + // Disable reason: If component unmounts, nothing bad can happen + // eslint-disable-next-line @wordpress/react-no-unsafe-timeout + setTimeout(() => titleInput?.focus(), TRANSITION_DURATION); + }, [titleInput]); + + const handleClose = useCallback(() => setShowDialog(false), []); + + const text = hasFutureDate + ? __('Schedule', 'web-stories') + : __('Publish', 'web-stories'); + + return ( + <> + + {text} + + + + ); +} + +export default Publish; diff --git a/assets/src/edit-story/components/header/buttons/switchToDraft.js b/assets/src/edit-story/components/header/buttons/switchToDraft.js new file mode 100644 index 000000000000..e85d07f2dfca --- /dev/null +++ b/assets/src/edit-story/components/header/buttons/switchToDraft.js @@ -0,0 +1,57 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { useCallback } from 'react'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useStory, useLocalMedia } from '../../../app'; +import { Outline } from '../../button'; + +function SwitchToDraft() { + const { isSaving, saveStory } = useStory( + ({ + state: { + meta: { isSaving }, + }, + actions: { saveStory }, + }) => ({ isSaving, saveStory }) + ); + const { isUploading } = useLocalMedia((state) => ({ + isUploading: state.state.isUploading, + })); + + const handleUnPublish = useCallback(() => saveStory({ status: 'draft' }), [ + saveStory, + ]); + + return ( + + {__('Switch to Draft', 'web-stories')} + + ); +} + +export default SwitchToDraft; diff --git a/assets/src/edit-story/components/header/buttons/update.js b/assets/src/edit-story/components/header/buttons/update.js new file mode 100644 index 000000000000..feed757b0a01 --- /dev/null +++ b/assets/src/edit-story/components/header/buttons/update.js @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useStory, useLocalMedia, useHistory } from '../../../app'; +import { Outline, Primary } from '../../button'; +import { useGlobalKeyDownEffect } from '../../keyboard'; + +function Update() { + const { isSaving, status, saveStory } = useStory( + ({ + state: { + meta: { isSaving }, + story: { status }, + }, + actions: { saveStory }, + }) => ({ isSaving, status, saveStory }) + ); + const { isUploading } = useLocalMedia((state) => ({ + isUploading: state.state.isUploading, + })); + const { + state: { hasNewChanges }, + } = useHistory(); + + useGlobalKeyDownEffect( + { key: ['mod+s'] }, + (event) => { + event.preventDefault(); + if (isSaving) { + return; + } + saveStory(); + }, + [saveStory, isSaving] + ); + + let text; + switch (status) { + case 'publish': + case 'private': + text = __('Update', 'web-stories'); + break; + case 'future': + text = __('Schedule', 'web-stories'); + break; + default: + text = __('Save draft', 'web-stories'); + return ( + saveStory({ status: 'draft' })} + isDisabled={isSaving || isUploading || !hasNewChanges} + > + {text} + + ); + } + + return ( + saveStory()} isDisabled={isSaving || isUploading}> + {text} + + ); +} + +export default Update; diff --git a/assets/src/edit-story/components/header/context.js b/assets/src/edit-story/components/header/context.js new file mode 100644 index 000000000000..a8c3f6a3acc0 --- /dev/null +++ b/assets/src/edit-story/components/header/context.js @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { createContext } from 'react'; + +const HeaderContext = createContext({}); + +export default HeaderContext; diff --git a/assets/src/edit-story/components/header/headerLayout.js b/assets/src/edit-story/components/header/headerLayout.js index 8c730a510880..0fb8744d659c 100644 --- a/assets/src/edit-story/components/header/headerLayout.js +++ b/assets/src/edit-story/components/header/headerLayout.js @@ -19,13 +19,22 @@ */ import styled from 'styled-components'; +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + /** * Internal dependencies */ import Buttons from './buttons'; import Title from './title'; +import HeaderProvider from './provider'; -const Background = styled.header` +const Background = styled.header.attrs({ + role: 'group', + 'aria-label': __('Story canvas header', 'web-stories'), +})` display: flex; align-items: center; justify-content: space-between; @@ -43,14 +52,16 @@ const ButtonCell = styled.div` function HeaderLayout() { return ( - - - - </Head> - <ButtonCell> - <Buttons /> - </ButtonCell> - </Background> + <HeaderProvider> + <Background> + <Head> + <Title /> + </Head> + <ButtonCell> + <Buttons /> + </ButtonCell> + </Background> + </HeaderProvider> ); } diff --git a/assets/src/edit-story/components/header/karma/publish.karma.js b/assets/src/edit-story/components/header/karma/publish.karma.js new file mode 100644 index 000000000000..bee378e7f330 --- /dev/null +++ b/assets/src/edit-story/components/header/karma/publish.karma.js @@ -0,0 +1,53 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import { Fixture } from '../../../karma'; + +describe('Publish integration', () => { + let fixture; + + beforeEach(async () => { + fixture = new Fixture(); + await fixture.render(); + }); + + afterEach(() => { + fixture.restore(); + }); + + describe('CUJ: Creator can Preview & Publish Their Story: Publish story', () => { + it('should be warned when trying to publish without a title', async () => { + await fixture.events.click(fixture.editor.canvas.header.publish); + + await fixture.snapshot('Publish without title dialog'); + + await fixture.events.sleep(500); + + await fixture.events.click( + fixture.editor.getByRoleIn(fixture.document, 'button', { + name: /Add a title/i, + }) + ); + + await fixture.events.sleep(500); + + await fixture.snapshot('Adding a title'); + }); + }); +}); diff --git a/assets/src/edit-story/components/header/provider.js b/assets/src/edit-story/components/header/provider.js new file mode 100644 index 000000000000..7d1dbb036c73 --- /dev/null +++ b/assets/src/edit-story/components/header/provider.js @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { useState } from 'react'; + +/** + * Internal dependencies + */ +import HeaderContext from './context'; + +function HeaderProvider({ children }) { + const [titleInput, setTitleInput] = useState(); + const value = { + titleInput, + setTitleInput, + }; + return ( + <HeaderContext.Provider value={value}>{children}</HeaderContext.Provider> + ); +} + +HeaderProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default HeaderProvider; diff --git a/assets/src/edit-story/components/header/stories/titleMissingDialog.js b/assets/src/edit-story/components/header/stories/titleMissingDialog.js new file mode 100644 index 000000000000..941013aac01b --- /dev/null +++ b/assets/src/edit-story/components/header/stories/titleMissingDialog.js @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { action } from '@storybook/addon-actions'; + +/** + * Internal dependencies + */ +import TitleMissingDialog from '../titleMissingDialog'; + +export default { + title: 'Stories Editor/Components/Dialog/Title-Missing', + component: TitleMissingDialog, +}; + +export const _default = () => { + return ( + <TitleMissingDialog + open + onClose={action('close')} + onFix={action('fix')} + onIgnore={action('ignore')} + /> + ); +}; diff --git a/assets/src/edit-story/components/header/test/buttons.js b/assets/src/edit-story/components/header/test/buttons.js index 9435d9591b50..4130348a84ae 100644 --- a/assets/src/edit-story/components/header/test/buttons.js +++ b/assets/src/edit-story/components/header/test/buttons.js @@ -17,7 +17,12 @@ /** * External dependencies */ -import { fireEvent } from '@testing-library/react'; +import { + fireEvent, + screen, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import Modal from 'react-modal'; /** * Internal dependencies @@ -25,21 +30,23 @@ import { fireEvent } from '@testing-library/react'; import StoryContext from '../../../app/story/context'; import ConfigContext from '../../../app/config/context'; import MediaContext from '../../../app/media/context'; +import HistoryContext from '../../../app/history/context'; import Buttons from '../buttons'; import { renderWithTheme } from '../../../testUtils'; -function setupButtons( - extraStoryProps, - extraMetaProps, - extraMediaProps, - extraConfigProps -) { +function setupButtons({ + story: extraStoryProps, + meta: extraMetaProps, + media: extraMediaProps, + config: extraConfigProps, + history: extraHistoryProps, +} = {}) { const saveStory = jest.fn(); const autoSave = jest.fn(); const storyContextValue = { state: { - meta: { isSaving: false, ...extraMetaProps }, + meta: { isSaving: false, isFreshlyPublished: false, ...extraMetaProps }, story: { status: 'draft', storyId: 123, date: null, ...extraStoryProps }, }, actions: { saveStory, autoSave }, @@ -57,15 +64,20 @@ function setupButtons( state: { ...extraMediaProps }, }, }; + const historyContextValue = { + state: { ...extraHistoryProps }, + }; const { getByRole } = renderWithTheme( - <ConfigContext.Provider value={configValue}> - <StoryContext.Provider value={storyContextValue}> - <MediaContext.Provider value={mediaContextValue}> - <Buttons /> - </MediaContext.Provider> - </StoryContext.Provider> - </ConfigContext.Provider> + <HistoryContext.Provider value={historyContextValue}> + <ConfigContext.Provider value={configValue}> + <StoryContext.Provider value={storyContextValue}> + <MediaContext.Provider value={mediaContextValue}> + <Buttons /> + </MediaContext.Provider> + </StoryContext.Provider> + </ConfigContext.Provider> + </HistoryContext.Provider> ); return { getByRole, @@ -85,6 +97,17 @@ describe('buttons', () => { replace: jest.fn(), }, }; + let modalWrapper; + + beforeAll(() => { + modalWrapper = document.createElement('aside'); + document.documentElement.appendChild(modalWrapper); + Modal.setAppElement(modalWrapper); + }); + + afterAll(() => { + document.documentElement.removeChild(modalWrapper); + }); it('should display Publish button when in draft mode', () => { const { getByRole } = setupButtons(); @@ -92,8 +115,45 @@ describe('buttons', () => { expect(publishButton).toBeDefined(); }); - it('should update window location when publishing', () => { + it('should not be able to save draft if no changes', () => { const { getByRole, saveStory } = setupButtons(); + + const saveDraftButton = getByRole('button', { name: 'Save draft' }); + expect(saveDraftButton).toBeDisabled(); + fireEvent.click(saveDraftButton); + + expect(saveStory).not.toHaveBeenCalled(); + }); + + it('should be able to save draft if changes', () => { + const { getByRole, saveStory } = setupButtons({ + history: { hasNewChanges: true }, + }); + + const saveDraftButton = getByRole('button', { name: 'Save draft' }); + expect(saveDraftButton).toBeEnabled(); + fireEvent.click(saveDraftButton); + + expect(saveStory).toHaveBeenCalledTimes(1); + }); + + it('should be able to save a post if has changes and already published', () => { + const { getByRole, saveStory } = setupButtons({ + history: { hasNewChanges: true }, + story: { status: 'publish' }, + }); + + const updateButton = getByRole('button', { name: 'Update' }); + expect(updateButton).toBeEnabled(); + fireEvent.click(updateButton); + + expect(saveStory).toHaveBeenCalledTimes(1); + }); + + it('should update window location when publishing', () => { + const { getByRole, saveStory } = setupButtons({ + story: { title: 'Some title' }, + }); const publishButton = getByRole('button', { name: 'Publish' }); fireEvent.click(publishButton); @@ -101,8 +161,51 @@ describe('buttons', () => { expect(window.location.href).toContain('post=123&action=edit'); }); + it('should save post via shortcut', () => { + const { saveStory } = setupButtons({ + story: { title: 'Some title' }, + }); + + fireEvent.keyDown(document.body, { + key: 'S', + which: 83, + ctrlKey: true, + }); + + expect(saveStory).toHaveBeenCalledTimes(1); + }); + + it('should not save post via shortcut if already saving', () => { + const { saveStory } = setupButtons({ + story: { title: 'Some title' }, + meta: { isSaving: true }, + }); + + fireEvent.keyDown(document.body, { + key: 'S', + which: 83, + ctrlKey: true, + }); + + expect(saveStory).not.toHaveBeenCalled(); + }); + + it('should display post-publish dialog if recently published', async () => { + setupButtons({ meta: { isFreshlyPublished: true } }); + + const dismissButton = screen.getByRole('button', { name: 'Dismiss' }); + expect(dismissButton).toBeDefined(); + fireEvent.click(dismissButton); + + await waitForElementToBeRemoved(() => + screen.getByRole('button', { name: 'Dismiss' }) + ); + }); + it('should display Switch to draft button when published', () => { - const { getByRole, saveStory } = setupButtons({ status: 'publish' }); + const { getByRole, saveStory } = setupButtons({ + story: { status: 'publish' }, + }); const draftButton = getByRole('button', { name: 'Switch to Draft' }); expect(draftButton).toBeDefined(); @@ -112,8 +215,11 @@ describe('buttons', () => { it('should display Schedule button when future date is set', () => { const { getByRole, saveStory } = setupButtons({ - status: 'draft', - date: FUTURE_DATE, + story: { + title: 'Some title', + status: 'draft', + date: FUTURE_DATE, + }, }); const scheduleButton = getByRole('button', { name: 'Schedule' }); @@ -122,10 +228,58 @@ describe('buttons', () => { expect(saveStory).toHaveBeenCalledTimes(1); }); + it('should only save a story without a title if confirmed', async () => { + const { getByRole, saveStory } = setupButtons({ + story: { + title: '', + status: 'draft', + }, + }); + const publishButton = getByRole('button', { name: 'Publish' }); + expect(publishButton).toBeDefined(); + fireEvent.click(publishButton); + + const publishAnywayButton = screen.getByRole('button', { + name: 'Publish without title', + }); + expect(publishAnywayButton).toBeDefined(); + fireEvent.click(publishAnywayButton); + + expect(saveStory).toHaveBeenCalledTimes(1); + + await waitForElementToBeRemoved(() => + screen.getByRole('button', { name: 'Publish without title' }) + ); + }); + + it('should not save a story without a title if opting to add a title', async () => { + const { getByRole, saveStory } = setupButtons({ + story: { + title: '', + status: 'draft', + }, + }); + const publishButton = getByRole('button', { name: 'Publish' }); + expect(publishButton).toBeDefined(); + fireEvent.click(publishButton); + + const addTitleButton = screen.getByRole('button', { name: 'Add a title' }); + expect(addTitleButton).toBeDefined(); + fireEvent.click(addTitleButton); + + expect(saveStory).not.toHaveBeenCalled(); + + await waitForElementToBeRemoved(() => + screen.getByRole('button', { name: 'Add a title' }) + ); + }); + it('should display Schedule button with future status', () => { const { getByRole } = setupButtons({ - status: 'future', - date: FUTURE_DATE, + story: { + status: 'future', + date: FUTURE_DATE, + }, }); const scheduleButton = getByRole('button', { name: 'Schedule' }); @@ -133,7 +287,7 @@ describe('buttons', () => { }); it('should display loading indicator while the story is updating', () => { - const { getByRole } = setupButtons({}, { isSaving: true }); + const { getByRole } = setupButtons({ meta: { isSaving: true } }); expect(getByRole('progressbar')).toBeInTheDocument(); expect(getByRole('button', { name: 'Save draft' })).toBeDisabled(); expect(getByRole('button', { name: 'Preview' })).toBeDisabled(); @@ -141,25 +295,24 @@ describe('buttons', () => { }); it('should disable buttons while upload is in progress', () => { - const { getByRole } = setupButtons({}, {}, { isUploading: true }); + const { getByRole } = setupButtons({ media: { isUploading: true } }); expect(getByRole('button', { name: 'Save draft' })).toBeDisabled(); expect(getByRole('button', { name: 'Preview' })).toBeDisabled(); expect(getByRole('button', { name: 'Publish' })).toBeDisabled(); }); it('should disable publish button when user lacks permission', () => { - const { getByRole } = setupButtons( - {}, - {}, - {}, - { capabilities: { hasPublishAction: false } } - ); + const { getByRole } = setupButtons({ + config: { capabilities: { hasPublishAction: false } }, + }); expect(getByRole('button', { name: 'Publish' })).toBeDisabled(); }); it('should open draft preview when clicking on Preview via about:blank', () => { const { getByRole, saveStory } = setupButtons({ - link: 'https://example.com', + story: { + link: 'https://example.com', + }, }); const previewButton = getByRole('button', { name: 'Preview' }); @@ -198,8 +351,10 @@ describe('buttons', () => { it('should open preview for a published story when clicking on Preview via about:blank', () => { const { getByRole, autoSave } = setupButtons({ - link: 'https://example.com', - status: 'publish', + story: { + link: 'https://example.com', + status: 'publish', + }, }); const previewButton = getByRole('button', { name: 'Preview' }); autoSave.mockImplementation(() => ({ diff --git a/assets/src/edit-story/components/header/title.js b/assets/src/edit-story/components/header/title.js index 4f64b396768f..5ceee72cef3d 100644 --- a/assets/src/edit-story/components/header/title.js +++ b/assets/src/edit-story/components/header/title.js @@ -31,6 +31,7 @@ import { __ } from '@wordpress/i18n'; import { useStory } from '../../app/story'; import { useConfig } from '../../app/config'; import cleanForSlug from '../../utils/cleanForSlug'; +import useHeader from './use'; const Input = styled.input` color: ${({ theme }) => `${theme.colors.fg.white} !important`}; @@ -53,6 +54,7 @@ function Title() { actions: { updateStory }, }) => ({ title, slug, updateStory }) ); + const { setTitleInput } = useHeader(); const { storyId } = useConfig(); @@ -74,6 +76,7 @@ function Title() { return ( <Input + ref={setTitleInput} value={title} type={'text'} onBlur={handleBlur} diff --git a/assets/src/edit-story/components/header/titleMissingDialog.js b/assets/src/edit-story/components/header/titleMissingDialog.js new file mode 100644 index 000000000000..c5f14161ca52 --- /dev/null +++ b/assets/src/edit-story/components/header/titleMissingDialog.js @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import styled from 'styled-components'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { Plain } from '../button'; +import Dialog from '../dialog'; +import Link from '../link'; + +const Paragraph = styled.p` + font-family: ${({ theme }) => theme.fonts.body1.family}; + font-size: ${({ theme }) => theme.fonts.body1.size}; + line-height: ${({ theme }) => theme.fonts.body1.lineHeight}; + letter-spacing: ${({ theme }) => theme.fonts.body1.letterSpacing}; +`; + +function TitleMissingDialog({ open, onIgnore, onFix, onClose }) { + const link = __( + 'https://amp.dev/documentation/guides-and-tutorials/start/create_successful_stories/#title', + 'web-stories' + ); + return ( + <Dialog + open={open} + onClose={onClose} + title={__('Missing title', 'web-stories')} + actions={ + <> + <Plain onClick={onFix}>{__('Add a title', 'web-stories')}</Plain> + <Plain onClick={onIgnore}> + {__('Publish without title', 'web-stories')} + </Plain> + </> + } + > + <Paragraph> + {__( + 'We recommend adding a title to the story prior to publishing.', + 'web-stories' + )}{' '} + <Link href={link} target="_blank" rel="noopener noreferrer"> + {__('Learn more.', 'web-stories')} + </Link> + </Paragraph> + </Dialog> + ); +} + +TitleMissingDialog.propTypes = { + open: PropTypes.bool.isRequired, + onIgnore: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + onFix: PropTypes.func.isRequired, +}; + +export default TitleMissingDialog; diff --git a/assets/src/edit-story/components/header/use.js b/assets/src/edit-story/components/header/use.js new file mode 100644 index 000000000000..f8cc71b43884 --- /dev/null +++ b/assets/src/edit-story/components/header/use.js @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { useContext } from 'react'; + +/** + * Internal dependencies + */ +import HeaderContext from './context'; + +function useHeader() { + return useContext(HeaderContext); +} + +export default useHeader; diff --git a/assets/src/edit-story/karma/fixture/containers/canvas.js b/assets/src/edit-story/karma/fixture/containers/canvas.js index f958a2a5028e..d78a7f14b543 100644 --- a/assets/src/edit-story/karma/fixture/containers/canvas.js +++ b/assets/src/edit-story/karma/fixture/containers/canvas.js @@ -59,6 +59,14 @@ export class Canvas extends Container { Fullbleed ); } + + get header() { + return this._get( + this.getAllByRole('group', { name: 'Story canvas header' })[0], + 'header', + Header + ); + } } /** @@ -211,3 +219,40 @@ class Controls extends Container { return this.getByRole('button', { name: 'Click to pause' }); } } + +/** + * The story header + */ +class Header extends Container { + constructor(node, path) { + super(node, path); + } + + get title() { + return this.getByRole('textbox', { name: 'Edit: Story title' }); + } + + get saveDraft() { + return this.getByRole('button', { name: 'Save draft' }); + } + + get switchToDraft() { + return this.getByRole('button', { name: 'Switch to draft' }); + } + + get update() { + return this.getByRole('button', { name: 'Update' }); + } + + get publish() { + return this.getByRole('button', { name: 'Publish' }); + } + + get preview() { + return this.getByRole('button', { name: 'Preview' }); + } + + get schedule() { + return this.getByRole('button', { name: 'Schedule' }); + } +} diff --git a/assets/src/edit-story/karma/fixture/fixture.js b/assets/src/edit-story/karma/fixture/fixture.js index ad4945a6b1c8..6776aa6e0257 100644 --- a/assets/src/edit-story/karma/fixture/fixture.js +++ b/assets/src/edit-story/karma/fixture/fixture.js @@ -47,6 +47,7 @@ const DEFAULT_CONFIG = { allowedFileTypes: ['png', 'jpeg', 'jpg', 'gif', 'mp4', 'ogg'], capabilities: { hasUploadMediaAction: true, + hasPublishAction: true, }, version: '1.0.0-alpha.9', }; @@ -115,6 +116,10 @@ export class Fixture { return this._container; } + get document() { + return this._container.ownerDocument; + } + get screen() { return this._screen; }