web: enable cross-theme screenshots in Chromatic (#20349)

* web: enable cross-theme screenshots in Chromatic

* web: use useRedesignToggle in create-chromatic-story

* web: remove redundant condition

* web: add additional comments

* web: remove unused var

* web: add comment

* web: add @storybook/client-api to dev deps

* web: handle initial redesign-theme class toggle in toolbar component

* web: remove redundant import

* Update client/branded/src/global-styles/colors-redesign.scss

Co-authored-by: Felix Becker <felix.b@outlook.com>

* web: use for of loop

* web: update renovate.md

* web: disable Chromatic workflow if no related files were changed

* web: disable renovate rebase on conflict to limit the number of Chromatic uploads

* web: update comment

* Update enterprise/dev/ci/internal/ci/is-storybook-affected.go

Co-authored-by: Tom Ross <tom@umpox.com>

* web: do not ignore .graphqlrc.yml and run Chromatic if isMasterDryRun

Co-authored-by: Felix Becker <felix.b@outlook.com>
Co-authored-by: Tom Ross <tom@umpox.com>
This commit is contained in:
Valery Bugakov 2021-04-28 22:19:55 +08:00 committed by GitHub
parent a2830c9416
commit 0cf1f0ca7f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 262 additions and 55 deletions

View File

@ -35,9 +35,7 @@ $theme-colors-redesign: (
merged: $redesign-purple,
);
:root.theme-redesign,
// Descendant selector is required for Storybook `addRedesignVariants` helper.
:root .theme-redesign {
.theme-redesign {
--primary: #{map-get($theme-colors-redesign, 'primary')};
--primary-3: #{$redesign-indigo};
--brand-secondary: #a305e1;

View File

@ -1,27 +0,0 @@
import React, { ReactElement } from 'react'
import { REDESIGN_CLASS_NAME } from './useRedesignToggle'
/**
*
* To rely on screenshot tests for redesigned components in Storybook, this wrapper function
* renders a copy of the story wrapped into a div with redesign class next to the initial story.
*
* @example
* const { add } = storiesOf('wildcard/PageSelector', module).addDecorator(story => (
* <BrandedStory styles={webStyles}>
* {() => addRedesignVariants(<div className="container web-content mt-3">{story()}</div>)}
* </BrandedStory>
* ))
*
*/
export const addRedesignVariants = (story: ReactElement): ReactElement => (
<>
{story}
<hr className="mb-3" />
<div className={REDESIGN_CLASS_NAME}>
<div className="badge badge-secondary">Redesign variant</div>
{story}
</div>
</>
)

View File

@ -0,0 +1,37 @@
import { PublishedStoreItem } from '@storybook/client-api'
import { raw } from '@storybook/react'
import isChromatic from 'chromatic/isChromatic'
import { addStory } from './add-story'
// Execute logic below only in the environment where Chromatic snapshots are captured.
if (isChromatic()) {
// Get an array of all stories which are already added to the `StoryStore`.
// Use `raw()` because we don't want to apply any filtering and sorting on the array of stories.
const storeItems = raw() as PublishedStoreItem[]
// Add three more versions of each story to test visual regressions with Chromatic snapshots.
// In other environments, these themes can be explored by a user via toolbar toggles.
for (const storeItem of storeItems) {
// Default theme + Dark mode.
addStory({
storeItem,
isDarkModeEnabled: true,
isRedesignEnabled: false,
})
// Redesign theme + Light mode.
addStory({
storeItem,
isDarkModeEnabled: false,
isRedesignEnabled: true,
})
// Redesign theme + Dark mode.
addStory({
storeItem,
isDarkModeEnabled: true,
isRedesignEnabled: true,
})
}
}

View File

@ -0,0 +1,60 @@
import { PublishedStoreItem, StoryStore } from '@storybook/client-api'
import { toId } from '@storybook/csf'
import { createChromaticStory, CreateChromaticStoryOptions } from './create-chromatic-story'
// This global reference is used internally by Storybook:
// https://github.com/storybookjs/storybook/blob/3ec358f71c6111838092397d13fbe35b627a9a9d/lib/core-client/src/preview/start.ts#L43
declare global {
interface Window {
__STORYBOOK_STORY_STORE__: StoryStore
}
}
// See the discussion about `StoryStore` usage in stories:
// https://github.com/storybookjs/storybook/discussions/12050#discussioncomment-125658
const storyStore = window.__STORYBOOK_STORY_STORE__
interface AddStoryOptions extends Pick<CreateChromaticStoryOptions, 'isRedesignEnabled' | 'isDarkModeEnabled'> {
storeItem: PublishedStoreItem
}
export const addStory = (options: AddStoryOptions): void => {
const {
storeItem: { name, kind, storyFn, parameters },
isDarkModeEnabled,
isRedesignEnabled,
} = options
// Add suffix to the story name based on theme options:
// 1. Default + Dark: "Text" -> "Text 🌚"
// 2. Redesign + Light: "Text" -> "Text [Redesign]"
// 3. Redesign + Dark: "Text" -> "Text [Redesign] 🌚"
const storyName = [name, isRedesignEnabled && '[Redesign]', isDarkModeEnabled && '🌚'].filter(Boolean).join(' ')
/**
* Use `storyStore.addStory()` to avoid applying decorators to stories, because `PublishedStoreItem.storyFn` already has decorators applied.
* `storiesOf().add()` usage API would result in decorators duplication. It's possible to avoid this issue using `PublishedStoreItem.getOriginal()`,
* which returns only story function without any decorators and story context. It means that we should apply them manually and
* keep this logic in sync with Storybook internals to have consistent behavior. `storyStore.addStory()` allows to avoid it.
*/
storyStore.addStory(
{
id: toId(kind, storyName),
kind,
name: storyName,
parameters,
loaders: [],
storyFn: createChromaticStory({
storyFn,
isDarkModeEnabled,
isRedesignEnabled,
}),
},
{
// The default `applyDecorators` implementation accepts `decorators` as a second arg and applies them to the `storyFn`.
// Our `storyFn` already has all the decorators applied, so we just return it.
applyDecorators: storyFn => storyFn,
}
)
}

View File

@ -0,0 +1,47 @@
import { StoryFn } from '@storybook/addons'
import React, { ReactElement, useEffect } from 'react'
import { useDarkMode } from 'storybook-dark-mode'
import { useRedesignToggle } from '@sourcegraph/shared/src/util/useRedesignToggle'
import { THEME_DARK_CLASS, THEME_LIGHT_CLASS } from '../themes'
export interface CreateChromaticStoryOptions {
storyFn: StoryFn<ReactElement>
isRedesignEnabled: boolean
isDarkModeEnabled: boolean
}
// Wrap `storyFn` into a decorator which takes care of CSS classes toggling based on received theme options.
export const createChromaticStory = (options: CreateChromaticStoryOptions): StoryFn => () => {
const { storyFn, isRedesignEnabled, isDarkModeEnabled } = options
// The `storyFn` is retrieved from the `StoryStore`, so it already has a `StoryContext`.
// We can safely change its type to remove required props `StoryContext` props check.
const Story = storyFn as React.ComponentType
const [, setRedesignToggle] = useRedesignToggle()
const isDarkModeEnabledInitially = useDarkMode()
useEffect(() => {
setRedesignToggle(isRedesignEnabled)
// 'storybook-dark-mode' doesn't expose any API to toggle dark/light theme programmatically, so we do it manually.
document.body.classList.toggle(THEME_DARK_CLASS, isDarkModeEnabled)
document.body.classList.toggle(THEME_LIGHT_CLASS, !isDarkModeEnabled)
return () => {
// Do not enable redesign theme if it was disabled before this story was opened.
if (isRedesignEnabled) {
setRedesignToggle(!isRedesignEnabled)
}
// Always toggle dark mode back to the previous value because otherwise, it might be out of sync with the toolbar toggle.
document.body.classList.toggle(THEME_DARK_CLASS, isDarkModeEnabledInitially)
document.body.classList.toggle(THEME_LIGHT_CLASS, !isDarkModeEnabledInitially)
}
// We need to execute `useEffect` callback once to take snapshot in Chromatic, so we can omit dependencies here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <Story />
}

View File

@ -7,6 +7,10 @@ import { Configuration, DefinePlugin, ProgressPlugin, RuleSetUseItem, RuleSetUse
const rootPath = path.resolve(__dirname, '../../../')
const monacoEditorPaths = [path.resolve(rootPath, 'node_modules', 'monaco-editor')]
// Stories in this file are guarded by the `isChromatic()` check. It will result in noop in all other environments.
const chromaticStoriesGlob = path.resolve(rootPath, 'client/storybook/src/chromatic-story/Chromatic.story.tsx')
// Due to an issue with constant recompiling (https://github.com/storybookjs/storybook/issues/14342)
// we need to make the globs more specific (`(web|shared..)` also doesn't work). Once the above issue
// is fixed, this can be removed and watched for `client/**/*.story.tsx` again.
@ -32,7 +36,7 @@ const getCSSLoaders = (...loaders: RuleSetUseItem[]): RuleSetUse => [
]
const config = {
stories: storiesGlobs,
stories: [...storiesGlobs, chromaticStoriesGlob],
addons: [
'@storybook/addon-knobs',
'@storybook/addon-actions',

View File

@ -9,7 +9,7 @@ import { withDesign } from 'storybook-addon-designs'
import { setLinkComponent, AnchorLink } from '@sourcegraph/shared/src/components/Link'
import { getIsRedesignEnabled, REDESIGN_CLASS_NAME } from '@sourcegraph/shared/src/util/useRedesignToggle'
import * as themes from './themes'
import { themeDark, themeLight, THEME_DARK_CLASS, THEME_LIGHT_CLASS } from './themes'
const withConsoleDecorator: DecoratorFunction<ReactElement> = (storyFn, context): ReactElement =>
withConsole()(storyFn)(context)
@ -19,10 +19,10 @@ export const decorators = [withDesign, withConsoleDecorator]
export const parameters = {
darkMode: {
stylePreview: true,
darkClass: 'theme-dark',
lightClass: 'theme-light',
light: themes.light,
dark: themes.dark,
lightClass: THEME_LIGHT_CLASS,
darkClass: THEME_DARK_CLASS,
light: themeLight,
dark: themeDark,
},
}

View File

@ -1,10 +1,12 @@
import { addons } from '@storybook/addons'
import { Icons, IconButton } from '@storybook/components'
import React, { ReactElement } from 'react'
import { SET_STORIES } from '@storybook/core-events'
import React, { ReactElement, useEffect } from 'react'
import { useRedesignToggle, REDESIGN_CLASS_NAME } from '@sourcegraph/shared/src/util/useRedesignToggle'
const toggleRedesignClass = (element: HTMLElement, isRedesignEnabled: boolean): void => {
element.classList.toggle(REDESIGN_CLASS_NAME, !isRedesignEnabled)
element.classList.toggle(REDESIGN_CLASS_NAME, isRedesignEnabled)
}
const updatePreview = (isRedesignEnabled: boolean): void => {
@ -29,10 +31,26 @@ const updateManager = (isRedesignEnabled: boolean): void => {
export const RedesignToggleStorybook = (): ReactElement => {
const [isRedesignEnabled, setIsRedesignEnabled] = useRedesignToggle()
useEffect(() => {
const handleIsRedesignEnabledChange = (): void => {
updatePreview(isRedesignEnabled)
updateManager(isRedesignEnabled)
}
handleIsRedesignEnabledChange()
const channel = addons.getChannel()
// Preview iframe is not available on toolbar mount.
// Wait for the SET_STORIES event, after which the iframe is accessible, and ensure that the redesign-theme class is in place.
channel.on(SET_STORIES, handleIsRedesignEnabledChange)
return () => {
channel.removeListener(SET_STORIES, handleIsRedesignEnabledChange)
}
}, [isRedesignEnabled])
const handleRedesignToggle = (): void => {
setIsRedesignEnabled(!isRedesignEnabled)
updatePreview(isRedesignEnabled)
updateManager(isRedesignEnabled)
}
return (

View File

@ -1,6 +1,9 @@
import { ThemeVars, themes } from '@storybook/theming'
import openColor from 'open-color'
export const THEME_DARK_CLASS = 'theme-dark'
export const THEME_LIGHT_CLASS = 'theme-light'
// Themes use the colors from our webapp.
const common: Omit<ThemeVars, 'base'> = {
colorPrimary: openColor.blue[6],
@ -13,7 +16,7 @@ const common: Omit<ThemeVars, 'base'> = {
fontCode: 'sfmono-regular, consolas, menlo, dejavu sans mono, monospace',
}
export const dark: ThemeVars = {
export const themeDark: ThemeVars = {
...themes.dark,
...common,
appBg: '#1c2736',
@ -25,7 +28,7 @@ export const dark: ThemeVars = {
inputTextColor: '#ffffff',
}
export const light: ThemeVars = {
export const themeLight: ThemeVars = {
...themes.light,
...common,
appBg: '#fbfdff',

View File

@ -41,8 +41,8 @@ We heavily customize Renovate to save more time. Possible configurations include
- Setting different reviewers for certain dependencies
- Grouping certain dependencies
- Automerging certain low-risk dependencies
- Updating certain dependencies out-of-schedule aswell
- Auto merging certain low-risk dependencies
- Updating certain dependencies out-of-schedule as well
- Assigning certain labels for easier filtering
If you see an opportunity to improve the configuration, raise a pull request to update the `renovate.json` in the repository or our [configuration shared between repositories](https://github.com/sourcegraph/renovate-config/blob/master/renovate.json).

View File

@ -0,0 +1,63 @@
package ci
import (
"path/filepath"
"strings"
)
// Changes in the files below will be ignored by the Storybook workflow.
var ignoredRootFiles []string = []string{
"jest.config.base.js",
"graphql-schema-linter.config.js",
"libsqlite3-pcre.dylib",
".mocharc.js",
"go.mod",
"LICENSE",
"renovate.json",
"jest.config.js",
"LICENSE.apache",
".stylelintrc.json",
".percy.yml",
".tool-versions",
"go.sum",
".golangci.yml",
".stylelintignore",
".gitmodules",
".prettierignore",
".editorconfig",
"prettier.config.js",
".dockerignore",
"doc.go",
".gitignore",
".gitattributes",
".eslintrc.js",
"sg.config.yaml",
".eslintignore",
".mailmap",
"LICENSE.enterprise",
"CODENOTIFY",
}
func contains(s []string, str string) bool {
for _, v := range s {
if v == str {
return true
}
}
return false
}
func isAllowedRootFile(p string) bool {
return filepath.Dir(p) == "." && !contains(ignoredRootFiles, p)
}
// Run Storybook workflow only if related files were changed.
func (c Config) isStorybookAffected() bool {
for _, p := range c.changedFiles {
if !strings.HasSuffix(p, ".md") && (strings.HasPrefix(p, "client/") || isAllowedRootFile(p)) {
return true
}
}
return false
}

View File

@ -90,17 +90,19 @@ func addSharedTests(c Config) func(pipeline *bk.Pipeline) {
bk.Cmd("dev/ci/codecov.sh -c -F typescript -F integration"),
bk.ArtifactPaths("./puppeteer/*.png"))
// Upload storybook to Chromatic
chromaticCommand := "yarn chromatic --exit-zero-on-changes --exit-once-uploaded"
if !c.isPR() {
chromaticCommand += " --auto-accept-changes"
if c.isMasterDryRun || c.isStorybookAffected() {
// Upload storybook to Chromatic
chromaticCommand := "yarn chromatic --exit-zero-on-changes --exit-once-uploaded"
if !c.isPR() {
chromaticCommand += " --auto-accept-changes"
}
pipeline.AddStep(":chromatic: Upload storybook to Chromatic",
bk.AutomaticRetry(5),
bk.Cmd("yarn --mutex network --frozen-lockfile --network-timeout 60000"),
bk.Cmd("yarn gulp generate"),
bk.Env("MINIFY", "1"),
bk.Cmd(chromaticCommand))
}
pipeline.AddStep(":chromatic: Upload storybook to Chromatic",
bk.AutomaticRetry(5),
bk.Cmd("yarn --mutex network --frozen-lockfile --network-timeout 60000"),
bk.Cmd("yarn gulp generate"),
bk.Env("MINIFY", "1"),
bk.Cmd(chromaticCommand))
// Shared tests
pipeline.AddStep(":jest: Test shared client code",

View File

@ -121,6 +121,7 @@
"@storybook/addon-toolbars": "^6.2.9",
"@storybook/addons": "^6.2.9",
"@storybook/components": "^6.2.9",
"@storybook/client-api": "^6.2.9",
"@storybook/core": "^6.2.9",
"@storybook/react": "^6.2.9",
"@storybook/theming": "^6.2.9",

View File

@ -2,6 +2,7 @@
"$schema": "http://json.schemastore.org/renovate",
"extends": ["github>sourcegraph/renovate-config"],
"semanticCommits": false,
"rebaseWhen": "never",
"packageRules": [
{
"matchDepTypes": ["engines"],

View File

@ -3275,7 +3275,7 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/client-api@6.2.9":
"@storybook/client-api@6.2.9", "@storybook/client-api@^6.2.9":
version "6.2.9"
resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.2.9.tgz#f0bb44e9b2692adfbf30d7ff751c6dd44bcfe1ce"
integrity sha512-aLvEUVkbvv6Qo/2mF4rFCecdqi2CGOUDdsV1a6EFIVS/9gXFdpirsOwKHo9qNjacGdWPlBYGCUcbrw+DvNaSFA==