@open-mercato/ui 0.6.6-develop.6474.1.2c411ad42b → 0.6.6-develop.6480.1.309f47ca1c
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.
|
@@ -8,7 +8,15 @@ import { cn } from "@open-mercato/shared/lib/utils";
|
|
|
8
8
|
import { useProgress } from "./useProgress.js";
|
|
9
9
|
import { useAutoHideCompletedJobs } from "./useAutoHideCompletedJobs.js";
|
|
10
10
|
import { apiCall } from "../utils/apiCall.js";
|
|
11
|
-
|
|
11
|
+
import { useBackendChrome } from "../BackendChromeProvider.js";
|
|
12
|
+
import { hasFeature } from "@open-mercato/shared/security/features";
|
|
13
|
+
function ProgressTopBar(props) {
|
|
14
|
+
const { payload } = useBackendChrome();
|
|
15
|
+
const canViewProgress = hasFeature(payload?.grantedFeatures, "progress.view");
|
|
16
|
+
if (!canViewProgress) return null;
|
|
17
|
+
return /* @__PURE__ */ jsx(ProgressTopBarContent, { ...props });
|
|
18
|
+
}
|
|
19
|
+
function ProgressTopBarContent({ className, t, completedAutoHideMs }) {
|
|
12
20
|
const { activeJobs, recentlyCompleted, refresh } = useProgress();
|
|
13
21
|
const visibleCompleted = useAutoHideCompletedJobs(recentlyCompleted, completedAutoHideMs);
|
|
14
22
|
const [expanded, setExpanded] = React.useState(false);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/progress/ProgressTopBar.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { ChevronDown, ChevronUp, Loader2, CheckCircle, XCircle, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { Progress } from '../../primitives/progress'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useProgress } from './useProgress'\nimport { useAutoHideCompletedJobs } from './useAutoHideCompletedJobs'\nimport type { ProgressJobDto } from './useProgressPoll'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCall } from '../utils/apiCall'\n\nexport type ProgressTopBarProps = {\n className?: string\n t: TranslateFn\n /**\n * How long (ms) to keep successfully completed jobs visible before auto-hiding.\n * Set to `false` or `0` to disable auto-hide. Defaults to 10 000 ms.\n * Failed and cancelled jobs are never auto-hidden.\n */\n completedAutoHideMs?: number | false\n}\n\nexport function ProgressTopBar({ className, t, completedAutoHideMs }: ProgressTopBarProps) {\n const { activeJobs, recentlyCompleted, refresh } = useProgress()\n const visibleCompleted = useAutoHideCompletedJobs(recentlyCompleted, completedAutoHideMs)\n const [expanded, setExpanded] = React.useState(false)\n\n // `om:progress:expanded` is a trivial scalar flag ('true' | 'false') with no\n // schema to evolve, so it is intentionally kept raw rather than wrapped in a\n // versioned envelope (the versioning threshold for structured persisted state\n // lives in `@open-mercato/shared/lib/browser/versionedPreference`).\n React.useEffect(() => {\n const saved = localStorage.getItem('om:progress:expanded')\n if (saved === 'true') setExpanded(true)\n }, [])\n\n React.useEffect(() => {\n localStorage.setItem('om:progress:expanded', String(expanded))\n }, [expanded])\n\n const hasActiveJobs = activeJobs.length > 0\n const hasRecentJobs = visibleCompleted.length > 0\n\n if (!hasActiveJobs && !hasRecentJobs) return null\n\n return (\n <div className={cn('border-b bg-background', className)}>\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={() => setExpanded(!expanded)}\n className=\"h-auto w-full justify-between rounded-none bg-background px-4 py-2 hover:bg-muted\"\n >\n <div className=\"flex items-center gap-2 text-sm\">\n {hasActiveJobs ? (\n <>\n <Loader2 className=\"h-4 w-4 animate-spin text-primary\" />\n <span>\n {t('progress.activeCount', '{count} operations running', { count: activeJobs.length })}\n </span>\n {activeJobs[0] && (\n <span className=\"text-muted-foreground\">\n \u2014 {activeJobs[0].name}{' '}\n {activeJobs[0].totalCount && activeJobs[0].totalCount > 0\n ? `(${activeJobs[0].progressPercent}%)`\n : `(${activeJobs[0].processedCount.toLocaleString()} ${t('progress.processed', 'processed')})`}\n </span>\n )}\n </>\n ) : (\n <>\n <CheckCircle className=\"h-4 w-4 text-status-success-icon\" />\n <span className=\"text-muted-foreground\">\n {t('progress.recentlyCompleted', '{count} operations completed', { count: visibleCompleted.length })}\n </span>\n </>\n )}\n </div>\n {expanded ? (\n <ChevronUp className=\"h-4 w-4\" />\n ) : (\n <ChevronDown className=\"h-4 w-4\" />\n )}\n </Button>\n\n {expanded && (\n <div className=\"space-y-2 bg-background px-4 pb-3\">\n {activeJobs.map((job) => (\n <ProgressJobCard key={job.id} job={job} t={t} onCancel={refresh} />\n ))}\n {visibleCompleted.map((job) => (\n <ProgressJobCard key={job.id} job={job} t={t} />\n ))}\n </div>\n )}\n </div>\n )\n}\n\nfunction ProgressJobCard({ job, t, onCancel }: { job: ProgressJobDto; t: TranslateFn; onCancel?: () => void }) {\n const [cancelling, setCancelling] = React.useState(false)\n\n const handleCancel = async () => {\n if (!job.cancellable || cancelling) return\n setCancelling(true)\n try {\n await apiCall(`/api/progress/jobs/${job.id}`, { method: 'DELETE' })\n onCancel?.()\n } finally {\n setCancelling(false)\n }\n }\n\n const isActive = job.status === 'pending' || job.status === 'running'\n const isFailed = job.status === 'failed'\n const isCompleted = job.status === 'completed'\n\n return (\n <div className={cn(\n 'rounded-md border bg-card p-3',\n isFailed && 'border-destructive/50 bg-destructive/5',\n isCompleted && 'border-status-success-border bg-status-success-bg',\n )}>\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2\">\n {isActive && <Loader2 className=\"h-4 w-4 animate-spin text-primary flex-shrink-0\" />}\n {isCompleted && <CheckCircle className=\"h-4 w-4 text-status-success-icon flex-shrink-0\" />}\n {isFailed && <XCircle className=\"h-4 w-4 text-destructive flex-shrink-0\" />}\n <span className=\"font-medium truncate\">{job.name}</span>\n </div>\n\n {job.description && (\n <p className=\"text-sm text-muted-foreground mt-1 truncate\">{job.description}</p>\n )}\n\n {isFailed && job.errorMessage && (\n <p className=\"text-sm text-destructive mt-1\">{job.errorMessage}</p>\n )}\n </div>\n\n {isActive && job.cancellable && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={handleCancel}\n disabled={cancelling}\n className=\"flex-shrink-0\"\n aria-label={t('progress.actions.cancel', 'Cancel')}\n >\n <X className=\"h-4 w-4\" />\n </Button>\n )}\n </div>\n\n {isActive && (\n <div className=\"mt-2 space-y-1\">\n {job.totalCount && job.totalCount > 0 ? (\n <Progress value={job.progressPercent} className=\"h-2\" />\n ) : (\n <IndeterminateProgressBar className=\"h-2\" />\n )}\n <div className=\"flex justify-between text-xs text-muted-foreground\">\n <span>\n {job.totalCount\n ? `${job.processedCount.toLocaleString()} / ${job.totalCount.toLocaleString()}`\n : `${job.processedCount.toLocaleString()} ${t('progress.processed', 'processed')}`\n }\n </span>\n {job.etaSeconds != null && job.etaSeconds > 0 && (\n <span>{formatEta(job.etaSeconds, t)}</span>\n )}\n </div>\n </div>\n )}\n </div>\n )\n}\n\nfunction IndeterminateProgressBar({ className }: { className?: string }) {\n return (\n <div className={cn('relative w-full overflow-hidden rounded-full bg-secondary', className)}>\n <div className=\"absolute inset-y-0 left-0 w-1/2 animate-pulse rounded-full bg-primary/80\" />\n <div className=\"absolute inset-y-0 right-0 w-1/3 rounded-full bg-primary/10\" />\n </div>\n )\n}\n\nfunction formatEta(seconds: number, t: TranslateFn): string {\n if (seconds < 60) {\n return t('progress.eta.seconds', '{count}s remaining', { count: seconds })\n }\n if (seconds < 3600) {\n const minutes = Math.ceil(seconds / 60)\n return t('progress.eta.minutes', '{count}m remaining', { count: minutes })\n }\n const hours = Math.floor(seconds / 3600)\n const mins = Math.ceil((seconds % 3600) / 60)\n return t('progress.eta.hoursMinutes', '{hours}h {minutes}m remaining', { hours, minutes: mins })\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { ChevronDown, ChevronUp, Loader2, CheckCircle, XCircle, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { Progress } from '../../primitives/progress'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useProgress } from './useProgress'\nimport { useAutoHideCompletedJobs } from './useAutoHideCompletedJobs'\nimport type { ProgressJobDto } from './useProgressPoll'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCall } from '../utils/apiCall'\nimport { useBackendChrome } from '../BackendChromeProvider'\nimport { hasFeature } from '@open-mercato/shared/security/features'\n\nexport type ProgressTopBarProps = {\n className?: string\n t: TranslateFn\n /**\n * How long (ms) to keep successfully completed jobs visible before auto-hiding.\n * Set to `false` or `0` to disable auto-hide. Defaults to 10 000 ms.\n * Failed and cancelled jobs are never auto-hidden.\n */\n completedAutoHideMs?: number | false\n}\n\nexport function ProgressTopBar(props: ProgressTopBarProps) {\n const { payload } = useBackendChrome()\n // The progress read routes (`/api/progress/active`, `/api/progress/jobs`) are\n // gated on `progress.view`. Users without the feature would only get 403s, so\n // skip mounting the polling/SSE hooks entirely instead of firing doomed reads.\n const canViewProgress = hasFeature(payload?.grantedFeatures, 'progress.view')\n if (!canViewProgress) return null\n return <ProgressTopBarContent {...props} />\n}\n\nfunction ProgressTopBarContent({ className, t, completedAutoHideMs }: ProgressTopBarProps) {\n const { activeJobs, recentlyCompleted, refresh } = useProgress()\n const visibleCompleted = useAutoHideCompletedJobs(recentlyCompleted, completedAutoHideMs)\n const [expanded, setExpanded] = React.useState(false)\n\n // `om:progress:expanded` is a trivial scalar flag ('true' | 'false') with no\n // schema to evolve, so it is intentionally kept raw rather than wrapped in a\n // versioned envelope (the versioning threshold for structured persisted state\n // lives in `@open-mercato/shared/lib/browser/versionedPreference`).\n React.useEffect(() => {\n const saved = localStorage.getItem('om:progress:expanded')\n if (saved === 'true') setExpanded(true)\n }, [])\n\n React.useEffect(() => {\n localStorage.setItem('om:progress:expanded', String(expanded))\n }, [expanded])\n\n const hasActiveJobs = activeJobs.length > 0\n const hasRecentJobs = visibleCompleted.length > 0\n\n if (!hasActiveJobs && !hasRecentJobs) return null\n\n return (\n <div className={cn('border-b bg-background', className)}>\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={() => setExpanded(!expanded)}\n className=\"h-auto w-full justify-between rounded-none bg-background px-4 py-2 hover:bg-muted\"\n >\n <div className=\"flex items-center gap-2 text-sm\">\n {hasActiveJobs ? (\n <>\n <Loader2 className=\"h-4 w-4 animate-spin text-primary\" />\n <span>\n {t('progress.activeCount', '{count} operations running', { count: activeJobs.length })}\n </span>\n {activeJobs[0] && (\n <span className=\"text-muted-foreground\">\n \u2014 {activeJobs[0].name}{' '}\n {activeJobs[0].totalCount && activeJobs[0].totalCount > 0\n ? `(${activeJobs[0].progressPercent}%)`\n : `(${activeJobs[0].processedCount.toLocaleString()} ${t('progress.processed', 'processed')})`}\n </span>\n )}\n </>\n ) : (\n <>\n <CheckCircle className=\"h-4 w-4 text-status-success-icon\" />\n <span className=\"text-muted-foreground\">\n {t('progress.recentlyCompleted', '{count} operations completed', { count: visibleCompleted.length })}\n </span>\n </>\n )}\n </div>\n {expanded ? (\n <ChevronUp className=\"h-4 w-4\" />\n ) : (\n <ChevronDown className=\"h-4 w-4\" />\n )}\n </Button>\n\n {expanded && (\n <div className=\"space-y-2 bg-background px-4 pb-3\">\n {activeJobs.map((job) => (\n <ProgressJobCard key={job.id} job={job} t={t} onCancel={refresh} />\n ))}\n {visibleCompleted.map((job) => (\n <ProgressJobCard key={job.id} job={job} t={t} />\n ))}\n </div>\n )}\n </div>\n )\n}\n\nfunction ProgressJobCard({ job, t, onCancel }: { job: ProgressJobDto; t: TranslateFn; onCancel?: () => void }) {\n const [cancelling, setCancelling] = React.useState(false)\n\n const handleCancel = async () => {\n if (!job.cancellable || cancelling) return\n setCancelling(true)\n try {\n await apiCall(`/api/progress/jobs/${job.id}`, { method: 'DELETE' })\n onCancel?.()\n } finally {\n setCancelling(false)\n }\n }\n\n const isActive = job.status === 'pending' || job.status === 'running'\n const isFailed = job.status === 'failed'\n const isCompleted = job.status === 'completed'\n\n return (\n <div className={cn(\n 'rounded-md border bg-card p-3',\n isFailed && 'border-destructive/50 bg-destructive/5',\n isCompleted && 'border-status-success-border bg-status-success-bg',\n )}>\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2\">\n {isActive && <Loader2 className=\"h-4 w-4 animate-spin text-primary flex-shrink-0\" />}\n {isCompleted && <CheckCircle className=\"h-4 w-4 text-status-success-icon flex-shrink-0\" />}\n {isFailed && <XCircle className=\"h-4 w-4 text-destructive flex-shrink-0\" />}\n <span className=\"font-medium truncate\">{job.name}</span>\n </div>\n\n {job.description && (\n <p className=\"text-sm text-muted-foreground mt-1 truncate\">{job.description}</p>\n )}\n\n {isFailed && job.errorMessage && (\n <p className=\"text-sm text-destructive mt-1\">{job.errorMessage}</p>\n )}\n </div>\n\n {isActive && job.cancellable && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={handleCancel}\n disabled={cancelling}\n className=\"flex-shrink-0\"\n aria-label={t('progress.actions.cancel', 'Cancel')}\n >\n <X className=\"h-4 w-4\" />\n </Button>\n )}\n </div>\n\n {isActive && (\n <div className=\"mt-2 space-y-1\">\n {job.totalCount && job.totalCount > 0 ? (\n <Progress value={job.progressPercent} className=\"h-2\" />\n ) : (\n <IndeterminateProgressBar className=\"h-2\" />\n )}\n <div className=\"flex justify-between text-xs text-muted-foreground\">\n <span>\n {job.totalCount\n ? `${job.processedCount.toLocaleString()} / ${job.totalCount.toLocaleString()}`\n : `${job.processedCount.toLocaleString()} ${t('progress.processed', 'processed')}`\n }\n </span>\n {job.etaSeconds != null && job.etaSeconds > 0 && (\n <span>{formatEta(job.etaSeconds, t)}</span>\n )}\n </div>\n </div>\n )}\n </div>\n )\n}\n\nfunction IndeterminateProgressBar({ className }: { className?: string }) {\n return (\n <div className={cn('relative w-full overflow-hidden rounded-full bg-secondary', className)}>\n <div className=\"absolute inset-y-0 left-0 w-1/2 animate-pulse rounded-full bg-primary/80\" />\n <div className=\"absolute inset-y-0 right-0 w-1/3 rounded-full bg-primary/10\" />\n </div>\n )\n}\n\nfunction formatEta(seconds: number, t: TranslateFn): string {\n if (seconds < 60) {\n return t('progress.eta.seconds', '{count}s remaining', { count: seconds })\n }\n if (seconds < 3600) {\n const minutes = Math.ceil(seconds / 60)\n return t('progress.eta.minutes', '{count}m remaining', { count: minutes })\n }\n const hours = Math.floor(seconds / 3600)\n const mins = Math.ceil((seconds % 3600) / 60)\n return t('progress.eta.hoursMinutes', '{hours}h {minutes}m remaining', { hours, minutes: mins })\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6480.1.309f47ca1c",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,13 +155,13 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.6-develop.6480.1.309f47ca1c",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
164
|
+
"@open-mercato/shared": "0.6.6-develop.6480.1.309f47ca1c",
|
|
165
165
|
"@testing-library/dom": "^10.4.1",
|
|
166
166
|
"@testing-library/jest-dom": "^6.9.1",
|
|
167
167
|
"@testing-library/react": "^16.3.1",
|
|
@@ -9,6 +9,8 @@ import { useAutoHideCompletedJobs } from './useAutoHideCompletedJobs'
|
|
|
9
9
|
import type { ProgressJobDto } from './useProgressPoll'
|
|
10
10
|
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
11
11
|
import { apiCall } from '../utils/apiCall'
|
|
12
|
+
import { useBackendChrome } from '../BackendChromeProvider'
|
|
13
|
+
import { hasFeature } from '@open-mercato/shared/security/features'
|
|
12
14
|
|
|
13
15
|
export type ProgressTopBarProps = {
|
|
14
16
|
className?: string
|
|
@@ -21,7 +23,17 @@ export type ProgressTopBarProps = {
|
|
|
21
23
|
completedAutoHideMs?: number | false
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
export function ProgressTopBar(
|
|
26
|
+
export function ProgressTopBar(props: ProgressTopBarProps) {
|
|
27
|
+
const { payload } = useBackendChrome()
|
|
28
|
+
// The progress read routes (`/api/progress/active`, `/api/progress/jobs`) are
|
|
29
|
+
// gated on `progress.view`. Users without the feature would only get 403s, so
|
|
30
|
+
// skip mounting the polling/SSE hooks entirely instead of firing doomed reads.
|
|
31
|
+
const canViewProgress = hasFeature(payload?.grantedFeatures, 'progress.view')
|
|
32
|
+
if (!canViewProgress) return null
|
|
33
|
+
return <ProgressTopBarContent {...props} />
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ProgressTopBarContent({ className, t, completedAutoHideMs }: ProgressTopBarProps) {
|
|
25
37
|
const { activeJobs, recentlyCompleted, refresh } = useProgress()
|
|
26
38
|
const visibleCompleted = useAutoHideCompletedJobs(recentlyCompleted, completedAutoHideMs)
|
|
27
39
|
const [expanded, setExpanded] = React.useState(false)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { render } from '@testing-library/react'
|
|
5
|
+
|
|
6
|
+
import type { ProgressJobDto, UseProgressPollResult } from '../useProgressPoll'
|
|
7
|
+
|
|
8
|
+
const mockUseProgress = jest.fn<UseProgressPollResult, []>()
|
|
9
|
+
const mockUseBackendChrome = jest.fn<{ payload: { grantedFeatures: string[] } | null }, []>()
|
|
10
|
+
|
|
11
|
+
jest.mock('../useProgress', () => ({
|
|
12
|
+
useProgress: () => mockUseProgress(),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
jest.mock('../../BackendChromeProvider', () => ({
|
|
16
|
+
useBackendChrome: () => mockUseBackendChrome(),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
// Real auto-hide hook keeps completed jobs visible for this test.
|
|
20
|
+
jest.mock('../useAutoHideCompletedJobs', () => ({
|
|
21
|
+
useAutoHideCompletedJobs: (jobs: ProgressJobDto[]) => jobs,
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
import { ProgressTopBar } from '../ProgressTopBar'
|
|
25
|
+
|
|
26
|
+
const activeJob: ProgressJobDto = {
|
|
27
|
+
id: 'job-1',
|
|
28
|
+
name: 'Bulk update',
|
|
29
|
+
status: 'running',
|
|
30
|
+
processedCount: 3,
|
|
31
|
+
totalCount: 10,
|
|
32
|
+
progressPercent: 30,
|
|
33
|
+
} as unknown as ProgressJobDto
|
|
34
|
+
|
|
35
|
+
const translate = ((key: string, fallback?: string) => fallback ?? key) as never
|
|
36
|
+
|
|
37
|
+
function progressResult(overrides: Partial<UseProgressPollResult> = {}): UseProgressPollResult {
|
|
38
|
+
return {
|
|
39
|
+
activeJobs: [activeJob],
|
|
40
|
+
recentlyCompleted: [],
|
|
41
|
+
isLoading: false,
|
|
42
|
+
error: null,
|
|
43
|
+
refresh: () => {},
|
|
44
|
+
...overrides,
|
|
45
|
+
} as UseProgressPollResult
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('ProgressTopBar progress.view gating', () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
mockUseProgress.mockReset()
|
|
51
|
+
mockUseBackendChrome.mockReset()
|
|
52
|
+
mockUseProgress.mockReturnValue(progressResult())
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders nothing and never subscribes to progress reads when progress.view is not granted', () => {
|
|
56
|
+
mockUseBackendChrome.mockReturnValue({ payload: { grantedFeatures: ['configs.manage'] } })
|
|
57
|
+
const { container } = render(<ProgressTopBar t={translate} />)
|
|
58
|
+
expect(container.firstChild).toBeNull()
|
|
59
|
+
// useProgress fires `/api/progress/active`; it must not run without the feature.
|
|
60
|
+
expect(mockUseProgress).not.toHaveBeenCalled()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('renders nothing when the chrome payload has no granted features yet', () => {
|
|
64
|
+
mockUseBackendChrome.mockReturnValue({ payload: null })
|
|
65
|
+
const { container } = render(<ProgressTopBar t={translate} />)
|
|
66
|
+
expect(container.firstChild).toBeNull()
|
|
67
|
+
expect(mockUseProgress).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('renders the bar and subscribes to progress reads when progress.view is granted', () => {
|
|
71
|
+
mockUseBackendChrome.mockReturnValue({ payload: { grantedFeatures: ['progress.view'] } })
|
|
72
|
+
const { container } = render(<ProgressTopBar t={translate} />)
|
|
73
|
+
expect(mockUseProgress).toHaveBeenCalled()
|
|
74
|
+
expect(container.textContent).toContain('Bulk update')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('honors wildcard grants such as progress.* for the read gate', () => {
|
|
78
|
+
mockUseBackendChrome.mockReturnValue({ payload: { grantedFeatures: ['progress.*'] } })
|
|
79
|
+
const { container } = render(<ProgressTopBar t={translate} />)
|
|
80
|
+
expect(mockUseProgress).toHaveBeenCalled()
|
|
81
|
+
expect(container.textContent).toContain('Bulk update')
|
|
82
|
+
})
|
|
83
|
+
})
|