@furystack/shades-common-components 12.0.1 → 12.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +26 -0
- package/esm/components/avatar.d.ts.map +1 -1
- package/esm/components/avatar.js +3 -1
- package/esm/components/avatar.js.map +1 -1
- package/esm/components/avatar.spec.js +4 -4
- package/esm/components/avatar.spec.js.map +1 -1
- package/esm/components/cache-view.d.ts +46 -0
- package/esm/components/cache-view.d.ts.map +1 -0
- package/esm/components/cache-view.js +65 -0
- package/esm/components/cache-view.js.map +1 -0
- package/esm/components/cache-view.spec.d.ts +2 -0
- package/esm/components/cache-view.spec.d.ts.map +1 -0
- package/esm/components/cache-view.spec.js +183 -0
- package/esm/components/cache-view.spec.js.map +1 -0
- package/esm/components/icons/icon-definitions.d.ts +82 -0
- package/esm/components/icons/icon-definitions.d.ts.map +1 -1
- package/esm/components/icons/icon-definitions.js +717 -0
- package/esm/components/icons/icon-definitions.js.map +1 -1
- package/esm/components/icons/icon-definitions.spec.js +22 -2
- package/esm/components/icons/icon-definitions.spec.js.map +1 -1
- package/esm/components/icons/icon-types.d.ts +10 -0
- package/esm/components/icons/icon-types.d.ts.map +1 -1
- package/esm/components/icons/index.d.ts +1 -1
- package/esm/components/icons/index.d.ts.map +1 -1
- package/esm/components/index.d.ts +1 -0
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +1 -0
- package/esm/components/index.js.map +1 -1
- package/esm/components/page-container/index.d.ts +1 -1
- package/esm/components/page-container/index.js +1 -1
- package/esm/components/page-container/page-header.d.ts +5 -5
- package/esm/components/page-container/page-header.d.ts.map +1 -1
- package/esm/components/page-container/page-header.js +3 -3
- package/esm/components/skeleton.d.ts.map +1 -1
- package/esm/components/skeleton.js +2 -11
- package/esm/components/skeleton.js.map +1 -1
- package/esm/components/skeleton.spec.js +6 -55
- package/esm/components/skeleton.spec.js.map +1 -1
- package/esm/components/suggest/index.d.ts +1 -1
- package/esm/components/suggest/index.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/components/avatar.spec.tsx +4 -4
- package/src/components/avatar.tsx +3 -1
- package/src/components/cache-view.spec.tsx +210 -0
- package/src/components/cache-view.tsx +103 -0
- package/src/components/icons/icon-definitions.spec.ts +28 -2
- package/src/components/icons/icon-definitions.ts +759 -0
- package/src/components/icons/icon-types.ts +12 -0
- package/src/components/icons/index.ts +1 -1
- package/src/components/index.ts +1 -0
- package/src/components/page-container/index.tsx +1 -1
- package/src/components/page-container/page-header.tsx +5 -5
- package/src/components/skeleton.spec.tsx +6 -73
- package/src/components/skeleton.tsx +2 -11
- package/src/components/suggest/index.tsx +1 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Cache } from '@furystack/cache'
|
|
2
|
+
import type { CacheResult, CacheWithValue } from '@furystack/cache'
|
|
3
|
+
import { Shade, createComponent, flushUpdates } from '@furystack/shades'
|
|
4
|
+
import { sleepAsync } from '@furystack/utils'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { CacheView } from './cache-view.js'
|
|
7
|
+
|
|
8
|
+
const TestContent = Shade<{ data: CacheWithValue<string> }>({
|
|
9
|
+
shadowDomName: 'test-cache-content',
|
|
10
|
+
render: ({ props }) => <span className="content-value">{props.data.value}</span>,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const renderCacheView = async (
|
|
14
|
+
cache: Cache<string, [string]>,
|
|
15
|
+
args: [string],
|
|
16
|
+
options?: {
|
|
17
|
+
loader?: JSX.Element
|
|
18
|
+
error?: (error: unknown, retry: () => void) => JSX.Element
|
|
19
|
+
},
|
|
20
|
+
) => {
|
|
21
|
+
const el = (
|
|
22
|
+
<div>
|
|
23
|
+
<CacheView cache={cache} args={args} content={TestContent} loader={options?.loader} error={options?.error} />
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
const cacheView = el.firstElementChild as JSX.Element
|
|
27
|
+
cacheView.updateComponent()
|
|
28
|
+
await flushUpdates()
|
|
29
|
+
return { container: el, cacheView }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('CacheView', () => {
|
|
33
|
+
it('should be defined', () => {
|
|
34
|
+
expect(CacheView).toBeDefined()
|
|
35
|
+
expect(typeof CacheView).toBe('function')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should create a cache-view element', () => {
|
|
39
|
+
const cache = new Cache<string, [string]>({ load: async (key) => key })
|
|
40
|
+
const el = (<CacheView cache={cache} args={['test']} content={TestContent} />) as unknown as HTMLElement
|
|
41
|
+
expect(el).toBeDefined()
|
|
42
|
+
expect(el.tagName?.toLowerCase()).toBe('shade-cache-view')
|
|
43
|
+
cache[Symbol.dispose]()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('loading state', () => {
|
|
47
|
+
it('should render null by default when loading', async () => {
|
|
48
|
+
const cache = new Cache<string, [string]>({
|
|
49
|
+
load: () => new Promise(() => {}),
|
|
50
|
+
})
|
|
51
|
+
const { cacheView } = await renderCacheView(cache, ['test'])
|
|
52
|
+
expect(cacheView.querySelector('test-cache-content')).toBeNull()
|
|
53
|
+
expect(cacheView.querySelector('shade-result')).toBeNull()
|
|
54
|
+
cache[Symbol.dispose]()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should render custom loader when provided', async () => {
|
|
58
|
+
const cache = new Cache<string, [string]>({
|
|
59
|
+
load: () => new Promise(() => {}),
|
|
60
|
+
})
|
|
61
|
+
const { cacheView } = await renderCacheView(cache, ['test'], {
|
|
62
|
+
loader: (<span className="custom-loader">Loading...</span>) as unknown as JSX.Element,
|
|
63
|
+
})
|
|
64
|
+
const loader = cacheView.querySelector('.custom-loader')
|
|
65
|
+
expect(loader).not.toBeNull()
|
|
66
|
+
expect(loader?.textContent).toBe('Loading...')
|
|
67
|
+
cache[Symbol.dispose]()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('loaded state', () => {
|
|
72
|
+
it('should render content when cache has loaded value', async () => {
|
|
73
|
+
const cache = new Cache<string, [string]>({ load: async (key) => `Hello ${key}` })
|
|
74
|
+
await cache.get('world')
|
|
75
|
+
const { cacheView } = await renderCacheView(cache, ['world'])
|
|
76
|
+
const contentEl = cacheView.querySelector('test-cache-content')
|
|
77
|
+
expect(contentEl).not.toBeNull()
|
|
78
|
+
const contentComponent = contentEl as JSX.Element
|
|
79
|
+
contentComponent.updateComponent()
|
|
80
|
+
await flushUpdates()
|
|
81
|
+
const valueEl = contentComponent.querySelector('.content-value')
|
|
82
|
+
expect(valueEl?.textContent).toBe('Hello world')
|
|
83
|
+
cache[Symbol.dispose]()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('failed state', () => {
|
|
88
|
+
it('should render default error UI when cache has failed', async () => {
|
|
89
|
+
const cache = new Cache<string, [string]>({
|
|
90
|
+
load: async () => {
|
|
91
|
+
throw new Error('Test failure')
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
try {
|
|
95
|
+
await cache.get('test')
|
|
96
|
+
} catch {
|
|
97
|
+
// expected
|
|
98
|
+
}
|
|
99
|
+
const { cacheView } = await renderCacheView(cache, ['test'])
|
|
100
|
+
const resultEl = cacheView.querySelector('shade-result')
|
|
101
|
+
expect(resultEl).not.toBeNull()
|
|
102
|
+
const resultComponent = resultEl as JSX.Element
|
|
103
|
+
resultComponent.updateComponent()
|
|
104
|
+
await flushUpdates()
|
|
105
|
+
expect(resultComponent.querySelector('.result-title')?.textContent).toBe('Something went wrong')
|
|
106
|
+
cache[Symbol.dispose]()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should render custom error UI when error prop is provided', async () => {
|
|
110
|
+
const cache = new Cache<string, [string]>({
|
|
111
|
+
load: async () => {
|
|
112
|
+
throw new Error('Custom failure')
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
try {
|
|
116
|
+
await cache.get('test')
|
|
117
|
+
} catch {
|
|
118
|
+
// expected
|
|
119
|
+
}
|
|
120
|
+
const customError = vi.fn(
|
|
121
|
+
(err: unknown, _retry: () => void) =>
|
|
122
|
+
(<span className="custom-error">{String(err)}</span>) as unknown as JSX.Element,
|
|
123
|
+
)
|
|
124
|
+
const { cacheView } = await renderCacheView(cache, ['test'], { error: customError })
|
|
125
|
+
expect(customError).toHaveBeenCalledOnce()
|
|
126
|
+
expect(customError.mock.calls[0][0]).toBeInstanceOf(Error)
|
|
127
|
+
const customErrorEl = cacheView.querySelector('.custom-error')
|
|
128
|
+
expect(customErrorEl).not.toBeNull()
|
|
129
|
+
cache[Symbol.dispose]()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should not render content when failed even if stale value exists', async () => {
|
|
133
|
+
const cache = new Cache<string, [string]>({ load: async (key) => key })
|
|
134
|
+
await cache.get('test')
|
|
135
|
+
cache.setExplicitValue({
|
|
136
|
+
loadArgs: ['test'],
|
|
137
|
+
value: { status: 'failed', error: new Error('fail'), value: 'stale', updatedAt: new Date() },
|
|
138
|
+
})
|
|
139
|
+
const { cacheView } = await renderCacheView(cache, ['test'])
|
|
140
|
+
expect(cacheView.querySelector('test-cache-content')).toBeNull()
|
|
141
|
+
expect(cacheView.querySelector('shade-result')).not.toBeNull()
|
|
142
|
+
cache[Symbol.dispose]()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should call cache.reload when retry is invoked', async () => {
|
|
146
|
+
const loadFn = vi.fn<(key: string) => Promise<string>>(async () => {
|
|
147
|
+
throw new Error('fail')
|
|
148
|
+
})
|
|
149
|
+
const cache = new Cache<string, [string]>({ load: loadFn })
|
|
150
|
+
try {
|
|
151
|
+
await cache.get('test')
|
|
152
|
+
} catch {
|
|
153
|
+
// expected
|
|
154
|
+
}
|
|
155
|
+
let capturedRetry: (() => void) | undefined
|
|
156
|
+
const customError = (_err: unknown, retry: () => void) => {
|
|
157
|
+
capturedRetry = retry
|
|
158
|
+
return (<span className="custom-error">Error</span>) as unknown as JSX.Element
|
|
159
|
+
}
|
|
160
|
+
await renderCacheView(cache, ['test'], { error: customError })
|
|
161
|
+
expect(capturedRetry).toBeDefined()
|
|
162
|
+
|
|
163
|
+
loadFn.mockResolvedValueOnce('recovered')
|
|
164
|
+
capturedRetry!()
|
|
165
|
+
await sleepAsync(50)
|
|
166
|
+
|
|
167
|
+
const observable = cache.getObservable('test')
|
|
168
|
+
const result = observable.getValue()
|
|
169
|
+
expect(result.status).toBe('loaded')
|
|
170
|
+
expect(result.value).toBe('recovered')
|
|
171
|
+
cache[Symbol.dispose]()
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('obsolete state', () => {
|
|
176
|
+
it('should render content when obsolete and trigger reload', async () => {
|
|
177
|
+
const loadFn = vi.fn(async (key: string) => `Hello ${key}`)
|
|
178
|
+
const cache = new Cache<string, [string]>({ load: loadFn })
|
|
179
|
+
await cache.get('test')
|
|
180
|
+
cache.setObsolete('test')
|
|
181
|
+
|
|
182
|
+
const { cacheView } = await renderCacheView(cache, ['test'])
|
|
183
|
+
const contentEl = cacheView.querySelector('test-cache-content')
|
|
184
|
+
expect(contentEl).not.toBeNull()
|
|
185
|
+
|
|
186
|
+
await sleepAsync(50)
|
|
187
|
+
// reload should have been called (initial load + obsolete reload)
|
|
188
|
+
expect(loadFn).toHaveBeenCalledTimes(2)
|
|
189
|
+
cache[Symbol.dispose]()
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('error takes priority over value', () => {
|
|
194
|
+
it('should show error when failed with value, not content', async () => {
|
|
195
|
+
const cache = new Cache<string, [string]>({ load: async (key) => key })
|
|
196
|
+
await cache.get('test')
|
|
197
|
+
const failedWithValue: CacheResult<string> = {
|
|
198
|
+
status: 'failed',
|
|
199
|
+
error: new Error('whoops'),
|
|
200
|
+
value: 'stale-data',
|
|
201
|
+
updatedAt: new Date(),
|
|
202
|
+
}
|
|
203
|
+
cache.setExplicitValue({ loadArgs: ['test'], value: failedWithValue })
|
|
204
|
+
const { cacheView } = await renderCacheView(cache, ['test'])
|
|
205
|
+
expect(cacheView.querySelector('test-cache-content')).toBeNull()
|
|
206
|
+
expect(cacheView.querySelector('shade-result')).not.toBeNull()
|
|
207
|
+
cache[Symbol.dispose]()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Cache, CacheWithValue } from '@furystack/cache'
|
|
2
|
+
import { hasCacheValue, isFailedCacheResult, isObsoleteCacheResult } from '@furystack/cache'
|
|
3
|
+
import type { ShadeComponent } from '@furystack/shades'
|
|
4
|
+
import { Shade, createComponent } from '@furystack/shades'
|
|
5
|
+
|
|
6
|
+
import { Button } from './button.js'
|
|
7
|
+
import { Result } from './result.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Props for the CacheView component.
|
|
11
|
+
* @typeParam TData - The type of data stored in the cache
|
|
12
|
+
* @typeParam TArgs - The tuple type of arguments used to identify the cache entry
|
|
13
|
+
*/
|
|
14
|
+
export type CacheViewProps<TData, TArgs extends any[]> = {
|
|
15
|
+
/** The cache instance to observe and control */
|
|
16
|
+
cache: Cache<TData, TArgs>
|
|
17
|
+
/** The arguments identifying which cache entry to display */
|
|
18
|
+
args: TArgs
|
|
19
|
+
/** Shades component rendered when a value is available (loaded or obsolete). Receives CacheWithValue<TData>. */
|
|
20
|
+
content: ShadeComponent<{ data: CacheWithValue<TData> }>
|
|
21
|
+
/** Optional custom loader element. Default: null (nothing shown when loading). */
|
|
22
|
+
loader?: JSX.Element
|
|
23
|
+
/**
|
|
24
|
+
* Optional custom error UI. Receives the error and a retry callback.
|
|
25
|
+
* The retry callback calls cache.reload(...args).
|
|
26
|
+
* If not provided, a default Result + retry Button is shown.
|
|
27
|
+
*/
|
|
28
|
+
error?: (error: unknown, retry: () => void) => JSX.Element
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const getDefaultErrorUi = (error: unknown, retry: () => void): JSX.Element =>
|
|
32
|
+
(
|
|
33
|
+
<Result status="error" title="Something went wrong" subtitle={String(error)}>
|
|
34
|
+
<Button variant="outlined" onclick={retry}>
|
|
35
|
+
Retry
|
|
36
|
+
</Button>
|
|
37
|
+
</Result>
|
|
38
|
+
) as unknown as JSX.Element
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* CacheView renders the state of a cache entry for the given cache + args.
|
|
42
|
+
*
|
|
43
|
+
* It subscribes to the cache observable and handles all states:
|
|
44
|
+
* 1. **Error first** - If the cache entry has failed, shows the error UI with a retry button.
|
|
45
|
+
* 2. **Value next** - If the entry has a value (loaded or obsolete), renders the content component.
|
|
46
|
+
* When obsolete, it also triggers a reload automatically.
|
|
47
|
+
* 3. **Loading last** - If there is no value and no error, shows the loader (or null by default).
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```tsx
|
|
51
|
+
* const MyContent = Shade<{ data: CacheWithValue<User> }>({
|
|
52
|
+
* shadowDomName: 'my-content',
|
|
53
|
+
* render: ({ props }) => <div>{props.data.value.name}</div>,
|
|
54
|
+
* })
|
|
55
|
+
*
|
|
56
|
+
* <CacheView cache={userCache} args={[userId]} content={MyContent} />
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export const CacheView: <TData, TArgs extends any[]>(props: CacheViewProps<TData, TArgs>) => JSX.Element = Shade({
|
|
60
|
+
shadowDomName: 'shade-cache-view',
|
|
61
|
+
render: ({ props, useObservable, useState }): JSX.Element | null => {
|
|
62
|
+
const { cache, args, content, loader, error } = props
|
|
63
|
+
|
|
64
|
+
const argsKey = JSON.stringify(args)
|
|
65
|
+
const observable = cache.getObservable(...args)
|
|
66
|
+
|
|
67
|
+
const [result] = useObservable(`cache-${argsKey}`, observable)
|
|
68
|
+
|
|
69
|
+
const [lastReloadedArgsKey, setLastReloadedArgsKey] = useState<string | null>('lastReloadedArgsKey', null)
|
|
70
|
+
|
|
71
|
+
const retry = () => {
|
|
72
|
+
cache.reload(...args).catch(() => {
|
|
73
|
+
/* error state will be set by cache */
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 1. Error first
|
|
78
|
+
if (isFailedCacheResult(result)) {
|
|
79
|
+
const errorRenderer = error ?? getDefaultErrorUi
|
|
80
|
+
return errorRenderer(result.error, retry)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 2. Value next
|
|
84
|
+
if (hasCacheValue(result)) {
|
|
85
|
+
if (isObsoleteCacheResult(result)) {
|
|
86
|
+
if (lastReloadedArgsKey !== argsKey) {
|
|
87
|
+
setLastReloadedArgsKey(argsKey)
|
|
88
|
+
cache.reload(...args).catch(() => {
|
|
89
|
+
/* error state will be set by cache */
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
} else if (lastReloadedArgsKey !== null) {
|
|
93
|
+
setLastReloadedArgsKey(null)
|
|
94
|
+
}
|
|
95
|
+
return createComponent(content as ShadeComponent<{ data: CacheWithValue<unknown> }>, {
|
|
96
|
+
data: result,
|
|
97
|
+
}) as unknown as JSX.Element
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Loading last
|
|
101
|
+
return loader ?? null
|
|
102
|
+
},
|
|
103
|
+
})
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import * as allIcons from './icon-definitions.js'
|
|
3
|
+
import type { IconCategory } from './icon-types.js'
|
|
3
4
|
|
|
4
5
|
const iconEntries = Object.entries(allIcons)
|
|
5
6
|
|
|
7
|
+
const validCategories: IconCategory[] = ['Actions', 'Navigation', 'Status', 'Content', 'UI', 'Common']
|
|
8
|
+
|
|
6
9
|
describe('Icon definitions', () => {
|
|
7
|
-
it('should export at least
|
|
8
|
-
expect(iconEntries.length).toBeGreaterThanOrEqual(
|
|
10
|
+
it('should export at least 100 icons', () => {
|
|
11
|
+
expect(iconEntries.length).toBeGreaterThanOrEqual(100)
|
|
9
12
|
})
|
|
10
13
|
|
|
11
14
|
describe.each(iconEntries)('%s', (_name, icon) => {
|
|
@@ -46,6 +49,29 @@ describe('Icon definitions', () => {
|
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
})
|
|
52
|
+
|
|
53
|
+
it('should have a non-empty name', () => {
|
|
54
|
+
expect(typeof icon.name).toBe('string')
|
|
55
|
+
expect(icon.name!.length).toBeGreaterThan(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should have a non-empty description', () => {
|
|
59
|
+
expect(typeof icon.description).toBe('string')
|
|
60
|
+
expect(icon.description!.length).toBeGreaterThan(0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should have at least one keyword', () => {
|
|
64
|
+
expect(Array.isArray(icon.keywords)).toBe(true)
|
|
65
|
+
expect(icon.keywords!.length).toBeGreaterThanOrEqual(1)
|
|
66
|
+
for (const kw of icon.keywords!) {
|
|
67
|
+
expect(typeof kw).toBe('string')
|
|
68
|
+
expect(kw.length).toBeGreaterThan(0)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should have a valid category', () => {
|
|
73
|
+
expect(validCategories).toContain(icon.category)
|
|
74
|
+
})
|
|
49
75
|
})
|
|
50
76
|
|
|
51
77
|
it('should not have duplicate path data across different icons', () => {
|