@prenta/admin 2.6.1 → 2.8.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 +132 -0
- package/design-system/tokens.css +196 -85
- package/design-system/tokens.json +310 -85
- package/design-system/tokens.scss +163 -44
- package/design-system/tokens.tailwind.cjs +64 -1
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +43 -21
- package/dist/AdminRoot.js.map +1 -1
- package/dist/LoginRoot.d.ts.map +1 -1
- package/dist/LoginRoot.js +2 -1
- package/dist/LoginRoot.js.map +1 -1
- package/dist/__tests__/components/author-avatar.test.js +3 -17
- package/dist/__tests__/components/author-avatar.test.js.map +1 -1
- package/dist/__tests__/components/notification-bell.test.js +86 -15
- package/dist/__tests__/components/notification-bell.test.js.map +1 -1
- package/dist/__tests__/components/responsive-tiers.render.test.d.ts +2 -0
- package/dist/__tests__/components/responsive-tiers.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/responsive-tiers.render.test.js +313 -0
- package/dist/__tests__/components/responsive-tiers.render.test.js.map +1 -0
- package/dist/__tests__/components/serp-preview.render.test.js +6 -3
- package/dist/__tests__/components/serp-preview.render.test.js.map +1 -1
- package/dist/__tests__/components/shell-primitives.render.test.d.ts +2 -0
- package/dist/__tests__/components/shell-primitives.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/shell-primitives.render.test.js +716 -0
- package/dist/__tests__/components/shell-primitives.render.test.js.map +1 -0
- package/dist/__tests__/components/theme-provider-accent.test.js +49 -7
- package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -1
- package/dist/__tests__/components/toggle.test.js +10 -3
- package/dist/__tests__/components/toggle.test.js.map +1 -1
- package/dist/__tests__/components/ui-primitives.test.js +3 -7
- package/dist/__tests__/components/ui-primitives.test.js.map +1 -1
- package/dist/__tests__/layout/build-nav-items.test.js +1 -1
- package/dist/__tests__/layout/build-nav-items.test.js.map +1 -1
- package/dist/__tests__/layout/shell-nav.render.test.d.ts +2 -0
- package/dist/__tests__/layout/shell-nav.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/shell-nav.render.test.js +736 -0
- package/dist/__tests__/layout/shell-nav.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +8 -4
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -1
- package/dist/__tests__/layout/sidebar-groups.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-groups.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-groups.render.test.js +56 -0
- package/dist/__tests__/layout/sidebar-groups.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-menus.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-menus.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-menus.render.test.js +82 -0
- package/dist/__tests__/layout/sidebar-menus.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-plugin-nav.test.js +1 -1
- package/dist/__tests__/layout/sidebar-plugin-nav.test.js.map +1 -1
- package/dist/__tests__/layout/sidebar-posts.test.js +48 -38
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
- package/dist/__tests__/layout/sidebar-submenu.render.test.js +26 -10
- package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
- package/dist/__tests__/layout/topbar.render.test.d.ts +2 -0
- package/dist/__tests__/layout/topbar.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/topbar.render.test.js +406 -0
- package/dist/__tests__/layout/topbar.render.test.js.map +1 -0
- package/dist/__tests__/lib/api.test.js +2 -0
- package/dist/__tests__/lib/api.test.js.map +1 -1
- package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
- package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
- package/dist/__tests__/lib/batch-wizard.test.js +144 -0
- package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
- package/dist/__tests__/lib/job-drawer.test.d.ts +2 -0
- package/dist/__tests__/lib/job-drawer.test.d.ts.map +1 -0
- package/dist/__tests__/lib/job-drawer.test.js +395 -0
- package/dist/__tests__/lib/job-drawer.test.js.map +1 -0
- package/dist/__tests__/lib/job-view.test.d.ts +2 -0
- package/dist/__tests__/lib/job-view.test.d.ts.map +1 -0
- package/dist/__tests__/lib/job-view.test.js +484 -0
- package/dist/__tests__/lib/job-view.test.js.map +1 -0
- package/dist/__tests__/lib/jobs-service.test.d.ts +2 -0
- package/dist/__tests__/lib/jobs-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/jobs-service.test.js +142 -0
- package/dist/__tests__/lib/jobs-service.test.js.map +1 -0
- package/dist/__tests__/router/admin-route-order.test.d.ts +2 -0
- package/dist/__tests__/router/admin-route-order.test.d.ts.map +1 -0
- package/dist/__tests__/router/admin-route-order.test.js +36 -0
- package/dist/__tests__/router/admin-route-order.test.js.map +1 -0
- package/dist/__tests__/styles/ai-gradient-contrast.test.d.ts +2 -0
- package/dist/__tests__/styles/ai-gradient-contrast.test.d.ts.map +1 -0
- package/dist/__tests__/styles/ai-gradient-contrast.test.js +110 -0
- package/dist/__tests__/styles/ai-gradient-contrast.test.js.map +1 -0
- package/dist/__tests__/styles/breakpoint-order.test.d.ts +2 -0
- package/dist/__tests__/styles/breakpoint-order.test.d.ts.map +1 -0
- package/dist/__tests__/styles/breakpoint-order.test.js +52 -0
- package/dist/__tests__/styles/breakpoint-order.test.js.map +1 -0
- package/dist/__tests__/styles/responsive-layer.test.js +303 -12
- package/dist/__tests__/styles/responsive-layer.test.js.map +1 -1
- package/dist/__tests__/styles/tailwind-preset.test.d.ts +2 -0
- package/dist/__tests__/styles/tailwind-preset.test.d.ts.map +1 -0
- package/dist/__tests__/styles/tailwind-preset.test.js +147 -0
- package/dist/__tests__/styles/tailwind-preset.test.js.map +1 -0
- package/dist/__tests__/styles/theme-token-scope.test.js +9 -3
- package/dist/__tests__/styles/theme-token-scope.test.js.map +1 -1
- package/dist/__tests__/styles/tiers.test.d.ts +2 -0
- package/dist/__tests__/styles/tiers.test.d.ts.map +1 -0
- package/dist/__tests__/styles/tiers.test.js +121 -0
- package/dist/__tests__/styles/tiers.test.js.map +1 -0
- package/dist/__tests__/styles/tokens-drift.test.js +57 -6
- package/dist/__tests__/styles/tokens-drift.test.js.map +1 -1
- package/dist/__tests__/views/admin-lazy-route.render.test.js +27 -1
- package/dist/__tests__/views/admin-lazy-route.render.test.js.map +1 -1
- package/dist/__tests__/views/ai-settings.render.test.js +4 -4
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
- package/dist/__tests__/views/appearance-menus-route.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-menus-route.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-menus-route.render.test.js +50 -0
- package/dist/__tests__/views/appearance-menus-route.render.test.js.map +1 -0
- package/dist/__tests__/views/appearance-settings.render.test.js +8 -6
- package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/appearance-theme-provider.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-theme-provider.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-theme-provider.render.test.js +286 -0
- package/dist/__tests__/views/appearance-theme-provider.render.test.js.map +1 -0
- package/dist/__tests__/views/dashboard.test.js +37 -0
- package/dist/__tests__/views/dashboard.test.js.map +1 -1
- package/dist/__tests__/views/document-edit-navigation.render.test.d.ts +2 -0
- package/dist/__tests__/views/document-edit-navigation.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/document-edit-navigation.render.test.js +101 -0
- package/dist/__tests__/views/document-edit-navigation.render.test.js.map +1 -0
- package/dist/__tests__/views/document-edit-routes.render.test.d.ts +2 -0
- package/dist/__tests__/views/document-edit-routes.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/document-edit-routes.render.test.js +113 -0
- package/dist/__tests__/views/document-edit-routes.render.test.js.map +1 -0
- package/dist/__tests__/views/email-settings.render.test.js +2 -2
- package/dist/__tests__/views/form-entries.render.test.js +15 -0
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
- package/dist/__tests__/views/form-submissions.render.test.js +18 -0
- package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +12 -0
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/general-settings.render.test.js +26 -10
- package/dist/__tests__/views/general-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.js +266 -0
- package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
- package/dist/__tests__/views/jobs-drawer.render.test.d.ts +2 -0
- package/dist/__tests__/views/jobs-drawer.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/jobs-drawer.render.test.js +1348 -0
- package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -0
- package/dist/__tests__/views/jobs-list.render.test.d.ts +2 -0
- package/dist/__tests__/views/jobs-list.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/jobs-list.render.test.js +697 -0
- package/dist/__tests__/views/jobs-list.render.test.js.map +1 -0
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
- package/dist/__tests__/views/kpi-grid.test.d.ts +2 -0
- package/dist/__tests__/views/kpi-grid.test.d.ts.map +1 -0
- package/dist/__tests__/views/kpi-grid.test.js +26 -0
- package/dist/__tests__/views/kpi-grid.test.js.map +1 -0
- package/dist/__tests__/views/post-section-editor.test.js +3 -1
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/script-tags.render.test.js +25 -0
- package/dist/__tests__/views/script-tags.render.test.js.map +1 -1
- package/dist/__tests__/views/security-settings.render.test.js +9 -8
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +7 -7
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/settings-save-bar.render.test.d.ts +2 -0
- package/dist/__tests__/views/settings-save-bar.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/settings-save-bar.render.test.js +68 -0
- package/dist/__tests__/views/settings-save-bar.render.test.js.map +1 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js +23 -15
- package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
- package/dist/__tests__/views/settings-tabs.render.test.d.ts +2 -0
- package/dist/__tests__/views/settings-tabs.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/settings-tabs.render.test.js +319 -0
- package/dist/__tests__/views/settings-tabs.render.test.js.map +1 -0
- package/dist/__tests__/views/updates-tab.render.test.js +4 -3
- package/dist/__tests__/views/updates-tab.render.test.js.map +1 -1
- package/dist/components/Breadcrumbs.d.ts +8 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +6 -0
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +40 -4
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/LocaleSwitcher.d.ts.map +1 -1
- package/dist/components/LocaleSwitcher.js +1 -1
- package/dist/components/LocaleSwitcher.js.map +1 -1
- package/dist/components/NotificationBell.d.ts.map +1 -1
- package/dist/components/NotificationBell.js +44 -3
- package/dist/components/NotificationBell.js.map +1 -1
- package/dist/components/OfflineStatus.js +1 -1
- package/dist/components/OfflineStatus.js.map +1 -1
- package/dist/components/PresenceIndicator.d.ts.map +1 -1
- package/dist/components/PresenceIndicator.js +2 -1
- package/dist/components/PresenceIndicator.js.map +1 -1
- package/dist/components/ReflowMeter.js +2 -2
- package/dist/components/ReflowMeter.js.map +1 -1
- package/dist/components/SEOPanel.js +2 -2
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/components/ThemeProvider.d.ts +24 -4
- package/dist/components/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider.js +80 -22
- package/dist/components/ThemeProvider.js.map +1 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +3 -4
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/components/forms/EmbedPanel.js +1 -1
- package/dist/components/forms/EmbedPanel.js.map +1 -1
- package/dist/components/forms/FormEntryDetailPane.js +6 -1
- package/dist/components/forms/FormEntryDetailPane.js.map +1 -1
- package/dist/components/forms/ShortcodeGlossary.d.ts.map +1 -1
- package/dist/components/forms/ShortcodeGlossary.js +6 -1
- package/dist/components/forms/ShortcodeGlossary.js.map +1 -1
- package/dist/components/forms/primitives.d.ts +15 -5
- package/dist/components/forms/primitives.d.ts.map +1 -1
- package/dist/components/forms/primitives.js +11 -11
- package/dist/components/forms/primitives.js.map +1 -1
- package/dist/components/pages/tag-badges.d.ts +2 -1
- package/dist/components/pages/tag-badges.d.ts.map +1 -1
- package/dist/components/pages/tag-badges.js +4 -3
- package/dist/components/pages/tag-badges.js.map +1 -1
- package/dist/components/seo/PageAuditPanel.js +2 -2
- package/dist/components/seo/PageAuditPanel.js.map +1 -1
- package/dist/components/seo/SerpPreview.d.ts +4 -4
- package/dist/components/seo/SerpPreview.js +1 -1
- package/dist/components/seo/SerpPreview.js.map +1 -1
- package/dist/components/seo/primitives.js +2 -2
- package/dist/components/seo/primitives.js.map +1 -1
- package/dist/components/ui/AuthorAvatar.d.ts +4 -4
- package/dist/components/ui/AuthorAvatar.d.ts.map +1 -1
- package/dist/components/ui/AuthorAvatar.js +11 -10
- package/dist/components/ui/AuthorAvatar.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Badge.js +1 -1
- package/dist/components/ui/Badge.js.map +1 -1
- package/dist/components/ui/Breadcrumb.d.ts +27 -0
- package/dist/components/ui/Breadcrumb.d.ts.map +1 -0
- package/dist/components/ui/Breadcrumb.js +41 -0
- package/dist/components/ui/Breadcrumb.js.map +1 -0
- package/dist/components/ui/Button.d.ts +2 -2
- package/dist/components/ui/Button.d.ts.map +1 -1
- package/dist/components/ui/Button.js +31 -16
- package/dist/components/ui/Button.js.map +1 -1
- package/dist/components/ui/Card.d.ts +8 -4
- package/dist/components/ui/Card.d.ts.map +1 -1
- package/dist/components/ui/Card.js +21 -13
- package/dist/components/ui/Card.js.map +1 -1
- package/dist/components/ui/CardListControls.d.ts +34 -0
- package/dist/components/ui/CardListControls.d.ts.map +1 -0
- package/dist/components/ui/CardListControls.js +25 -0
- package/dist/components/ui/CardListControls.js.map +1 -0
- package/dist/components/ui/CountChip.d.ts +20 -0
- package/dist/components/ui/CountChip.d.ts.map +1 -0
- package/dist/components/ui/CountChip.js +48 -0
- package/dist/components/ui/CountChip.js.map +1 -0
- package/dist/components/ui/FilterPill.d.ts +2 -1
- package/dist/components/ui/FilterPill.d.ts.map +1 -1
- package/dist/components/ui/FilterPill.js +3 -2
- package/dist/components/ui/FilterPill.js.map +1 -1
- package/dist/components/ui/Input.d.ts +1 -1
- package/dist/components/ui/InspectorPane.d.ts +1 -1
- package/dist/components/ui/InspectorPane.d.ts.map +1 -1
- package/dist/components/ui/InspectorPane.js +15 -4
- package/dist/components/ui/InspectorPane.js.map +1 -1
- package/dist/components/ui/Modal.d.ts.map +1 -1
- package/dist/components/ui/Modal.js +10 -2
- package/dist/components/ui/Modal.js.map +1 -1
- package/dist/components/ui/PageHeader.d.ts +28 -0
- package/dist/components/ui/PageHeader.d.ts.map +1 -0
- package/dist/components/ui/PageHeader.js +18 -0
- package/dist/components/ui/PageHeader.js.map +1 -0
- package/dist/components/ui/RowActions.d.ts +5 -0
- package/dist/components/ui/RowActions.d.ts.map +1 -1
- package/dist/components/ui/RowActions.js +7 -2
- package/dist/components/ui/RowActions.js.map +1 -1
- package/dist/components/ui/Segmented.d.ts +21 -0
- package/dist/components/ui/Segmented.d.ts.map +1 -0
- package/dist/components/ui/Segmented.js +41 -0
- package/dist/components/ui/Segmented.js.map +1 -0
- package/dist/components/ui/Toaster.d.ts +12 -0
- package/dist/components/ui/Toaster.d.ts.map +1 -0
- package/dist/components/ui/Toaster.js +103 -0
- package/dist/components/ui/Toaster.js.map +1 -0
- package/dist/components/ui/Toggle.d.ts +4 -3
- package/dist/components/ui/Toggle.d.ts.map +1 -1
- package/dist/components/ui/Toggle.js +16 -12
- package/dist/components/ui/Toggle.js.map +1 -1
- package/dist/components/ui/UnderlineTabs.d.ts +56 -0
- package/dist/components/ui/UnderlineTabs.d.ts.map +1 -0
- package/dist/components/ui/UnderlineTabs.js +205 -0
- package/dist/components/ui/UnderlineTabs.js.map +1 -0
- package/dist/components/ui/UnsavedBar.d.ts +25 -0
- package/dist/components/ui/UnsavedBar.d.ts.map +1 -0
- package/dist/components/ui/UnsavedBar.js +22 -0
- package/dist/components/ui/UnsavedBar.js.map +1 -0
- package/dist/components/ui/admin-links.d.ts +11 -0
- package/dist/components/ui/admin-links.d.ts.map +1 -0
- package/dist/components/ui/admin-links.js +10 -0
- package/dist/components/ui/admin-links.js.map +1 -0
- package/dist/components/ui/card-table.d.ts +43 -0
- package/dist/components/ui/card-table.d.ts.map +1 -0
- package/dist/components/ui/card-table.js +31 -0
- package/dist/components/ui/card-table.js.map +1 -0
- package/dist/components/virtual-window.d.ts +7 -0
- package/dist/components/virtual-window.d.ts.map +1 -1
- package/dist/components/virtual-window.js +5 -2
- package/dist/components/virtual-window.js.map +1 -1
- package/dist/fields/RelationshipField.js +1 -1
- package/dist/fields/RelationshipField.js.map +1 -1
- package/dist/files/dm-mono-latin-400-normal.woff +0 -0
- package/dist/files/dm-mono-latin-400-normal.woff2 +0 -0
- package/dist/files/dm-mono-latin-500-normal.woff +0 -0
- package/dist/files/dm-mono-latin-500-normal.woff2 +0 -0
- package/dist/files/dm-mono-latin-ext-400-normal.woff +0 -0
- package/dist/files/dm-mono-latin-ext-400-normal.woff2 +0 -0
- package/dist/files/dm-mono-latin-ext-500-normal.woff +0 -0
- package/dist/files/dm-mono-latin-ext-500-normal.woff2 +0 -0
- package/dist/files/dm-sans-latin-400-normal.woff +0 -0
- package/dist/files/dm-sans-latin-400-normal.woff2 +0 -0
- package/dist/files/dm-sans-latin-500-normal.woff +0 -0
- package/dist/files/dm-sans-latin-500-normal.woff2 +0 -0
- package/dist/files/dm-sans-latin-600-normal.woff +0 -0
- package/dist/files/dm-sans-latin-600-normal.woff2 +0 -0
- package/dist/files/dm-sans-latin-700-normal.woff +0 -0
- package/dist/files/dm-sans-latin-700-normal.woff2 +0 -0
- package/dist/files/dm-sans-latin-ext-400-normal.woff +0 -0
- package/dist/files/dm-sans-latin-ext-400-normal.woff2 +0 -0
- package/dist/files/dm-sans-latin-ext-500-normal.woff +0 -0
- package/dist/files/dm-sans-latin-ext-500-normal.woff2 +0 -0
- package/dist/files/dm-sans-latin-ext-600-normal.woff +0 -0
- package/dist/files/dm-sans-latin-ext-600-normal.woff2 +0 -0
- package/dist/files/dm-sans-latin-ext-700-normal.woff +0 -0
- package/dist/files/dm-sans-latin-ext-700-normal.woff2 +0 -0
- package/dist/hooks/useAppearanceBootstrap.d.ts.map +1 -1
- package/dist/hooks/useAppearanceBootstrap.js +9 -3
- package/dist/hooks/useAppearanceBootstrap.js.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Header.d.ts +14 -5
- package/dist/layout/Header.d.ts.map +1 -1
- package/dist/layout/Header.js +78 -13
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.d.ts +5 -3
- package/dist/layout/Layout.d.ts.map +1 -1
- package/dist/layout/Layout.js +45 -54
- package/dist/layout/Layout.js.map +1 -1
- package/dist/layout/MobileTabBar.d.ts +20 -0
- package/dist/layout/MobileTabBar.d.ts.map +1 -0
- package/dist/layout/MobileTabBar.js +29 -0
- package/dist/layout/MobileTabBar.js.map +1 -0
- package/dist/layout/Sidebar.d.ts +8 -26
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +309 -345
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/layout/nav-config.d.ts +101 -0
- package/dist/layout/nav-config.d.ts.map +1 -0
- package/dist/layout/nav-config.js +297 -0
- package/dist/layout/nav-config.js.map +1 -0
- package/dist/layout/primitives/AdminShell.d.ts +10 -2
- package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
- package/dist/layout/primitives/AdminShell.js +114 -6
- package/dist/layout/primitives/AdminShell.js.map +1 -1
- package/dist/layout/topbar-classes.d.ts +8 -0
- package/dist/layout/topbar-classes.d.ts.map +1 -0
- package/dist/layout/topbar-classes.js +8 -0
- package/dist/layout/topbar-classes.js.map +1 -0
- package/dist/layout/use-nav-items.d.ts +10 -0
- package/dist/layout/use-nav-items.d.ts.map +1 -0
- package/dist/layout/use-nav-items.js +39 -0
- package/dist/layout/use-nav-items.js.map +1 -0
- package/dist/lib/api.d.ts +8 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +8 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/appearance-api.d.ts +3 -1
- package/dist/lib/appearance-api.d.ts.map +1 -1
- package/dist/lib/appearance-api.js +1 -0
- package/dist/lib/appearance-api.js.map +1 -1
- package/dist/lib/batch-wizard.d.ts +84 -0
- package/dist/lib/batch-wizard.d.ts.map +1 -0
- package/dist/lib/batch-wizard.js +122 -0
- package/dist/lib/batch-wizard.js.map +1 -0
- package/dist/lib/chrome-service.d.ts +69 -0
- package/dist/lib/chrome-service.d.ts.map +1 -0
- package/dist/lib/chrome-service.js +108 -0
- package/dist/lib/chrome-service.js.map +1 -0
- package/dist/lib/job-drawer.d.ts +128 -0
- package/dist/lib/job-drawer.d.ts.map +1 -0
- package/dist/lib/job-drawer.js +351 -0
- package/dist/lib/job-drawer.js.map +1 -0
- package/dist/lib/job-view.d.ts +230 -0
- package/dist/lib/job-view.d.ts.map +1 -0
- package/dist/lib/job-view.js +419 -0
- package/dist/lib/job-view.js.map +1 -0
- package/dist/lib/jobs-service.d.ts +152 -0
- package/dist/lib/jobs-service.d.ts.map +1 -0
- package/dist/lib/jobs-service.js +220 -0
- package/dist/lib/jobs-service.js.map +1 -0
- package/dist/lib/sheet-focus.d.ts +24 -0
- package/dist/lib/sheet-focus.d.ts.map +1 -0
- package/dist/lib/sheet-focus.js +61 -0
- package/dist/lib/sheet-focus.js.map +1 -0
- package/dist/lib/tiers.d.ts +30 -0
- package/dist/lib/tiers.d.ts.map +1 -0
- package/dist/lib/tiers.js +43 -0
- package/dist/lib/tiers.js.map +1 -0
- package/dist/prenta-admin.css +1 -1
- package/dist/router/admin-routes.d.ts.map +1 -1
- package/dist/router/admin-routes.js +8 -0
- package/dist/router/admin-routes.js.map +1 -1
- package/dist/router/index.d.ts +5 -1
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +7 -0
- package/dist/router/index.js.map +1 -1
- package/dist/views/ApiKeys.js +2 -2
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/AppearanceChrome.d.ts +24 -0
- package/dist/views/AppearanceChrome.d.ts.map +1 -0
- package/dist/views/AppearanceChrome.js +461 -0
- package/dist/views/AppearanceChrome.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +73 -53
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +14 -8
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts +6 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +44 -8
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/FormDeliveries.d.ts.map +1 -1
- package/dist/views/FormDeliveries.js +7 -1
- package/dist/views/FormDeliveries.js.map +1 -1
- package/dist/views/FormEntries.d.ts.map +1 -1
- package/dist/views/FormEntries.js +11 -2
- package/dist/views/FormEntries.js.map +1 -1
- package/dist/views/FormSubmissions.d.ts +1 -1
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +15 -4
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +6 -4
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/FormsSettings.d.ts +2 -0
- package/dist/views/FormsSettings.d.ts.map +1 -0
- package/dist/views/FormsSettings.js +37 -0
- package/dist/views/FormsSettings.js.map +1 -0
- package/dist/views/Jobs/BusyButton.d.ts +15 -0
- package/dist/views/Jobs/BusyButton.d.ts.map +1 -0
- package/dist/views/Jobs/BusyButton.js +26 -0
- package/dist/views/Jobs/BusyButton.js.map +1 -0
- package/dist/views/Jobs/CancelJobDialog.d.ts +27 -0
- package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -0
- package/dist/views/Jobs/CancelJobDialog.js +76 -0
- package/dist/views/Jobs/CancelJobDialog.js.map +1 -0
- package/dist/views/Jobs/DrawerActivity.d.ts +16 -0
- package/dist/views/Jobs/DrawerActivity.d.ts.map +1 -0
- package/dist/views/Jobs/DrawerActivity.js +59 -0
- package/dist/views/Jobs/DrawerActivity.js.map +1 -0
- package/dist/views/Jobs/DrawerOverview.d.ts +28 -0
- package/dist/views/Jobs/DrawerOverview.d.ts.map +1 -0
- package/dist/views/Jobs/DrawerOverview.js +32 -0
- package/dist/views/Jobs/DrawerOverview.js.map +1 -0
- package/dist/views/Jobs/DrawerResults.d.ts +25 -0
- package/dist/views/Jobs/DrawerResults.d.ts.map +1 -0
- package/dist/views/Jobs/DrawerResults.js +50 -0
- package/dist/views/Jobs/DrawerResults.js.map +1 -0
- package/dist/views/Jobs/DrawerReview.d.ts +51 -0
- package/dist/views/Jobs/DrawerReview.d.ts.map +1 -0
- package/dist/views/Jobs/DrawerReview.js +110 -0
- package/dist/views/Jobs/DrawerReview.js.map +1 -0
- package/dist/views/Jobs/JobDrawer.d.ts +51 -0
- package/dist/views/Jobs/JobDrawer.d.ts.map +1 -0
- package/dist/views/Jobs/JobDrawer.js +378 -0
- package/dist/views/Jobs/JobDrawer.js.map +1 -0
- package/dist/views/Jobs/JobsTable.d.ts +21 -0
- package/dist/views/Jobs/JobsTable.d.ts.map +1 -0
- package/dist/views/Jobs/JobsTable.js +81 -0
- package/dist/views/Jobs/JobsTable.js.map +1 -0
- package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
- package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
- package/dist/views/Jobs/NewBatchJob.js +282 -0
- package/dist/views/Jobs/NewBatchJob.js.map +1 -0
- package/dist/views/Jobs/WorkerStrip.d.ts +24 -0
- package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -0
- package/dist/views/Jobs/WorkerStrip.js +63 -0
- package/dist/views/Jobs/WorkerStrip.js.map +1 -0
- package/dist/views/Jobs/atoms.d.ts +19 -0
- package/dist/views/Jobs/atoms.d.ts.map +1 -0
- package/dist/views/Jobs/atoms.js +52 -0
- package/dist/views/Jobs/atoms.js.map +1 -0
- package/dist/views/Jobs/focus.d.ts +29 -0
- package/dist/views/Jobs/focus.d.ts.map +1 -0
- package/dist/views/Jobs/focus.js +64 -0
- package/dist/views/Jobs/focus.js.map +1 -0
- package/dist/views/Jobs/icons.d.ts +22 -0
- package/dist/views/Jobs/icons.d.ts.map +1 -0
- package/dist/views/Jobs/icons.js +23 -0
- package/dist/views/Jobs/icons.js.map +1 -0
- package/dist/views/Jobs/useJobDetail.d.ts +21 -0
- package/dist/views/Jobs/useJobDetail.d.ts.map +1 -0
- package/dist/views/Jobs/useJobDetail.js +39 -0
- package/dist/views/Jobs/useJobDetail.js.map +1 -0
- package/dist/views/Jobs/useJobsFeed.d.ts +35 -0
- package/dist/views/Jobs/useJobsFeed.d.ts.map +1 -0
- package/dist/views/Jobs/useJobsFeed.js +231 -0
- package/dist/views/Jobs/useJobsFeed.js.map +1 -0
- package/dist/views/Jobs/useReviewItems.d.ts +39 -0
- package/dist/views/Jobs/useReviewItems.d.ts.map +1 -0
- package/dist/views/Jobs/useReviewItems.js +140 -0
- package/dist/views/Jobs/useReviewItems.js.map +1 -0
- package/dist/views/Jobs.d.ts +19 -1
- package/dist/views/Jobs.d.ts.map +1 -1
- package/dist/views/Jobs.js +204 -35
- package/dist/views/Jobs.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +37 -9
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +34 -5
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -1
- package/dist/views/Posts/NewPostTypeInstructions.js +12 -2
- package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -1
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +10 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts +1 -2
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +31 -3
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +3 -3
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +20 -15
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +115 -51
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/SpamFolder.d.ts +2 -0
- package/dist/views/SpamFolder.d.ts.map +1 -0
- package/dist/views/SpamFolder.js +14 -0
- package/dist/views/SpamFolder.js.map +1 -0
- package/dist/views/Trash.d.ts +6 -1
- package/dist/views/Trash.d.ts.map +1 -1
- package/dist/views/Trash.js +7 -2
- package/dist/views/Trash.js.map +1 -1
- package/dist/views/Users.js +2 -2
- package/dist/views/Users.js.map +1 -1
- package/dist/views/WidgetsPlaceholder.d.ts +2 -0
- package/dist/views/WidgetsPlaceholder.d.ts.map +1 -0
- package/dist/views/WidgetsPlaceholder.js +15 -0
- package/dist/views/WidgetsPlaceholder.js.map +1 -0
- package/dist/views/dashboard/kpi-grid.d.ts +18 -0
- package/dist/views/dashboard/kpi-grid.d.ts.map +1 -0
- package/dist/views/dashboard/kpi-grid.js +36 -0
- package/dist/views/dashboard/kpi-grid.js.map +1 -0
- package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
- package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
- package/dist/views/header-footer/AnnouncementTab.js +43 -0
- package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
- package/dist/views/header-footer/ChromeField.d.ts +25 -0
- package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
- package/dist/views/header-footer/ChromeField.js +99 -0
- package/dist/views/header-footer/ChromeField.js.map +1 -0
- package/dist/views/header-footer/ChromePreview.d.ts +31 -0
- package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
- package/dist/views/header-footer/ChromePreview.js +195 -0
- package/dist/views/header-footer/ChromePreview.js.map +1 -0
- package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
- package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
- package/dist/views/header-footer/DeveloperTab.js +98 -0
- package/dist/views/header-footer/DeveloperTab.js.map +1 -0
- package/dist/views/header-footer/RegionTab.d.ts +27 -0
- package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
- package/dist/views/header-footer/RegionTab.js +80 -0
- package/dist/views/header-footer/RegionTab.js.map +1 -0
- package/dist/views/header-footer/chrome-form.d.ts +137 -0
- package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
- package/dist/views/header-footer/chrome-form.js +518 -0
- package/dist/views/header-footer/chrome-form.js.map +1 -0
- package/dist/views/header-footer/code-samples.d.ts +30 -0
- package/dist/views/header-footer/code-samples.d.ts.map +1 -0
- package/dist/views/header-footer/code-samples.js +127 -0
- package/dist/views/header-footer/code-samples.js.map +1 -0
- package/dist/views/header-footer/controls.d.ts +57 -0
- package/dist/views/header-footer/controls.d.ts.map +1 -0
- package/dist/views/header-footer/controls.js +43 -0
- package/dist/views/header-footer/controls.js.map +1 -0
- package/dist/views/lazy-views.d.ts +4 -0
- package/dist/views/lazy-views.d.ts.map +1 -1
- package/dist/views/lazy-views.js +4 -0
- package/dist/views/lazy-views.js.map +1 -1
- package/dist/views/page-editor/PageTab.js +1 -1
- package/dist/views/page-editor/PageTab.js.map +1 -1
- package/dist/views/page-editor/ReviewBar.js +1 -1
- package/dist/views/page-editor/ReviewBar.js.map +1 -1
- package/dist/views/page-editor/VersionHistoryPanel.js +1 -1
- package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -1
- package/dist/views/seo/AuditTab.js +1 -1
- package/dist/views/seo/AuditTab.js.map +1 -1
- package/dist/views/seo/OverviewTab.d.ts.map +1 -1
- package/dist/views/seo/OverviewTab.js +2 -2
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/ProposalsTab.js +1 -1
- package/dist/views/seo/ProposalsTab.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
- package/dist/views/settings/AISettingsTab.js +4 -2
- package/dist/views/settings/AISettingsTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +1 -1
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/AdminThemeCard.d.ts.map +1 -1
- package/dist/views/settings/AdminThemeCard.js +63 -15
- package/dist/views/settings/AdminThemeCard.js.map +1 -1
- package/dist/views/settings/AppearanceTab.d.ts.map +1 -1
- package/dist/views/settings/AppearanceTab.js +4 -2
- package/dist/views/settings/AppearanceTab.js.map +1 -1
- package/dist/views/settings/BrandPreviewCard.js +1 -1
- package/dist/views/settings/BrandPreviewCard.js.map +1 -1
- package/dist/views/settings/BrandVoiceCard.js +1 -1
- package/dist/views/settings/BrandVoiceCard.js.map +1 -1
- package/dist/views/settings/EmailSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/EmailSettingsTab.js +5 -3
- package/dist/views/settings/EmailSettingsTab.js.map +1 -1
- package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/GeneralSettingsTab.js +4 -2
- package/dist/views/settings/GeneralSettingsTab.js.map +1 -1
- package/dist/views/settings/ProviderConnectionCard.js +1 -1
- package/dist/views/settings/ProviderConnectionCard.js.map +1 -1
- package/dist/views/settings/SearchDataCard.js +1 -1
- package/dist/views/settings/SearchDataCard.js.map +1 -1
- package/dist/views/settings/SecurityStatusCard.js +2 -2
- package/dist/views/settings/SecurityStatusCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +4 -2
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.js +2 -2
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +5 -3
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/SiteIndexStatusCard.js +1 -1
- package/dist/views/settings/SiteIndexStatusCard.js.map +1 -1
- package/dist/views/settings/SiteThemeCard.js +1 -1
- package/dist/views/settings/UpdatesTab.d.ts.map +1 -1
- package/dist/views/settings/UpdatesTab.js +6 -5
- package/dist/views/settings/UpdatesTab.js.map +1 -1
- package/dist/views/settings/components.d.ts +7 -11
- package/dist/views/settings/components.d.ts.map +1 -1
- package/dist/views/settings/components.js +18 -12
- package/dist/views/settings/components.js.map +1 -1
- package/dist/views/settings/useAppearanceSettings.d.ts +7 -1
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
- package/dist/views/settings/useAppearanceSettings.js +89 -22
- package/dist/views/settings/useAppearanceSettings.js.map +1 -1
- package/dist/views/users/UserDetailDrawer.js +1 -1
- package/dist/views/users/UserDetailDrawer.js.map +1 -1
- package/package.json +10 -8
- package/src/AdminRoot.tsx +103 -31
- package/src/LoginRoot.tsx +2 -1
- package/src/__tests__/components/author-avatar.test.tsx +3 -20
- package/src/__tests__/components/notification-bell.test.tsx +146 -14
- package/src/__tests__/components/responsive-tiers.render.test.tsx +461 -0
- package/src/__tests__/components/serp-preview.render.test.tsx +6 -3
- package/src/__tests__/components/shell-primitives.render.test.tsx +951 -0
- package/src/__tests__/components/theme-provider-accent.test.tsx +83 -8
- package/src/__tests__/components/toggle.test.tsx +11 -3
- package/src/__tests__/components/ui-primitives.test.tsx +5 -8
- package/src/__tests__/layout/build-nav-items.test.ts +1 -1
- package/src/__tests__/layout/shell-nav.render.test.tsx +983 -0
- package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +8 -4
- package/src/__tests__/layout/sidebar-groups.render.test.tsx +116 -0
- package/src/__tests__/layout/sidebar-menus.render.test.tsx +116 -0
- package/src/__tests__/layout/sidebar-plugin-nav.test.ts +1 -1
- package/src/__tests__/layout/sidebar-posts.test.ts +48 -40
- package/src/__tests__/layout/sidebar-submenu.render.test.tsx +35 -10
- package/src/__tests__/layout/topbar.render.test.tsx +464 -0
- package/src/__tests__/lib/api.test.ts +2 -0
- package/src/__tests__/lib/batch-wizard.test.ts +199 -0
- package/src/__tests__/lib/job-drawer.test.ts +495 -0
- package/src/__tests__/lib/job-view.test.ts +558 -0
- package/src/__tests__/lib/jobs-service.test.ts +167 -0
- package/src/__tests__/router/admin-route-order.test.ts +39 -0
- package/src/__tests__/styles/ai-gradient-contrast.test.ts +135 -0
- package/src/__tests__/styles/breakpoint-order.test.ts +68 -0
- package/src/__tests__/styles/responsive-layer.test.ts +367 -12
- package/src/__tests__/styles/tailwind-preset.test.ts +154 -0
- package/src/__tests__/styles/theme-token-scope.test.ts +10 -3
- package/src/__tests__/styles/tiers.test.ts +136 -0
- package/src/__tests__/styles/tokens-drift.test.ts +67 -6
- package/src/__tests__/views/admin-lazy-route.render.test.tsx +41 -1
- package/src/__tests__/views/ai-settings.render.test.tsx +4 -4
- package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
- package/src/__tests__/views/appearance-menus-route.render.test.tsx +58 -0
- package/src/__tests__/views/appearance-settings.render.test.tsx +10 -8
- package/src/__tests__/views/appearance-theme-provider.render.test.tsx +347 -0
- package/src/__tests__/views/dashboard.test.tsx +48 -0
- package/src/__tests__/views/document-edit-navigation.render.test.tsx +130 -0
- package/src/__tests__/views/document-edit-routes.render.test.tsx +130 -0
- package/src/__tests__/views/email-settings.render.test.tsx +2 -2
- package/src/__tests__/views/form-entries.render.test.tsx +22 -0
- package/src/__tests__/views/form-submissions.render.test.tsx +22 -0
- package/src/__tests__/views/forms-list.render.test.tsx +13 -0
- package/src/__tests__/views/general-settings.render.test.tsx +38 -11
- package/src/__tests__/views/header-footer-form.test.ts +333 -0
- package/src/__tests__/views/jobs-drawer.render.test.tsx +1553 -0
- package/src/__tests__/views/jobs-list.render.test.tsx +853 -0
- package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
- package/src/__tests__/views/kpi-grid.test.ts +29 -0
- package/src/__tests__/views/post-section-editor.test.tsx +5 -1
- package/src/__tests__/views/script-tags.render.test.tsx +29 -0
- package/src/__tests__/views/security-settings.render.test.tsx +9 -10
- package/src/__tests__/views/seo-settings.render.test.tsx +7 -9
- package/src/__tests__/views/settings-save-bar.render.test.tsx +91 -0
- package/src/__tests__/views/settings-save-footer.render.test.tsx +25 -15
- package/src/__tests__/views/settings-tabs.render.test.tsx +371 -0
- package/src/__tests__/views/updates-tab.render.test.tsx +4 -3
- package/src/components/Breadcrumbs.tsx +7 -1
- package/src/components/CommandPalette.tsx +51 -3
- package/src/components/LocaleSwitcher.tsx +5 -6
- package/src/components/NotificationBell.tsx +74 -18
- package/src/components/OfflineStatus.tsx +1 -1
- package/src/components/PresenceIndicator.tsx +2 -1
- package/src/components/ReflowMeter.tsx +3 -3
- package/src/components/SEOPanel.tsx +2 -2
- package/src/components/ThemeProvider.tsx +122 -42
- package/src/components/TipTapEditor.tsx +3 -4
- package/src/components/forms/EmbedPanel.tsx +1 -1
- package/src/components/forms/FormEntryDetailPane.tsx +7 -1
- package/src/components/forms/ShortcodeGlossary.tsx +7 -1
- package/src/components/forms/primitives.tsx +37 -21
- package/src/components/pages/tag-badges.tsx +4 -3
- package/src/components/seo/PageAuditPanel.tsx +2 -2
- package/src/components/seo/SerpPreview.tsx +5 -5
- package/src/components/seo/primitives.tsx +2 -2
- package/src/components/ui/AuthorAvatar.tsx +11 -10
- package/src/components/ui/Badge.tsx +1 -1
- package/src/components/ui/Breadcrumb.tsx +109 -0
- package/src/components/ui/Button.tsx +40 -15
- package/src/components/ui/Card.tsx +35 -32
- package/src/components/ui/CardListControls.tsx +90 -0
- package/src/components/ui/CountChip.tsx +90 -0
- package/src/components/ui/FilterPill.tsx +5 -3
- package/src/components/ui/InspectorPane.tsx +21 -7
- package/src/components/ui/Modal.tsx +20 -6
- package/src/components/ui/PageHeader.tsx +63 -0
- package/src/components/ui/RowActions.tsx +7 -2
- package/src/components/ui/Segmented.tsx +97 -0
- package/src/components/ui/Toaster.tsx +139 -0
- package/src/components/ui/Toggle.tsx +18 -14
- package/src/components/ui/UnderlineTabs.tsx +373 -0
- package/src/components/ui/UnsavedBar.tsx +119 -0
- package/src/components/ui/admin-links.tsx +20 -0
- package/src/components/ui/card-table.ts +34 -0
- package/src/components/virtual-window.tsx +13 -2
- package/src/fields/RelationshipField.tsx +1 -1
- package/src/hooks/useAppearanceBootstrap.ts +9 -3
- package/src/index.ts +19 -0
- package/src/layout/Header.tsx +210 -135
- package/src/layout/Layout.tsx +61 -108
- package/src/layout/MobileTabBar.tsx +92 -0
- package/src/layout/Sidebar.tsx +555 -593
- package/src/layout/nav-config.tsx +425 -0
- package/src/layout/primitives/AdminShell.tsx +154 -6
- package/src/layout/topbar-classes.ts +8 -0
- package/src/layout/use-nav-items.ts +40 -0
- package/src/lib/api.ts +18 -1
- package/src/lib/appearance-api.ts +4 -0
- package/src/lib/batch-wizard.ts +188 -0
- package/src/lib/chrome-service.ts +200 -0
- package/src/lib/job-drawer.ts +424 -0
- package/src/lib/job-view.ts +550 -0
- package/src/lib/jobs-service.ts +345 -0
- package/src/lib/sheet-focus.ts +71 -0
- package/src/lib/tiers.ts +43 -0
- package/src/router/admin-routes.ts +8 -0
- package/src/router/index.ts +9 -1
- package/src/styles/prenta-tokens.css +280 -82
- package/src/styles/responsive.css +460 -22
- package/src/styles/theme.css +192 -28
- package/src/views/ApiKeys.tsx +2 -2
- package/src/views/AppearanceChrome.tsx +732 -0
- package/src/views/CollectionList.tsx +264 -205
- package/src/views/Dashboard.tsx +26 -13
- package/src/views/DocumentEdit.tsx +111 -56
- package/src/views/FormDeliveries.tsx +30 -17
- package/src/views/FormEntries.tsx +28 -20
- package/src/views/FormSubmissions.tsx +32 -34
- package/src/views/Forms.tsx +55 -34
- package/src/views/FormsSettings.tsx +94 -0
- package/src/views/Jobs/BusyButton.tsx +56 -0
- package/src/views/Jobs/CancelJobDialog.tsx +145 -0
- package/src/views/Jobs/DrawerActivity.tsx +142 -0
- package/src/views/Jobs/DrawerOverview.tsx +139 -0
- package/src/views/Jobs/DrawerResults.tsx +155 -0
- package/src/views/Jobs/DrawerReview.tsx +464 -0
- package/src/views/Jobs/JobDrawer.tsx +636 -0
- package/src/views/Jobs/JobsTable.tsx +267 -0
- package/src/views/Jobs/NewBatchJob.tsx +857 -0
- package/src/views/Jobs/WorkerStrip.tsx +149 -0
- package/src/views/Jobs/atoms.tsx +93 -0
- package/src/views/Jobs/focus.ts +67 -0
- package/src/views/Jobs/icons.tsx +111 -0
- package/src/views/Jobs/useJobDetail.ts +64 -0
- package/src/views/Jobs/useJobsFeed.ts +249 -0
- package/src/views/Jobs/useReviewItems.ts +187 -0
- package/src/views/Jobs.tsx +396 -105
- package/src/views/MediaBrowser.tsx +71 -21
- package/src/views/Pages/PagesListView.tsx +115 -39
- package/src/views/Posts/NewPostTypeInstructions.tsx +15 -10
- package/src/views/Posts/PostTypesView.tsx +28 -6
- package/src/views/Posts/PostsListView.tsx +130 -40
- package/src/views/SEO.tsx +23 -8
- package/src/views/ScriptTags.tsx +233 -153
- package/src/views/Settings.tsx +215 -256
- package/src/views/SpamFolder.tsx +32 -0
- package/src/views/Trash.tsx +12 -1
- package/src/views/Users.tsx +2 -2
- package/src/views/WidgetsPlaceholder.tsx +33 -0
- package/src/views/dashboard/kpi-grid.ts +38 -0
- package/src/views/header-footer/AnnouncementTab.tsx +231 -0
- package/src/views/header-footer/ChromeField.tsx +358 -0
- package/src/views/header-footer/ChromePreview.tsx +497 -0
- package/src/views/header-footer/DeveloperTab.tsx +331 -0
- package/src/views/header-footer/RegionTab.tsx +398 -0
- package/src/views/header-footer/chrome-form.ts +603 -0
- package/src/views/header-footer/code-samples.ts +146 -0
- package/src/views/header-footer/controls.tsx +166 -0
- package/src/views/lazy-views.tsx +12 -0
- package/src/views/page-editor/PageTab.tsx +2 -2
- package/src/views/page-editor/ReviewBar.tsx +1 -1
- package/src/views/page-editor/VersionHistoryPanel.tsx +1 -1
- package/src/views/seo/AuditTab.tsx +1 -1
- package/src/views/seo/OverviewTab.tsx +6 -5
- package/src/views/seo/ProposalsTab.tsx +1 -1
- package/src/views/settings/AISettingsTab.tsx +10 -6
- package/src/views/settings/AccessProtectionCard.tsx +1 -1
- package/src/views/settings/AdminThemeCard.tsx +228 -35
- package/src/views/settings/AppearanceTab.tsx +10 -6
- package/src/views/settings/BrandPreviewCard.tsx +1 -1
- package/src/views/settings/BrandVoiceCard.tsx +1 -1
- package/src/views/settings/EmailSettingsTab.tsx +11 -7
- package/src/views/settings/GeneralSettingsTab.tsx +10 -6
- package/src/views/settings/ProviderConnectionCard.tsx +1 -1
- package/src/views/settings/SearchDataCard.tsx +1 -1
- package/src/views/settings/SecurityStatusCard.tsx +2 -2
- package/src/views/settings/SecurityTab.tsx +10 -6
- package/src/views/settings/SeoAutopilotCard.tsx +2 -2
- package/src/views/settings/SeoSettingsTab.tsx +13 -9
- package/src/views/settings/SiteIndexStatusCard.tsx +1 -1
- package/src/views/settings/SiteThemeCard.tsx +1 -1
- package/src/views/settings/UpdatesTab.tsx +107 -101
- package/src/views/settings/components.tsx +26 -62
- package/src/views/settings/useAppearanceSettings.ts +116 -25
- package/src/views/users/UserDetailDrawer.tsx +1 -1
- package/dist/components/ui/StatPill.d.ts +0 -18
- package/dist/components/ui/StatPill.d.ts.map +0 -1
- package/dist/components/ui/StatPill.js +0 -5
- package/dist/components/ui/StatPill.js.map +0 -1
- package/src/components/ui/StatPill.tsx +0 -37
|
@@ -0,0 +1,1553 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
4
|
+
import type { Job, JobEvent, JobReviewItem } from '@prenta/core'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The job drawer (P-18): a modal dialog with Overview, Review or Results,
|
|
8
|
+
* Activity and Details, on the real endpoints. The list's refresh is
|
|
9
|
+
* answered by the `fetch` stub; the drawer's reads and decisions by the
|
|
10
|
+
* `cmsApi` mock. Both read `server` below.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MIN = 60_000
|
|
14
|
+
|
|
15
|
+
interface Reply {
|
|
16
|
+
data?: unknown
|
|
17
|
+
body?: unknown
|
|
18
|
+
error?: string
|
|
19
|
+
status: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Call {
|
|
23
|
+
endpoint: string
|
|
24
|
+
method: string
|
|
25
|
+
body: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Server {
|
|
29
|
+
jobs: Job[]
|
|
30
|
+
details: Record<string, Partial<Job>>
|
|
31
|
+
events: Record<string, JobEvent[]>
|
|
32
|
+
items: Record<string, JobReviewItem[]>
|
|
33
|
+
/** `GET /jobs/:id/review` fails with this status. */
|
|
34
|
+
reviewError: { status: number; message: string } | null
|
|
35
|
+
decide: (jobId: string, itemId: string, body: Record<string, unknown>) => Reply | Promise<Reply>
|
|
36
|
+
approveAll: (jobId: string) => Reply | Promise<Reply>
|
|
37
|
+
/** `POST /jobs/run`, `/jobs/:id/cancel` and `/jobs/:id/retry`. */
|
|
38
|
+
action: (endpoint: string, body: unknown) => Reply | Promise<Reply>
|
|
39
|
+
calls: Call[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const server: Server = {
|
|
43
|
+
jobs: [],
|
|
44
|
+
details: {},
|
|
45
|
+
events: {},
|
|
46
|
+
items: {},
|
|
47
|
+
reviewError: null,
|
|
48
|
+
decide: () => ({ error: 'not stubbed', status: 500 }),
|
|
49
|
+
approveAll: () => ({ error: 'not stubbed', status: 500 }),
|
|
50
|
+
action: () => ({ error: 'not stubbed', status: 500 }),
|
|
51
|
+
calls: [],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ok(data: unknown, extra: Record<string, unknown> = {}): Reply {
|
|
55
|
+
return { data, body: { data, ...extra }, status: 200 }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cmsApiStub(endpoint: string, init: RequestInit = {}): Reply | Promise<Reply> {
|
|
59
|
+
const method = (init.method ?? 'GET').toUpperCase()
|
|
60
|
+
const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined
|
|
61
|
+
server.calls.push({ endpoint, method, body })
|
|
62
|
+
const url = new URL(endpoint, 'http://x')
|
|
63
|
+
const [, , jobId, part, itemId] = url.pathname.split('/')
|
|
64
|
+
const job = server.jobs.find((entry) => entry.id === jobId)
|
|
65
|
+
if (method === 'GET' && part === undefined) {
|
|
66
|
+
return job ? ok({ ...job, ...server.details[job.id] }) : { error: 'Job not found', status: 404 }
|
|
67
|
+
}
|
|
68
|
+
if (method === 'GET' && part === 'events') return ok({ events: server.events[jobId!] ?? [] })
|
|
69
|
+
if (method === 'GET' && part === 'review') {
|
|
70
|
+
if (server.reviewError)
|
|
71
|
+
return { error: server.reviewError.message, status: server.reviewError.status }
|
|
72
|
+
const limit = Number(url.searchParams.get('limit'))
|
|
73
|
+
const cursor = url.searchParams.get('cursor')
|
|
74
|
+
const all = [...(server.items[jobId!] ?? [])].sort((a, b) => a.id.localeCompare(b.id))
|
|
75
|
+
const after = cursor ? all.filter((item) => item.id > cursor) : all
|
|
76
|
+
const items = after.slice(0, limit)
|
|
77
|
+
const last = items.at(-1)
|
|
78
|
+
return ok({ items, nextCursor: items.length === limit && last ? last.id : null })
|
|
79
|
+
}
|
|
80
|
+
if (method === 'POST' && part === 'review' && itemId === 'approve-all')
|
|
81
|
+
return server.approveAll(jobId!)
|
|
82
|
+
if (method === 'POST' && part === 'review') return server.decide(jobId!, itemId!, body)
|
|
83
|
+
if (method === 'POST' && (jobId === 'run' || part === 'cancel' || part === 'retry'))
|
|
84
|
+
return server.action(endpoint, body)
|
|
85
|
+
return { error: `unexpected ${method} ${endpoint}`, status: 404 }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
vi.mock('../../lib/api.js', () => ({
|
|
89
|
+
getApiBase: () => '/api/cms',
|
|
90
|
+
cmsApi: vi.fn(async (endpoint: string, init?: RequestInit) => cmsApiStub(endpoint, init)),
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
const toast = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn() }))
|
|
94
|
+
vi.mock('sonner', () => ({ toast }))
|
|
95
|
+
|
|
96
|
+
/** The list's refresh reads through fetch (it needs response headers). */
|
|
97
|
+
function fetchStub(url: string) {
|
|
98
|
+
const endpoint = url.replace(/^\/api\/cms/, '')
|
|
99
|
+
const data = endpoint.endsWith('/jobs/worker')
|
|
100
|
+
? { lastTickAt: null, lastTickMs: null, nextTickAt: null, budgetMs: 20000, schedules: [] }
|
|
101
|
+
: { jobs: server.jobs }
|
|
102
|
+
return Promise.resolve({
|
|
103
|
+
ok: true,
|
|
104
|
+
status: 200,
|
|
105
|
+
headers: { get: () => null },
|
|
106
|
+
json: async () => ({ data }),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { Jobs } = await import('../../views/Jobs.js')
|
|
111
|
+
|
|
112
|
+
type Viewer = { role: string | null; userId: string | null }
|
|
113
|
+
/** Someone who may run, cancel and retry any job. */
|
|
114
|
+
const ADMIN: Viewer = { role: 'ADMIN', userId: 'u1' }
|
|
115
|
+
|
|
116
|
+
function iso(offsetMs: number): string {
|
|
117
|
+
return new Date(Date.now() + offsetMs).toISOString()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function batch(overrides: Partial<Job> & { done?: number; total?: number }): Job {
|
|
121
|
+
const { done = 19, total = 42, ...rest } = overrides
|
|
122
|
+
return {
|
|
123
|
+
id: 'job_417',
|
|
124
|
+
type: 'batch-ai',
|
|
125
|
+
status: 'running',
|
|
126
|
+
title: 'Rewrite meta descriptions',
|
|
127
|
+
scope: '42 posts with a missing or over-length description',
|
|
128
|
+
runAt: iso(0),
|
|
129
|
+
attempts: 0,
|
|
130
|
+
maxAttempts: 3,
|
|
131
|
+
progress: { done, total, unit: 'documents', cursor: String(done), completed: done },
|
|
132
|
+
lastError: null,
|
|
133
|
+
explain: null,
|
|
134
|
+
summary: null,
|
|
135
|
+
lockedAt: iso(-5_000),
|
|
136
|
+
cancelRequestedAt: null,
|
|
137
|
+
cancelledBy: null,
|
|
138
|
+
createdBy: { id: 'u1', name: 'Brandon West', initials: 'BW' },
|
|
139
|
+
createdAt: iso(-19 * MIN),
|
|
140
|
+
updatedAt: iso(0),
|
|
141
|
+
finishedAt: null,
|
|
142
|
+
usage: { tokensIn: 11_500, tokensOut: 5_800 },
|
|
143
|
+
payload: {
|
|
144
|
+
operation: 'meta',
|
|
145
|
+
instruction: 'Plain, specific, under 155 characters.',
|
|
146
|
+
source: 'blog',
|
|
147
|
+
statusFilter: 'all',
|
|
148
|
+
documentIds: ['d1', 'd2', 'd3'],
|
|
149
|
+
review: 'review',
|
|
150
|
+
onApprove: 'draft',
|
|
151
|
+
},
|
|
152
|
+
review: { pending: 0, approved: 0, rejected: 0 },
|
|
153
|
+
...rest,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function item(id: string, overrides: Partial<JobReviewItem> = {}): JobReviewItem {
|
|
158
|
+
return {
|
|
159
|
+
id,
|
|
160
|
+
jobId: 'job_417',
|
|
161
|
+
documentId: `doc-${id}`,
|
|
162
|
+
documentTitle: `Post ${id}`,
|
|
163
|
+
url: `/blog/${id}`,
|
|
164
|
+
field: 'metaDescription',
|
|
165
|
+
before: '',
|
|
166
|
+
after: `Suggested description for ${id}.`,
|
|
167
|
+
editedAfter: null,
|
|
168
|
+
status: 'pending',
|
|
169
|
+
decidedBy: null,
|
|
170
|
+
decidedAt: null,
|
|
171
|
+
appliedRevisionId: null,
|
|
172
|
+
...overrides,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Items r01…rNN on a job, all pending. */
|
|
177
|
+
function pending(jobId: string, count: number): JobReviewItem[] {
|
|
178
|
+
return Array.from({ length: count }, (_, index) =>
|
|
179
|
+
item(`r${String(index + 1).padStart(2, '0')}`, { jobId }),
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function decided(entry: JobReviewItem, status: 'approved' | 'rejected'): JobReviewItem {
|
|
184
|
+
return {
|
|
185
|
+
...entry,
|
|
186
|
+
status,
|
|
187
|
+
decidedAt: iso(0),
|
|
188
|
+
decidedBy: { id: 'u1', name: 'Brandon West', initials: 'BW' },
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
beforeEach(() => {
|
|
193
|
+
vi.stubGlobal('fetch', vi.fn(fetchStub))
|
|
194
|
+
Object.assign(server, {
|
|
195
|
+
jobs: [],
|
|
196
|
+
details: {},
|
|
197
|
+
events: {},
|
|
198
|
+
items: {},
|
|
199
|
+
reviewError: null,
|
|
200
|
+
calls: [],
|
|
201
|
+
approveAll: () => ({ error: 'not stubbed', status: 500 }),
|
|
202
|
+
action: () => ({ error: 'not stubbed', status: 500 }),
|
|
203
|
+
})
|
|
204
|
+
// By default a decision goes through: the change takes the new status.
|
|
205
|
+
server.decide = (jobId, itemId, body) => {
|
|
206
|
+
const list = server.items[jobId] ?? []
|
|
207
|
+
const index = list.findIndex((entry) => entry.id === itemId)
|
|
208
|
+
const current = list[index]!
|
|
209
|
+
const next =
|
|
210
|
+
body.action === 'undo'
|
|
211
|
+
? { ...current, status: 'pending' as const, decidedAt: null, decidedBy: null }
|
|
212
|
+
: decided(
|
|
213
|
+
body.after ? { ...current, editedAfter: String(body.after) } : current,
|
|
214
|
+
body.action === 'approve' ? 'approved' : 'rejected',
|
|
215
|
+
)
|
|
216
|
+
list[index] = next
|
|
217
|
+
return ok(next, { documentStatus: body.onApprove === 'publish' ? 'PUBLISHED' : 'DRAFT' })
|
|
218
|
+
}
|
|
219
|
+
toast.mockClear()
|
|
220
|
+
toast.error.mockClear()
|
|
221
|
+
})
|
|
222
|
+
afterEach(() => {
|
|
223
|
+
vi.useRealTimers()
|
|
224
|
+
vi.unstubAllGlobals()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
function rowButton(id: string): HTMLElement {
|
|
228
|
+
const button = document.getElementById(`job-open-${id}`)
|
|
229
|
+
if (!button) throw new Error(`no row ${id}`)
|
|
230
|
+
return button
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function renderJobs(onNavigate: (path: string) => void = () => {}, viewer: Viewer = ADMIN) {
|
|
234
|
+
render(<Jobs onNavigate={onNavigate} {...viewer} />)
|
|
235
|
+
await screen.findByRole('list', { name: 'Jobs' })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function openJob(id: string, tab?: string): Promise<HTMLElement> {
|
|
239
|
+
const row = rowButton(id)
|
|
240
|
+
row.focus()
|
|
241
|
+
fireEvent.click(row)
|
|
242
|
+
const dialog = await screen.findByRole('dialog')
|
|
243
|
+
if (tab) fireEvent.click(within(dialog).getByRole('tab', { name: new RegExp(`^${tab}`) }))
|
|
244
|
+
return dialog
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function card(id: string): HTMLElement {
|
|
248
|
+
const element = document.querySelector<HTMLElement>(`[data-review-item="${id}"]`)
|
|
249
|
+
if (!element) throw new Error(`no change ${id}`)
|
|
250
|
+
return element
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Radix hides its positioned menu in happy-dom, so the items are queried with `hidden`. */
|
|
254
|
+
async function approveWith(entry: HTMLElement, option: string) {
|
|
255
|
+
const trigger = within(entry).getByRole('button', { name: /^Approve/ })
|
|
256
|
+
fireEvent.keyDown(trigger, { key: 'Enter' })
|
|
257
|
+
await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('true'))
|
|
258
|
+
fireEvent.click(screen.getByRole('menuitem', { name: option, hidden: true }))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function menuOptions(entry: HTMLElement): Promise<string[]> {
|
|
262
|
+
const trigger = within(entry).getByRole('button', { name: /^Approve/ })
|
|
263
|
+
fireEvent.keyDown(trigger, { key: 'Enter' })
|
|
264
|
+
await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('true'))
|
|
265
|
+
const options = screen
|
|
266
|
+
.getAllByRole('menuitem', { hidden: true })
|
|
267
|
+
.map((option) => option.textContent ?? '')
|
|
268
|
+
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' })
|
|
269
|
+
await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('false'))
|
|
270
|
+
return options
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const posts = (method: string, part: string) =>
|
|
274
|
+
server.calls.filter((call) => call.method === method && call.endpoint.includes(part))
|
|
275
|
+
|
|
276
|
+
/** A change's note (not a live region). */
|
|
277
|
+
function noteOf(entry: HTMLElement): string {
|
|
278
|
+
return entry.querySelector('[data-review-note]')?.textContent ?? ''
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** What the drawer's live region last announced. */
|
|
282
|
+
function announced(): string {
|
|
283
|
+
return document.querySelector('[data-job-drawer] [aria-live="polite"]')?.textContent ?? ''
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Let "Approve all" act: it ignores activations for 600ms after Review opens. */
|
|
287
|
+
async function waitOutArming() {
|
|
288
|
+
await act(async () => {
|
|
289
|
+
await vi.advanceTimersByTimeAsync(700)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
describe('Jobs drawer', () => {
|
|
294
|
+
it('opens from the row and returns focus to it when Escape closes it', async () => {
|
|
295
|
+
server.jobs = [batch({})]
|
|
296
|
+
await renderJobs()
|
|
297
|
+
const row = rowButton('job_417')
|
|
298
|
+
expect(row.getAttribute('aria-haspopup')).toBe('dialog')
|
|
299
|
+
const dialog = await openJob('job_417')
|
|
300
|
+
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
|
301
|
+
expect(screen.getByRole('dialog', { name: 'Rewrite meta descriptions' })).toBe(dialog)
|
|
302
|
+
expect(row.closest('li')?.className).toContain('bg-acc-l')
|
|
303
|
+
fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape' })
|
|
304
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
305
|
+
await waitFor(() => expect(document.activeElement).toBe(row))
|
|
306
|
+
expect(row.closest('li')?.className).not.toContain('bg-acc-l')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('focuses the row when the opener never took focus (Safari clicks)', async () => {
|
|
310
|
+
server.jobs = [batch({})]
|
|
311
|
+
await renderJobs()
|
|
312
|
+
const row = rowButton('job_417')
|
|
313
|
+
;(document.activeElement as HTMLElement | null)?.blur()
|
|
314
|
+
fireEvent.click(row)
|
|
315
|
+
await screen.findByRole('dialog')
|
|
316
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
|
317
|
+
await waitFor(() => expect(document.activeElement).toBe(row))
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('shows the overview: status sentence, progress, facts and instruction', async () => {
|
|
321
|
+
server.jobs = [batch({ review: { pending: 6, approved: 0, rejected: 0 } })]
|
|
322
|
+
server.items.job_417 = pending('job_417', 6)
|
|
323
|
+
await renderJobs()
|
|
324
|
+
const dialog = await openJob('job_417')
|
|
325
|
+
expect(
|
|
326
|
+
within(dialog).getByRole('tab', { name: 'Overview' }).getAttribute('aria-selected'),
|
|
327
|
+
).toBe('true')
|
|
328
|
+
expect(dialog.textContent).toContain(
|
|
329
|
+
'Running slice 20 of 42 now. Each slice applies the operation to one document.',
|
|
330
|
+
)
|
|
331
|
+
expect(dialog.textContent).toContain('19 / 42 documents')
|
|
332
|
+
const facts = within(dialog).getByText('Attempts').closest('div')!
|
|
333
|
+
expect(facts.textContent).toBe('Attempts1 of 3')
|
|
334
|
+
expect(within(dialog).getByText('17,300 tokens')).toBeTruthy()
|
|
335
|
+
expect(within(dialog).getByText('Rewrite meta descriptions', { selector: 'dd' })).toBeTruthy()
|
|
336
|
+
expect(dialog.textContent).toContain('“Plain, specific, under 155 characters.”')
|
|
337
|
+
expect(within(dialog).getByRole('button', { name: 'Review 6 changes' })).toBeTruthy()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('shows the full error with Copy and the AI explanation on a failed job', async () => {
|
|
341
|
+
const stack = 'ProviderError: 429 Too Many Requests\n at callModel (lib/ai/provider.ts:88)'
|
|
342
|
+
server.jobs = [
|
|
343
|
+
batch({
|
|
344
|
+
id: 'job_415',
|
|
345
|
+
status: 'dead',
|
|
346
|
+
attempts: 3,
|
|
347
|
+
done: 7,
|
|
348
|
+
total: 30,
|
|
349
|
+
lockedAt: null,
|
|
350
|
+
lastError: 'ProviderError: 429 Too Many Requests',
|
|
351
|
+
explain: 'Your AI provider was rate-limiting requests.',
|
|
352
|
+
}),
|
|
353
|
+
]
|
|
354
|
+
server.details.job_415 = { lastError: stack }
|
|
355
|
+
const writeText = vi.fn(async () => {})
|
|
356
|
+
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
|
357
|
+
await renderJobs()
|
|
358
|
+
const dialog = await openJob('job_415')
|
|
359
|
+
expect(dialog.textContent).toContain('Stopped after 3 attempts at document 8.')
|
|
360
|
+
await waitFor(() => expect(dialog.querySelector('pre')?.textContent).toBe(stack))
|
|
361
|
+
expect(within(dialog).getByText('What happened')).toBeTruthy()
|
|
362
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Copy' }))
|
|
363
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Error copied'))
|
|
364
|
+
expect(writeText).toHaveBeenCalledWith(stack)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('counts from the API: badge and footer use review.pending, and changes load 20 at a time', async () => {
|
|
368
|
+
server.jobs = [batch({ review: { pending: 30, approved: 0, rejected: 0 } })]
|
|
369
|
+
server.items.job_417 = pending('job_417', 30)
|
|
370
|
+
await renderJobs()
|
|
371
|
+
const dialog = await openJob('job_417', 'Review')
|
|
372
|
+
await waitFor(() => expect(dialog.querySelectorAll('[data-review-item]')).toHaveLength(20))
|
|
373
|
+
expect(within(dialog).getByRole('tab', { name: /^Review/ }).textContent).toBe(
|
|
374
|
+
'Review30 waiting',
|
|
375
|
+
)
|
|
376
|
+
expect(within(dialog).getByRole('button', { name: 'Approve all 30' })).toBeTruthy()
|
|
377
|
+
expect(dialog.textContent).toContain('30 waiting · 0 approved · 0 rejected')
|
|
378
|
+
const reads = posts('GET', '/review')
|
|
379
|
+
expect(reads.at(-1)?.endpoint).toBe('/jobs/job_417/review?limit=20')
|
|
380
|
+
|
|
381
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Show 10 more' }))
|
|
382
|
+
// Loading and drawing 10 more changes takes about 0.2s here, but CI runs
|
|
383
|
+
// the suite 9-13 times slower with coverage on, past waitFor's 1s default.
|
|
384
|
+
await waitFor(() => expect(dialog.querySelectorAll('[data-review-item]')).toHaveLength(30), {
|
|
385
|
+
timeout: 5_000,
|
|
386
|
+
})
|
|
387
|
+
expect(posts('GET', '/review').at(-1)?.endpoint).toBe(
|
|
388
|
+
'/jobs/job_417/review?limit=20&cursor=r20',
|
|
389
|
+
)
|
|
390
|
+
expect(within(dialog).queryByRole('button', { name: /^Show/ })).toBeNull()
|
|
391
|
+
await waitFor(() => expect(document.activeElement).toBe(card('r21')), { timeout: 5_000 })
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('keeps focus on the footer button as Review n changes becomes Approve all', async () => {
|
|
395
|
+
server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
|
|
396
|
+
server.items.job_417 = pending('job_417', 2)
|
|
397
|
+
await renderJobs()
|
|
398
|
+
const dialog = await openJob('job_417')
|
|
399
|
+
const footer = within(dialog).getByRole('button', { name: 'Review 2 changes' })
|
|
400
|
+
footer.focus()
|
|
401
|
+
fireEvent.click(footer)
|
|
402
|
+
await waitFor(() => expect(footer.textContent).toBe('Approve all 2'))
|
|
403
|
+
expect(document.activeElement).toBe(footer)
|
|
404
|
+
expect(
|
|
405
|
+
within(dialog)
|
|
406
|
+
.getByRole('tab', { name: /^Review/ })
|
|
407
|
+
.getAttribute('aria-selected'),
|
|
408
|
+
).toBe('true')
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('offers Save as draft and Publish, and publishes when asked', async () => {
|
|
412
|
+
server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
|
|
413
|
+
server.items.job_417 = pending('job_417', 2)
|
|
414
|
+
await renderJobs()
|
|
415
|
+
await openJob('job_417', 'Review')
|
|
416
|
+
await waitFor(() => card('r01'))
|
|
417
|
+
expect(await menuOptions(card('r01'))).toEqual(['Save as draft', 'Publish'])
|
|
418
|
+
await approveWith(card('r01'), 'Publish')
|
|
419
|
+
const undo = await within(card('r01')).findByRole('button', { name: 'Undo' })
|
|
420
|
+
expect(posts('POST', '/review/r01').at(-1)?.body).toEqual({
|
|
421
|
+
action: 'approve',
|
|
422
|
+
onApprove: 'publish',
|
|
423
|
+
})
|
|
424
|
+
expect(within(card('r01')).getByText('Approved')).toBeTruthy()
|
|
425
|
+
await waitFor(() => expect(document.activeElement).toBe(undo))
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('reads Keep current status on a published document and leaves its change in review', async () => {
|
|
429
|
+
server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
|
|
430
|
+
server.items.job_417 = pending('job_417', 2)
|
|
431
|
+
server.decide = (_jobId, _itemId, body) =>
|
|
432
|
+
body.onApprove === 'publish'
|
|
433
|
+
? ok(decided(server.items.job_417![0]!, 'approved'), { documentStatus: 'PUBLISHED' })
|
|
434
|
+
: {
|
|
435
|
+
error:
|
|
436
|
+
'This document is published, so the change stays in review. Approve it with Publish to apply it live.',
|
|
437
|
+
status: 409,
|
|
438
|
+
}
|
|
439
|
+
await renderJobs()
|
|
440
|
+
await openJob('job_417', 'Review')
|
|
441
|
+
await waitFor(() => card('r01'))
|
|
442
|
+
await approveWith(card('r01'), 'Save as draft')
|
|
443
|
+
await waitFor(() => expect(noteOf(card('r01'))).toContain('so the change stays in review'))
|
|
444
|
+
// The note isn't a live region (it appears with its text); the drawer announces it.
|
|
445
|
+
expect(card('r01').querySelector('[role="status"], [role="alert"]')).toBeNull()
|
|
446
|
+
expect(announced()).toBe(
|
|
447
|
+
'Post r01: This document is published, so the change stays in review. Approve it with Publish to apply it live.',
|
|
448
|
+
)
|
|
449
|
+
expect(within(card('r01')).queryByText('Approved')).toBeNull()
|
|
450
|
+
expect(await menuOptions(card('r01'))).toEqual(['Keep current status', 'Publish'])
|
|
451
|
+
// Another document isn't known to be published.
|
|
452
|
+
expect(await menuOptions(card('r02'))).toEqual(['Save as draft', 'Publish'])
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('assumes published documents when the job only took published ones', async () => {
|
|
456
|
+
server.jobs = [
|
|
457
|
+
batch({
|
|
458
|
+
review: { pending: 1, approved: 0, rejected: 0 },
|
|
459
|
+
payload: { ...(batch({}).payload as object), statusFilter: 'published' } as Job['payload'],
|
|
460
|
+
}),
|
|
461
|
+
]
|
|
462
|
+
server.items.job_417 = pending('job_417', 1)
|
|
463
|
+
await renderJobs()
|
|
464
|
+
await openJob('job_417', 'Review')
|
|
465
|
+
await waitFor(() => card('r01'))
|
|
466
|
+
expect(await menuOptions(card('r01'))).toEqual(['Keep current status', 'Publish'])
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it.each([
|
|
470
|
+
[
|
|
471
|
+
'changed while saving',
|
|
472
|
+
'The document changed while this was being saved, so nothing was written. Try again.',
|
|
473
|
+
],
|
|
474
|
+
[
|
|
475
|
+
'the publish state moved',
|
|
476
|
+
'This document is published, so the change stays in review. Approve it with Publish to apply it live.',
|
|
477
|
+
],
|
|
478
|
+
])('explains a 409 when %s, and keeps the change pending', async (_, message) => {
|
|
479
|
+
server.jobs = [batch({ review: { pending: 1, approved: 0, rejected: 0 } })]
|
|
480
|
+
server.items.job_417 = pending('job_417', 1)
|
|
481
|
+
server.decide = () => ({ error: message, status: 409 })
|
|
482
|
+
await renderJobs()
|
|
483
|
+
await openJob('job_417', 'Review')
|
|
484
|
+
await waitFor(() => card('r01'))
|
|
485
|
+
await approveWith(card('r01'), 'Save as draft')
|
|
486
|
+
await waitFor(() => expect(card('r01').textContent).toContain(message))
|
|
487
|
+
expect(within(card('r01')).getByRole('button', { name: /^Approve/ })).toBeTruthy()
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('shows link changes as text, and explains a link whose page is gone', async () => {
|
|
491
|
+
const markup = 'Compare <b>plans</b> & pricing <img src=x onerror=alert(1)> on the page.'
|
|
492
|
+
server.jobs = [
|
|
493
|
+
batch({
|
|
494
|
+
id: 'job_413',
|
|
495
|
+
status: 'succeeded',
|
|
496
|
+
lockedAt: null,
|
|
497
|
+
title: 'Add internal links',
|
|
498
|
+
review: { pending: 1, approved: 0, rejected: 1 },
|
|
499
|
+
}),
|
|
500
|
+
]
|
|
501
|
+
server.items.job_413 = [
|
|
502
|
+
item('l1', {
|
|
503
|
+
jobId: 'job_413',
|
|
504
|
+
field: 'body',
|
|
505
|
+
before: 'Learn more about us before you get in touch.',
|
|
506
|
+
after: 'Learn more <a href="/about">about us</a> before you get in touch.',
|
|
507
|
+
}),
|
|
508
|
+
item('l2', {
|
|
509
|
+
jobId: 'job_413',
|
|
510
|
+
field: 'body',
|
|
511
|
+
status: 'rejected',
|
|
512
|
+
before: markup,
|
|
513
|
+
after: markup.replace('the page', '<a href="/platform">the page</a>'),
|
|
514
|
+
}),
|
|
515
|
+
]
|
|
516
|
+
server.decide = () => ({
|
|
517
|
+
error: "The page this links to, /about, isn't published at that address any more.",
|
|
518
|
+
status: 409,
|
|
519
|
+
})
|
|
520
|
+
await renderJobs()
|
|
521
|
+
await openJob('job_413', 'Review')
|
|
522
|
+
await waitFor(() => card('l2'))
|
|
523
|
+
expect(card('l2').querySelector('img, b, a[href="/platform"]')).toBeNull()
|
|
524
|
+
expect(card('l2').textContent).toContain('<b>plans</b>')
|
|
525
|
+
expect(card('l1').querySelector('mark')?.textContent).toBe('about us')
|
|
526
|
+
expect(card('l1').textContent).toContain('→ /about')
|
|
527
|
+
expect(within(card('l1')).queryByRole('button', { name: 'Edit' })).toBeNull()
|
|
528
|
+
await approveWith(card('l1'), 'Publish')
|
|
529
|
+
await waitFor(() =>
|
|
530
|
+
expect(noteOf(card('l1'))).toContain("isn't published at that address any more"),
|
|
531
|
+
)
|
|
532
|
+
expect(announced()).toContain("isn't published at that address any more")
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
it('shows the notice when undo leaves a document published', async () => {
|
|
536
|
+
const approvedItem = decided(item('r01'), 'approved')
|
|
537
|
+
server.jobs = [batch({ review: { pending: 0, approved: 1, rejected: 0 } })]
|
|
538
|
+
server.items.job_417 = [approvedItem]
|
|
539
|
+
const notice =
|
|
540
|
+
'The document stays published. Undo put back the previous text, and the page shows it now.'
|
|
541
|
+
server.decide = () =>
|
|
542
|
+
ok({ ...approvedItem, status: 'pending' }, { documentStatus: 'PUBLISHED', notice })
|
|
543
|
+
await renderJobs()
|
|
544
|
+
await openJob('job_417', 'Review')
|
|
545
|
+
await waitFor(() => card('r01'))
|
|
546
|
+
fireEvent.click(within(card('r01')).getByRole('button', { name: 'Undo' }))
|
|
547
|
+
await waitFor(() => expect(noteOf(card('r01'))).toContain(notice))
|
|
548
|
+
expect(announced()).toBe(`Post r01 is back in review. ${notice}`)
|
|
549
|
+
await waitFor(() =>
|
|
550
|
+
expect(document.activeElement).toBe(
|
|
551
|
+
within(card('r01')).getByRole('button', { name: /^Approve/ }),
|
|
552
|
+
),
|
|
553
|
+
)
|
|
554
|
+
// The document is now known to be published.
|
|
555
|
+
expect(await menuOptions(card('r01'))).toEqual(['Keep current status', 'Publish'])
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('keeps an edit in progress across a poll and a tab switch, then approves the edit', async () => {
|
|
559
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
560
|
+
server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
|
|
561
|
+
server.items.job_417 = pending('job_417', 2)
|
|
562
|
+
await renderJobs()
|
|
563
|
+
const dialog = await openJob('job_417', 'Review')
|
|
564
|
+
await waitFor(() => card('r01'))
|
|
565
|
+
fireEvent.click(within(card('r01')).getByRole('button', { name: 'Edit' }))
|
|
566
|
+
const box = within(card('r01')).getByRole('textbox', { name: 'Edit suggestion for Post r01' })
|
|
567
|
+
await waitFor(() => expect(document.activeElement).toBe(box))
|
|
568
|
+
fireEvent.change(box, { target: { value: 'Edited by hand.' } })
|
|
569
|
+
|
|
570
|
+
// A slice adds a suggestion: the list refresh sees the job change and the changes are read again.
|
|
571
|
+
server.items.job_417!.push(item('r03'))
|
|
572
|
+
server.jobs = [
|
|
573
|
+
batch({ updatedAt: iso(5_000), review: { pending: 3, approved: 0, rejected: 0 } }),
|
|
574
|
+
]
|
|
575
|
+
const before = posts('GET', '/review').length
|
|
576
|
+
await act(async () => {
|
|
577
|
+
await vi.advanceTimersByTimeAsync(6_000)
|
|
578
|
+
})
|
|
579
|
+
await waitFor(() => expect(posts('GET', '/review').length).toBeGreaterThan(before))
|
|
580
|
+
await waitFor(() => card('r03'))
|
|
581
|
+
expect((within(card('r01')).getByRole('textbox') as HTMLTextAreaElement).value).toBe(
|
|
582
|
+
'Edited by hand.',
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
fireEvent.click(within(dialog).getByRole('tab', { name: 'Overview' }))
|
|
586
|
+
fireEvent.click(within(dialog).getByRole('tab', { name: /^Review/ }))
|
|
587
|
+
await waitFor(() => card('r01'))
|
|
588
|
+
expect((within(card('r01')).getByRole('textbox') as HTMLTextAreaElement).value).toBe(
|
|
589
|
+
'Edited by hand.',
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
await approveWith(card('r01'), 'Save as draft')
|
|
593
|
+
await within(card('r01')).findByRole('button', { name: 'Undo' })
|
|
594
|
+
expect(posts('POST', '/review/r01').at(-1)?.body).toEqual({
|
|
595
|
+
action: 'approve',
|
|
596
|
+
onApprove: 'draft',
|
|
597
|
+
after: 'Edited by hand.',
|
|
598
|
+
})
|
|
599
|
+
expect(card('r01').textContent).toContain('Edited by hand.')
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('re-reads the changes when one was decided elsewhere', async () => {
|
|
603
|
+
server.jobs = [batch({ review: { pending: 1, approved: 0, rejected: 0 } })]
|
|
604
|
+
server.items.job_417 = pending('job_417', 1)
|
|
605
|
+
let answer: (reply: Reply) => void = () => {}
|
|
606
|
+
server.decide = () =>
|
|
607
|
+
new Promise<Reply>((resolve) => {
|
|
608
|
+
answer = resolve
|
|
609
|
+
})
|
|
610
|
+
await renderJobs()
|
|
611
|
+
await openJob('job_417', 'Review')
|
|
612
|
+
await waitFor(() => card('r01'))
|
|
613
|
+
await approveWith(card('r01'), 'Save as draft')
|
|
614
|
+
const trigger = within(card('r01')).getByRole('button', { name: /^Approve/ })
|
|
615
|
+
await waitFor(() => expect(document.activeElement).toBe(trigger))
|
|
616
|
+
server.items.job_417![0] = decided(server.items.job_417![0]!, 'rejected')
|
|
617
|
+
answer({ error: 'This change was already decided.', status: 409 })
|
|
618
|
+
await waitFor(() => expect(within(card('r01')).getByText('Rejected')).toBeTruthy())
|
|
619
|
+
expect(card('r01').textContent).toContain('This change was already decided.')
|
|
620
|
+
expect(announced()).toBe('Post r01: This change was already decided.')
|
|
621
|
+
// Approve went with the re-read; focus goes to the Undo that replaced it.
|
|
622
|
+
await waitFor(() =>
|
|
623
|
+
expect(document.activeElement).toBe(
|
|
624
|
+
within(card('r01')).getByRole('button', { name: 'Undo' }),
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it('puts focus on the panel when the change pressed is gone', async () => {
|
|
630
|
+
server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
|
|
631
|
+
server.items.job_417 = pending('job_417', 2)
|
|
632
|
+
server.decide = () => {
|
|
633
|
+
server.items.job_417 = server.items.job_417!.filter((entry) => entry.id !== 'r01')
|
|
634
|
+
return { error: 'This change no longer exists.', status: 409 }
|
|
635
|
+
}
|
|
636
|
+
await renderJobs()
|
|
637
|
+
const dialog = await openJob('job_417', 'Review')
|
|
638
|
+
await waitFor(() => card('r01'))
|
|
639
|
+
const reject = within(card('r01')).getByRole('button', { name: 'Reject' })
|
|
640
|
+
reject.focus()
|
|
641
|
+
fireEvent.click(reject)
|
|
642
|
+
await waitFor(() => expect(dialog.querySelector('[data-review-item="r01"]')).toBeNull())
|
|
643
|
+
await waitFor(() => expect(document.activeElement).toBe(within(dialog).getByRole('tabpanel')))
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('leaves focus where the reviewer moved it while a decision was out', async () => {
|
|
647
|
+
server.jobs = [batch({ review: { pending: 2, approved: 0, rejected: 0 } })]
|
|
648
|
+
server.items.job_417 = pending('job_417', 2)
|
|
649
|
+
let answer: (reply: Reply) => void = () => {}
|
|
650
|
+
server.decide = () =>
|
|
651
|
+
new Promise<Reply>((resolve) => {
|
|
652
|
+
answer = resolve
|
|
653
|
+
})
|
|
654
|
+
await renderJobs()
|
|
655
|
+
await openJob('job_417', 'Review')
|
|
656
|
+
await waitFor(() => card('r01'))
|
|
657
|
+
await approveWith(card('r01'), 'Publish')
|
|
658
|
+
// The closed menu hands focus back to Approve (on a timer), while the request is out.
|
|
659
|
+
const trigger = within(card('r01')).getByRole('button', { name: /^Approve/ })
|
|
660
|
+
await waitFor(() => expect(document.activeElement).toBe(trigger))
|
|
661
|
+
// Meanwhile they start editing the next change.
|
|
662
|
+
fireEvent.click(within(card('r02')).getByRole('button', { name: 'Edit' }))
|
|
663
|
+
const box = within(card('r02')).getByRole('textbox')
|
|
664
|
+
await waitFor(() => expect(document.activeElement).toBe(box))
|
|
665
|
+
answer(ok(decided(server.items.job_417![0]!, 'approved'), { documentStatus: 'PUBLISHED' }))
|
|
666
|
+
await within(card('r01')).findByRole('button', { name: 'Undo' })
|
|
667
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
668
|
+
expect(document.activeElement).toBe(box)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('doesn’t approve all on a second click, a second Enter or a held key', async () => {
|
|
672
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
673
|
+
server.jobs = [batch({ review: { pending: 3, approved: 0, rejected: 0 } })]
|
|
674
|
+
server.items.job_417 = pending('job_417', 3)
|
|
675
|
+
server.approveAll = () => ok({ approved: 3, skipped: 0, remaining: 0 })
|
|
676
|
+
await renderJobs()
|
|
677
|
+
const dialog = await openJob('job_417')
|
|
678
|
+
const footer = within(dialog).getByRole('button', { name: 'Review 3 changes' })
|
|
679
|
+
footer.focus()
|
|
680
|
+
// A double-click: the first opens Review, the second lands on Approve all.
|
|
681
|
+
fireEvent.click(footer)
|
|
682
|
+
fireEvent.click(footer)
|
|
683
|
+
await waitFor(() => card('r01'))
|
|
684
|
+
expect(footer.textContent).toBe('Approve all 3')
|
|
685
|
+
// Enter again, quickly.
|
|
686
|
+
fireEvent.keyDown(footer, { key: 'Enter' })
|
|
687
|
+
fireEvent.click(footer)
|
|
688
|
+
expect(posts('POST', '/approve-all')).toHaveLength(0)
|
|
689
|
+
|
|
690
|
+
await waitOutArming()
|
|
691
|
+
// A held Enter: its repeats click too.
|
|
692
|
+
fireEvent.keyDown(footer, { key: 'Enter', repeat: true })
|
|
693
|
+
fireEvent.click(footer)
|
|
694
|
+
expect(posts('POST', '/approve-all')).toHaveLength(0)
|
|
695
|
+
fireEvent.keyUp(footer, { key: 'Enter' })
|
|
696
|
+
|
|
697
|
+
fireEvent.keyDown(footer, { key: 'Enter' })
|
|
698
|
+
fireEvent.click(footer)
|
|
699
|
+
await waitFor(() => expect(posts('POST', '/approve-all')).toHaveLength(1))
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('approves an edit still open as edited when approving all', async () => {
|
|
703
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
704
|
+
server.jobs = [batch({ review: { pending: 3, approved: 0, rejected: 0 } })]
|
|
705
|
+
server.items.job_417 = pending('job_417', 3)
|
|
706
|
+
server.approveAll = () => ok({ approved: 2, skipped: 0, remaining: 0 })
|
|
707
|
+
await renderJobs()
|
|
708
|
+
const dialog = await openJob('job_417', 'Review')
|
|
709
|
+
await waitFor(() => card('r02'))
|
|
710
|
+
fireEvent.click(within(card('r02')).getByRole('button', { name: 'Edit' }))
|
|
711
|
+
fireEvent.change(within(card('r02')).getByRole('textbox'), {
|
|
712
|
+
target: { value: 'Rewritten by hand.' },
|
|
713
|
+
})
|
|
714
|
+
await waitOutArming()
|
|
715
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Approve all 3' }))
|
|
716
|
+
await waitFor(() =>
|
|
717
|
+
expect(toast).toHaveBeenCalledWith('3 changes approved · saved as drafts', undefined),
|
|
718
|
+
)
|
|
719
|
+
const order = server.calls.filter((call) => call.method === 'POST').map((call) => call.endpoint)
|
|
720
|
+
expect(order).toEqual(['/jobs/job_417/review/r02', '/jobs/job_417/review/approve-all'])
|
|
721
|
+
expect(posts('POST', '/review/r02')[0]?.body).toEqual({
|
|
722
|
+
action: 'approve',
|
|
723
|
+
after: 'Rewritten by hand.',
|
|
724
|
+
})
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it.each([
|
|
728
|
+
['an emptied edit (400)', 400, 'The approved text is empty.'],
|
|
729
|
+
[
|
|
730
|
+
'changed while saving (409)',
|
|
731
|
+
409,
|
|
732
|
+
'The document changed while this was being saved, so nothing was written. Try again.',
|
|
733
|
+
],
|
|
734
|
+
['a server error', 500, 'Internal server error'],
|
|
735
|
+
])('stops approving all when an open edit is refused: %s', async (_, status, message) => {
|
|
736
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
737
|
+
server.jobs = [batch({ review: { pending: 3, approved: 0, rejected: 0 } })]
|
|
738
|
+
server.items.job_417 = pending('job_417', 3)
|
|
739
|
+
server.decide = () => ({ error: message, status })
|
|
740
|
+
server.approveAll = () => ok({ approved: 3, skipped: 0, remaining: 0 })
|
|
741
|
+
await renderJobs()
|
|
742
|
+
const dialog = await openJob('job_417', 'Review')
|
|
743
|
+
await waitFor(() => card('r02'))
|
|
744
|
+
fireEvent.click(within(card('r02')).getByRole('button', { name: 'Edit' }))
|
|
745
|
+
fireEvent.change(within(card('r02')).getByRole('textbox'), { target: { value: '' } })
|
|
746
|
+
await waitOutArming()
|
|
747
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Approve all 3' }))
|
|
748
|
+
await waitFor(() =>
|
|
749
|
+
expect(toast.error).toHaveBeenCalledWith('Approve all stopped', {
|
|
750
|
+
description:
|
|
751
|
+
'1 edited change couldn’t be approved, so nothing else was, and the AI’s text doesn’t replace an edit. Each change says why.',
|
|
752
|
+
}),
|
|
753
|
+
)
|
|
754
|
+
// The server's approve-all never ran, so it couldn't apply the AI's text to r02.
|
|
755
|
+
expect(posts('POST', '/approve-all')).toHaveLength(0)
|
|
756
|
+
expect(announced()).toContain(`Post r02: ${message}`)
|
|
757
|
+
expect(noteOf(card('r02'))).toContain(message)
|
|
758
|
+
expect(within(card('r02')).getByRole('textbox')).toBeTruthy()
|
|
759
|
+
expect(within(card('r02')).queryByText('Approved')).toBeNull()
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
it('locks every change while approving all', async () => {
|
|
763
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
764
|
+
server.jobs = [batch({ review: { pending: 3, approved: 0, rejected: 0 } })]
|
|
765
|
+
server.items.job_417 = pending('job_417', 3)
|
|
766
|
+
const approveEdit = server.decide
|
|
767
|
+
let editAnswered: () => void = () => {}
|
|
768
|
+
server.decide = (jobId, itemId, body) =>
|
|
769
|
+
new Promise<Reply>((resolve) => {
|
|
770
|
+
editAnswered = () => resolve(approveEdit(jobId, itemId, body) as Reply)
|
|
771
|
+
})
|
|
772
|
+
let bulkAnswered: (reply: Reply) => void = () => {}
|
|
773
|
+
server.approveAll = () =>
|
|
774
|
+
new Promise<Reply>((resolve) => {
|
|
775
|
+
bulkAnswered = resolve
|
|
776
|
+
})
|
|
777
|
+
await renderJobs()
|
|
778
|
+
const dialog = await openJob('job_417', 'Review')
|
|
779
|
+
await waitFor(() => card('r02'))
|
|
780
|
+
fireEvent.click(within(card('r02')).getByRole('button', { name: 'Edit' }))
|
|
781
|
+
const box = within(card('r02')).getByRole('textbox') as HTMLTextAreaElement
|
|
782
|
+
fireEvent.change(box, { target: { value: 'Mine.' } })
|
|
783
|
+
await waitOutArming()
|
|
784
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Approve all 3' }))
|
|
785
|
+
|
|
786
|
+
const locked = () => {
|
|
787
|
+
// The open edit is read-only, so what's sent is what's shown.
|
|
788
|
+
expect(box.readOnly).toBe(true)
|
|
789
|
+
fireEvent.change(box, { target: { value: 'Mine, changed.' } })
|
|
790
|
+
expect(box.value).toBe('Mine.')
|
|
791
|
+
// Another change can't be edited or decided.
|
|
792
|
+
const edit = within(card('r01')).getByRole('button', { name: 'Edit' })
|
|
793
|
+
expect(edit.getAttribute('aria-disabled')).toBe('true')
|
|
794
|
+
fireEvent.click(edit)
|
|
795
|
+
expect(within(card('r01')).queryByRole('textbox')).toBeNull()
|
|
796
|
+
fireEvent.click(within(card('r01')).getByRole('button', { name: 'Reject' }))
|
|
797
|
+
expect(posts('POST', '/review/r01')).toHaveLength(0)
|
|
798
|
+
}
|
|
799
|
+
// While the edit is being approved…
|
|
800
|
+
await waitFor(() => expect(posts('POST', '/review/r02')).toHaveLength(1))
|
|
801
|
+
locked()
|
|
802
|
+
// …and while the server's approve-all runs.
|
|
803
|
+
act(() => editAnswered())
|
|
804
|
+
await waitFor(() => expect(posts('POST', '/approve-all')).toHaveLength(1))
|
|
805
|
+
expect(posts('POST', '/review/r02')[0]?.body).toEqual({ action: 'approve', after: 'Mine.' })
|
|
806
|
+
const edit = within(card('r01')).getByRole('button', { name: 'Edit' })
|
|
807
|
+
expect(edit.getAttribute('aria-disabled')).toBe('true')
|
|
808
|
+
fireEvent.click(edit)
|
|
809
|
+
expect(within(card('r01')).queryByRole('textbox')).toBeNull()
|
|
810
|
+
|
|
811
|
+
bulkAnswered(ok({ approved: 2, skipped: 0, remaining: 0 }))
|
|
812
|
+
await waitFor(() => expect(edit.getAttribute('aria-disabled')).toBeNull())
|
|
813
|
+
fireEvent.click(edit)
|
|
814
|
+
expect(within(card('r01')).getByRole('textbox')).toBeTruthy()
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('still approves all when an open edit stays in review on a published document', async () => {
|
|
818
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
819
|
+
server.jobs = [batch({ review: { pending: 3, approved: 0, rejected: 0 } })]
|
|
820
|
+
server.items.job_417 = pending('job_417', 3)
|
|
821
|
+
const published =
|
|
822
|
+
'This document is published, so the change stays in review. Approve it with Publish to apply it live.'
|
|
823
|
+
server.decide = () => ({ error: published, status: 409 })
|
|
824
|
+
// The server skips that change too: it's published and the job saves drafts.
|
|
825
|
+
server.approveAll = () => ok({ approved: 2, skipped: 1, remaining: 1 })
|
|
826
|
+
await renderJobs()
|
|
827
|
+
const dialog = await openJob('job_417', 'Review')
|
|
828
|
+
await waitFor(() => card('r02'))
|
|
829
|
+
fireEvent.click(within(card('r02')).getByRole('button', { name: 'Edit' }))
|
|
830
|
+
await waitOutArming()
|
|
831
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Approve all 3' }))
|
|
832
|
+
await waitFor(() => expect(posts('POST', '/approve-all')).toHaveLength(1))
|
|
833
|
+
await waitFor(() => expect(announced()).toContain(`Post r02: ${published}`))
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
it('approves all, asking again while changes are left that it didn’t get to', async () => {
|
|
837
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
838
|
+
server.jobs = [
|
|
839
|
+
batch({
|
|
840
|
+
status: 'succeeded',
|
|
841
|
+
lockedAt: null,
|
|
842
|
+
review: { pending: 45, approved: 0, rejected: 0 },
|
|
843
|
+
}),
|
|
844
|
+
]
|
|
845
|
+
server.items.job_417 = pending('job_417', 45)
|
|
846
|
+
const rounds = [
|
|
847
|
+
{ approved: 20, skipped: 2, remaining: 25 },
|
|
848
|
+
{ approved: 20, skipped: 3, remaining: 5 },
|
|
849
|
+
{ approved: 2, skipped: 3, remaining: 3 },
|
|
850
|
+
]
|
|
851
|
+
server.approveAll = () => ok(rounds.shift() ?? { approved: 0, skipped: 0, remaining: 0 })
|
|
852
|
+
await renderJobs()
|
|
853
|
+
const dialog = await openJob('job_417', 'Review')
|
|
854
|
+
await waitFor(() => card('r01'))
|
|
855
|
+
await waitOutArming()
|
|
856
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Approve all 45' }))
|
|
857
|
+
await waitFor(() =>
|
|
858
|
+
expect(toast).toHaveBeenCalledWith('42 changes approved · saved as drafts', {
|
|
859
|
+
description:
|
|
860
|
+
'3 changes stayed in review. Changes to published documents need Publish, so approve those one at a time.',
|
|
861
|
+
}),
|
|
862
|
+
)
|
|
863
|
+
expect(posts('POST', '/approve-all')).toHaveLength(3)
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('hides Review from someone who can’t read the suggested changes', async () => {
|
|
867
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
868
|
+
server.jobs = [
|
|
869
|
+
batch({
|
|
870
|
+
status: 'succeeded',
|
|
871
|
+
lockedAt: null,
|
|
872
|
+
review: { pending: 6, approved: 0, rejected: 0 },
|
|
873
|
+
}),
|
|
874
|
+
]
|
|
875
|
+
server.items.job_417 = pending('job_417', 6)
|
|
876
|
+
server.reviewError = {
|
|
877
|
+
status: 403,
|
|
878
|
+
message: 'Only people who can edit content can read suggested changes',
|
|
879
|
+
}
|
|
880
|
+
await renderJobs()
|
|
881
|
+
// The list's Review button asks for the Review tab.
|
|
882
|
+
fireEvent.click(
|
|
883
|
+
within(rowButton('job_417').closest('li')!).getByRole('button', { name: /Review/ }),
|
|
884
|
+
)
|
|
885
|
+
const dialog = await screen.findByRole('dialog')
|
|
886
|
+
await waitFor(() =>
|
|
887
|
+
expect(
|
|
888
|
+
within(dialog)
|
|
889
|
+
.getAllByRole('tab')
|
|
890
|
+
.map((tab) => tab.textContent),
|
|
891
|
+
).toEqual(['Overview', 'Activity', 'Details']),
|
|
892
|
+
)
|
|
893
|
+
expect(
|
|
894
|
+
within(dialog).getByRole('tab', { name: 'Overview' }).getAttribute('aria-selected'),
|
|
895
|
+
).toBe('true')
|
|
896
|
+
expect(within(dialog).queryByRole('button', { name: /Review \d/ })).toBeNull()
|
|
897
|
+
|
|
898
|
+
// The job changing doesn't ask again: the 403 stands while the drawer is open.
|
|
899
|
+
const asked = posts('GET', '/review').length
|
|
900
|
+
server.jobs = [
|
|
901
|
+
batch({
|
|
902
|
+
status: 'succeeded',
|
|
903
|
+
lockedAt: null,
|
|
904
|
+
updatedAt: iso(5_000),
|
|
905
|
+
review: { pending: 7, approved: 0, rejected: 0 },
|
|
906
|
+
}),
|
|
907
|
+
]
|
|
908
|
+
await act(async () => {
|
|
909
|
+
await vi.advanceTimersByTimeAsync(6_000)
|
|
910
|
+
})
|
|
911
|
+
await waitFor(() => expect(dialog.textContent).toContain('Activity'))
|
|
912
|
+
expect(posts('GET', '/review')).toHaveLength(asked)
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
it('shows an SEO scan’s results, linking each issue to its screen', async () => {
|
|
916
|
+
const onNavigate = vi.fn()
|
|
917
|
+
const seo = batch({
|
|
918
|
+
id: 'job_414',
|
|
919
|
+
type: 'seo-scan',
|
|
920
|
+
status: 'succeeded',
|
|
921
|
+
title: 'SEO scan',
|
|
922
|
+
scope: 'All indexable pages',
|
|
923
|
+
lockedAt: null,
|
|
924
|
+
createdBy: { kind: 'cron', schedule: 'weekly-seo' },
|
|
925
|
+
payload: { trigger: 'cron' },
|
|
926
|
+
summary: 'Score is up 4 points from last week.',
|
|
927
|
+
done: 340,
|
|
928
|
+
total: 340,
|
|
929
|
+
finishedAt: iso(-60 * MIN),
|
|
930
|
+
})
|
|
931
|
+
delete seo.review
|
|
932
|
+
server.jobs = [seo]
|
|
933
|
+
server.details.job_414 = {
|
|
934
|
+
results: {
|
|
935
|
+
score: 78,
|
|
936
|
+
prevScore: 74,
|
|
937
|
+
found: 23,
|
|
938
|
+
resolved: 11,
|
|
939
|
+
categories: [
|
|
940
|
+
{
|
|
941
|
+
key: 'missing-meta',
|
|
942
|
+
label: 'Missing meta description',
|
|
943
|
+
count: 5,
|
|
944
|
+
href: '/seo/content',
|
|
945
|
+
},
|
|
946
|
+
{ key: 'bad', label: 'Somewhere else', count: 1, href: 'javascript:alert(1)' },
|
|
947
|
+
],
|
|
948
|
+
digest: { sentAt: iso(-60 * MIN), recipients: 3 },
|
|
949
|
+
},
|
|
950
|
+
}
|
|
951
|
+
await renderJobs(onNavigate)
|
|
952
|
+
const dialog = await openJob('job_414', 'Results')
|
|
953
|
+
await within(dialog).findByText('Site score')
|
|
954
|
+
expect(within(dialog).getByText('+4')).toBeTruthy()
|
|
955
|
+
expect(dialog.textContent).toContain('Fixed since last scan11')
|
|
956
|
+
expect(within(dialog).getByText('Score is up 4 points from last week.')).toBeTruthy()
|
|
957
|
+
const link = within(dialog).getByRole('link', { name: /Missing meta description/ })
|
|
958
|
+
expect(link.getAttribute('href')).toBe('/seo/content')
|
|
959
|
+
fireEvent.click(link)
|
|
960
|
+
expect(onNavigate).toHaveBeenCalledWith('/seo/content')
|
|
961
|
+
expect(within(dialog).queryByRole('link', { name: /Somewhere else/ })).toBeNull()
|
|
962
|
+
expect(dialog.textContent).toContain('SEO digest: Sent to 3 people')
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
it('lists activity newest first, folding older finished slices', async () => {
|
|
966
|
+
server.jobs = [batch({})]
|
|
967
|
+
const slice = (minutes: number, n: number): JobEvent[] => [
|
|
968
|
+
{
|
|
969
|
+
at: iso(-minutes * MIN),
|
|
970
|
+
kind: 'yielded',
|
|
971
|
+
message: 'Slice finished',
|
|
972
|
+
detail: `Processed ${n} of 42`,
|
|
973
|
+
durationMs: 11_800,
|
|
974
|
+
tokens: 920,
|
|
975
|
+
},
|
|
976
|
+
{ at: iso(-minutes * MIN - 12_000), kind: 'claimed', message: 'Slice started' },
|
|
977
|
+
]
|
|
978
|
+
server.events.job_417 = [
|
|
979
|
+
{ at: iso(0), kind: 'claimed', message: 'Slice started' },
|
|
980
|
+
...slice(1, 18),
|
|
981
|
+
...slice(2, 17),
|
|
982
|
+
...slice(3, 16),
|
|
983
|
+
...slice(4, 15),
|
|
984
|
+
{ at: iso(-19 * MIN), kind: 'queued', message: 'Queued' },
|
|
985
|
+
]
|
|
986
|
+
await renderJobs()
|
|
987
|
+
const dialog = await openJob('job_417', 'Activity')
|
|
988
|
+
const list = await within(dialog).findByRole('list')
|
|
989
|
+
const titles = within(list)
|
|
990
|
+
.getAllByRole('listitem')
|
|
991
|
+
.map((entry) => entry.querySelector('b, button')?.textContent)
|
|
992
|
+
expect(titles).toEqual([
|
|
993
|
+
'Slice started',
|
|
994
|
+
'Slice finished',
|
|
995
|
+
'Slice finished',
|
|
996
|
+
'2 earlier slices',
|
|
997
|
+
'Slice started',
|
|
998
|
+
'Queued',
|
|
999
|
+
])
|
|
1000
|
+
expect(list.textContent).toContain('Processed 18 of 42 · 11.8s · 920 tokens · yielded')
|
|
1001
|
+
expect(list.textContent).toContain('Brandon West started it')
|
|
1002
|
+
const fold = within(list).getByRole('button', { name: '2 earlier slices' })
|
|
1003
|
+
fireEvent.click(fold)
|
|
1004
|
+
expect(fold.getAttribute('aria-expanded')).toBe('true')
|
|
1005
|
+
expect(list.textContent).toContain('Processed 15 of 42')
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it('keeps an open fold open, with focus on it, as the job’s events grow', async () => {
|
|
1009
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
1010
|
+
server.jobs = [batch({})]
|
|
1011
|
+
const finished = (minutes: number, n: number): JobEvent => ({
|
|
1012
|
+
at: iso(-minutes * MIN),
|
|
1013
|
+
kind: 'yielded',
|
|
1014
|
+
message: 'Slice finished',
|
|
1015
|
+
detail: `Processed ${n} of 42`,
|
|
1016
|
+
})
|
|
1017
|
+
server.events.job_417 = [
|
|
1018
|
+
{ at: iso(0), kind: 'claimed', message: 'Slice started' },
|
|
1019
|
+
finished(1, 18),
|
|
1020
|
+
finished(2, 17),
|
|
1021
|
+
finished(3, 16),
|
|
1022
|
+
finished(4, 15),
|
|
1023
|
+
{ at: iso(-19 * MIN), kind: 'queued', message: 'Queued' },
|
|
1024
|
+
]
|
|
1025
|
+
await renderJobs()
|
|
1026
|
+
const dialog = await openJob('job_417', 'Activity')
|
|
1027
|
+
const fold = await within(dialog).findByRole('button', { name: '2 earlier slices' })
|
|
1028
|
+
fireEvent.click(fold)
|
|
1029
|
+
fold.focus()
|
|
1030
|
+
|
|
1031
|
+
// A slice finishes: the newest event moves the fold's newer edge.
|
|
1032
|
+
server.events.job_417 = [
|
|
1033
|
+
{ at: iso(5_000), kind: 'claimed', message: 'Slice started' },
|
|
1034
|
+
finished(-0.05, 19),
|
|
1035
|
+
...server.events.job_417.slice(1),
|
|
1036
|
+
]
|
|
1037
|
+
server.jobs = [batch({ updatedAt: iso(5_000), done: 20 })]
|
|
1038
|
+
await act(async () => {
|
|
1039
|
+
await vi.advanceTimersByTimeAsync(6_000)
|
|
1040
|
+
})
|
|
1041
|
+
await waitFor(() => expect(dialog.textContent).toContain('Processed 19 of 42'))
|
|
1042
|
+
const after = within(dialog).getByRole('button', { name: '3 earlier slices' })
|
|
1043
|
+
expect(after).toBe(fold)
|
|
1044
|
+
expect(after.getAttribute('aria-expanded')).toBe('true')
|
|
1045
|
+
expect(document.activeElement).toBe(fold)
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
it('shows the raw row, with the document ids counted', async () => {
|
|
1049
|
+
server.jobs = [batch({})]
|
|
1050
|
+
await renderJobs()
|
|
1051
|
+
const dialog = await openJob('job_417', 'Details')
|
|
1052
|
+
expect(dialog.textContent).toContain('The raw job row, for developers. Read only.')
|
|
1053
|
+
const raw = within(dialog).getByLabelText('Job row as JSON').textContent ?? ''
|
|
1054
|
+
expect(raw).toContain('"documentIds": "[3 ids]"')
|
|
1055
|
+
expect(raw).toContain('"id": "job_417"')
|
|
1056
|
+
})
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
/*
|
|
1060
|
+
* P-19: the footer's Cancel job, "Retry from document {n+1}" and "Retry now",
|
|
1061
|
+
* the cancel dialog over the drawer, Run now reusing a job, and who sees them.
|
|
1062
|
+
*/
|
|
1063
|
+
|
|
1064
|
+
function scan(
|
|
1065
|
+
type: 'seo-scan' | 'content-health',
|
|
1066
|
+
overrides: Partial<Job> & { done?: number; total?: number } = {},
|
|
1067
|
+
): Job {
|
|
1068
|
+
const job = batch({
|
|
1069
|
+
id: type === 'seo-scan' ? 'job_scan' : 'job_health',
|
|
1070
|
+
type,
|
|
1071
|
+
title: type === 'seo-scan' ? 'SEO scan' : 'Content health',
|
|
1072
|
+
scope: type === 'seo-scan' ? 'All indexable pages' : 'All published documents',
|
|
1073
|
+
createdBy: { kind: 'cron', schedule: type === 'seo-scan' ? 'weekly-seo' : 'nightly-health' },
|
|
1074
|
+
payload: { trigger: 'cron' },
|
|
1075
|
+
...overrides,
|
|
1076
|
+
})
|
|
1077
|
+
delete job.review
|
|
1078
|
+
return job
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** A batch that ran out of attempts at document 8. */
|
|
1082
|
+
function failed(overrides: Partial<Job> = {}): Job {
|
|
1083
|
+
return batch({
|
|
1084
|
+
id: 'job_415',
|
|
1085
|
+
title: 'Translate excerpts to Spanish',
|
|
1086
|
+
status: 'dead',
|
|
1087
|
+
attempts: 3,
|
|
1088
|
+
done: 7,
|
|
1089
|
+
total: 30,
|
|
1090
|
+
lockedAt: null,
|
|
1091
|
+
lastError: 'ProviderError: 429 Too Many Requests',
|
|
1092
|
+
...overrides,
|
|
1093
|
+
})
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** A batch waiting out a failed slice's backoff. */
|
|
1097
|
+
function waiting(overrides: Partial<Job> = {}): Job {
|
|
1098
|
+
return batch({
|
|
1099
|
+
id: 'job_418',
|
|
1100
|
+
title: 'Generate alt text',
|
|
1101
|
+
status: 'queued',
|
|
1102
|
+
attempts: 1,
|
|
1103
|
+
done: 9,
|
|
1104
|
+
total: 64,
|
|
1105
|
+
lockedAt: null,
|
|
1106
|
+
lastError: 'ImageFetchError: img_2291.webp returned 404',
|
|
1107
|
+
runAt: iso(30_000),
|
|
1108
|
+
...overrides,
|
|
1109
|
+
})
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** The job as the server has it after an action; the list's next read returns it. */
|
|
1113
|
+
function patchJob(id: string, patch: Partial<Job>): Job {
|
|
1114
|
+
server.jobs = server.jobs.map((job) =>
|
|
1115
|
+
job.id === id ? { ...job, ...patch, updatedAt: iso(1_000) } : job,
|
|
1116
|
+
)
|
|
1117
|
+
return server.jobs.find((job) => job.id === id)!
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function panelOf(drawer: HTMLElement): HTMLElement {
|
|
1121
|
+
return drawer.querySelector<HTMLElement>('[role="tabpanel"]')!
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** The cancel dialog, over the drawer or the list. */
|
|
1125
|
+
function cancelDialog(): Promise<HTMLElement> {
|
|
1126
|
+
return screen.findByRole('dialog', { name: /^Cancel “/ })
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
async function cancelDialogGone() {
|
|
1130
|
+
await waitFor(() => expect(screen.queryByRole('dialog', { name: /^Cancel “/ })).toBeNull())
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Click as a browser does: the button takes focus first. */
|
|
1134
|
+
function press(button: HTMLElement) {
|
|
1135
|
+
button.focus()
|
|
1136
|
+
fireEvent.click(button)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
describe('Jobs drawer actions (P-19)', () => {
|
|
1140
|
+
it('opens the cancel dialog over the drawer; Keep running and Escape go back to Cancel job', async () => {
|
|
1141
|
+
server.jobs = [batch({})]
|
|
1142
|
+
await renderJobs()
|
|
1143
|
+
const drawer = await openJob('job_417')
|
|
1144
|
+
const cancel = within(drawer).getByRole('button', { name: 'Cancel job' })
|
|
1145
|
+
expect(cancel.getAttribute('aria-haspopup')).toBe('dialog')
|
|
1146
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1147
|
+
|
|
1148
|
+
cancel.focus()
|
|
1149
|
+
fireEvent.click(cancel)
|
|
1150
|
+
const dialog = await cancelDialog()
|
|
1151
|
+
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
|
1152
|
+
expect(dialog.textContent).toContain(
|
|
1153
|
+
'It’s in the middle of a slice. It will stop when that slice finishes, usually within 20 seconds. The 19 changes already made stay in review. Nothing is applied unless you approve it.',
|
|
1154
|
+
)
|
|
1155
|
+
// The drawer's controls are hidden behind it. (aria-hidden keeps the
|
|
1156
|
+
// drawer's live region, and so the drawer element, exposed.)
|
|
1157
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1158
|
+
expect(within(drawer).queryByRole('tab')).toBeNull()
|
|
1159
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep running' }))
|
|
1160
|
+
await cancelDialogGone()
|
|
1161
|
+
await waitFor(() => expect(document.activeElement).toBe(cancel))
|
|
1162
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBe(cancel)
|
|
1163
|
+
|
|
1164
|
+
// Escape closes the dialog on top, not the drawer under it.
|
|
1165
|
+
fireEvent.click(cancel)
|
|
1166
|
+
await cancelDialog()
|
|
1167
|
+
fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape' })
|
|
1168
|
+
await cancelDialogGone()
|
|
1169
|
+
expect(screen.getByRole('dialog', { name: 'Rewrite meta descriptions' })).toBe(drawer)
|
|
1170
|
+
await waitFor(() => expect(document.activeElement).toBe(cancel))
|
|
1171
|
+
expect(posts('POST', '/cancel')).toHaveLength(0)
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it('uses the kit’s copy for each state', async () => {
|
|
1175
|
+
const auto = batch({}).payload as Extract<Job['payload'], { review: string }>
|
|
1176
|
+
server.jobs = [
|
|
1177
|
+
batch({ status: 'queued', lockedAt: null, payload: { ...auto, review: 'auto' } }),
|
|
1178
|
+
scan('content-health', { status: 'running', done: 50, total: 412 }),
|
|
1179
|
+
scan('seo-scan', { status: 'queued', lockedAt: null, done: 0, total: 340 }),
|
|
1180
|
+
]
|
|
1181
|
+
await renderJobs()
|
|
1182
|
+
const copy = async (id: string) => {
|
|
1183
|
+
const drawer = await openJob(id)
|
|
1184
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Cancel job' }))
|
|
1185
|
+
const text = (await cancelDialog()).textContent ?? ''
|
|
1186
|
+
fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape' })
|
|
1187
|
+
await cancelDialogGone()
|
|
1188
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
|
|
1189
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
1190
|
+
return text
|
|
1191
|
+
}
|
|
1192
|
+
expect(await copy('job_417')).toContain(
|
|
1193
|
+
'It hasn’t started its next slice, so it stops right away. The 19 documents already changed keep their changes. You can undo them from each document’s history.',
|
|
1194
|
+
)
|
|
1195
|
+
expect(await copy('job_health')).toContain(
|
|
1196
|
+
'It’s in the middle of a slice. It will stop when that slice finishes, usually within 20 seconds. Progress so far is kept. The next nightly cron run starts fresh.',
|
|
1197
|
+
)
|
|
1198
|
+
expect(await copy('job_scan')).toContain(
|
|
1199
|
+
'It hasn’t started its next slice, so it stops right away. Progress so far is kept. The next weekly cron run starts fresh.',
|
|
1200
|
+
)
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
it('cancels a running job: Cancelling · Stops after this slice, announced, focus in the drawer', async () => {
|
|
1204
|
+
server.jobs = [batch({})]
|
|
1205
|
+
server.action = (endpoint) =>
|
|
1206
|
+
endpoint === '/jobs/job_417/cancel'
|
|
1207
|
+
? ok(patchJob('job_417', { cancelRequestedAt: iso(0) }))
|
|
1208
|
+
: { error: `unexpected ${endpoint}`, status: 500 }
|
|
1209
|
+
await renderJobs()
|
|
1210
|
+
const drawer = await openJob('job_417')
|
|
1211
|
+
press(within(drawer).getByRole('button', { name: 'Cancel job' }))
|
|
1212
|
+
press(within(await cancelDialog()).getByRole('button', { name: 'Cancel job' }))
|
|
1213
|
+
|
|
1214
|
+
await cancelDialogGone()
|
|
1215
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Stopping after this slice'))
|
|
1216
|
+
await waitFor(() => expect(announced()).toBe('Stopping after this slice'))
|
|
1217
|
+
expect(within(drawer).getAllByText('Cancelling').length).toBeGreaterThan(0)
|
|
1218
|
+
expect(drawer.textContent).toContain(
|
|
1219
|
+
'Stopping when the current slice finishes, usually within 20 seconds.',
|
|
1220
|
+
)
|
|
1221
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1222
|
+
// Cancel job went with the cancel, so focus lands on the panel showing the new state.
|
|
1223
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
|
|
1224
|
+
expect(posts('POST', '/cancel')).toHaveLength(1)
|
|
1225
|
+
|
|
1226
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
|
|
1227
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
1228
|
+
const row = rowButton('job_417').closest('li')!
|
|
1229
|
+
expect(within(row).getByText('Cancelling')).toBeTruthy()
|
|
1230
|
+
expect(within(row).getByText('Stops after this slice')).toBeTruthy()
|
|
1231
|
+
expect(within(row).queryByRole('button', { name: /^Cancel/ })).toBeNull()
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
it('shows a cancel’s outcome only once its dialog has closed', async () => {
|
|
1235
|
+
server.jobs = [batch({ status: 'queued', lockedAt: null })]
|
|
1236
|
+
let answer: (reply: Reply) => void = () => {}
|
|
1237
|
+
server.action = () => new Promise<Reply>((resolve) => (answer = resolve))
|
|
1238
|
+
await renderJobs()
|
|
1239
|
+
const drawer = await openJob('job_417')
|
|
1240
|
+
press(within(drawer).getByRole('button', { name: 'Cancel job' }))
|
|
1241
|
+
const dialog = await cancelDialog()
|
|
1242
|
+
press(within(dialog).getByRole('button', { name: 'Cancel job' }))
|
|
1243
|
+
await waitFor(() => expect(posts('POST', '/cancel')).toHaveLength(1))
|
|
1244
|
+
// While the dialog is open Radix hides the rest of the page from screen
|
|
1245
|
+
// readers: the toaster, and the drawer with its live region.
|
|
1246
|
+
const dialogsAtToast: number[] = []
|
|
1247
|
+
toast.mockImplementationOnce(() => {
|
|
1248
|
+
dialogsAtToast.push(document.querySelectorAll('[role="dialog"]').length)
|
|
1249
|
+
})
|
|
1250
|
+
await act(async () => {
|
|
1251
|
+
answer(ok(patchJob('job_417', { status: 'cancelled', cancelRequestedAt: iso(0) })))
|
|
1252
|
+
})
|
|
1253
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Job cancelled'))
|
|
1254
|
+
expect(dialogsAtToast).toEqual([1])
|
|
1255
|
+
await waitFor(() => expect(announced()).toBe('Job cancelled'))
|
|
1256
|
+
// Focus was back in the drawer before the outcome was announced.
|
|
1257
|
+
expect(document.activeElement).toBe(panelOf(drawer))
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1260
|
+
it('posts one cancel for a double click, and opens one dialog for a double click on Cancel job', async () => {
|
|
1261
|
+
server.jobs = [batch({})]
|
|
1262
|
+
let answer: (reply: Reply) => void = () => {}
|
|
1263
|
+
server.action = () => new Promise<Reply>((resolve) => (answer = resolve))
|
|
1264
|
+
await renderJobs()
|
|
1265
|
+
const drawer = await openJob('job_417')
|
|
1266
|
+
const open = within(drawer).getByRole('button', { name: 'Cancel job' })
|
|
1267
|
+
fireEvent.click(open)
|
|
1268
|
+
fireEvent.click(open)
|
|
1269
|
+
const dialog = await cancelDialog()
|
|
1270
|
+
expect(screen.getAllByRole('dialog', { name: /^Cancel “/ })).toHaveLength(1)
|
|
1271
|
+
|
|
1272
|
+
const confirm = within(dialog).getByRole('button', { name: 'Cancel job' })
|
|
1273
|
+
confirm.focus()
|
|
1274
|
+
fireEvent.click(confirm)
|
|
1275
|
+
fireEvent.click(confirm)
|
|
1276
|
+
await waitFor(() => expect(confirm.getAttribute('aria-busy')).toBe('true'))
|
|
1277
|
+
fireEvent.click(confirm)
|
|
1278
|
+
expect(document.activeElement).toBe(confirm)
|
|
1279
|
+
// Keep running waits for the answer too.
|
|
1280
|
+
expect(
|
|
1281
|
+
within(dialog).getByRole('button', { name: 'Keep running' }).getAttribute('aria-disabled'),
|
|
1282
|
+
).toBe('true')
|
|
1283
|
+
await act(async () => {
|
|
1284
|
+
answer(ok(patchJob('job_417', { cancelRequestedAt: iso(0) })))
|
|
1285
|
+
})
|
|
1286
|
+
await cancelDialogGone()
|
|
1287
|
+
expect(posts('POST', '/cancel')).toHaveLength(1)
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1290
|
+
it('reads the list again on a 409 from a cancel that already finished, then says so', async () => {
|
|
1291
|
+
server.jobs = [batch({ review: { pending: 4, approved: 0, rejected: 0 } })]
|
|
1292
|
+
server.action = () => {
|
|
1293
|
+
// The last slice finished just before the cancel arrived.
|
|
1294
|
+
patchJob('job_417', {
|
|
1295
|
+
status: 'succeeded',
|
|
1296
|
+
lockedAt: null,
|
|
1297
|
+
finishedAt: iso(0),
|
|
1298
|
+
progress: { done: 42, total: 42, unit: 'documents', cursor: '42', completed: 42 },
|
|
1299
|
+
})
|
|
1300
|
+
return { error: 'This job already finished.', status: 409 }
|
|
1301
|
+
}
|
|
1302
|
+
await renderJobs()
|
|
1303
|
+
const drawer = await openJob('job_417')
|
|
1304
|
+
press(within(drawer).getByRole('button', { name: 'Cancel job' }))
|
|
1305
|
+
const dialog = await cancelDialog()
|
|
1306
|
+
const listReads = vi.mocked(fetch).mock.calls.length
|
|
1307
|
+
press(within(dialog).getByRole('button', { name: 'Cancel job' }))
|
|
1308
|
+
|
|
1309
|
+
await cancelDialogGone()
|
|
1310
|
+
const message = '“Rewrite meta descriptions” wasn’t cancelled: This job already finished.'
|
|
1311
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
|
|
1312
|
+
expect(toast.error).not.toHaveBeenCalled()
|
|
1313
|
+
expect(vi.mocked(fetch).mock.calls.length).toBeGreaterThan(listReads)
|
|
1314
|
+
// The dialog closed onto the job as it is now: finished, with nothing to cancel.
|
|
1315
|
+
expect(within(drawer).getAllByText('Needs review').length).toBeGreaterThan(0)
|
|
1316
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1317
|
+
await waitFor(() => expect(announced()).toBe(message))
|
|
1318
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
it('says a job had already finished when the cancel comes back unchanged', async () => {
|
|
1322
|
+
server.jobs = [scan('content-health', { status: 'running', done: 50, total: 412 })]
|
|
1323
|
+
server.action = () =>
|
|
1324
|
+
ok(patchJob('job_health', { status: 'succeeded', lockedAt: null, finishedAt: iso(0) }))
|
|
1325
|
+
await renderJobs()
|
|
1326
|
+
const drawer = await openJob('job_health')
|
|
1327
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Cancel job' }))
|
|
1328
|
+
fireEvent.click(within(await cancelDialog()).getByRole('button', { name: 'Cancel job' }))
|
|
1329
|
+
await waitFor(() =>
|
|
1330
|
+
expect(toast).toHaveBeenCalledWith('“Content health” had already finished.'),
|
|
1331
|
+
)
|
|
1332
|
+
expect(within(drawer).getAllByText('Succeeded').length).toBeGreaterThan(0)
|
|
1333
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
it('retries a failed job from the drawer: Retry from document 8, then focus on the panel', async () => {
|
|
1337
|
+
server.jobs = [failed()]
|
|
1338
|
+
server.action = (endpoint) =>
|
|
1339
|
+
endpoint === '/jobs/job_415/retry'
|
|
1340
|
+
? ok(patchJob('job_415', { status: 'queued', attempts: 0, lastError: null }))
|
|
1341
|
+
: { error: `unexpected ${endpoint}`, status: 500 }
|
|
1342
|
+
await renderJobs()
|
|
1343
|
+
const drawer = await openJob('job_415')
|
|
1344
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1345
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
|
|
1346
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
|
|
1347
|
+
// The kit's primary button.
|
|
1348
|
+
expect(retry.className).toContain('bg-acc')
|
|
1349
|
+
retry.focus()
|
|
1350
|
+
fireEvent.click(retry)
|
|
1351
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying from document 8'))
|
|
1352
|
+
await waitFor(() => expect(announced()).toBe('Retrying from document 8'))
|
|
1353
|
+
expect(within(drawer).getAllByText('In progress').length).toBeGreaterThan(0)
|
|
1354
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1355
|
+
// Queued again, it can be cancelled.
|
|
1356
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy()
|
|
1357
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
|
|
1358
|
+
expect(posts('POST', '/retry')).toHaveLength(1)
|
|
1359
|
+
})
|
|
1360
|
+
|
|
1361
|
+
it('counts pages for an SEO scan: Retry from page 101', async () => {
|
|
1362
|
+
server.jobs = [
|
|
1363
|
+
scan('seo-scan', {
|
|
1364
|
+
status: 'dead',
|
|
1365
|
+
attempts: 3,
|
|
1366
|
+
done: 100,
|
|
1367
|
+
total: 340,
|
|
1368
|
+
lockedAt: null,
|
|
1369
|
+
lastError: 'TimeoutError: fetch timed out',
|
|
1370
|
+
}),
|
|
1371
|
+
]
|
|
1372
|
+
await renderJobs()
|
|
1373
|
+
const drawer = await openJob('job_scan')
|
|
1374
|
+
expect(within(drawer).getByRole('button', { name: 'Retry from page 101' })).toBeTruthy()
|
|
1375
|
+
})
|
|
1376
|
+
|
|
1377
|
+
it('posts one retry for a double click, keeping focus on the busy button', async () => {
|
|
1378
|
+
server.jobs = [failed()]
|
|
1379
|
+
let answer: (reply: Reply) => void = () => {}
|
|
1380
|
+
server.action = () => new Promise<Reply>((resolve) => (answer = resolve))
|
|
1381
|
+
await renderJobs()
|
|
1382
|
+
const drawer = await openJob('job_415')
|
|
1383
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
|
|
1384
|
+
retry.focus()
|
|
1385
|
+
fireEvent.click(retry)
|
|
1386
|
+
fireEvent.click(retry)
|
|
1387
|
+
await waitFor(() => expect(retry.getAttribute('aria-busy')).toBe('true'))
|
|
1388
|
+
expect(retry.hasAttribute('disabled')).toBe(false)
|
|
1389
|
+
expect(document.activeElement).toBe(retry)
|
|
1390
|
+
fireEvent.click(retry)
|
|
1391
|
+
expect(posts('POST', '/retry')).toHaveLength(1)
|
|
1392
|
+
await act(async () => {
|
|
1393
|
+
answer(ok(patchJob('job_415', { status: 'queued', attempts: 0, lastError: null })))
|
|
1394
|
+
})
|
|
1395
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying from document 8'))
|
|
1396
|
+
expect(posts('POST', '/retry')).toHaveLength(1)
|
|
1397
|
+
})
|
|
1398
|
+
|
|
1399
|
+
it('reads the list again on a 409 from a retry, and shows the job as it is now', async () => {
|
|
1400
|
+
server.jobs = [failed()]
|
|
1401
|
+
server.action = () => {
|
|
1402
|
+
// Someone else retried it first, and the worker has picked it up.
|
|
1403
|
+
patchJob('job_415', { status: 'running', attempts: 0, lastError: null, lockedAt: iso(0) })
|
|
1404
|
+
return { error: 'Only a failed job, or one waiting to retry, can be retried', status: 409 }
|
|
1405
|
+
}
|
|
1406
|
+
await renderJobs()
|
|
1407
|
+
const drawer = await openJob('job_415')
|
|
1408
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
|
|
1409
|
+
retry.focus()
|
|
1410
|
+
fireEvent.click(retry)
|
|
1411
|
+
const message =
|
|
1412
|
+
'“Translate excerpts to Spanish” wasn’t retried: Only a failed job, or one waiting to retry, can be retried'
|
|
1413
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
|
|
1414
|
+
expect(toast.error).not.toHaveBeenCalled()
|
|
1415
|
+
// The answer shows with the job as it is now, read again: running.
|
|
1416
|
+
expect(within(drawer).getAllByText('Running').length).toBeGreaterThan(0)
|
|
1417
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1418
|
+
await waitFor(() => expect(announced()).toBe(message))
|
|
1419
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
|
|
1420
|
+
})
|
|
1421
|
+
|
|
1422
|
+
it('keeps Retry and focus on a refusal that isn’t a 409', async () => {
|
|
1423
|
+
server.jobs = [failed()]
|
|
1424
|
+
server.action = () => ({ error: 'Insufficient permissions', status: 403 })
|
|
1425
|
+
await renderJobs()
|
|
1426
|
+
const drawer = await openJob('job_415')
|
|
1427
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
|
|
1428
|
+
retry.focus()
|
|
1429
|
+
fireEvent.click(retry)
|
|
1430
|
+
const message = '“Translate excerpts to Spanish” wasn’t retried: Insufficient permissions'
|
|
1431
|
+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message))
|
|
1432
|
+
await waitFor(() => expect(announced()).toBe(message))
|
|
1433
|
+
await waitFor(() => expect(retry.getAttribute('aria-busy')).toBeNull())
|
|
1434
|
+
expect(document.activeElement).toBe(retry)
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
it('offers Retry now on a job waiting to retry, but not while cancelling or on a stale lock', async () => {
|
|
1438
|
+
server.jobs = [
|
|
1439
|
+
waiting(),
|
|
1440
|
+
waiting({ id: 'job_c', title: 'Cancelling batch', cancelRequestedAt: iso(0) }),
|
|
1441
|
+
batch({
|
|
1442
|
+
id: 'job_stale',
|
|
1443
|
+
title: 'Stuck batch',
|
|
1444
|
+
status: 'running',
|
|
1445
|
+
done: 4,
|
|
1446
|
+
attempts: 1,
|
|
1447
|
+
lockedAt: iso(-11 * MIN),
|
|
1448
|
+
}),
|
|
1449
|
+
]
|
|
1450
|
+
server.action = (endpoint) =>
|
|
1451
|
+
endpoint === '/jobs/job_418/retry'
|
|
1452
|
+
? ok(patchJob('job_418', { attempts: 0, lastError: null, runAt: iso(0) }))
|
|
1453
|
+
: { error: `unexpected ${endpoint}`, status: 500 }
|
|
1454
|
+
await renderJobs()
|
|
1455
|
+
|
|
1456
|
+
let drawer = await openJob('job_c')
|
|
1457
|
+
expect(within(drawer).getAllByText('Cancelling').length).toBeGreaterThan(0)
|
|
1458
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
|
|
1459
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1460
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
|
|
1461
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
1462
|
+
|
|
1463
|
+
// The server still holds a stale lock as running; the next worker run picks it up.
|
|
1464
|
+
drawer = await openJob('job_stale')
|
|
1465
|
+
expect(within(drawer).getAllByText('Retrying').length).toBeGreaterThan(0)
|
|
1466
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
|
|
1467
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy()
|
|
1468
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
|
|
1469
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
1470
|
+
|
|
1471
|
+
drawer = await openJob('job_418')
|
|
1472
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy()
|
|
1473
|
+
const now = within(drawer).getByRole('button', { name: 'Retry now' })
|
|
1474
|
+
// The kit's plain `.jb-btn`, not the primary retry.
|
|
1475
|
+
expect(now.className).toContain('bg-card')
|
|
1476
|
+
now.focus()
|
|
1477
|
+
fireEvent.click(now)
|
|
1478
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying on the next worker run'))
|
|
1479
|
+
await waitFor(() => expect(announced()).toBe('Retrying on the next worker run'))
|
|
1480
|
+
expect(posts('POST', '/jobs/job_418/retry')).toHaveLength(1)
|
|
1481
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
|
|
1482
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
|
|
1483
|
+
})
|
|
1484
|
+
|
|
1485
|
+
it('opens the running scan on Run now and announces the reuse in the drawer', async () => {
|
|
1486
|
+
const running = scan('seo-scan', { status: 'running', done: 40, total: 340 })
|
|
1487
|
+
server.jobs = [running]
|
|
1488
|
+
server.action = (endpoint) =>
|
|
1489
|
+
endpoint === '/jobs/run'
|
|
1490
|
+
? ok({ job: running, reused: true })
|
|
1491
|
+
: { error: `unexpected ${endpoint}`, status: 500 }
|
|
1492
|
+
await renderJobs()
|
|
1493
|
+
fireEvent.click(screen.getByRole('button', { name: 'Run now: SEO scan' }))
|
|
1494
|
+
const drawer = await screen.findByRole('dialog', { name: 'SEO scan' })
|
|
1495
|
+
expect(toast).toHaveBeenCalledWith('SEO scan is already running. Reusing that job.')
|
|
1496
|
+
await waitFor(() => expect(announced()).toBe('SEO scan is already running. Reusing that job.'))
|
|
1497
|
+
expect(drawer.hasAttribute('data-job-drawer')).toBe(true)
|
|
1498
|
+
expect(posts('POST', '/jobs/run')).toHaveLength(1)
|
|
1499
|
+
})
|
|
1500
|
+
|
|
1501
|
+
it('hides Run now, Cancel and Retry from a viewer', async () => {
|
|
1502
|
+
server.jobs = [batch({}), failed(), waiting()]
|
|
1503
|
+
await renderJobs(() => {}, { role: 'VIEWER', userId: 'u9' })
|
|
1504
|
+
expect(screen.queryByRole('button', { name: /^Run now/ })).toBeNull()
|
|
1505
|
+
expect(screen.queryByRole('button', { name: /^Cancel / })).toBeNull()
|
|
1506
|
+
expect(screen.queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1507
|
+
// The failure alert keeps See why.
|
|
1508
|
+
expect(within(screen.getByRole('alert')).getByRole('button', { name: 'See why' })).toBeTruthy()
|
|
1509
|
+
|
|
1510
|
+
for (const id of ['job_417', 'job_415', 'job_418']) {
|
|
1511
|
+
const drawer = await openJob(id)
|
|
1512
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1513
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1514
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
|
|
1515
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
1516
|
+
}
|
|
1517
|
+
})
|
|
1518
|
+
|
|
1519
|
+
it('shows nothing to act with when there’s no role', async () => {
|
|
1520
|
+
server.jobs = [batch({}), failed()]
|
|
1521
|
+
await renderJobs(() => {}, { role: null, userId: null })
|
|
1522
|
+
expect(screen.queryByRole('button', { name: /^Run now/ })).toBeNull()
|
|
1523
|
+
expect(screen.queryByRole('button', { name: /^Cancel / })).toBeNull()
|
|
1524
|
+
expect(screen.queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1525
|
+
})
|
|
1526
|
+
|
|
1527
|
+
it('lets an editor retry a scan, and only a batch they queued', async () => {
|
|
1528
|
+
server.jobs = [
|
|
1529
|
+
failed({ createdBy: { id: 'u2', name: 'Dana Cole', initials: 'DC' } }),
|
|
1530
|
+
failed({
|
|
1531
|
+
id: 'job_mine',
|
|
1532
|
+
title: 'My batch',
|
|
1533
|
+
createdBy: { id: 'u7', name: 'Ed', initials: 'E' },
|
|
1534
|
+
}),
|
|
1535
|
+
scan('content-health', {
|
|
1536
|
+
status: 'dead',
|
|
1537
|
+
attempts: 3,
|
|
1538
|
+
done: 50,
|
|
1539
|
+
total: 412,
|
|
1540
|
+
lockedAt: null,
|
|
1541
|
+
lastError: 'Error: boom',
|
|
1542
|
+
}),
|
|
1543
|
+
]
|
|
1544
|
+
await renderJobs(() => {}, { role: 'EDITOR', userId: 'u7' })
|
|
1545
|
+
expect(screen.getByRole('button', { name: 'Run now: SEO scan' })).toBeTruthy()
|
|
1546
|
+
expect(screen.queryByRole('button', { name: 'Retry Translate excerpts to Spanish' })).toBeNull()
|
|
1547
|
+
expect(screen.getByRole('button', { name: 'Retry My batch' })).toBeTruthy()
|
|
1548
|
+
expect(screen.getByRole('button', { name: 'Retry Content health' })).toBeTruthy()
|
|
1549
|
+
|
|
1550
|
+
const drawer = await openJob('job_415')
|
|
1551
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1552
|
+
})
|
|
1553
|
+
})
|