mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 13:11:49 +00:00
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:
parent
a2830c9416
commit
0cf1f0ca7f
@ -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;
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
37
client/storybook/src/chromatic-story/Chromatic.story.tsx
Normal file
37
client/storybook/src/chromatic-story/Chromatic.story.tsx
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
60
client/storybook/src/chromatic-story/add-story.ts
Normal file
60
client/storybook/src/chromatic-story/add-story.ts
Normal 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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -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 />
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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).
|
||||
|
||||
63
enterprise/dev/ci/internal/ci/is-storybook-affected.go
Normal file
63
enterprise/dev/ci/internal/ci/is-storybook-affected.go
Normal 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
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
"$schema": "http://json.schemastore.org/renovate",
|
||||
"extends": ["github>sourcegraph/renovate-config"],
|
||||
"semanticCommits": false,
|
||||
"rebaseWhen": "never",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDepTypes": ["engines"],
|
||||
|
||||
@ -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==
|
||||
|
||||
Loading…
Reference in New Issue
Block a user