Skip to content

Commit 450e28c

Browse files
Add new loading indicator
1 parent f20d04f commit 450e28c

14 files changed

Lines changed: 137 additions & 395 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/app': patch
3+
'@shopify/cli-kit': patch
4+
---
5+
6+
Replace task and app dev spinners with a branded Shopify loading indicator.

docs/cli-kit/ui-kit/contributing.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- [`Text` vs `Box`](#text-vs-box)
1515
- [Utility components](#utility-components)
1616
- [`TokenizedText`](#tokenizedtext)
17-
- [`TextAnimation`](#textanimation)
17+
- [`LoadingIndicator`](#loadingindicator)
1818
- [Helpful tips](#helpful-tips)
1919
- [Handling user input](#handling-user-input)
2020
- [Components that deal with async functions](#components-that-deal-with-async-functions)
@@ -166,11 +166,10 @@ add a new interface named `ItalicToken` in the `TokenizedText` and decide how it
166166
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
167167
might need this new token or not. If the answer is no, then a simple regular component will suffice.
168168

169-
#### `TextAnimation`
169+
#### `LoadingIndicator`
170170

171-
At the moment this component simply animates text with a rainbow effect, however it can be extended to support more animations.
172-
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)
173-
implemented animations and take inspiration from there.
171+
This component renders the branded loading indicator used by task-based UI components. `LoadingBar` renders it only in TTY
172+
environments so captured and redirected output remains static.
174173

175174
## Helpful tips
176175

packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,28 @@ describe('DevSessionUI', () => {
7474
devSessionStatusManager.updateStatus(initialStatus)
7575
})
7676

77+
test('renders the branded loading indicator for loading status messages', async () => {
78+
devSessionStatusManager.updateStatus({
79+
statusMessage: {message: 'Preparing dev preview', type: 'loading'},
80+
})
81+
82+
const renderInstance = render(
83+
<DevSessionUI
84+
processes={[]}
85+
abortController={new AbortController()}
86+
devSessionStatusManager={devSessionStatusManager}
87+
shopFqdn="mystore.myshopify.com"
88+
onAbort={onAbort}
89+
/>,
90+
)
91+
92+
await waitForContent(renderInstance, 'Preparing dev preview')
93+
94+
expect(unstyled(renderInstance.lastFrame()!)).toMatch(/S[> ] Preparing dev preview \.\.\./)
95+
96+
renderInstance.unmount()
97+
})
98+
7799
test('renders a stream of concurrent outputs from sub-processes, shortcuts and URLs', async () => {
78100
// Given
79101
let backendPromiseResolve: () => void

packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
1-
import {Spinner} from './Spinner.js'
21
import {TabPanel, Tab, TabShortcut} from './TabPanel.js'
32
import metadata from '../../../../metadata.js'
4-
import {
5-
DevSessionStatus,
6-
DevSessionStatusManager,
7-
DevSessionStatusMessageType,
8-
} from '../../processes/dev-session/dev-session-status-manager.js'
3+
import {DevSessionStatus, DevSessionStatusManager} from '../../processes/dev-session/dev-session-status-manager.js'
94
import {MAX_EXTENSION_HANDLE_LENGTH} from '../../../../models/extensions/schemas.js'
105
import {buildDevConsoleURL} from '../../../../utilities/app/app-url.js'
116
import {OutputProcess} from '@shopify/cli-kit/node/output'
12-
import {Alert, ConcurrentOutput, Link, TabularData} from '@shopify/cli-kit/node/ui/components'
7+
import {Alert, ConcurrentOutput, Link, LoadingIndicator, TabularData} from '@shopify/cli-kit/node/ui/components'
138
import {useAbortSignal} from '@shopify/cli-kit/node/ui/hooks'
149
import React, {FunctionComponent, useEffect, useMemo, useState} from 'react'
1510
import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort'
@@ -26,6 +21,16 @@ interface DevStatusShortcut extends TabShortcut {
2621
url?: string
2722
}
2823

24+
const StatusMessage = ({message, type}: NonNullable<DevSessionStatus['statusMessage']>) => {
25+
if (type === 'loading') return <LoadingIndicator title={message} />
26+
27+
return (
28+
<Text>
29+
{type === 'success' ? '✅' : '❌'} {message}
30+
</Text>
31+
)
32+
}
33+
2934
interface DevSesionUIProps {
3035
processes: OutputProcess[]
3136
abortController: AbortController
@@ -103,17 +108,6 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
103108
{isActive: Boolean(canUseShortcuts)},
104109
)
105110

106-
const getStatusIndicator = (type: DevSessionStatusMessageType) => {
107-
switch (type) {
108-
case 'loading':
109-
return <Spinner />
110-
case 'success':
111-
return '✅'
112-
case 'error':
113-
return '❌'
114-
}
115-
}
116-
117111
const devStatusShortcuts: DevStatusShortcut[] = [
118112
{
119113
key: 'p',
@@ -170,9 +164,7 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
170164
content: (
171165
<>
172166
{status.statusMessage && (
173-
<Text>
174-
{getStatusIndicator(status.statusMessage.type)} {status.statusMessage.message}
175-
</Text>
167+
<StatusMessage message={status.statusMessage.message} type={status.statusMessage.type} />
176168
)}
177169
{canUseShortcuts && activeShortcuts.length > 0 && (
178170
<Box marginTop={1} flexDirection="column">

packages/app/src/cli/services/dev/ui/components/Spinner.tsx

Lines changed: 0 additions & 18 deletions
This file was deleted.

packages/cli-kit/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,6 @@
134134
"find-up": "6.3.0",
135135
"form-data": "4.0.6",
136136
"fs-extra": "11.1.0",
137-
"gradient-string": "2.0.2",
138137
"graphql": "16.14.2",
139138
"graphql-request": "6.1.0",
140139
"h3": "1.15.11",
@@ -167,7 +166,6 @@
167166
"devDependencies": {
168167
"@types/diff": "^5.2.3",
169168
"@types/fs-extra": "9.0.13",
170-
"@types/gradient-string": "^1.1.2",
171169
"@types/lodash": "4.17.24",
172170
"@types/react": "^19.0.0",
173171
"@types/react-dom": "^19.0.0",

packages/cli-kit/src/private/node/ui/components/LoadingBar.test.tsx

Lines changed: 43 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ import {LoadingBar} from './LoadingBar.js'
22
import {Stdout} from '../../ui.js'
33
import {render} from '../../testing/ui.js'
44
import {shouldDisplayColors, unstyled} from '../../../../public/node/output.js'
5-
import useLayout from '../hooks/use-layout.js'
6-
import React from 'react'
5+
import React, {act} from 'react'
76

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

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

1917
beforeEach(() => {
20-
vi.mocked(useLayout).mockReturnValue({
21-
twoThirds: 53,
22-
oneThird: 27,
23-
fullWidth: 80,
24-
})
2518
vi.mocked(shouldDisplayColors).mockReturnValue(true)
2619
})
2720

28-
/**
29-
* Creates a Stdout test double simulating a TTY stream.
30-
* On real Node streams, isTTY is only present as an own property when the
31-
* stream IS a TTY.
32-
*/
33-
function createTTYStdout(columns = 100) {
34-
const stdout = new Stdout({columns}) as Stdout & {isTTY: boolean}
21+
function createTTYStdout() {
22+
const stdout = new Stdout({columns: 100}) as Stdout & {isTTY: boolean}
3523
stdout.isTTY = true
3624
return stdout
3725
}
3826

39-
/**
40-
* Renders LoadingBar with a TTY stdout so the animated progress bar renders.
41-
*/
4227
function renderWithTTY(element: React.ReactElement) {
4328
const stdout = createTTYStdout()
4429
const instance = render(element, {stdout})
4530
return {lastFrame: stdout.lastFrame, unmount: instance.unmount}
4631
}
4732

4833
describe('LoadingBar', () => {
49-
test('renders loading bar with default colored characters', async () => {
50-
const {lastFrame} = renderWithTTY(<LoadingBar title="Loading content" />)
51-
52-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
53-
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
54-
Loading content ..."
55-
`)
56-
})
34+
test('renders the Shopify loading indicator', async () => {
35+
const {lastFrame, unmount} = renderWithTTY(<LoadingBar title="Loading content" />)
36+
const frame = lastFrame()!
5737

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

61-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
62-
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
63-
Processing files ..."
64-
`)
43+
unmount()
6544
})
6645

67-
test('renders loading bar with hill pattern when shouldDisplayColors returns false', async () => {
68-
vi.mocked(shouldDisplayColors).mockReturnValue(false)
69-
const {lastFrame} = renderWithTTY(<LoadingBar title="Downloading packages" />)
70-
71-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
72-
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
73-
Downloading packages ..."
74-
`)
75-
})
46+
test('blinks the chevron without shifting the title', async () => {
47+
vi.useFakeTimers()
48+
const {lastFrame, unmount} = renderWithTTY(<LoadingBar title="Uploading theme" />)
7649

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

81-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
82-
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
83-
Building app ..."
84-
`)
55+
expect(unstyled(lastFrame()!)).toBe('S Uploading theme ...')
56+
} finally {
57+
unmount()
58+
vi.useRealTimers()
59+
}
8560
})
8661

87-
test('handles narrow terminal width correctly in no-color mode', async () => {
88-
vi.mocked(useLayout).mockReturnValue({twoThirds: 15, oneThird: 8, fullWidth: 23})
89-
const {lastFrame} = renderWithTTY(<LoadingBar title="Installing" noColor />)
90-
91-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
92-
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇
93-
Installing ..."
94-
`)
95-
})
62+
test('renders the chevron without color when noColor is true', async () => {
63+
const {lastFrame, unmount} = renderWithTTY(<LoadingBar title="Processing files" noColor />)
64+
const frame = lastFrame()!
9665

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

101-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
102-
"▁▁▁▂▂
103-
Wait ..."
104-
`)
69+
unmount()
10570
})
10671

107-
test('handles wide terminal width correctly', async () => {
108-
vi.mocked(useLayout).mockReturnValue({twoThirds: 100, oneThird: 50, fullWidth: 150})
109-
const {lastFrame} = renderWithTTY(<LoadingBar title="Synchronizing data" />)
110-
111-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
112-
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
113-
Synchronizing data ..."
114-
`)
115-
})
72+
test('renders the chevron without color when colors are disabled', async () => {
73+
vi.mocked(shouldDisplayColors).mockReturnValue(false)
74+
const {lastFrame, unmount} = renderWithTTY(<LoadingBar title="Downloading packages" />)
75+
const frame = lastFrame()!
11676

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

121-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
122-
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁
123-
Analyzing dependencies ..."
124-
`)
80+
unmount()
12581
})
12682

127-
test('renders correctly with empty title', async () => {
128-
const {lastFrame} = renderWithTTY(<LoadingBar title="" />)
129-
130-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
131-
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
132-
..."
133-
`)
134-
})
83+
test('renders correctly with an empty title', async () => {
84+
const {lastFrame, unmount} = renderWithTTY(<LoadingBar title="" />)
13585

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

140-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
141-
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
142-
Testing override ..."
143-
`)
88+
unmount()
14489
})
14590

146-
test('renders consistently with same props', async () => {
147-
const props = {title: 'Consistent test', noColor: false}
148-
const {lastFrame: frame1} = renderWithTTY(<LoadingBar {...props} />)
149-
const {lastFrame: frame2} = renderWithTTY(<LoadingBar {...props} />)
150-
151-
expect(frame1()).toBe(frame2())
152-
})
153-
154-
test('hides progress bar when noProgressBar is true', async () => {
155-
vi.mocked(shouldDisplayColors).mockReturnValue(true)
91+
test('hides the loading indicator when noProgressBar is true', async () => {
15692
const {lastFrame} = renderWithTTY(<LoadingBar title="task 1" noProgressBar />)
15793

158-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`"task 1 ..."`)
94+
expect(unstyled(lastFrame()!)).toBe('task 1 ...')
15995
})
16096

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

165-
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`"Installing dependencies ..."`)
166-
})
167-
168-
test('shows animated progress bar when output stream is a TTY', async () => {
169-
const {lastFrame} = renderWithTTY(<LoadingBar title="Uploading theme" />)
170-
171-
const frame = unstyled(lastFrame()!)
172-
expect(frame).toContain('▀')
173-
expect(frame).toContain('Uploading theme ...')
100+
expect(unstyled(lastFrame()!)).toBe('Installing dependencies ...')
174101
})
175102
})

0 commit comments

Comments
 (0)