@open-mercato/ui 0.6.4-develop.4121.1.0d7f20d229 → 0.6.4-develop.4133.1.48fc6c8f7b
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/.turbo/turbo-build.log +1 -1
- package/dist/backend/AppShell.js +2 -2
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +3 -2
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/dashboard/index.js +4 -1
- package/dist/backend/dashboard/index.js.map +2 -2
- package/dist/backend/dashboard/widgetData.js +59 -0
- package/dist/backend/dashboard/widgetData.js.map +7 -0
- package/dist/backend/dashboard/widgetDataBatcher.js +61 -0
- package/dist/backend/dashboard/widgetDataBatcher.js.map +7 -0
- package/dist/backend/progress/ProgressTopBar.js +6 -4
- package/dist/backend/progress/ProgressTopBar.js.map +2 -2
- package/dist/backend/progress/useAutoHideCompletedJobs.js +65 -0
- package/dist/backend/progress/useAutoHideCompletedJobs.js.map +7 -0
- package/dist/primitives/date-picker.js +1 -0
- package/dist/primitives/date-picker.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -2
- package/src/backend/__tests__/useAutoHideCompletedJobs.test.tsx +179 -0
- package/src/backend/dashboard/DashboardScreen.tsx +3 -0
- package/src/backend/dashboard/__tests__/widgetDataBatcher.test.ts +97 -0
- package/src/backend/dashboard/index.ts +1 -0
- package/src/backend/dashboard/widgetData.tsx +84 -0
- package/src/backend/dashboard/widgetDataBatcher.ts +100 -0
- package/src/backend/progress/ProgressTopBar.tsx +12 -4
- package/src/backend/progress/useAutoHideCompletedJobs.ts +100 -0
- package/src/primitives/__tests__/date-picker.test.tsx +12 -0
- package/src/primitives/date-picker.tsx +1 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/primitives/date-picker.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { Locale } from 'date-fns/locale'\nimport { CalendarIcon } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Popover, PopoverContent, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { Button } from './button'\nimport { LinkButton } from './link-button'\nimport { TimeInput } from '../backend/inputs/TimeInput'\nimport { formatWithPublicDateFormat, resolvePublicDateFormat, resolvePublicDateTimeFormat } from './date-format'\n\nexport type DatePickerFooter = 'apply-cancel' | 'today-clear' | 'none'\n\nexport type DatePickerProps = {\n value?: Date | null\n onChange: (value: Date | null) => void\n placeholder?: string\n size?: 'sm' | 'default'\n disabled?: boolean\n readOnly?: boolean\n /**\n * Footer mode in the popover.\n * - `'apply-cancel'` (default, Figma-aligned): selecting a day stages a draft;\n * Apply commits and closes; Cancel reverts and closes.\n * - `'today-clear'`: legacy behavior \u2014 Today commits today, Clear sets null.\n * - `'none'`: no footer; selecting a day commits immediately and closes.\n */\n footer?: DatePickerFooter\n /**\n * Whether selecting a day commits the value and closes the popover.\n * - In `footer='apply-cancel'`: ignored (commit happens via Apply).\n * - In `footer='today-clear'`: default `true` for backward compatibility.\n * - In `footer='none'`: always `true` (popover always closes after selection).\n */\n closeOnSelect?: boolean\n /**\n * Whether to show the \"Today\" link in `footer='today-clear'` mode. Default `true`.\n * Only meaningful when `footer='today-clear'`.\n */\n showTodayButton?: boolean\n /**\n * Whether to show the \"Clear\" link in `footer='today-clear'` mode. Default `true`.\n * Only meaningful when `footer='today-clear'`.\n */\n showClearButton?: boolean\n withTime?: boolean\n minuteStep?: number\n align?: 'start' | 'center' | 'end'\n minDate?: Date\n maxDate?: Date\n locale?: Locale\n displayFormat?: string\n className?: string\n popoverClassName?: string\n id?: string\n name?: string\n required?: boolean\n 'aria-label'?: string\n 'aria-describedby'?: string\n}\n\nfunction extractTime(date: Date): string {\n return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`\n}\n\nfunction applyTimeToDate(base: Date, time: string): Date {\n const [hourStr = '0', minuteStr = '0'] = time.split(':')\n const hour = parseInt(hourStr, 10)\n const minute = parseInt(minuteStr, 10)\n const next = new Date(base)\n next.setHours(isNaN(hour) ? 0 : hour, isNaN(minute) ? 0 : minute, 0, 0)\n return next\n}\n\nfunction startOfDay(date: Date): Date {\n const next = new Date(date)\n next.setHours(0, 0, 0, 0)\n return next\n}\n\nexport function DatePicker({\n value,\n onChange,\n placeholder,\n size = 'default',\n disabled = false,\n readOnly = false,\n footer = 'apply-cancel',\n closeOnSelect,\n showTodayButton = true,\n showClearButton = true,\n withTime = false,\n minuteStep = 1,\n align = 'start',\n minDate,\n maxDate,\n locale,\n displayFormat,\n className,\n popoverClassName,\n id,\n name,\n required,\n 'aria-label': ariaLabel,\n 'aria-describedby': ariaDescribedBy,\n}: DatePickerProps) {\n const resolvedCloseOnSelect = closeOnSelect ?? footer === 'today-clear'\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const [draft, setDraft] = React.useState<Date | null>(value ?? null)\n\n const isInteractive = !disabled && !readOnly\n const useDraft = footer === 'apply-cancel'\n\n React.useEffect(() => {\n if (open) setDraft(value ?? null)\n }, [open, value])\n\n const resolvedFormat = withTime\n ? resolvePublicDateTimeFormat(locale, displayFormat)\n : resolvePublicDateFormat(locale, displayFormat)\n const placeholderText = placeholder ?? (\n withTime\n ? t('ui.dateTimePicker.placeholder', 'Pick date and time')\n : t('ui.datePicker.placeholder', 'Pick a date')\n )\n\n const formattedValue = React.useMemo(() => {\n if (!value) return null\n return formatWithPublicDateFormat(value, resolvedFormat, locale)\n }, [value, resolvedFormat, locale])\n\n const handleDaySelect = React.useCallback(\n (day: Date | undefined) => {\n if (!day || !isInteractive) return\n const reference = withTime ? (useDraft ? draft : value) : null\n const base = withTime\n ? applyTimeToDate(day, reference ? extractTime(reference) : '00:00')\n : startOfDay(day)\n if (useDraft) {\n setDraft(base)\n } else {\n onChange(base)\n if (footer === 'none' || resolvedCloseOnSelect) setOpen(false)\n }\n },\n [isInteractive, withTime, useDraft, draft, value, footer, resolvedCloseOnSelect, onChange],\n )\n\n const handleTimeChange = React.useCallback(\n (time: string) => {\n const target = useDraft ? draft : value\n if (!target) return\n const next = applyTimeToDate(target, time)\n if (useDraft) setDraft(next)\n else onChange(next)\n },\n [useDraft, draft, value, onChange],\n )\n\n const handleApply = React.useCallback(() => {\n onChange(draft)\n setOpen(false)\n }, [draft, onChange])\n\n const handleCancel = React.useCallback(() => {\n setDraft(value ?? null)\n setOpen(false)\n }, [value])\n\n const handleToday = React.useCallback(() => {\n const today = withTime ? new Date() : startOfDay(new Date())\n onChange(today)\n setOpen(false)\n }, [withTime, onChange])\n\n const handleClear = React.useCallback(() => {\n onChange(null)\n setOpen(false)\n }, [onChange])\n\n const disabledMatcher = React.useMemo(() => {\n if (!minDate && !maxDate) return undefined\n const matchers: import('react-day-picker').Matcher[] = []\n if (minDate) matchers.push({ before: minDate })\n if (maxDate) matchers.push({ after: maxDate })\n return matchers\n }, [minDate, maxDate])\n\n const heightClass = size === 'sm' ? 'h-8 px-2.5 text-xs' : 'h-9 px-3 text-sm'\n const selectedDate = useDraft ? draft : value\n\n return (\n <Popover open={open} onOpenChange={isInteractive ? setOpen : undefined}>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n id={id}\n name={name}\n aria-label={ariaLabel}\n aria-describedby={ariaDescribedBy}\n aria-haspopup=\"dialog\"\n aria-required={required ? true : undefined}\n data-crud-focus-target=\"\"\n data-slot=\"date-picker-trigger\"\n disabled={disabled}\n className={cn(\n 'w-full inline-flex items-center gap-2 rounded-md border border-input bg-background shadow-xs transition-colors text-left',\n 'focus-visible:outline-none focus-visible:shadow-focus focus-visible:border-foreground',\n 'hover:bg-muted/40',\n 'disabled:bg-bg-disabled disabled:border-border-disabled disabled:shadow-none disabled:hover:bg-bg-disabled disabled:cursor-not-allowed',\n 'aria-invalid:border-destructive',\n heightClass,\n readOnly && 'cursor-default opacity-70',\n !formattedValue && 'text-muted-foreground',\n className,\n )}\n onClick={isInteractive ? undefined : (event) => event.preventDefault()}\n >\n <CalendarIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"flex-1 truncate\">\n {formattedValue ?? placeholderText}\n </span>\n </button>\n </PopoverTrigger>\n <PopoverContent align={align} className={cn('w-auto p-0', popoverClassName)}>\n <Calendar\n mode=\"single\"\n selected={selectedDate ?? undefined}\n onSelect={handleDaySelect}\n locale={locale}\n disabled={disabledMatcher}\n autoFocus\n />\n {withTime && (\n <div className=\"flex items-center gap-2 border-t px-3 py-2\">\n <span className=\"text-sm text-muted-foreground shrink-0\">\n {t('ui.datePicker.timeLabel', 'Time')}:\n </span>\n <TimeInput\n value={selectedDate ? extractTime(selectedDate) : undefined}\n onChange={handleTimeChange}\n minuteStep={minuteStep}\n disabled={!isInteractive || !selectedDate}\n />\n </div>\n )}\n {footer === 'apply-cancel' && (\n <div className=\"flex items-center justify-end gap-2 border-t px-3 py-2\">\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={handleCancel}>\n {t('ui.datePicker.cancelButton', 'Cancel')}\n </Button>\n <Button type=\"button\" size=\"sm\" onClick={handleApply}>\n {t('ui.datePicker.applyButton', 'Apply')}\n </Button>\n </div>\n )}\n {footer === 'today-clear' && (showTodayButton || showClearButton) && (\n <div className=\"flex items-center justify-between gap-2 border-t px-3 py-2\">\n {showTodayButton ? (\n <LinkButton type=\"button\" variant=\"primary\" onClick={handleToday}>\n {t('ui.datePicker.todayButton', 'Today')}\n </LinkButton>\n ) : null}\n {showClearButton ? (\n <LinkButton type=\"button\" variant=\"gray\" onClick={handleClear} className=\"ml-auto\">\n {t('ui.datePicker.clearButton', 'Clear')}\n </LinkButton>\n ) : null}\n </div>\n )}\n </PopoverContent>\n </Popover>\n )\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { Locale } from 'date-fns/locale'\nimport { CalendarIcon } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Popover, PopoverContent, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { Button } from './button'\nimport { LinkButton } from './link-button'\nimport { TimeInput } from '../backend/inputs/TimeInput'\nimport { formatWithPublicDateFormat, resolvePublicDateFormat, resolvePublicDateTimeFormat } from './date-format'\n\nexport type DatePickerFooter = 'apply-cancel' | 'today-clear' | 'none'\n\nexport type DatePickerProps = {\n value?: Date | null\n onChange: (value: Date | null) => void\n placeholder?: string\n size?: 'sm' | 'default'\n disabled?: boolean\n readOnly?: boolean\n /**\n * Footer mode in the popover.\n * - `'apply-cancel'` (default, Figma-aligned): selecting a day stages a draft;\n * Apply commits and closes; Cancel reverts and closes.\n * - `'today-clear'`: legacy behavior \u2014 Today commits today, Clear sets null.\n * - `'none'`: no footer; selecting a day commits immediately and closes.\n */\n footer?: DatePickerFooter\n /**\n * Whether selecting a day commits the value and closes the popover.\n * - In `footer='apply-cancel'`: ignored (commit happens via Apply).\n * - In `footer='today-clear'`: default `true` for backward compatibility.\n * - In `footer='none'`: always `true` (popover always closes after selection).\n */\n closeOnSelect?: boolean\n /**\n * Whether to show the \"Today\" link in `footer='today-clear'` mode. Default `true`.\n * Only meaningful when `footer='today-clear'`.\n */\n showTodayButton?: boolean\n /**\n * Whether to show the \"Clear\" link in `footer='today-clear'` mode. Default `true`.\n * Only meaningful when `footer='today-clear'`.\n */\n showClearButton?: boolean\n withTime?: boolean\n minuteStep?: number\n align?: 'start' | 'center' | 'end'\n minDate?: Date\n maxDate?: Date\n locale?: Locale\n displayFormat?: string\n className?: string\n popoverClassName?: string\n id?: string\n name?: string\n required?: boolean\n 'aria-label'?: string\n 'aria-describedby'?: string\n}\n\nfunction extractTime(date: Date): string {\n return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`\n}\n\nfunction applyTimeToDate(base: Date, time: string): Date {\n const [hourStr = '0', minuteStr = '0'] = time.split(':')\n const hour = parseInt(hourStr, 10)\n const minute = parseInt(minuteStr, 10)\n const next = new Date(base)\n next.setHours(isNaN(hour) ? 0 : hour, isNaN(minute) ? 0 : minute, 0, 0)\n return next\n}\n\nfunction startOfDay(date: Date): Date {\n const next = new Date(date)\n next.setHours(0, 0, 0, 0)\n return next\n}\n\nexport function DatePicker({\n value,\n onChange,\n placeholder,\n size = 'default',\n disabled = false,\n readOnly = false,\n footer = 'apply-cancel',\n closeOnSelect,\n showTodayButton = true,\n showClearButton = true,\n withTime = false,\n minuteStep = 1,\n align = 'start',\n minDate,\n maxDate,\n locale,\n displayFormat,\n className,\n popoverClassName,\n id,\n name,\n required,\n 'aria-label': ariaLabel,\n 'aria-describedby': ariaDescribedBy,\n}: DatePickerProps) {\n const resolvedCloseOnSelect = closeOnSelect ?? footer === 'today-clear'\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const [draft, setDraft] = React.useState<Date | null>(value ?? null)\n\n const isInteractive = !disabled && !readOnly\n const useDraft = footer === 'apply-cancel'\n\n React.useEffect(() => {\n if (open) setDraft(value ?? null)\n }, [open, value])\n\n const resolvedFormat = withTime\n ? resolvePublicDateTimeFormat(locale, displayFormat)\n : resolvePublicDateFormat(locale, displayFormat)\n const placeholderText = placeholder ?? (\n withTime\n ? t('ui.dateTimePicker.placeholder', 'Pick date and time')\n : t('ui.datePicker.placeholder', 'Pick a date')\n )\n\n const formattedValue = React.useMemo(() => {\n if (!value) return null\n return formatWithPublicDateFormat(value, resolvedFormat, locale)\n }, [value, resolvedFormat, locale])\n\n const handleDaySelect = React.useCallback(\n (day: Date | undefined) => {\n if (!day || !isInteractive) return\n const reference = withTime ? (useDraft ? draft : value) : null\n const base = withTime\n ? applyTimeToDate(day, reference ? extractTime(reference) : '00:00')\n : startOfDay(day)\n if (useDraft) {\n setDraft(base)\n } else {\n onChange(base)\n if (footer === 'none' || resolvedCloseOnSelect) setOpen(false)\n }\n },\n [isInteractive, withTime, useDraft, draft, value, footer, resolvedCloseOnSelect, onChange],\n )\n\n const handleTimeChange = React.useCallback(\n (time: string) => {\n const target = useDraft ? draft : value\n if (!target) return\n const next = applyTimeToDate(target, time)\n if (useDraft) setDraft(next)\n else onChange(next)\n },\n [useDraft, draft, value, onChange],\n )\n\n const handleApply = React.useCallback(() => {\n onChange(draft)\n setOpen(false)\n }, [draft, onChange])\n\n const handleCancel = React.useCallback(() => {\n setDraft(value ?? null)\n setOpen(false)\n }, [value])\n\n const handleToday = React.useCallback(() => {\n const today = withTime ? new Date() : startOfDay(new Date())\n onChange(today)\n setOpen(false)\n }, [withTime, onChange])\n\n const handleClear = React.useCallback(() => {\n onChange(null)\n setOpen(false)\n }, [onChange])\n\n const disabledMatcher = React.useMemo(() => {\n if (!minDate && !maxDate) return undefined\n const matchers: import('react-day-picker').Matcher[] = []\n if (minDate) matchers.push({ before: minDate })\n if (maxDate) matchers.push({ after: maxDate })\n return matchers\n }, [minDate, maxDate])\n\n const heightClass = size === 'sm' ? 'h-8 px-2.5 text-xs' : 'h-9 px-3 text-sm'\n const selectedDate = useDraft ? draft : value\n\n return (\n <Popover open={open} onOpenChange={isInteractive ? setOpen : undefined}>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n id={id}\n name={name}\n aria-label={ariaLabel}\n aria-describedby={ariaDescribedBy}\n aria-haspopup=\"dialog\"\n aria-required={required ? true : undefined}\n data-crud-focus-target=\"\"\n data-slot=\"date-picker-trigger\"\n disabled={disabled}\n className={cn(\n 'w-full inline-flex items-center gap-2 rounded-md border border-input bg-background shadow-xs transition-colors text-left',\n 'focus-visible:outline-none focus-visible:shadow-focus focus-visible:border-foreground',\n 'hover:bg-muted/40',\n 'disabled:bg-bg-disabled disabled:border-border-disabled disabled:shadow-none disabled:hover:bg-bg-disabled disabled:cursor-not-allowed',\n 'aria-invalid:border-destructive',\n heightClass,\n readOnly && 'cursor-default opacity-70',\n !formattedValue && 'text-muted-foreground',\n className,\n )}\n onClick={isInteractive ? undefined : (event) => event.preventDefault()}\n >\n <CalendarIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"flex-1 truncate\">\n {formattedValue ?? placeholderText}\n </span>\n </button>\n </PopoverTrigger>\n <PopoverContent align={align} className={cn('w-auto p-0', popoverClassName)}>\n <Calendar\n mode=\"single\"\n selected={selectedDate ?? undefined}\n defaultMonth={selectedDate ?? undefined}\n onSelect={handleDaySelect}\n locale={locale}\n disabled={disabledMatcher}\n autoFocus\n />\n {withTime && (\n <div className=\"flex items-center gap-2 border-t px-3 py-2\">\n <span className=\"text-sm text-muted-foreground shrink-0\">\n {t('ui.datePicker.timeLabel', 'Time')}:\n </span>\n <TimeInput\n value={selectedDate ? extractTime(selectedDate) : undefined}\n onChange={handleTimeChange}\n minuteStep={minuteStep}\n disabled={!isInteractive || !selectedDate}\n />\n </div>\n )}\n {footer === 'apply-cancel' && (\n <div className=\"flex items-center justify-end gap-2 border-t px-3 py-2\">\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={handleCancel}>\n {t('ui.datePicker.cancelButton', 'Cancel')}\n </Button>\n <Button type=\"button\" size=\"sm\" onClick={handleApply}>\n {t('ui.datePicker.applyButton', 'Apply')}\n </Button>\n </div>\n )}\n {footer === 'today-clear' && (showTodayButton || showClearButton) && (\n <div className=\"flex items-center justify-between gap-2 border-t px-3 py-2\">\n {showTodayButton ? (\n <LinkButton type=\"button\" variant=\"primary\" onClick={handleToday}>\n {t('ui.datePicker.todayButton', 'Today')}\n </LinkButton>\n ) : null}\n {showClearButton ? (\n <LinkButton type=\"button\" variant=\"gray\" onClick={handleClear} className=\"ml-auto\">\n {t('ui.datePicker.clearButton', 'Clear')}\n </LinkButton>\n ) : null}\n </div>\n )}\n </PopoverContent>\n </Popover>\n )\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.4-develop.
|
|
3
|
+
"version": "0.6.4-develop.4133.1.48fc6c8f7b",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -141,13 +141,13 @@
|
|
|
141
141
|
"remark-gfm": "^4.0.1"
|
|
142
142
|
},
|
|
143
143
|
"peerDependencies": {
|
|
144
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
144
|
+
"@open-mercato/shared": "0.6.4-develop.4133.1.48fc6c8f7b",
|
|
145
145
|
"react": ">=18.0.0",
|
|
146
146
|
"react-dom": ">=18.0.0",
|
|
147
147
|
"react-is": ">=18.0.0"
|
|
148
148
|
},
|
|
149
149
|
"devDependencies": {
|
|
150
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
150
|
+
"@open-mercato/shared": "0.6.4-develop.4133.1.48fc6c8f7b",
|
|
151
151
|
"@testing-library/dom": "^10.4.1",
|
|
152
152
|
"@testing-library/jest-dom": "^6.9.1",
|
|
153
153
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -111,6 +111,11 @@ export type AppShellProps = {
|
|
|
111
111
|
profileSectionTitle?: string
|
|
112
112
|
profilePathPrefixes?: string[]
|
|
113
113
|
mobileSidebarSlot?: React.ReactNode
|
|
114
|
+
/**
|
|
115
|
+
* How long (ms) to keep successfully completed progress operations visible
|
|
116
|
+
* before auto-hiding. Pass `false` or `0` to disable. Defaults to 10 000 ms.
|
|
117
|
+
*/
|
|
118
|
+
progressCompletedAutoHideMs?: number | false
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
type Breadcrumb = Array<{ label: string; href?: string }>
|
|
@@ -417,7 +422,7 @@ export function AppShell(props: AppShellProps) {
|
|
|
417
422
|
)
|
|
418
423
|
}
|
|
419
424
|
|
|
420
|
-
function AppShellBody({ productName, logo, email, canManageUpgradeActions = false, groups, rightHeaderSlot, children, sidebarCollapsedDefault = false, currentTitle, breadcrumb, version, settingsSectionTitle, settingsPathPrefixes = [], settingsSections, profileSections, profileSectionTitle, profilePathPrefixes = [], mobileSidebarSlot }: AppShellProps) {
|
|
425
|
+
function AppShellBody({ productName, logo, email, canManageUpgradeActions = false, groups, rightHeaderSlot, children, sidebarCollapsedDefault = false, currentTitle, breadcrumb, version, settingsSectionTitle, settingsPathPrefixes = [], settingsSections, profileSections, profileSectionTitle, profilePathPrefixes = [], mobileSidebarSlot, progressCompletedAutoHideMs }: AppShellProps) {
|
|
421
426
|
const pathname = usePathname()
|
|
422
427
|
const searchParams = useSearchParams()
|
|
423
428
|
const t = useT()
|
|
@@ -1337,7 +1342,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1337
1342
|
)}
|
|
1338
1343
|
</div>
|
|
1339
1344
|
</header>
|
|
1340
|
-
<ProgressTopBar t={t} className="sticky top-0 z-sticky" />
|
|
1345
|
+
<ProgressTopBar t={t} className="sticky top-0 z-sticky" completedAutoHideMs={progressCompletedAutoHideMs} />
|
|
1341
1346
|
<main className="flex-1 p-4 lg:p-6 mx-auto w-full max-w-screen-2xl">
|
|
1342
1347
|
<InjectionSpot spotId={BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID} context={injectionContext} />
|
|
1343
1348
|
<FlashMessages />
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { renderHook, act } from '@testing-library/react'
|
|
7
|
+
import { useAutoHideCompletedJobs } from '../progress/useAutoHideCompletedJobs'
|
|
8
|
+
import type { ProgressJobDto } from '../progress/useProgressPoll'
|
|
9
|
+
|
|
10
|
+
function makeJob(overrides: Partial<ProgressJobDto> = {}): ProgressJobDto {
|
|
11
|
+
return {
|
|
12
|
+
id: 'job-1',
|
|
13
|
+
jobType: 'bulk-delete',
|
|
14
|
+
name: 'Delete selected',
|
|
15
|
+
status: 'completed',
|
|
16
|
+
progressPercent: 100,
|
|
17
|
+
processedCount: 5,
|
|
18
|
+
cancellable: false,
|
|
19
|
+
finishedAt: new Date().toISOString(),
|
|
20
|
+
...overrides,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('useAutoHideCompletedJobs', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
jest.useFakeTimers()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
jest.useRealTimers()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('hides a completed job after the timeout elapses', () => {
|
|
34
|
+
const jobs = [makeJob({ id: 'j1', finishedAt: new Date().toISOString() })]
|
|
35
|
+
const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
|
|
36
|
+
|
|
37
|
+
expect(result.current).toHaveLength(1)
|
|
38
|
+
|
|
39
|
+
act(() => { jest.advanceTimersByTime(10_000) })
|
|
40
|
+
|
|
41
|
+
expect(result.current).toHaveLength(0)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('keeps failed jobs visible after the timeout', () => {
|
|
45
|
+
const jobs = [
|
|
46
|
+
makeJob({ id: 'j-ok', status: 'completed', finishedAt: new Date().toISOString() }),
|
|
47
|
+
makeJob({ id: 'j-fail', status: 'failed', finishedAt: new Date().toISOString() }),
|
|
48
|
+
]
|
|
49
|
+
const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
|
|
50
|
+
|
|
51
|
+
act(() => { jest.advanceTimersByTime(10_000) })
|
|
52
|
+
|
|
53
|
+
expect(result.current).toHaveLength(1)
|
|
54
|
+
expect(result.current[0].id).toBe('j-fail')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps cancelled jobs visible after the timeout', () => {
|
|
58
|
+
const jobs = [
|
|
59
|
+
makeJob({ id: 'j-cancel', status: 'cancelled', finishedAt: new Date().toISOString() }),
|
|
60
|
+
]
|
|
61
|
+
const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
|
|
62
|
+
|
|
63
|
+
act(() => { jest.advanceTimersByTime(10_000) })
|
|
64
|
+
|
|
65
|
+
expect(result.current).toHaveLength(1)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('hides each job based on its own finishedAt (no false-positive cascade)', () => {
|
|
69
|
+
const now = Date.now()
|
|
70
|
+
const jobs = [
|
|
71
|
+
makeJob({ id: 'j-early', finishedAt: new Date(now - 8_000).toISOString() }), // finished 8s ago
|
|
72
|
+
makeJob({ id: 'j-late', finishedAt: new Date(now).toISOString() }), // just finished
|
|
73
|
+
]
|
|
74
|
+
const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
|
|
75
|
+
|
|
76
|
+
// After 2s more, the early job (8s+2s=10s) should expire
|
|
77
|
+
act(() => { jest.advanceTimersByTime(2_000) })
|
|
78
|
+
expect(result.current.map((j) => j.id)).toEqual(['j-late'])
|
|
79
|
+
|
|
80
|
+
// After another 8s, the late job expires too
|
|
81
|
+
act(() => { jest.advanceTimersByTime(8_000) })
|
|
82
|
+
expect(result.current).toHaveLength(0)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('immediately hides completed jobs whose finishedAt is already past the timeout', () => {
|
|
86
|
+
const pastFinishedAt = new Date(Date.now() - 15_000).toISOString() // finished 15s ago
|
|
87
|
+
const jobs = [makeJob({ id: 'j-old', finishedAt: pastFinishedAt })]
|
|
88
|
+
const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
|
|
89
|
+
|
|
90
|
+
// No timer needed — should already be filtered out
|
|
91
|
+
expect(result.current).toHaveLength(0)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('does not hide when timeoutMs is false', () => {
|
|
95
|
+
const jobs = [makeJob({ id: 'j1' })]
|
|
96
|
+
const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, false))
|
|
97
|
+
|
|
98
|
+
act(() => { jest.advanceTimersByTime(60_000) })
|
|
99
|
+
|
|
100
|
+
expect(result.current).toHaveLength(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('does not hide when timeoutMs is 0', () => {
|
|
104
|
+
const jobs = [makeJob({ id: 'j1' })]
|
|
105
|
+
const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 0))
|
|
106
|
+
|
|
107
|
+
act(() => { jest.advanceTimersByTime(60_000) })
|
|
108
|
+
|
|
109
|
+
expect(result.current).toHaveLength(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('respects a custom timeout', () => {
|
|
113
|
+
const jobs = [makeJob({ id: 'j1', finishedAt: new Date().toISOString() })]
|
|
114
|
+
const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 3_000))
|
|
115
|
+
|
|
116
|
+
act(() => { jest.advanceTimersByTime(2_999) })
|
|
117
|
+
expect(result.current).toHaveLength(1)
|
|
118
|
+
|
|
119
|
+
act(() => { jest.advanceTimersByTime(1) })
|
|
120
|
+
expect(result.current).toHaveLength(0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('does not re-schedule a timer when jobs list updates with the same completed job', () => {
|
|
124
|
+
const job = makeJob({ id: 'j1', finishedAt: new Date().toISOString() })
|
|
125
|
+
const { result, rerender } = renderHook(
|
|
126
|
+
({ jobs }: { jobs: ProgressJobDto[] }) => useAutoHideCompletedJobs(jobs, 10_000),
|
|
127
|
+
{ initialProps: { jobs: [job] } },
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
act(() => { jest.advanceTimersByTime(5_000) })
|
|
131
|
+
// Simulate poll refresh — same job still in list
|
|
132
|
+
rerender({ jobs: [job] })
|
|
133
|
+
|
|
134
|
+
// Timer should expire at original 10s mark, not reset to 15s
|
|
135
|
+
act(() => { jest.advanceTimersByTime(5_000) })
|
|
136
|
+
expect(result.current).toHaveLength(0)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('clears timers on unmount without errors', () => {
|
|
140
|
+
const jobs = [makeJob({ id: 'j1', finishedAt: new Date().toISOString() })]
|
|
141
|
+
const { unmount } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
|
|
142
|
+
|
|
143
|
+
expect(() => {
|
|
144
|
+
unmount()
|
|
145
|
+
jest.advanceTimersByTime(15_000)
|
|
146
|
+
}).not.toThrow()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('treats a malformed finishedAt as "just finished" — does not error and hides after full timeout', () => {
|
|
150
|
+
const jobs = [makeJob({ id: 'j-bad', finishedAt: 'not-a-date' })]
|
|
151
|
+
const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
|
|
152
|
+
|
|
153
|
+
// Should be visible initially (malformed → treated as now)
|
|
154
|
+
expect(result.current).toHaveLength(1)
|
|
155
|
+
|
|
156
|
+
act(() => { jest.advanceTimersByTime(10_000) })
|
|
157
|
+
|
|
158
|
+
expect(result.current).toHaveLength(0)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('stays hidden when list cycles through empty — does not restart timer', () => {
|
|
162
|
+
const job = makeJob({ id: 'j1', finishedAt: new Date().toISOString() })
|
|
163
|
+
const { result, rerender } = renderHook(
|
|
164
|
+
({ jobs }: { jobs: ProgressJobDto[] }) => useAutoHideCompletedJobs(jobs, 10_000),
|
|
165
|
+
{ initialProps: { jobs: [job] } },
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// Advance past the timeout so job expires
|
|
169
|
+
act(() => { jest.advanceTimersByTime(10_000) })
|
|
170
|
+
expect(result.current).toHaveLength(0)
|
|
171
|
+
|
|
172
|
+
// List goes empty then comes back with the same job
|
|
173
|
+
rerender({ jobs: [] })
|
|
174
|
+
rerender({ jobs: [job] })
|
|
175
|
+
|
|
176
|
+
// Job should stay hidden — scheduledRef prevents re-scheduling
|
|
177
|
+
expect(result.current).toHaveLength(0)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -14,6 +14,7 @@ import { GripVertical, Info, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } fr
|
|
|
14
14
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
15
15
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
16
16
|
import { InjectionSpot } from '../injection/InjectionSpot'
|
|
17
|
+
import { WidgetDataBatchProvider } from './widgetData'
|
|
17
18
|
|
|
18
19
|
type DashboardWidgetSize = 'sm' | 'md' | 'lg'
|
|
19
20
|
|
|
@@ -429,6 +430,7 @@ export function DashboardScreen() {
|
|
|
429
430
|
</div>
|
|
430
431
|
)}
|
|
431
432
|
|
|
433
|
+
<WidgetDataBatchProvider>
|
|
432
434
|
<div className={cn(
|
|
433
435
|
'grid gap-3 sm:gap-4 md:gap-6',
|
|
434
436
|
'grid-cols-1',
|
|
@@ -493,6 +495,7 @@ export function DashboardScreen() {
|
|
|
493
495
|
)
|
|
494
496
|
})}
|
|
495
497
|
</div>
|
|
498
|
+
</WidgetDataBatchProvider>
|
|
496
499
|
|
|
497
500
|
{layout.length === 0 && (
|
|
498
501
|
<div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground">
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
createWidgetDataBatcher,
|
|
6
|
+
type WidgetDataBatchRequestEntry,
|
|
7
|
+
type WidgetDataBatchResultEntry,
|
|
8
|
+
} from '../widgetDataBatcher'
|
|
9
|
+
|
|
10
|
+
describe('createWidgetDataBatcher', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
jest.useFakeTimers()
|
|
13
|
+
})
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
jest.useRealTimers()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('coalesces requests fired within the window into a single send', async () => {
|
|
19
|
+
const send = jest.fn(async (entries: WidgetDataBatchRequestEntry[]) =>
|
|
20
|
+
entries.map((entry): WidgetDataBatchResultEntry => ({ id: entry.id, ok: true, data: entry.request })),
|
|
21
|
+
)
|
|
22
|
+
const batcher = createWidgetDataBatcher({ send, windowMs: 16 })
|
|
23
|
+
|
|
24
|
+
const p1 = batcher.fetch<{ widget: number }>({ widget: 1 })
|
|
25
|
+
const p2 = batcher.fetch<{ widget: number }>({ widget: 2 })
|
|
26
|
+
const p3 = batcher.fetch<{ widget: number }>({ widget: 3 })
|
|
27
|
+
|
|
28
|
+
expect(send).not.toHaveBeenCalled()
|
|
29
|
+
jest.advanceTimersByTime(16)
|
|
30
|
+
|
|
31
|
+
await expect(p1).resolves.toEqual({ widget: 1 })
|
|
32
|
+
await expect(p2).resolves.toEqual({ widget: 2 })
|
|
33
|
+
await expect(p3).resolves.toEqual({ widget: 3 })
|
|
34
|
+
|
|
35
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
36
|
+
expect(send.mock.calls[0][0]).toHaveLength(3)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('rejects only the failing widget and resolves the rest', async () => {
|
|
40
|
+
const send = jest.fn(async (entries: WidgetDataBatchRequestEntry[]) =>
|
|
41
|
+
entries.map((entry, index): WidgetDataBatchResultEntry =>
|
|
42
|
+
index === 1
|
|
43
|
+
? { id: entry.id, ok: false, error: 'boom' }
|
|
44
|
+
: { id: entry.id, ok: true, data: entry.request },
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
const batcher = createWidgetDataBatcher({ send, windowMs: 16 })
|
|
48
|
+
|
|
49
|
+
const ok = batcher.fetch({ widget: 'a' })
|
|
50
|
+
const bad = batcher.fetch({ widget: 'b' })
|
|
51
|
+
jest.advanceTimersByTime(16)
|
|
52
|
+
|
|
53
|
+
await expect(ok).resolves.toEqual({ widget: 'a' })
|
|
54
|
+
await expect(bad).rejects.toThrow('boom')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('rejects a caller whose id is missing from the response', async () => {
|
|
58
|
+
const send = jest.fn(async () => [] as WidgetDataBatchResultEntry[])
|
|
59
|
+
const batcher = createWidgetDataBatcher({ send, windowMs: 16 })
|
|
60
|
+
|
|
61
|
+
const p = batcher.fetch({ widget: 1 })
|
|
62
|
+
jest.advanceTimersByTime(16)
|
|
63
|
+
|
|
64
|
+
await expect(p).rejects.toThrow('No result returned for widget')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('rejects all callers when the batch send fails', async () => {
|
|
68
|
+
const send = jest.fn(async () => {
|
|
69
|
+
throw new Error('network down')
|
|
70
|
+
})
|
|
71
|
+
const batcher = createWidgetDataBatcher({ send, windowMs: 16 })
|
|
72
|
+
|
|
73
|
+
const p1 = batcher.fetch({ widget: 1 })
|
|
74
|
+
const p2 = batcher.fetch({ widget: 2 })
|
|
75
|
+
jest.advanceTimersByTime(16)
|
|
76
|
+
|
|
77
|
+
await expect(p1).rejects.toThrow('network down')
|
|
78
|
+
await expect(p2).rejects.toThrow('network down')
|
|
79
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('splits queues larger than maxBatchSize into multiple sends', async () => {
|
|
83
|
+
const send = jest.fn(async (entries: WidgetDataBatchRequestEntry[]) =>
|
|
84
|
+
entries.map((entry): WidgetDataBatchResultEntry => ({ id: entry.id, ok: true, data: entry.request })),
|
|
85
|
+
)
|
|
86
|
+
const batcher = createWidgetDataBatcher({ send, windowMs: 16, maxBatchSize: 2 })
|
|
87
|
+
|
|
88
|
+
const promises = [1, 2, 3, 4, 5].map((widget) => batcher.fetch({ widget }))
|
|
89
|
+
jest.advanceTimersByTime(16)
|
|
90
|
+
|
|
91
|
+
await Promise.all(promises)
|
|
92
|
+
expect(send).toHaveBeenCalledTimes(3)
|
|
93
|
+
expect(send.mock.calls[0][0]).toHaveLength(2)
|
|
94
|
+
expect(send.mock.calls[1][0]).toHaveLength(2)
|
|
95
|
+
expect(send.mock.calls[2][0]).toHaveLength(1)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { apiCall } from '../utils/apiCall'
|
|
5
|
+
import {
|
|
6
|
+
createWidgetDataBatcher,
|
|
7
|
+
type WidgetDataBatchResultEntry,
|
|
8
|
+
type WidgetDataBatchSender,
|
|
9
|
+
} from './widgetDataBatcher'
|
|
10
|
+
|
|
11
|
+
const SINGLE_ENDPOINT = '/api/dashboards/widgets/data'
|
|
12
|
+
const BATCH_ENDPOINT = '/api/dashboards/widgets/data/batch'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
export type WidgetDataFetcher = <TResponse>(request: unknown) => Promise<TResponse>
|
|
16
|
+
|
|
17
|
+
function readError(result: unknown, fallback: string): string {
|
|
18
|
+
const errorMsg = (result as Record<string, unknown> | null)?.error
|
|
19
|
+
return typeof errorMsg === 'string' ? errorMsg : fallback
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fallback used when a widget renders outside a {@link WidgetDataBatchProvider}
|
|
24
|
+
* (e.g. a standalone preview). Issues a single un-batched request so widgets
|
|
25
|
+
* keep working without the dashboard host.
|
|
26
|
+
*/
|
|
27
|
+
const singleWidgetDataFetch: WidgetDataFetcher = async (request) => {
|
|
28
|
+
const call = await apiCall(SINGLE_ENDPOINT, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify(request),
|
|
32
|
+
})
|
|
33
|
+
if (!call.ok) {
|
|
34
|
+
throw new Error(readError(call.result, 'Failed to fetch widget data'))
|
|
35
|
+
}
|
|
36
|
+
return call.result as never
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const WidgetDataContext = React.createContext<WidgetDataFetcher>(singleWidgetDataFetch)
|
|
40
|
+
|
|
41
|
+
const batchSender: WidgetDataBatchSender = async (entries) => {
|
|
42
|
+
const call = await apiCall<{ results?: WidgetDataBatchResultEntry[] }>(BATCH_ENDPOINT, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ requests: entries }),
|
|
46
|
+
})
|
|
47
|
+
if (!call.ok || !call.result || !Array.isArray(call.result.results)) {
|
|
48
|
+
throw new Error(readError(call.result, 'Failed to fetch widget data'))
|
|
49
|
+
}
|
|
50
|
+
return call.result.results
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type WidgetDataBatchProviderProps = {
|
|
54
|
+
children: React.ReactNode
|
|
55
|
+
windowMs?: number
|
|
56
|
+
maxBatchSize?: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Provides a batched widget-data fetcher to descendant dashboard widgets.
|
|
61
|
+
* Requests fired within a short window collapse into one POST to the batch
|
|
62
|
+
* endpoint, so a first dashboard render issues a single authenticated request
|
|
63
|
+
* instead of one per widget (see #2273).
|
|
64
|
+
*/
|
|
65
|
+
export function WidgetDataBatchProvider({
|
|
66
|
+
children,
|
|
67
|
+
windowMs,
|
|
68
|
+
maxBatchSize,
|
|
69
|
+
}: WidgetDataBatchProviderProps): React.ReactElement {
|
|
70
|
+
const batcher = React.useMemo(
|
|
71
|
+
() => createWidgetDataBatcher({ send: batchSender, windowMs, maxBatchSize }),
|
|
72
|
+
[windowMs, maxBatchSize],
|
|
73
|
+
)
|
|
74
|
+
const fetcher = React.useCallback<WidgetDataFetcher>(
|
|
75
|
+
(request) => batcher.fetch(request),
|
|
76
|
+
[batcher],
|
|
77
|
+
)
|
|
78
|
+
return <WidgetDataContext.Provider value={fetcher}>{children}</WidgetDataContext.Provider>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Returns the active widget-data fetcher (batched when inside a provider). */
|
|
82
|
+
export function useWidgetData(): WidgetDataFetcher {
|
|
83
|
+
return React.useContext(WidgetDataContext)
|
|
84
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export type WidgetDataBatchRequestEntry = { id: string; request: unknown }
|
|
2
|
+
|
|
3
|
+
export type WidgetDataBatchResultEntry =
|
|
4
|
+
| { id: string; ok: true; data: unknown }
|
|
5
|
+
| { id: string; ok: false; error: string }
|
|
6
|
+
|
|
7
|
+
export type WidgetDataBatchSender = (
|
|
8
|
+
entries: WidgetDataBatchRequestEntry[],
|
|
9
|
+
) => Promise<WidgetDataBatchResultEntry[]>
|
|
10
|
+
|
|
11
|
+
export type WidgetDataBatcherOptions = {
|
|
12
|
+
send: WidgetDataBatchSender
|
|
13
|
+
/** Coalescing window in ms. Widget requests fired within the window share one HTTP call. */
|
|
14
|
+
windowMs?: number
|
|
15
|
+
/** Upper bound on requests per HTTP call; larger queues are split into multiple sends. */
|
|
16
|
+
maxBatchSize?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type WidgetDataBatcher = {
|
|
20
|
+
fetch: <TResponse>(request: unknown) => Promise<TResponse>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type PendingCall = {
|
|
24
|
+
id: string
|
|
25
|
+
request: unknown
|
|
26
|
+
resolve: (value: unknown) => void
|
|
27
|
+
reject: (error: unknown) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_WINDOW_MS = 16
|
|
31
|
+
const DEFAULT_MAX_BATCH_SIZE = 50
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Coalesces independent widget-data requests fired within a short window into a
|
|
35
|
+
* single batch call. Each call resolves with its own slice of the batch result,
|
|
36
|
+
* so callers stay decoupled from one another while collapsing N HTTP requests
|
|
37
|
+
* (and N server-side container/RBAC/org-scope rebuilds) into one (see #2273).
|
|
38
|
+
*/
|
|
39
|
+
export function createWidgetDataBatcher(options: WidgetDataBatcherOptions): WidgetDataBatcher {
|
|
40
|
+
const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS
|
|
41
|
+
const maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE
|
|
42
|
+
|
|
43
|
+
let queue: PendingCall[] = []
|
|
44
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
45
|
+
let counter = 0
|
|
46
|
+
|
|
47
|
+
const flush = () => {
|
|
48
|
+
timer = null
|
|
49
|
+
const pending = queue
|
|
50
|
+
queue = []
|
|
51
|
+
if (pending.length === 0) return
|
|
52
|
+
|
|
53
|
+
for (let start = 0; start < pending.length; start += maxBatchSize) {
|
|
54
|
+
const chunk = pending.slice(start, start + maxBatchSize)
|
|
55
|
+
void dispatchChunk(chunk)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const dispatchChunk = async (chunk: PendingCall[]) => {
|
|
60
|
+
try {
|
|
61
|
+
const results = await options.send(chunk.map((call) => ({ id: call.id, request: call.request })))
|
|
62
|
+
const byId = new Map<string, WidgetDataBatchResultEntry>()
|
|
63
|
+
for (const result of results) byId.set(result.id, result)
|
|
64
|
+
|
|
65
|
+
for (const call of chunk) {
|
|
66
|
+
const result = byId.get(call.id)
|
|
67
|
+
if (!result) {
|
|
68
|
+
call.reject(new Error('No result returned for widget'))
|
|
69
|
+
} else if (result.ok) {
|
|
70
|
+
call.resolve(result.data)
|
|
71
|
+
} else {
|
|
72
|
+
call.reject(new Error(result.error))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
for (const call of chunk) call.reject(error)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const scheduleFlush = () => {
|
|
81
|
+
if (timer != null) return
|
|
82
|
+
timer = setTimeout(flush, windowMs)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
fetch<TResponse>(request: unknown): Promise<TResponse> {
|
|
87
|
+
counter += 1
|
|
88
|
+
const id = `w${counter}`
|
|
89
|
+
return new Promise<TResponse>((resolve, reject) => {
|
|
90
|
+
queue.push({
|
|
91
|
+
id,
|
|
92
|
+
request,
|
|
93
|
+
resolve: (value) => resolve(value as TResponse),
|
|
94
|
+
reject,
|
|
95
|
+
})
|
|
96
|
+
scheduleFlush()
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|