diff --git a/code/lib/core-common/src/utils/get-storybook-info.ts b/code/lib/core-common/src/utils/get-storybook-info.ts index bcdb4eee3970..fe183d566b7c 100644 --- a/code/lib/core-common/src/utils/get-storybook-info.ts +++ b/code/lib/core-common/src/utils/get-storybook-info.ts @@ -83,7 +83,7 @@ const getRendererInfo = (packageJson: PackageJson) => { const validConfigExtensions = ['ts', 'js', 'tsx', 'jsx', 'mjs', 'cjs']; -const findConfigFile = (prefix: string, configDir: string) => { +export const findConfigFile = (prefix: string, configDir: string) => { const filePrefix = path.join(configDir, prefix); const extension = validConfigExtensions.find((ext: string) => fse.existsSync(`${filePrefix}.${ext}`) diff --git a/code/lib/core-events/src/index.ts b/code/lib/core-events/src/index.ts index 64a452dc11f2..beec768961de 100644 --- a/code/lib/core-events/src/index.ts +++ b/code/lib/core-events/src/index.ts @@ -68,6 +68,7 @@ enum events { REQUEST_WHATS_NEW_DATA = 'requestWhatsNewData', RESULT_WHATS_NEW_DATA = 'resultWhatsNewData', SET_WHATS_NEW_CACHE = 'setWhatsNewCache', + TOGGLE_WHATS_NEW_NOTIFICATIONS = 'toggleWhatsNewNotifications', } // Enables: `import Events from ...` @@ -118,6 +119,7 @@ export const { REQUEST_WHATS_NEW_DATA, RESULT_WHATS_NEW_DATA, SET_WHATS_NEW_CACHE, + TOGGLE_WHATS_NEW_NOTIFICATIONS, } = events; // Used to break out of the current render without showing a redbox @@ -133,10 +135,12 @@ export type WhatsNewData = status: 'SUCCESS'; title: string; url: string; + blogUrl?: string; publishedAt: string; excerpt: string; postIsRead: boolean; showNotification: boolean; + disableWhatsNewNotifications: boolean; } | { status: 'ERROR'; diff --git a/code/lib/core-server/src/presets/common-preset.ts b/code/lib/core-server/src/presets/common-preset.ts index 79439587cb32..800f7ccaf0f7 100644 --- a/code/lib/core-server/src/presets/common-preset.ts +++ b/code/lib/core-server/src/presets/common-preset.ts @@ -1,6 +1,8 @@ import { pathExists, readFile } from 'fs-extra'; import { deprecate, logger } from '@storybook/node-logger'; +import { telemetry } from '@storybook/telemetry'; import { + findConfigFile, getDirectoryFromWorkingDir, getPreviewBodyTemplate, getPreviewHeadTemplate, @@ -15,7 +17,7 @@ import type { StorybookConfig, StoryIndexer, } from '@storybook/types'; -import { loadCsf } from '@storybook/csf-tools'; +import { loadCsf, readConfig, writeConfig } from '@storybook/csf-tools'; import { join } from 'path'; import { dedent } from 'ts-dedent'; import fetch from 'node-fetch'; @@ -25,9 +27,11 @@ import { REQUEST_WHATS_NEW_DATA, RESULT_WHATS_NEW_DATA, SET_WHATS_NEW_CACHE, + TOGGLE_WHATS_NEW_NOTIFICATIONS, } from '@storybook/core-events'; import { parseStaticDir } from '../utils/server-statics'; import { defaultStaticDirs } from '../utils/constants'; +import { sendTelemetryError } from '../withTelemetry'; const interpolate = (string: string, data: Record = {}) => Object.entries(data).reduce((acc, [k, v]) => acc.replace(new RegExp(`%${k}%`, 'g'), v), string); @@ -188,7 +192,6 @@ export const features = async ( storyStoreV7: true, argTypeTargetsV7: true, legacyDecoratorFileOrder: false, - whatsNewNotifications: false, }); export const storyIndexers = async (indexers?: StoryIndexer[]) => { @@ -246,10 +249,18 @@ const WHATS_NEW_CACHE = 'whats-new-cache'; const WHATS_NEW_URL = 'https://storybook.js.org/whats-new/v1'; // Grabbed from the implementation: https://github.com/storybookjs/dx-functions/blob/main/netlify/functions/whats-new.ts -type WhatsNewResponse = { title: string; url: string; publishedAt: string; excerpt: string }; +type WhatsNewResponse = { + title: string; + url: string; + blogUrl?: string; + publishedAt: string; + excerpt: string; +}; // eslint-disable-next-line @typescript-eslint/naming-convention -export const experimental_serverChannel = (channel: Channel, options: Options) => { +export const experimental_serverChannel = async (channel: Channel, options: Options) => { + const coreOptions = await options.presets.apply('core'); + channel.on(SET_WHATS_NEW_CACHE, async (data: WhatsNewCache) => { const cache: WhatsNewCache = await options.cache.get(WHATS_NEW_CACHE).catch((e) => { logger.verbose(e); @@ -266,12 +277,19 @@ export const experimental_serverChannel = (channel: Channel, options: Options) = throw response; })) as WhatsNewResponse; + const main = await readConfig(findConfigFile('main', options.configDir)); + const disableWhatsNewNotifications = main.getFieldValue([ + 'core', + 'disableWhatsNewNotifications', + ]); + const cache: WhatsNewCache = (await options.cache.get(WHATS_NEW_CACHE)) ?? {}; const data = { ...post, status: 'SUCCESS', postIsRead: post.url === cache.lastReadPost, showNotification: post.url !== cache.lastDismissedPost && post.url !== cache.lastReadPost, + disableWhatsNewNotifications, } satisfies WhatsNewData; channel.emit(RESULT_WHATS_NEW_DATA, { data }); } catch (e) { @@ -282,5 +300,29 @@ export const experimental_serverChannel = (channel: Channel, options: Options) = } }); + channel.on( + TOGGLE_WHATS_NEW_NOTIFICATIONS, + async ({ disableWhatsNewNotifications }: { disableWhatsNewNotifications: boolean }) => { + const isTelemetryEnabled = coreOptions.disableTelemetry !== true; + try { + const main = await readConfig(findConfigFile('main', options.configDir)); + main.setFieldValue(['core', 'disableWhatsNewNotifications'], disableWhatsNewNotifications); + await writeConfig(main); + + if (isTelemetryEnabled) { + await telemetry('core-config', { disableWhatsNewNotifications }); + } + } catch (error) { + if (isTelemetryEnabled) { + await sendTelemetryError(error, 'core-config', { + cliOptions: options, + presetOptions: { ...options, corePresets: [], overridePresets: [] }, + skipPrompt: true, + }); + } + } + } + ); + return channel; }; diff --git a/code/lib/core-server/src/withTelemetry.ts b/code/lib/core-server/src/withTelemetry.ts index 2eda1325587a..ef231fe83c24 100644 --- a/code/lib/core-server/src/withTelemetry.ts +++ b/code/lib/core-server/src/withTelemetry.ts @@ -9,6 +9,7 @@ type TelemetryOptions = { cliOptions: CLIOptions; presetOptions?: Parameters[0]; printError?: (err: any) => void; + skipPrompt?: boolean; }; const promptCrashReports = async () => { @@ -30,7 +31,11 @@ const promptCrashReports = async () => { type ErrorLevel = 'none' | 'error' | 'full'; -async function getErrorLevel({ cliOptions, presetOptions }: TelemetryOptions): Promise { +async function getErrorLevel({ + cliOptions, + presetOptions, + skipPrompt, +}: TelemetryOptions): Promise { if (cliOptions.disableTelemetry) return 'none'; // If we are running init or similar, we just have to go with true here @@ -54,6 +59,10 @@ async function getErrorLevel({ cliOptions, presetOptions }: TelemetryOptions): P (await cache.get('enableCrashReports')) ?? (await cache.get('enableCrashreports')); if (valueFromCache !== undefined) return valueFromCache ? 'full' : 'error'; + if (skipPrompt) { + return 'error'; + } + const valueFromPrompt = await promptCrashReports(); if (valueFromPrompt !== undefined) return valueFromPrompt ? 'full' : 'error'; diff --git a/code/lib/manager-api/src/modules/whatsnew.ts b/code/lib/manager-api/src/modules/whatsnew.ts index b970d0e96bac..21d0596683f6 100644 --- a/code/lib/manager-api/src/modules/whatsnew.ts +++ b/code/lib/manager-api/src/modules/whatsnew.ts @@ -4,6 +4,7 @@ import { REQUEST_WHATS_NEW_DATA, RESULT_WHATS_NEW_DATA, SET_WHATS_NEW_CACHE, + TOGGLE_WHATS_NEW_NOTIFICATIONS, } from '@storybook/core-events'; import type { ModuleFn } from '../index'; @@ -14,6 +15,7 @@ export type SubState = { export type SubAPI = { isWhatsNewUnread(): boolean; whatsNewHasBeenRead(): void; + toggleWhatsNewNotifications(): void; }; const WHATS_NEW_NOTIFICATION_ID = 'whats-new'; @@ -39,6 +41,17 @@ export const init: ModuleFn = ({ fullAPI, store }) => { fullAPI.clearNotification(WHATS_NEW_NOTIFICATION_ID); } }, + toggleWhatsNewNotifications() { + if (state.whatsNewData?.status === 'SUCCESS') { + setWhatsNewState({ + ...state.whatsNewData, + disableWhatsNewNotifications: !state.whatsNewData.disableWhatsNewNotifications, + }); + fullAPI.emit(TOGGLE_WHATS_NEW_NOTIFICATIONS, { + disableWhatsNewNotifications: state.whatsNewData.disableWhatsNewNotifications, + }); + } + }, }; function getLatestWhatsNewPost(): Promise { @@ -63,9 +76,9 @@ export const init: ModuleFn = ({ fullAPI, store }) => { const isNewStoryBookUser = fullAPI.getUrlState().path.includes('onboarding'); if ( - global.FEATURES.whatsNewNotifications && !isNewStoryBookUser && whatsNewData.status === 'SUCCESS' && + !whatsNewData.disableWhatsNewNotifications && whatsNewData.showNotification ) { fullAPI.addNotification({ diff --git a/code/lib/telemetry/src/types.ts b/code/lib/telemetry/src/types.ts index e65da2747a34..25100149fd44 100644 --- a/code/lib/telemetry/src/types.ts +++ b/code/lib/telemetry/src/types.ts @@ -12,7 +12,8 @@ export type EventType = | 'canceled' | 'error' | 'error-metadata' - | 'version-update'; + | 'version-update' + | 'core-config'; export interface Dependency { version: string | undefined; diff --git a/code/lib/types/src/modules/core-common.ts b/code/lib/types/src/modules/core-common.ts index ff5141f5238a..24f4d8c8f9ce 100644 --- a/code/lib/types/src/modules/core-common.ts +++ b/code/lib/types/src/modules/core-common.ts @@ -38,6 +38,11 @@ export interface CoreConfig { * @see https://storybook.js.org/telemetry */ disableTelemetry?: boolean; + + /** + * Disables notifications for Storybook updates. + */ + disableWhatsNewNotifications?: boolean; /** * Enable crash reports to be sent to Storybook telemetry * @see https://storybook.js.org/telemetry @@ -305,11 +310,6 @@ export interface StorybookConfig { * Apply decorators from preview.js before decorators from addons or frameworks */ legacyDecoratorFileOrder?: boolean; - - /** - * Show a notification anytime a What's new? post is published in the Storybook blog. - */ - whatsNewNotifications?: boolean; }; /** diff --git a/code/ui/manager/src/components/sidebar/Menu.stories.tsx b/code/ui/manager/src/components/sidebar/Menu.stories.tsx index acf071da57ce..f9e743a6d84d 100644 --- a/code/ui/manager/src/components/sidebar/Menu.stories.tsx +++ b/code/ui/manager/src/components/sidebar/Menu.stories.tsx @@ -1,11 +1,11 @@ +import type { ComponentProps } from 'react'; import React from 'react'; import { expect } from '@storybook/jest'; import type { Meta, StoryObj } from '@storybook/react'; -import type { ComponentProps } from 'react'; import { TooltipLinkList } from '@storybook/components'; import { styled } from '@storybook/theming'; -import { within, userEvent, screen } from '@storybook/testing-library'; +import { screen, userEvent, within } from '@storybook/testing-library'; import type { State } from '@storybook/manager-api'; import { SidebarMenu, ToolbarMenu } from './Menu'; import { useMenu } from '../../containers/menu'; @@ -46,9 +46,8 @@ const DoubleThemeRenderingHack = styled.div({ export const Expanded: Story = { render: () => { - window.FEATURES.whatsNewNotifications = true; const menu = useMenu( - { whatsNewData: { status: 'SUCCESS' } } as State, + { whatsNewData: { status: 'SUCCESS', disableWhatsNewNotifications: false } } as State, { // @ts-expect-error (Converted from ts-ignore) getShortcutKeys: () => ({}), diff --git a/code/ui/manager/src/containers/menu.tsx b/code/ui/manager/src/containers/menu.tsx index 6d5272ad8fd6..a387cb24d5f3 100644 --- a/code/ui/manager/src/containers/menu.tsx +++ b/code/ui/manager/src/containers/menu.tsx @@ -5,7 +5,6 @@ import { Badge, Icons } from '@storybook/components'; import type { API, State } from '@storybook/manager-api'; import { shortcutToHumanString } from '@storybook/manager-api'; import { styled, useTheme } from '@storybook/theming'; -import { global } from '@storybook/global'; const focusableUIElements = { storySearchField: 'storybook-explorer-searchfield', @@ -69,7 +68,8 @@ export const useMenu = ( [api] ); - const whatsNewNotificationsEnabled = global.FEATURES.whatsNewNotifications; + const whatsNewNotificationsEnabled = + state.whatsNewData?.status === 'SUCCESS' && !state.disableWhatsNewNotifications; const isWhatsNewUnread = api.isWhatsNewUnread(); const whatsNew = useMemo( () => ({ diff --git a/code/ui/manager/src/containers/sidebar.tsx b/code/ui/manager/src/containers/sidebar.tsx index 6a9b606e2db4..86b9cb71ac6b 100755 --- a/code/ui/manager/src/containers/sidebar.tsx +++ b/code/ui/manager/src/containers/sidebar.tsx @@ -3,7 +3,6 @@ import React from 'react'; import type { Combo, StoriesHash } from '@storybook/manager-api'; import { Consumer } from '@storybook/manager-api'; -import { global } from '@storybook/global'; import { Sidebar as SidebarComponent } from '../components/sidebar/Sidebar'; import { useMenu } from './menu'; @@ -33,6 +32,9 @@ const Sidebar = React.memo(function Sideber() { enableShortcuts ); + const whatsNewNotificationsEnabled = + state.whatsNewData?.status === 'SUCCESS' && !state.disableWhatsNewNotifications; + return { title: name, url, @@ -44,7 +46,7 @@ const Sidebar = React.memo(function Sideber() { refId, viewMode, menu, - menuHighlighted: global.FEATURES.whatsNewNotifications && api.isWhatsNewUnread(), + menuHighlighted: whatsNewNotificationsEnabled && api.isWhatsNewUnread(), enableShortcuts, }; }; diff --git a/code/ui/manager/src/globals/exports.ts b/code/ui/manager/src/globals/exports.ts index 473867f750c0..43b7c50f5191 100644 --- a/code/ui/manager/src/globals/exports.ts +++ b/code/ui/manager/src/globals/exports.ts @@ -163,6 +163,7 @@ export default { 'STORY_SPECIFIED', 'STORY_THREW_EXCEPTION', 'STORY_UNCHANGED', + 'TOGGLE_WHATS_NEW_NOTIFICATIONS', 'UPDATE_GLOBALS', 'UPDATE_QUERY_PARAMS', 'UPDATE_STORY_ARGS', diff --git a/code/ui/manager/src/settings/about.tsx b/code/ui/manager/src/settings/about.tsx index cdb0db8c2a73..7fb70f5eae59 100644 --- a/code/ui/manager/src/settings/about.tsx +++ b/code/ui/manager/src/settings/about.tsx @@ -35,6 +35,7 @@ const UpgradeBlock = styled.div(({ theme }) => { borderRadius: 5, padding: 20, margin: 20, + marginTop: 0, maxWidth: 400, borderColor: theme.appBorderColor, fontSize: theme.typography.size.s2, diff --git a/code/ui/manager/src/settings/whats_new.tsx b/code/ui/manager/src/settings/whats_new.tsx index 4f0b5072259e..3d0569129917 100644 --- a/code/ui/manager/src/settings/whats_new.tsx +++ b/code/ui/manager/src/settings/whats_new.tsx @@ -1,8 +1,9 @@ -import type { FC, ComponentProps } from 'react'; -import React, { useEffect, useState, Fragment } from 'react'; -import { styled } from '@storybook/theming'; -import { Icons, Loader } from '@storybook/components'; -import { useStorybookApi } from '@storybook/manager-api'; +import type { ComponentProps, FC } from 'react'; +import React, { Fragment, useEffect, useState } from 'react'; +import { styled, useTheme } from '@storybook/theming'; +import { Button, IconButton, Icons, Loader } from '@storybook/components'; +import { useStorybookApi, useStorybookState } from '@storybook/manager-api'; +import { global } from '@storybook/global'; const Centered = styled.div({ top: '50%', @@ -26,6 +27,74 @@ const Message = styled.div(({ theme }) => ({ lineHeight: `16px`, })); +const Container = styled.div(({ theme }) => ({ + position: 'absolute', + width: '100%', + bottom: '40px', + background: theme.background.bar, + fontSize: `13px`, + borderTop: '1px solid', + borderColor: theme.appBorderColor, + padding: '8px 12px', + display: 'flex', + justifyContent: 'space-between', +})); + +const ToggleNotificationButton = styled(IconButton)(({ theme }) => ({ + fontWeight: theme.typography.weight.regular, + color: theme.color.mediumdark, + margin: 0, +})); + +const CopyButton = styled(Button)(() => ({ + margin: 0, + padding: 0, + borderRadius: 0, +})); + +export const WhatsNewFooter = ({ + isNotificationsEnabled, + onToggleNotifications, + onCopyLink, +}: { + isNotificationsEnabled: boolean; + onToggleNotifications?: () => void; + onCopyLink?: () => void; +}) => { + const theme = useTheme(); + const [copyText, setCopyText] = useState('Copy Link'); + const copyLink = () => { + onCopyLink(); + setCopyText('Copied!'); + setTimeout(() => setCopyText('Copy Link'), 4000); + }; + + return ( + +
+ +
Share this with your team.
+ + {copyText} + +
+ + {isNotificationsEnabled ? ( + <> + +  Hide notifications + + ) : ( + <> + +  Show notifications + + )} + +
+ ); +}; + const Iframe = styled.iframe<{ isLoaded: boolean }>( { position: 'absolute', @@ -37,7 +106,7 @@ const Iframe = styled.iframe<{ isLoaded: boolean }>( margin: 0, padding: 0, width: '100%', - height: 'calc(100% - 40px)', + height: 'calc(100% - 80px)', }, ({ isLoaded }) => ({ visibility: isLoaded ? 'visible' : 'hidden' }) ); @@ -72,25 +141,43 @@ export interface WhatsNewProps { isLoaded: boolean; onLoad: () => void; url?: string; + isNotificationsEnabled: boolean; + onCopyLink?: () => void; + onToggleNotifications?: () => void; } -const PureWhatsNewScreen: FC = ({ didHitMaxWaitTime, isLoaded, onLoad, url }) => ( +const PureWhatsNewScreen: FC = ({ + didHitMaxWaitTime, + isLoaded, + onLoad, + url, + onCopyLink, + onToggleNotifications, + isNotificationsEnabled, +}) => ( {!isLoaded && !didHitMaxWaitTime && } {didHitMaxWaitTime ? ( ) : ( -