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 (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
}
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 (
+ {children}
+ );
+}
+
+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 (
+
+ );
+};
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(
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
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 (
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 (
+
+ );
+}
+
+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;
}