diff --git a/assets/images/analytics-banner-bg.png b/assets/images/analytics-banner-bg.png new file mode 100644 index 000000000000..6b7a8a17cb9a Binary files /dev/null and b/assets/images/analytics-banner-bg.png differ diff --git a/assets/src/dashboard/app/index.js b/assets/src/dashboard/app/index.js index 684aa6ea3938..37724f5be649 100644 --- a/assets/src/dashboard/app/index.js +++ b/assets/src/dashboard/app/index.js @@ -49,7 +49,7 @@ import { } from '../components'; import ApiProvider from './api/apiProvider'; import { Route, RouterProvider, matchPath, useRouteHistory } from './router'; -import { ConfigProvider, useConfig } from './config'; +import { ConfigProvider } from './config'; import { EditorSettingsView, ExploreTemplatesView, @@ -65,9 +65,7 @@ const AppContent = () => { state: { currentPath }, } = useRouteHistory(); - const { capabilities: { canManageSettings } = {} } = useConfig(); - const enableSettingsView = - useFeature('enableSettingsView') && canManageSettings; + const enableSettingsView = useFeature('enableSettingsView'); useEffect(() => { const dynamicPageTitle = ROUTE_TITLES[currentPath] || ROUTE_TITLES.DEFAULT; diff --git a/assets/src/dashboard/app/views/editorSettings/index.js b/assets/src/dashboard/app/views/editorSettings/index.js index ea029f92c1f8..2504a09e6cc6 100644 --- a/assets/src/dashboard/app/views/editorSettings/index.js +++ b/assets/src/dashboard/app/views/editorSettings/index.js @@ -36,6 +36,7 @@ import { } from '../../../constants'; import { useConfig } from '../../config'; import { PageHeading } from '../shared'; +import useTelemetryOptIn from '../shared/useTelemetryOptIn'; import GoogleAnalyticsSettings from './googleAnalytics'; import { Main, Wrapper } from './components'; import PublisherLogoSettings from './publisherLogo'; @@ -45,11 +46,8 @@ const ACTIVE_DIALOG_REMOVE_LOGO = 'REMOVE_LOGO'; function EditorSettings() { const { - currentUser, - fetchCurrentUser, fetchSettings, updateSettings, - toggleWebStoriesTrackingOptIn, googleAnalyticsId, fetchMediaById, uploadMedia, @@ -63,7 +61,6 @@ function EditorSettings() { actions: { settingsApi: { fetchSettings, updateSettings }, mediaApi: { fetchMediaById, uploadMedia }, - usersApi: { fetchCurrentUser, toggleWebStoriesTrackingOptIn }, }, state: { settings: { @@ -72,7 +69,6 @@ function EditorSettings() { publisherLogoIds, }, media: { isLoading: isMediaLoading, mediaById, newlyCreatedMediaIds }, - currentUser, }, }) => ({ fetchSettings, @@ -85,9 +81,6 @@ function EditorSettings() { mediaById, newlyCreatedMediaIds, publisherLogoIds, - fetchCurrentUser, - toggleWebStoriesTrackingOptIn, - currentUser, }) ); @@ -97,6 +90,12 @@ function EditorSettings() { maxUploadFormatted, } = useConfig(); + const { + disabled, + toggleWebStoriesTrackingOptIn, + optedIn, + } = useTelemetryOptIn(); + const [activeDialog, setActiveDialog] = useState(null); const [activeLogo, setActiveLogo] = useState(''); const [mediaError, setMediaError] = useState(''); @@ -113,9 +112,10 @@ function EditorSettings() { */ useEffect(() => { - fetchSettings(); - fetchCurrentUser(); - }, [fetchCurrentUser, fetchSettings]); + if (canManageSettings) { + fetchSettings(); + } + }, [fetchSettings, canManageSettings]); useEffect(() => { if (newlyCreatedMediaIds.length > 0) { @@ -330,11 +330,9 @@ function EditorSettings() { /> )} diff --git a/assets/src/dashboard/app/views/exploreTemplates/header/test/header.js b/assets/src/dashboard/app/views/exploreTemplates/header/test/header.js index eeee0b49a0ac..7e45e396d3ef 100644 --- a/assets/src/dashboard/app/views/exploreTemplates/header/test/header.js +++ b/assets/src/dashboard/app/views/exploreTemplates/header/test/header.js @@ -27,7 +27,7 @@ import { VIEW_STYLE, TEMPLATES_GALLERY_SORT_OPTIONS, } from '../../../../../constants'; -import { renderWithThemeAndFlagsProvider } from '../../../../../testUtils'; +import { renderWithProviders } from '../../../../../testUtils'; import LayoutProvider from '../../../../../components/layout/provider'; import Header from '../'; @@ -69,7 +69,7 @@ const fakeTemplates = [ describe('Explore Templates
', function () { it('should have results label that says "Viewing all templates" on initial page view', function () { - const { getByText } = renderWithThemeAndFlagsProvider( + const { getByText } = renderWithProviders(
', function () { }} /> , - { enableInProgressTemplateActions: false } + { features: { enableInProgressTemplateActions: false } } ); expect(getByText('Viewing all templates')).toBeInTheDocument(); }); it('should render with the correct count label and search keyword.', function () { - const { getByPlaceholderText, getByText } = renderWithThemeAndFlagsProvider( + const { getByPlaceholderText, getByText } = renderWithProviders(
', function () { }} /> , - { enableInProgressTemplateActions: true } + { features: { enableInProgressTemplateActions: true } } ); expect(getByPlaceholderText('Search Templates').value).toBe('Harry Potter'); expect(getByText('8 results')).toBeInTheDocument(); @@ -118,7 +118,7 @@ describe('Explore Templates
', function () { it('should call the set keyword function when new text is searched', function () { const setKeywordFn = jest.fn(); - const { getByPlaceholderText } = renderWithThemeAndFlagsProvider( + const { getByPlaceholderText } = renderWithProviders(
', function () { }} /> , - { enableInProgressTemplateActions: true } + { features: { enableInProgressTemplateActions: true } } ); fireEvent.change(getByPlaceholderText('Search Templates'), { target: { value: 'Hermione Granger' }, @@ -145,7 +145,7 @@ describe('Explore Templates
', function () { it('should call the set sort function when a new sort is selected', function () { const setSortFn = jest.fn(); - const { getAllByText, getByText } = renderWithThemeAndFlagsProvider( + const { getAllByText, getByText } = renderWithProviders(
', function () { }} /> , - { enableInProgressTemplateActions: true } + { features: { enableInProgressTemplateActions: true } } ); fireEvent.click(getAllByText('Popular')[0].parentElement); fireEvent.click(getByText('Recent')); @@ -170,8 +170,8 @@ describe('Explore Templates
', function () { expect(setSortFn).toHaveBeenCalledWith('recent'); }); - it('should not render with search when enableInProgressTemplateActions is false', function () { - const { queryAllByRole } = renderWithThemeAndFlagsProvider( + it('should not render with search when features:{enableInProgressTemplateActions is false}', function () { + const { queryAllByRole } = renderWithProviders(
', function () { }} /> , - { enableInProgressTemplateActions: false } + { features: { enableInProgressTemplateActions: false } } ); expect(queryAllByRole('textbox')).toHaveLength(0); }); diff --git a/assets/src/dashboard/app/views/myStories/header/test/header.js b/assets/src/dashboard/app/views/myStories/header/test/header.js index 9deb9e62141b..7f4cc74ebf25 100644 --- a/assets/src/dashboard/app/views/myStories/header/test/header.js +++ b/assets/src/dashboard/app/views/myStories/header/test/header.js @@ -29,7 +29,7 @@ import { STORY_STATUSES, } from '../../../../../constants'; import LayoutProvider from '../../../../../components/layout/provider'; -import { renderWithTheme } from '../../../../../testUtils'; +import { renderWithProviders } from '../../../../../testUtils'; import Header from '../'; const fakeStories = [ @@ -64,7 +64,7 @@ const fakeStories = [ describe('My Stories
', function () { it('should have results label that says "Viewing all stories" on initial page view', function () { - const { getByText } = renderWithTheme( + const { getByText } = renderWithProviders(
', function () { }); it('should render with the correct count label and search keyword.', function () { - const { getByPlaceholderText, getByText } = renderWithTheme( + const { getByPlaceholderText, getByText } = renderWithProviders(
', function () { }); it('should have results label that says "Viewing drafts" when filter is set to drafts', function () { - const { getByText } = renderWithTheme( + const { getByText } = renderWithProviders(
', function () { }); it('should have 3 toggle buttons, one for each status that say how many items belong to that status', function () { - const { getByText } = renderWithTheme( + const { getByText } = renderWithProviders(
', function () { it('should call the set keyword function when new text is searched', async function () { const setKeywordFn = jest.fn(); - const { getByPlaceholderText } = renderWithTheme( + const { getByPlaceholderText } = renderWithProviders(
', function () { it('should call the set sort function when a new sort is selected', function () { const setSortFn = jest.fn(); - const { getAllByText, getByText } = renderWithTheme( + const { getAllByText, getByText } = renderWithProviders(
', function () { }); it('should render with the correct count label and search keyword.', function () { - const { getByPlaceholderText, getByText } = renderWithThemeAndFlagsProvider( + const { getByPlaceholderText, getByText } = renderWithProviders( ', function () { }} /> , - { enableInProgressStoryActions: false } + { features: { enableInProgressStoryActions: false } } ); expect(getByPlaceholderText('Search Templates').value).toBe('Harry Potter'); expect(getByText('3 results')).toBeInTheDocument(); @@ -89,7 +89,7 @@ describe('', function () { it('should call the set keyword function when new text is searched', function () { const setKeywordFn = jest.fn(); - const { getByPlaceholderText } = renderWithThemeAndFlagsProvider( + const { getByPlaceholderText } = renderWithProviders( ', function () { }} /> , - { enableInProgressStoryActions: false } + { features: { enableInProgressStoryActions: false } } ); fireEvent.change(getByPlaceholderText('Search Templates'), { target: { value: 'Hermione Granger' }, @@ -112,7 +112,7 @@ describe('', function () { it('should call the set sort function when a new sort is selected', function () { const setSortFn = jest.fn(); - const { getAllByText, getByText } = renderWithThemeAndFlagsProvider( + const { getAllByText, getByText } = renderWithProviders( ', function () { }} /> , - { enableInProgressStoryActions: false } + { features: { enableInProgressStoryActions: false } } ); fireEvent.click(getAllByText('Created by')[0].parentElement); fireEvent.click(getByText('Last modified')); @@ -134,7 +134,7 @@ describe('', function () { }); it('should render the content grid with the correct story count.', function () { - const { getAllByText } = renderWithThemeAndFlagsProvider( + const { getAllByText } = renderWithProviders( ', function () { }} /> , - { enableInProgressStoryActions: false } + { features: { enableInProgressStoryActions: false } } ); expect(getAllByText('Use template')).toHaveLength(fakeStories.length); diff --git a/assets/src/dashboard/app/views/shared/karma/telemetryBanner.karma.js b/assets/src/dashboard/app/views/shared/karma/telemetryBanner.karma.js new file mode 100644 index 000000000000..25db47cdebfe --- /dev/null +++ b/assets/src/dashboard/app/views/shared/karma/telemetryBanner.karma.js @@ -0,0 +1,123 @@ +/* + * 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/fixture'; +import useApi from '../../../api/useApi'; + +describe('Telemetry Banner', () => { + let fixture; + + beforeEach(async () => { + fixture = new Fixture(); + fixture.setFlags({ enableSettingsView: true }); + + await fixture.render(); + + // Set the initial value of optIn to be false (it's default is true in the api fixture) + const { toggleWebStoriesTrackingOptIn } = await fixture.renderHook(() => + useApi( + ({ + actions: { + usersApi: { toggleWebStoriesTrackingOptIn }, + }, + }) => ({ toggleWebStoriesTrackingOptIn }) + ) + ); + + await fixture.act(() => toggleWebStoriesTrackingOptIn()); + }); + + afterEach(() => { + fixture.restore(); + }); + + it('should render the telemetry opt in banner', async () => { + const bannerHeader = await fixture.screen.getByText( + /Help improve the editor!/ + ); + + expect(bannerHeader).toBeTruthy(); + }); + + it('should close the banner when the exit button is closed', async () => { + const exitButton = await fixture.screen.getByRole('button', { + name: /Dismiss Notice/, + }); + + await fixture.events.click(exitButton); + + const bannerHeader = await fixture.screen.queryByText( + /Help improve the editor!/ + ); + + expect(bannerHeader).toBeNull(); + }); + + it('should enable telemetry tracking when the checkbox is clicked', async () => { + let optedIn = await fixture.renderHook(() => + useApi( + ({ state: { currentUser } }) => + currentUser.data?.meta?.web_stories_tracking_optin ?? false + ) + ); + + expect(optedIn).toBeFalse(); + + const checkbox = await fixture.querySelector('#telemetry-banner-opt-in'); + + expect(checkbox).toBeTruthy(); + + await fixture.events.click(checkbox); + + optedIn = await fixture.renderHook(() => + useApi( + ({ state: { currentUser } }) => + currentUser.data?.meta?.web_stories_tracking_optin ?? false + ) + ); + + expect(optedIn).toBeTrue(); + + const bannerHeader = await fixture.screen.getByText( + /Your selection has been updated./ + ); + + expect(bannerHeader).toBeTruthy(); + }); + + it('should not display the banner after it has been closed with', async () => { + const exitButton = await fixture.screen.getByRole('button', { + name: /Dismiss Notice/, + }); + + await fixture.events.click(exitButton); + + let bannerHeader = await fixture.screen.queryByText( + /Help improve the editor!/ + ); + + expect(bannerHeader).toBeNull(); + + await fixture.render(); + + bannerHeader = await fixture.screen.queryByText(/Help improve the editor!/); + + expect(bannerHeader).toBeNull(); + }); +}); diff --git a/assets/src/dashboard/app/views/shared/pageHeading.js b/assets/src/dashboard/app/views/shared/pageHeading.js index 0f4f2b5f5a41..dc3831b1db23 100644 --- a/assets/src/dashboard/app/views/shared/pageHeading.js +++ b/assets/src/dashboard/app/views/shared/pageHeading.js @@ -31,6 +31,7 @@ import { StandardViewContentGutter, } from '../../../components'; import TypeaheadSearch from './typeaheadSearch'; +import TelemetryBanner from './telemetryBanner'; const Container = styled.div` padding: 10px 0 0; @@ -107,6 +108,7 @@ const PageHeading = ({ }) => { return ( + diff --git a/assets/src/dashboard/app/views/shared/stories/telemetryBanner.js b/assets/src/dashboard/app/views/shared/stories/telemetryBanner.js new file mode 100644 index 000000000000..35f98867c3a7 --- /dev/null +++ b/assets/src/dashboard/app/views/shared/stories/telemetryBanner.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. + */ +/** + * Internal dependencies + */ +/** + * External dependencies + */ +import { boolean } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; +import { TelemetryOptInBanner } from '../telemetryBanner'; + +export default { + title: 'Dashboard/Views/Shared/TelemetryBanner', + component: TelemetryOptInBanner, +}; + +export const _default = () => { + return ( + + ); +}; diff --git a/assets/src/dashboard/app/views/shared/telemetryBanner.js b/assets/src/dashboard/app/views/shared/telemetryBanner.js new file mode 100644 index 000000000000..0913c36e9cac --- /dev/null +++ b/assets/src/dashboard/app/views/shared/telemetryBanner.js @@ -0,0 +1,186 @@ +/* + * 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 PropTypes from 'prop-types'; +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { TypographyPresets } from '../../../components'; +import { Close as CloseSVG } from '../../../icons'; +import { ICON_METRICS } from '../../../constants'; +import { useConfig } from '../../config'; +import useTelemetryOptIn from './useTelemetryOptIn'; + +const Banner = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 161px; + margin: 0 20px; + padding-top: 24px; + background-image: url('${({ $backgroundUrl }) => $backgroundUrl}'); + border-radius: 8px; +`; + +const Header = styled.div` + width: 100%; + text-align: center; +`; + +const Title = styled.h1` + display: inline-block; + font-size: 18px; + font-weight: ${({ theme }) => theme.typography.weight.normal}; + line-height: 24px; + margin-bottom: 13px; +`; + +const Label = styled.label.attrs({ htmlFor: 'telemetry-banner-opt-in' })` + display: flex; +`; + +export const LabelText = styled.span` + ${TypographyPresets.Small}; + color: ${({ theme }) => theme.colors.gray400}; + margin-bottom: 16px; + max-width: 530px; +`; + +const VisitSettingsText = styled(LabelText)``; + +const CheckBox = styled.input.attrs({ + type: 'checkbox', + id: 'telemetry-banner-opt-in', +})` + height: 18px; + width: 18px; + margin: 5px 12px 0 0; +`; + +const CloseIcon = styled(CloseSVG).attrs(ICON_METRICS.TELEMETRY_BANNER_EXIT)` + color: ${({ theme }) => theme.colors.white}; + display: flex; + justify-content: flex-start; + align-items: center; +`; + +const ToggleButton = styled.button.attrs({ + ['aria-label']: __('Dismiss Notice', 'web-stories'), +})` + border: none; + padding: 4px; + border-radius: 50%; + background: ${({ theme }) => theme.colors.gray200}; + cursor: pointer; + float: right; + margin-right: 14px; + + &:hover svg { + color: ${({ theme }) => theme.colors.gray50}; + } + &:active svg { + color: ${({ theme }) => theme.colors.gray50}; + } +`; + +export function TelemetryOptInBanner({ + visible = true, + disabled = false, + onChange = () => {}, + onClose = () => {}, + checked = false, +}) { + const { assetsURL } = useConfig(); + + return visible ? ( + +
+ + {checked + ? __( + 'Your selection has been updated. Thank you for helping to improve the editor!', + 'web-stories' + ) + : __('Help improve the editor!', 'web-stories')} + + + + +
+ + + {__( + 'You can update your selection later by visiting Settings.', + 'web-stories' + )} + +
+ ) : null; +} + +TelemetryOptInBanner.propTypes = { + visible: PropTypes.bool.isRequired, + checked: PropTypes.bool.isRequired, + disabled: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default function TelemetryBannerContainer(props) { + const { + bannerVisible, + closeBanner, + optedIn, + disabled, + toggleWebStoriesTrackingOptIn, + } = useTelemetryOptIn(); + + return ( + + ); +} diff --git a/assets/src/dashboard/app/views/shared/test/telemetryBanner.js b/assets/src/dashboard/app/views/shared/test/telemetryBanner.js new file mode 100644 index 000000000000..e4963d3b9bde --- /dev/null +++ b/assets/src/dashboard/app/views/shared/test/telemetryBanner.js @@ -0,0 +1,113 @@ +/* + * 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 { useState } from 'react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import { TelemetryOptInBanner } from '../telemetryBanner'; +import { renderWithTheme } from '../../../../testUtils'; + +function TelemetryBannerTestContainer(props) { + const [state, setState] = useState({ + visible: true, + checked: false, + disabled: false, + ...props, + }); + + const onClose = () => + setState((prevState) => ({ ...prevState, visible: false })); + + const onChange = () => { + setState((prevState) => ({ + ...prevState, + checked: true, + })); + }; + + return ( + + ); +} + +describe('TelemetryBanner', () => { + it('should render visible with the checkbox unchecked', () => { + const { getByRole } = renderWithTheme(); + + const checkbox = getByRole('checkbox'); + + expect(checkbox).not.toBeChecked(); + }); + + it('should change the checkbox to checked and update the header when clicked', () => { + const { getByRole, getByText } = renderWithTheme( + + ); + + const checkbox = getByRole('checkbox'); + + expect(checkbox).not.toBeChecked(); + + let bannerHeader = getByText(/Help improve the editor!/); + + expect(bannerHeader).toBeInTheDocument(); + + userEvent.click(checkbox); + + expect(checkbox).toBeChecked(); + + bannerHeader = getByText(/Your selection has been updated./); + expect(bannerHeader).toBeInTheDocument(); + }); + + it('should close and not be visible when the close icon is clicked', () => { + const { getByRole } = renderWithTheme(); + + const closeButton = getByRole('button'); + + expect(closeButton).toBeInTheDocument(); + + userEvent.click(closeButton); + + expect(closeButton).not.toBeInTheDocument(); + }); + + it('should not be able to be checked when disabled', () => { + const { getByRole } = renderWithTheme( + + ); + + const checkbox = getByRole('checkbox'); + + expect(checkbox).not.toBeChecked(); + + userEvent.click(checkbox); + + expect(checkbox).not.toBeChecked(); + }); +}); diff --git a/assets/src/dashboard/app/views/shared/useTelemetryOptIn.js b/assets/src/dashboard/app/views/shared/useTelemetryOptIn.js new file mode 100644 index 000000000000..88e4f1acbe90 --- /dev/null +++ b/assets/src/dashboard/app/views/shared/useTelemetryOptIn.js @@ -0,0 +1,103 @@ +/* + * 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, useEffect, useRef } from 'react'; + +/** + * Internal dependencies + */ +import useApi from '../../api/useApi'; +import { useRouteHistory } from '../../router'; +import { APP_ROUTES } from '../../../constants'; + +// The value associated with this key indicates if the user has interacted with +// the banner previously. If they have, we do not show the banner again. +const LOCAL_STORAGE_KEY = 'web_stories_tracking_optin_banner_closed'; + +function setInitialBannerPreviouslyClosed() { + const storageValue = localStorage.getItem(LOCAL_STORAGE_KEY); + + return Boolean(JSON.parse(storageValue)); +} + +export default function useTelemetryOptIn() { + const [bannerPreviouslyClosed, setBannerPreviouslyClosed] = useState( + setInitialBannerPreviouslyClosed + ); + const [optInCheckboxClicked, setOptInCheckboxClicked] = useState(false); + const { + currentUser, + toggleWebStoriesTrackingOptIn, + fetchCurrentUser, + } = useApi( + ({ + state: { currentUser }, + actions: { + usersApi: { toggleWebStoriesTrackingOptIn, fetchCurrentUser }, + }, + }) => ({ currentUser, toggleWebStoriesTrackingOptIn, fetchCurrentUser }) + ); + const { currentPath } = useRouteHistory(({ state: { currentPath } }) => ({ + currentPath, + })); + + const dataIsLoaded = + currentUser.data.meta?.web_stories_tracking_optin !== undefined; + + const optedIn = Boolean(currentUser.data.meta?.web_stories_tracking_optin); + + const dataFetched = useRef(false); + + useEffect(() => { + if (!dataIsLoaded && !dataFetched.current) { + fetchCurrentUser(); + dataFetched.current = true; + } + }, [dataIsLoaded, fetchCurrentUser]); + + const _toggleWebStoriesTrackingOptIn = useCallback(() => { + toggleWebStoriesTrackingOptIn(); + localStorage.setItem(LOCAL_STORAGE_KEY, true); + setOptInCheckboxClicked(true); + }, [toggleWebStoriesTrackingOptIn]); + + const closeBanner = useCallback(() => { + setBannerPreviouslyClosed(true); + localStorage.setItem(LOCAL_STORAGE_KEY, true); + }, []); + + let bannerVisible = true; + + if ( + bannerPreviouslyClosed || // The banner has been closed before + currentPath === APP_ROUTES.EDITOR_SETTINGS || // The user is on the settings page + !dataIsLoaded || // currentUser is not loaded yet + (!optInCheckboxClicked && optedIn) // currentUser is loaded and optedIn is true but the user has not checked the opt in checkbox + ) { + bannerVisible = false; + } + + return { + bannerVisible, + optedIn, + disabled: currentUser.isUpdating, + closeBanner, + toggleWebStoriesTrackingOptIn: _toggleWebStoriesTrackingOptIn, + }; +} diff --git a/assets/src/dashboard/components/pageStructure/index.js b/assets/src/dashboard/components/pageStructure/index.js index 7f95a49e7397..ab8e76fbdaaf 100644 --- a/assets/src/dashboard/components/pageStructure/index.js +++ b/assets/src/dashboard/components/pageStructure/index.js @@ -100,17 +100,12 @@ export const LeftRailContainer = styled.nav.attrs({ export function LeftRail() { const { state } = useRouteHistory(); - const { - newStoryURL, - version, - capabilities: { canManageSettings } = {}, - } = useConfig(); + const { newStoryURL, version } = useConfig(); const leftRailRef = useRef(null); const upperContentRef = useRef(null); const enableInProgressViews = useFeature('enableInProgressViews'); - const enableSettingsViews = - useFeature('enableSettingsView') && canManageSettings; + const enableSettingsViews = useFeature('enableSettingsView'); const { state: { sideBarVisible }, diff --git a/assets/src/dashboard/constants/index.js b/assets/src/dashboard/constants/index.js index d8f886829fed..e20ba8a9d967 100644 --- a/assets/src/dashboard/constants/index.js +++ b/assets/src/dashboard/constants/index.js @@ -106,6 +106,7 @@ export const VIEW_STYLE_LABELS = { export const ICON_METRICS = { VIEW_STYLE: { width: 17, height: 14 }, LEFT_RIGHT_ARROW: { width: 16, height: 16 }, + TELEMETRY_BANNER_EXIT: { width: 10, height: 10 }, }; export const DASHBOARD_VIEWS = { diff --git a/assets/src/dashboard/karma/fixture.js b/assets/src/dashboard/karma/fixture.js index a6f562732998..9b4fa089bfba 100644 --- a/assets/src/dashboard/karma/fixture.js +++ b/assets/src/dashboard/karma/fixture.js @@ -190,6 +190,7 @@ export default class Fixture { restore() { window.location.hash = '#'; + localStorage.clear(); } /** diff --git a/assets/src/dashboard/testUtils/index.js b/assets/src/dashboard/testUtils/index.js index 06ef3365e47e..3a27fd3ac18e 100644 --- a/assets/src/dashboard/testUtils/index.js +++ b/assets/src/dashboard/testUtils/index.js @@ -15,5 +15,6 @@ */ export { default as renderWithTheme, + renderWithProviders, renderWithThemeAndFlagsProvider, } from './renderWithTheme'; diff --git a/assets/src/dashboard/testUtils/mockApiProvider.js b/assets/src/dashboard/testUtils/mockApiProvider.js new file mode 100644 index 000000000000..c632d9ea05e5 --- /dev/null +++ b/assets/src/dashboard/testUtils/mockApiProvider.js @@ -0,0 +1,76 @@ +/* + * 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 { useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ + +import { ApiContext } from '../app/api/apiProvider'; + +const noop = () => {}; + +export default function MockApiProvider({ children }) { + const [currentUser, setCurrentUser] = useState(getCurrentUserState()); + + const usersApi = useMemo( + () => ({ + fetchUsers: noop, + fetchCurrentUser: noop, + toggleWebStoriesTrackingOptIn: () => + setCurrentUser(toggleOptInTracking(currentUser)), + }), + [currentUser] + ); + + const value = useMemo( + () => ({ + state: { currentUser }, + actions: { usersApi }, + }), + [currentUser, usersApi] + ); + return {children}; +} + +MockApiProvider.propTypes = { + children: PropTypes.node, +}; + +function getCurrentUserState() { + return { + data: { id: 1, meta: { web_stories_tracking_optin: false } }, + isUpdating: false, + }; +} + +function toggleOptInTracking(currentUser) { + return { + ...currentUser, + data: { + ...currentUser.data, + meta: { + web_stories_tracking_optin: !currentUser.data.meta + .web_stories_tracking_optin, + }, + }, + }; +} diff --git a/assets/src/dashboard/testUtils/renderWithTheme.js b/assets/src/dashboard/testUtils/renderWithTheme.js index 5a9edfb26973..e3fe9569e78d 100644 --- a/assets/src/dashboard/testUtils/renderWithTheme.js +++ b/assets/src/dashboard/testUtils/renderWithTheme.js @@ -25,6 +25,8 @@ import { FlagsProvider } from 'flagged'; * Internal dependencies */ import theme from '../theme'; +import { ConfigProvider } from '../app/config'; +import MockApiProvider from './mockApiProvider'; // eslint-disable-next-line react/prop-types const WithThemeProvider = ({ children }) => { @@ -41,3 +43,30 @@ export const renderWithThemeAndFlagsProvider = (ui, featureFlags = {}) => { {ui} ); }; + +const defaultProviderValues = { + features: {}, + theme, + config: {}, +}; + +// Please use renderWithProviders instead of renderWithTheme or renderWithThemeAndFlagsProvider +// and feel free to add provider/mock provider as needed to this util. +// TODO: deprecate and replace instances of the above render utils +export const renderWithProviders = ( + ui, + providerValues = {}, + renderOptions = {} +) => { + const mergedProviderValues = { ...defaultProviderValues, ...providerValues }; + return render( + + + + {ui} + + + , + renderOptions + ); +}; diff --git a/includes/Tracking.php b/includes/Tracking.php index 7eabb998e8e7..1657f2117aff 100644 --- a/includes/Tracking.php +++ b/includes/Tracking.php @@ -70,7 +70,7 @@ public function init() { 'default' => false, 'show_in_rest' => true, 'auth_callback' => static function() { - return current_user_can( 'manage_options' ); + return current_user_can( 'edit_user', get_current_user_id() ); }, 'single' => true, ] diff --git a/package-lock.json b/package-lock.json index 4c0e6bd269ff..ac154423dad3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8852,6 +8852,26 @@ "@types/testing-library__react-hooks": "^3.3.0" } }, + "@testing-library/user-event": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.1.3.tgz", + "integrity": "sha512-U6tpKWbBMvqt8tIF77crr9VyP1W+yxK+c48xH5rvYwmT4MER5jvWAFqNzkn542Bt3qeDCn0aqwb0Pv+3mDbLtw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.10.2" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz", + "integrity": "sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + } + } + }, "@types/anymatch": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz", diff --git a/package.json b/package.json index 7c9a906678d7..1e2017774316 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.0.2", "@testing-library/react-hooks": "^3.4.1", + "@testing-library/user-event": "^12.1.3", "@wordpress/babel-plugin-import-jsx-pragma": "^2.7.0", "@wordpress/dependency-extraction-webpack-plugin": "^2.8.0", "@wordpress/e2e-test-utils": "^4.11.2", diff --git a/tests/phpunit/tests/Media.php b/tests/phpunit/tests/Media.php index 0070a704f1e6..859f79ae7d83 100644 --- a/tests/phpunit/tests/Media.php +++ b/tests/phpunit/tests/Media.php @@ -61,7 +61,7 @@ public function test_rest_api_init() { set_post_thumbnail( $video_attachment_id, $poster_attachment_id ); wp_set_object_terms( $video_attachment_id, 'editor', \Google\Web_Stories\Media::STORY_MEDIA_TAXONOMY ); - $request = new WP_REST_Request( 'GET', sprintf( '/web-stories/v1/media/%d', $video_attachment_id ) ); + $request = new WP_REST_Request( \WP_REST_Server::READABLE, sprintf( '/web-stories/v1/media/%d', $video_attachment_id ) ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); diff --git a/tests/phpunit/tests/REST_API/Stories_Autosaves_Controller.php b/tests/phpunit/tests/REST_API/Stories_Autosaves_Controller.php index 6d16a4c17a64..7fdcf631cc1f 100644 --- a/tests/phpunit/tests/REST_API/Stories_Autosaves_Controller.php +++ b/tests/phpunit/tests/REST_API/Stories_Autosaves_Controller.php @@ -67,7 +67,7 @@ public function test_create_item_as_author_should_not_strip_markup() { ] ); - $request = new WP_REST_Request( 'POST', '/web-stories/v1/web-story/' . $story . '/autosaves' ); + $request = new WP_REST_Request( \WP_REST_Server::CREATABLE, '/web-stories/v1/web-story/' . $story . '/autosaves' ); $request->set_body_params( [ 'content' => $unsanitized_content, diff --git a/tests/phpunit/tests/REST_API/Stories_Controller.php b/tests/phpunit/tests/REST_API/Stories_Controller.php index e353f1e308bd..d97fcc04c7a7 100644 --- a/tests/phpunit/tests/REST_API/Stories_Controller.php +++ b/tests/phpunit/tests/REST_API/Stories_Controller.php @@ -152,7 +152,7 @@ public function test_register_routes() { */ public function test_get_items() { wp_set_current_user( self::$user_id ); - $request = new WP_REST_Request( 'GET', '/web-stories/v1/web-story' ); + $request = new WP_REST_Request( \WP_REST_Server::READABLE, '/web-stories/v1/web-story' ); $request->set_param( 'status', [ 'draft' ] ); $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); @@ -177,7 +177,7 @@ public function test_get_items() { */ public function test_get_items_format() { wp_set_current_user( self::$user_id ); - $request = new WP_REST_Request( 'GET', '/web-stories/v1/web-story' ); + $request = new WP_REST_Request( \WP_REST_Server::READABLE, '/web-stories/v1/web-story' ); $request->set_param( 'status', [ 'draft' ] ); $request->set_param( 'context', 'edit' ); $request->set_param( '_web_stories_envelope', true ); @@ -219,7 +219,7 @@ public function test_get_item_schema() { * @covers ::filter_posts_clauses */ public function test_filter_posts_by_author_display_names() { - $request = new WP_REST_Request( 'GET', '/web-stories/v1/web-story' ); + $request = new WP_REST_Request( \WP_REST_Server::READABLE, '/web-stories/v1/web-story' ); $request->set_param( 'order', 'asc' ); $request->set_param( 'orderby', 'story_author' ); @@ -240,7 +240,7 @@ public function test_filter_posts_by_author_display_names() { 'Expected posts ordered by author display names' ); - $request = new WP_REST_Request( 'GET', '/web-stories/v1/web-story' ); + $request = new WP_REST_Request( \WP_REST_Server::READABLE, '/web-stories/v1/web-story' ); $request->set_param( 'order', 'desc' ); $request->set_param( 'orderby', 'story_author' ); @@ -298,7 +298,7 @@ public function test_create_item_as_author_should_not_strip_markup() { $unsanitized_content = file_get_contents( __DIR__ . '/../../data/story_post_content.html' ); $unsanitized_story_data = json_decode( file_get_contents( __DIR__ . '/../../data/story_post_content_filtered.json' ), true ); - $request = new WP_REST_Request( 'POST', '/web-stories/v1/web-story' ); + $request = new WP_REST_Request( \WP_REST_Server::CREATABLE, '/web-stories/v1/web-story' ); $request->set_body_params( [ 'content' => $unsanitized_content, @@ -328,7 +328,7 @@ public function test_update_item_as_author_should_not_strip_markup() { ] ); - $request = new WP_REST_Request( 'PUT', '/web-stories/v1/web-story/' . $story ); + $request = new WP_REST_Request( \WP_REST_Server::CREATABLE, '/web-stories/v1/web-story/' . $story ); $request->set_body_params( [ 'content' => $unsanitized_content, @@ -362,7 +362,7 @@ public function test_update_item_publisher_id() { $attachment_id = self::factory()->attachment->create_upload_object( __DIR__ . '/../../data/attachment.jpg', 0 ); - $request = new WP_REST_Request( 'PUT', '/web-stories/v1/web-story/' . $story ); + $request = new WP_REST_Request( \WP_REST_Server::CREATABLE, '/web-stories/v1/web-story/' . $story ); $request->set_body_params( [ 'content' => $unsanitized_content, diff --git a/tests/phpunit/tests/REST_API/Stories_Media_Controller.php b/tests/phpunit/tests/REST_API/Stories_Media_Controller.php index 2cdb53737da0..d77609907dcf 100644 --- a/tests/phpunit/tests/REST_API/Stories_Media_Controller.php +++ b/tests/phpunit/tests/REST_API/Stories_Media_Controller.php @@ -76,7 +76,7 @@ public function tearDown() { */ public function test_get_items_format() { wp_set_current_user( self::$user_id ); - $request = new WP_REST_Request( 'GET', '/web-stories/v1/media' ); + $request = new WP_REST_Request( \WP_REST_Server::READABLE, '/web-stories/v1/media' ); $request->set_param( 'context', 'edit' ); $request->set_param( '_web_stories_envelope', true ); $response = rest_get_server()->dispatch( $request ); diff --git a/tests/phpunit/tests/Tracking.php b/tests/phpunit/tests/Tracking.php index 8db364abd718..7d134ca76991 100644 --- a/tests/phpunit/tests/Tracking.php +++ b/tests/phpunit/tests/Tracking.php @@ -26,16 +26,30 @@ class Tracking extends \WP_UnitTestCase { protected static $user_id; + protected static $author_id; + public static function wpSetUpBeforeClass( $factory ) { self::$user_id = $factory->user->create( [ 'role' => 'administrator', ] ); + + self::$author_id = $factory->user->create( + [ + 'role' => 'author', + ] + ); } public static function wpTearDownAfterClass() { self::delete_user( self::$user_id ); + self::delete_user( self::$author_id ); + } + + public function tearDown() { + unregister_meta_key( 'user', \Google\Web_Stories\Tracking::OPTIN_META_KEY ); + parent::tearDown(); } /** @@ -46,13 +60,57 @@ public function test_add_optin_field_to_rest_api() { ( new \Google\Web_Stories\Tracking() )->init(); add_user_meta( get_current_user_id(), \Google\Web_Stories\Tracking::OPTIN_META_KEY, true ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/users/%d', self::$user_id ) ); + $request = new WP_REST_Request( \WP_REST_Server::READABLE, sprintf( '/wp/v2/users/%d', self::$user_id ) ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); + + $this->assertArrayHasKey( 'meta', $data ); $this->assertArrayHasKey( \Google\Web_Stories\Tracking::OPTIN_META_KEY, $data['meta'] ); $this->assertTrue( $data['meta'][ \Google\Web_Stories\Tracking::OPTIN_META_KEY ] ); } + /** + * @covers ::init + */ + public function test_add_optin_field_to_rest_api_for_author_user() { + wp_set_current_user( self::$author_id ); + ( new \Google\Web_Stories\Tracking() )->init(); + add_user_meta( get_current_user_id(), \Google\Web_Stories\Tracking::OPTIN_META_KEY, true ); + + $request = new WP_REST_Request( \WP_REST_Server::READABLE, '/wp/v2/users/me' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'meta', $data ); + $this->assertArrayHasKey( \Google\Web_Stories\Tracking::OPTIN_META_KEY, $data['meta'] ); + $this->assertTrue( $data['meta'][ \Google\Web_Stories\Tracking::OPTIN_META_KEY ] ); + } + + /** + * @covers ::init + */ + public function test_enables_author_user_to_update_meta_field() { + wp_set_current_user( self::$author_id ); + ( new \Google\Web_Stories\Tracking() )->init(); + add_user_meta( get_current_user_id(), \Google\Web_Stories\Tracking::OPTIN_META_KEY, false ); + + $request = new WP_REST_Request( \WP_REST_Server::CREATABLE, '/wp/v2/users/me' ); + $request->set_body_params( + [ + 'meta' => [ + \Google\Web_Stories\Tracking::OPTIN_META_KEY => true, + ], + ] + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'meta', $data ); + $this->assertArrayHasKey( \Google\Web_Stories\Tracking::OPTIN_META_KEY, $data['meta'] ); + $this->assertTrue( $data['meta'][ \Google\Web_Stories\Tracking::OPTIN_META_KEY ] ); + } + + /** * @covers ::init */