Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/branded-loading-indicator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/app': patch
'@shopify/cli-kit': patch
---

Replace task and app dev spinners with a branded Shopify loading indicator.
9 changes: 4 additions & 5 deletions docs/cli-kit/ui-kit/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
- [`Text` vs `Box`](#text-vs-box)
- [Utility components](#utility-components)
- [`TokenizedText`](#tokenizedtext)
- [`TextAnimation`](#textanimation)
- [`LoadingIndicator`](#loadingindicator)
- [Helpful tips](#helpful-tips)
- [Handling user input](#handling-user-input)
- [Components that deal with async functions](#components-that-deal-with-async-functions)
Expand Down Expand Up @@ -166,11 +166,10 @@ add a new interface named `ItalicToken` in the `TokenizedText` and decide how it
In this example we would use `inline`. But before you go ahead and add a new token, consider if all the users of UI kit
might need this new token or not. If the answer is no, then a simple regular component will suffice.

#### `TextAnimation`
#### `LoadingIndicator`

At the moment this component simply animates text with a rainbow effect, however it can be extended to support more animations.
If you wish to do so you can take a look at how [chalk-animation](https://github.com/bokub/chalk-animation/blob/master/index.js)
implemented animations and take inspiration from there.
This component renders the branded loading indicator used by task-based UI components. `LoadingBar` renders it only in TTY
environments so captured and redirected output remains static.

## Helpful tips

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@ describe('DevSessionUI', () => {
devSessionStatusManager.updateStatus(initialStatus)
})

test('renders the branded loading indicator for loading status messages', async () => {
devSessionStatusManager.updateStatus({
statusMessage: {message: 'Preparing dev preview', type: 'loading'},
})

const renderInstance = render(
<DevSessionUI
processes={[]}
abortController={new AbortController()}
devSessionStatusManager={devSessionStatusManager}
shopFqdn="mystore.myshopify.com"
onAbort={onAbort}
/>,
)

await waitForContent(renderInstance, 'Preparing dev preview')

expect(unstyled(renderInstance.lastFrame()!)).toMatch(/S[> ] Preparing dev preview \.\.\./)

renderInstance.unmount()
})

test('renders a stream of concurrent outputs from sub-processes, shortcuts and URLs', async () => {
// Given
let backendPromiseResolve: () => void
Expand Down
34 changes: 13 additions & 21 deletions packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import {Spinner} from './Spinner.js'
import {TabPanel, Tab, TabShortcut} from './TabPanel.js'
import metadata from '../../../../metadata.js'
import {
DevSessionStatus,
DevSessionStatusManager,
DevSessionStatusMessageType,
} from '../../processes/dev-session/dev-session-status-manager.js'
import {DevSessionStatus, DevSessionStatusManager} from '../../processes/dev-session/dev-session-status-manager.js'
import {MAX_EXTENSION_HANDLE_LENGTH} from '../../../../models/extensions/schemas.js'
import {buildDevConsoleURL} from '../../../../utilities/app/app-url.js'
import {OutputProcess} from '@shopify/cli-kit/node/output'
import {Alert, ConcurrentOutput, Link, TabularData} from '@shopify/cli-kit/node/ui/components'
import {Alert, ConcurrentOutput, Link, LoadingIndicator, TabularData} from '@shopify/cli-kit/node/ui/components'
import {useAbortSignal} from '@shopify/cli-kit/node/ui/hooks'
import React, {FunctionComponent, useEffect, useMemo, useState} from 'react'
import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort'
Expand All @@ -26,6 +21,16 @@ interface DevStatusShortcut extends TabShortcut {
url?: string
}

const StatusMessage = ({message, type}: NonNullable<DevSessionStatus['statusMessage']>) => {
if (type === 'loading') return <LoadingIndicator title={message} />

return (
<Text>
{type === 'success' ? '✅' : '❌'} {message}
</Text>
)
}

interface DevSesionUIProps {
processes: OutputProcess[]
abortController: AbortController
Expand Down Expand Up @@ -103,17 +108,6 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
{isActive: Boolean(canUseShortcuts)},
)

const getStatusIndicator = (type: DevSessionStatusMessageType) => {
switch (type) {
case 'loading':
return <Spinner />
case 'success':
return '✅'
case 'error':
return '❌'
}
}

const devStatusShortcuts: DevStatusShortcut[] = [
{
key: 'p',
Expand Down Expand Up @@ -170,9 +164,7 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
content: (
<>
{status.statusMessage && (
<Text>
{getStatusIndicator(status.statusMessage.type)} {status.statusMessage.message}
</Text>
<StatusMessage message={status.statusMessage.message} type={status.statusMessage.type} />
)}
{canUseShortcuts && activeShortcuts.length > 0 && (
<Box marginTop={1} flexDirection="column">
Expand Down
18 changes: 0 additions & 18 deletions packages/app/src/cli/services/dev/ui/components/Spinner.tsx

This file was deleted.

2 changes: 0 additions & 2 deletions packages/cli-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@
"find-up": "6.3.0",
"form-data": "4.0.6",
"fs-extra": "11.1.0",
"gradient-string": "2.0.2",
"graphql": "16.14.2",
"graphql-request": "6.1.0",
"h3": "1.15.11",
Expand Down Expand Up @@ -167,7 +166,6 @@
"devDependencies": {
"@types/diff": "^5.2.3",
"@types/fs-extra": "9.0.13",
"@types/gradient-string": "^1.1.2",
"@types/lodash": "4.17.24",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
Expand Down
159 changes: 43 additions & 116 deletions packages/cli-kit/src/private/node/ui/components/LoadingBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ import {LoadingBar} from './LoadingBar.js'
import {Stdout} from '../../ui.js'
import {render} from '../../testing/ui.js'
import {shouldDisplayColors, unstyled} from '../../../../public/node/output.js'
import useLayout from '../hooks/use-layout.js'
import React from 'react'
import React, {act} from 'react'

import {beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('../hooks/use-layout.js')
vi.mock('../../../../public/node/output.js', async () => {
const original: any = await vi.importActual('../../../../public/node/output.js')
return {
Expand All @@ -17,159 +15,88 @@ vi.mock('../../../../public/node/output.js', async () => {
})

beforeEach(() => {
vi.mocked(useLayout).mockReturnValue({
twoThirds: 53,
oneThird: 27,
fullWidth: 80,
})
vi.mocked(shouldDisplayColors).mockReturnValue(true)
})

/**
* Creates a Stdout test double simulating a TTY stream.
* On real Node streams, isTTY is only present as an own property when the
* stream IS a TTY.
*/
function createTTYStdout(columns = 100) {
const stdout = new Stdout({columns}) as Stdout & {isTTY: boolean}
function createTTYStdout() {
const stdout = new Stdout({columns: 100}) as Stdout & {isTTY: boolean}
stdout.isTTY = true
return stdout
}

/**
* Renders LoadingBar with a TTY stdout so the animated progress bar renders.
*/
function renderWithTTY(element: React.ReactElement) {
const stdout = createTTYStdout()
const instance = render(element, {stdout})
return {lastFrame: stdout.lastFrame, unmount: instance.unmount}
}

describe('LoadingBar', () => {
test('renders loading bar with default colored characters', async () => {
const {lastFrame} = renderWithTTY(<LoadingBar title="Loading content" />)

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Loading content ..."
`)
})
test('renders the Shopify loading indicator', async () => {
const {lastFrame, unmount} = renderWithTTY(<LoadingBar title="Loading content" />)
const frame = lastFrame()!

test('renders loading bar with hill pattern when noColor prop is true', async () => {
const {lastFrame} = renderWithTTY(<LoadingBar title="Processing files" noColor />)
expect(unstyled(frame)).toBe('S> Loading content ...')
expect(frame).toContain('\u001B[1m')
expect(frame).toContain('\u001B[3m')
expect(frame).toContain('\u001B[38;2;150;191;72m')

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
Processing files ..."
`)
unmount()
})

test('renders loading bar with hill pattern when shouldDisplayColors returns false', async () => {
vi.mocked(shouldDisplayColors).mockReturnValue(false)
const {lastFrame} = renderWithTTY(<LoadingBar title="Downloading packages" />)

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
Downloading packages ..."
`)
})
test('blinks the chevron without shifting the title', async () => {
vi.useFakeTimers()
const {lastFrame, unmount} = renderWithTTY(<LoadingBar title="Uploading theme" />)

test('handles narrow terminal width correctly', async () => {
vi.mocked(useLayout).mockReturnValue({twoThirds: 20, oneThird: 10, fullWidth: 30})
const {lastFrame} = renderWithTTY(<LoadingBar title="Building app" />)
try {
await act(async () => {
await vi.advanceTimersByTimeAsync(350)
})

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Building app ..."
`)
expect(unstyled(lastFrame()!)).toBe('S Uploading theme ...')
} finally {
unmount()
vi.useRealTimers()
}
})

test('handles narrow terminal width correctly in no-color mode', async () => {
vi.mocked(useLayout).mockReturnValue({twoThirds: 15, oneThird: 8, fullWidth: 23})
const {lastFrame} = renderWithTTY(<LoadingBar title="Installing" noColor />)

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇
Installing ..."
`)
})
test('renders the chevron without color when noColor is true', async () => {
const {lastFrame, unmount} = renderWithTTY(<LoadingBar title="Processing files" noColor />)
const frame = lastFrame()!

test('handles very narrow terminal width in no-color mode', async () => {
vi.mocked(useLayout).mockReturnValue({twoThirds: 5, oneThird: 3, fullWidth: 8})
const {lastFrame} = renderWithTTY(<LoadingBar title="Wait" noColor />)
expect(unstyled(frame)).toBe('S> Processing files ...')
expect(frame).not.toContain('\u001B[38;2;150;191;72m')

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"▁▁▁▂▂
Wait ..."
`)
unmount()
})

test('handles wide terminal width correctly', async () => {
vi.mocked(useLayout).mockReturnValue({twoThirds: 100, oneThird: 50, fullWidth: 150})
const {lastFrame} = renderWithTTY(<LoadingBar title="Synchronizing data" />)

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Synchronizing data ..."
`)
})
test('renders the chevron without color when colors are disabled', async () => {
vi.mocked(shouldDisplayColors).mockReturnValue(false)
const {lastFrame, unmount} = renderWithTTY(<LoadingBar title="Downloading packages" />)
const frame = lastFrame()!

test('handles wide terminal width correctly in no-color mode with pattern repetition', async () => {
vi.mocked(useLayout).mockReturnValue({twoThirds: 90, oneThird: 45, fullWidth: 135})
const {lastFrame} = renderWithTTY(<LoadingBar title="Analyzing dependencies" noColor />)
expect(unstyled(frame)).toBe('S> Downloading packages ...')
expect(frame).not.toContain('\u001B[38;2;150;191;72m')

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁
Analyzing dependencies ..."
`)
unmount()
})

test('renders correctly with empty title', async () => {
const {lastFrame} = renderWithTTY(<LoadingBar title="" />)

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
..."
`)
})
test('renders correctly with an empty title', async () => {
const {lastFrame, unmount} = renderWithTTY(<LoadingBar title="" />)

test('noColor prop overrides shouldDisplayColors when both would show colors', async () => {
vi.mocked(shouldDisplayColors).mockReturnValue(true)
const {lastFrame} = renderWithTTY(<LoadingBar title="Testing override" noColor />)
expect(unstyled(lastFrame()!)).toBe('S> ...')

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
Testing override ..."
`)
unmount()
})

test('renders consistently with same props', async () => {
const props = {title: 'Consistent test', noColor: false}
const {lastFrame: frame1} = renderWithTTY(<LoadingBar {...props} />)
const {lastFrame: frame2} = renderWithTTY(<LoadingBar {...props} />)

expect(frame1()).toBe(frame2())
})

test('hides progress bar when noProgressBar is true', async () => {
vi.mocked(shouldDisplayColors).mockReturnValue(true)
test('hides the loading indicator when noProgressBar is true', async () => {
const {lastFrame} = renderWithTTY(<LoadingBar title="task 1" noProgressBar />)

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`"task 1 ..."`)
expect(unstyled(lastFrame()!)).toBe('task 1 ...')
})

test('shows only static title text when output stream is not a TTY', async () => {
// Default test Stdout has no isTTY property, simulating a non-TTY stream
const {lastFrame} = render(<LoadingBar title="Installing dependencies" />)

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`"Installing dependencies ..."`)
})

test('shows animated progress bar when output stream is a TTY', async () => {
const {lastFrame} = renderWithTTY(<LoadingBar title="Uploading theme" />)

const frame = unstyled(lastFrame()!)
expect(frame).toContain('▀')
expect(frame).toContain('Uploading theme ...')
expect(unstyled(lastFrame()!)).toBe('Installing dependencies ...')
})
})
Loading
Loading