@open-mercato/ui 0.6.4-develop.4371.1.8f3030407e → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/AGENTS.md +2 -1
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +212 -85
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +77 -26
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/FlashMessages.js +21 -2
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +64 -41
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/PerspectiveSidebar.js +8 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/RowActions.js +1 -1
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
- package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
- package/dist/backend/conflicts/index.js +32 -0
- package/dist/backend/conflicts/index.js.map +7 -0
- package/dist/backend/conflicts/store.js +50 -0
- package/dist/backend/conflicts/store.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +1 -0
- package/dist/backend/detail/ActivitiesSection.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +2 -51
- package/dist/backend/detail/InlineEditors.js.map +3 -3
- package/dist/backend/detail/RecordNotFoundState.js +10 -5
- package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/backend/filters/FilteredEmptyResults.js +12 -4
- package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
- package/dist/backend/filters/ListEmptyState.js +44 -0
- package/dist/backend/filters/ListEmptyState.js.map +7 -0
- package/dist/backend/filters/SearchEmptyResults.js +28 -0
- package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
- package/dist/backend/icons/lucideRegistry.generated.js +8 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/inputs/MarkdownField.js +20 -0
- package/dist/backend/inputs/MarkdownField.js.map +7 -0
- package/dist/backend/inputs/MdxEditorImpl.js +98 -0
- package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/markdown/markdownToPlainText.js +29 -0
- package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +212 -0
- package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
- package/dist/backend/messages/index.js +4 -0
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
- package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
- package/dist/backend/notifications/NotificationDispatcher.js +1 -0
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/dist/backend/notifications/useNotificationsPoll.js +7 -0
- package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
- package/dist/backend/notifications/useNotificationsSse.js +8 -0
- package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +34 -0
- package/dist/backend/utils/optimisticLock.js.map +7 -0
- package/dist/portal/hooks/useTenantContext.js +1 -1
- package/dist/portal/hooks/useTenantContext.js.map +2 -2
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/checkbox.js +2 -1
- package/dist/primitives/checkbox.js.map +2 -2
- package/dist/types/css.d.js +1 -0
- package/dist/types/css.d.js.map +7 -0
- package/package.json +17 -13
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +320 -97
- package/src/backend/DataTable.tsx +113 -34
- package/src/backend/FlashMessages.tsx +31 -2
- package/src/backend/JsonBuilder.tsx +79 -26
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
- package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
- package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
- package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
- package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
- package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
- package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
- package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
- package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
- package/src/backend/conflicts/__tests__/store.test.ts +78 -0
- package/src/backend/conflicts/index.ts +49 -0
- package/src/backend/conflicts/store.ts +94 -0
- package/src/backend/detail/ActivitiesSection.tsx +1 -0
- package/src/backend/detail/InlineEditors.tsx +14 -76
- package/src/backend/detail/RecordNotFoundState.tsx +13 -6
- package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
- package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
- package/src/backend/filters/ListEmptyState.tsx +69 -0
- package/src/backend/filters/SearchEmptyResults.tsx +42 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
- package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/inputs/MarkdownField.tsx +34 -0
- package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
- package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
- package/src/backend/markdown/markdownToPlainText.ts +41 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
- package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
- package/src/backend/messages/index.ts +12 -0
- package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
- package/src/backend/notifications/NotificationDispatcher.ts +2 -0
- package/src/backend/notifications/useNotificationsPoll.ts +5 -0
- package/src/backend/notifications/useNotificationsSse.ts +6 -0
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
- package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
- package/src/backend/utils/optimisticLock.ts +70 -0
- package/src/portal/hooks/useTenantContext.ts +7 -2
- package/src/primitives/__tests__/checkbox.test.tsx +28 -0
- package/src/primitives/alert.tsx +1 -1
- package/src/primitives/checkbox.tsx +2 -1
- package/src/types/css.d.ts +5 -0
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
|
+
import { SearchX } from "lucide-react";
|
|
5
6
|
import { Button } from "@open-mercato/ui/primitives/button";
|
|
6
|
-
import {
|
|
7
|
+
import { EmptyState } from "@open-mercato/ui/primitives/empty-state";
|
|
8
|
+
import { formatErrorMessageLabel } from "./ErrorMessage";
|
|
7
9
|
import { cn } from "@open-mercato/shared/lib/utils";
|
|
8
10
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
9
11
|
|
|
@@ -13,6 +15,8 @@ export type RecordNotFoundStateProps = {
|
|
|
13
15
|
backHref?: string;
|
|
14
16
|
backLabel?: string;
|
|
15
17
|
action?: React.ReactNode;
|
|
18
|
+
/** Optional leading icon. Defaults to a neutral "search / not found" glyph. */
|
|
19
|
+
icon?: React.ReactNode;
|
|
16
20
|
className?: string;
|
|
17
21
|
};
|
|
18
22
|
|
|
@@ -22,12 +26,13 @@ export function RecordNotFoundState({
|
|
|
22
26
|
backHref,
|
|
23
27
|
backLabel,
|
|
24
28
|
action,
|
|
29
|
+
icon,
|
|
25
30
|
className,
|
|
26
31
|
}: RecordNotFoundStateProps) {
|
|
27
32
|
const t = useT();
|
|
28
33
|
const defaultAction = backHref ? (
|
|
29
34
|
<Button asChild variant="outline" size="sm">
|
|
30
|
-
<Link href={backHref}>{backLabel ?? t('ui.recordNotFound.backToList','Back to list')}</Link>
|
|
35
|
+
<Link href={backHref}>{backLabel ?? t('ui.recordNotFound.backToList', 'Back to list')}</Link>
|
|
31
36
|
</Button>
|
|
32
37
|
) : null;
|
|
33
38
|
|
|
@@ -38,10 +43,12 @@ export function RecordNotFoundState({
|
|
|
38
43
|
className,
|
|
39
44
|
)}
|
|
40
45
|
>
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
<EmptyState
|
|
47
|
+
variant="subtle"
|
|
48
|
+
icon={icon ?? <SearchX className="h-6 w-6" aria-hidden />}
|
|
49
|
+
title={formatErrorMessageLabel(label)}
|
|
50
|
+
description={description ? formatErrorMessageLabel(description) : undefined}
|
|
51
|
+
actions={action ?? defaultAction}
|
|
45
52
|
/>
|
|
46
53
|
</div>
|
|
47
54
|
);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { render, screen } from '@testing-library/react'
|
|
7
|
+
import { RecordNotFoundState } from '../RecordNotFoundState'
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
10
|
+
useT: () => (_key: string, fallback?: string) => fallback ?? _key,
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
jest.mock('next/link', () => {
|
|
14
|
+
const React = require('react')
|
|
15
|
+
return React.forwardRef(
|
|
16
|
+
({ children, href, ...rest }: any, ref: React.ForwardedRef<HTMLAnchorElement>) => (
|
|
17
|
+
<a href={typeof href === 'string' ? href : href?.toString?.()} ref={ref} {...rest}>
|
|
18
|
+
{children}
|
|
19
|
+
</a>
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('RecordNotFoundState', () => {
|
|
25
|
+
it('renders the label as the empty-state title', () => {
|
|
26
|
+
render(<RecordNotFoundState label="Company not found." />)
|
|
27
|
+
expect(screen.getByText('Company not found.')).toBeInTheDocument()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders a back link (role=link) with the given href and default label', () => {
|
|
31
|
+
render(<RecordNotFoundState label="Company not found." backHref="/backend/customers/companies" />)
|
|
32
|
+
const backLink = screen.getByRole('link', { name: /back to list/i })
|
|
33
|
+
expect(backLink).toBeInTheDocument()
|
|
34
|
+
expect(backLink).toHaveAttribute('href', '/backend/customers/companies')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('uses a custom backLabel when provided', () => {
|
|
38
|
+
render(
|
|
39
|
+
<RecordNotFoundState
|
|
40
|
+
label="Company not found."
|
|
41
|
+
backHref="/backend/customers/companies"
|
|
42
|
+
backLabel="Back to companies"
|
|
43
|
+
/>,
|
|
44
|
+
)
|
|
45
|
+
expect(screen.getByRole('link', { name: /back to companies/i })).toBeInTheDocument()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders a custom action instead of the default back link', () => {
|
|
49
|
+
render(
|
|
50
|
+
<RecordNotFoundState
|
|
51
|
+
label="Company not found."
|
|
52
|
+
backHref="/ignored"
|
|
53
|
+
action={<button type="button">Retry</button>}
|
|
54
|
+
/>,
|
|
55
|
+
)
|
|
56
|
+
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
|
57
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('renders no action when neither backHref nor action is provided', () => {
|
|
61
|
+
render(<RecordNotFoundState label="Company not found." />)
|
|
62
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
|
63
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('is a neutral empty state, not a destructive alert (regression guard for #2127)', () => {
|
|
67
|
+
render(<RecordNotFoundState label="Company not found." backHref="/backend/customers/companies" />)
|
|
68
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('renders a custom icon when provided', () => {
|
|
72
|
+
render(
|
|
73
|
+
<RecordNotFoundState
|
|
74
|
+
label="Company not found."
|
|
75
|
+
icon={<svg data-testid="custom-icon" />}
|
|
76
|
+
/>,
|
|
77
|
+
)
|
|
78
|
+
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -295,6 +295,33 @@ export function AdvancedFilterPanel(props: AdvancedFilterPanelProps) {
|
|
|
295
295
|
props.onOpenChange(false)
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
const { onFlush, onOpenChange, open } = props
|
|
299
|
+
|
|
300
|
+
React.useEffect(() => {
|
|
301
|
+
if (!open || pickerOpen || saveDialogOpen) return
|
|
302
|
+
const closeOnEscape = (event: KeyboardEvent) => {
|
|
303
|
+
if (event.key !== 'Escape') return
|
|
304
|
+
// Do NOT gate on `event.defaultPrevented`. Radix keeps a just-closed
|
|
305
|
+
// dropdown's dismissable layer mounted while its exit animation plays, and
|
|
306
|
+
// that layer still calls preventDefault() on Escape. Picking a value in the
|
|
307
|
+
// operator/value Select therefore leaves a layer that swallows the next
|
|
308
|
+
// Escape, so the panel never closes and the active-filter chips never render
|
|
309
|
+
// (regression seen on loaded standalone CI runners where the exit-animation
|
|
310
|
+
// unmount is delayed). Instead, only defer Escape to an inner dropdown that
|
|
311
|
+
// is *actually* open — a Radix Select trigger flips aria-expanded to "false"
|
|
312
|
+
// synchronously on selection, so a closing-but-animating dropdown is no
|
|
313
|
+
// longer treated as open here.
|
|
314
|
+
if (typeof document !== 'undefined'
|
|
315
|
+
&& document.querySelector('[role="combobox"][aria-expanded="true"]')) {
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
onOpenChange(false)
|
|
319
|
+
onFlush?.()
|
|
320
|
+
}
|
|
321
|
+
window.addEventListener('keydown', closeOnEscape)
|
|
322
|
+
return () => window.removeEventListener('keydown', closeOnEscape)
|
|
323
|
+
}, [onFlush, onOpenChange, open, pickerOpen, saveDialogOpen])
|
|
324
|
+
|
|
298
325
|
React.useEffect(() => {
|
|
299
326
|
if (!savedFilterStorageKey) {
|
|
300
327
|
setSavedFilters([])
|
|
@@ -9,27 +9,40 @@ export type FilteredEmptyResultsProps = {
|
|
|
9
9
|
canRemoveLast: boolean
|
|
10
10
|
onClearAll: () => void
|
|
11
11
|
onRemoveLast: () => void
|
|
12
|
+
/**
|
|
13
|
+
* When provided, a search is ALSO active alongside the filters. Switches the
|
|
14
|
+
* copy to a combined search+filter message and makes the primary action clear
|
|
15
|
+
* both the search and the filters (so a single click shows everyone).
|
|
16
|
+
*/
|
|
17
|
+
onClearSearch?: () => void
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
export function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast }: FilteredEmptyResultsProps) {
|
|
20
|
+
export function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast, onClearSearch }: FilteredEmptyResultsProps) {
|
|
15
21
|
const t = useT()
|
|
22
|
+
const searchActive = typeof onClearSearch === 'function'
|
|
23
|
+
const title = searchActive
|
|
24
|
+
? t('ui.advancedFilter.empty.noMatchesSearchFilters', 'No {entity} match your search and filters', { entity: entityNamePlural })
|
|
25
|
+
: t('ui.advancedFilter.empty.noMatches', 'No {entity} match these filters', { entity: entityNamePlural })
|
|
26
|
+
const description = searchActive
|
|
27
|
+
? t('ui.advancedFilter.empty.tryRemovingSearchFilters', 'Try a different search, remove a filter, or clear everything to see everyone.')
|
|
28
|
+
: t('ui.advancedFilter.empty.tryRemoving', 'Try removing the most restrictive filter, or clear all filters to see everyone.')
|
|
29
|
+
const primaryLabel = searchActive
|
|
30
|
+
? t('ui.advancedFilter.empty.clearSearchAndFilters', 'Clear search and filters')
|
|
31
|
+
: t('ui.advancedFilter.empty.clearAll', 'Clear all filters')
|
|
32
|
+
const handlePrimary = searchActive
|
|
33
|
+
? () => { onClearSearch?.(); onClearAll() }
|
|
34
|
+
: onClearAll
|
|
16
35
|
return (
|
|
17
36
|
<div className="flex flex-col items-center justify-center gap-4 py-12" data-testid="filtered-empty-results">
|
|
18
37
|
<div className="flex size-14 items-center justify-center rounded-full bg-muted">
|
|
19
38
|
<SearchX className="size-6 text-muted-foreground" />
|
|
20
39
|
</div>
|
|
21
40
|
<div className="flex flex-col items-center gap-1">
|
|
22
|
-
<div className="text-base font-semibold">
|
|
23
|
-
|
|
24
|
-
</div>
|
|
25
|
-
<div className="text-sm text-muted-foreground max-w-md text-center">
|
|
26
|
-
{t('ui.advancedFilter.empty.tryRemoving', 'Try removing the most restrictive filter, or clear all filters to see everyone.')}
|
|
27
|
-
</div>
|
|
41
|
+
<div className="text-base font-semibold">{title}</div>
|
|
42
|
+
<div className="text-sm text-muted-foreground max-w-md text-center">{description}</div>
|
|
28
43
|
</div>
|
|
29
44
|
<div className="flex items-center gap-2">
|
|
30
|
-
<Button type="button" onClick={
|
|
31
|
-
{t('ui.advancedFilter.empty.clearAll', 'Clear all filters')}
|
|
32
|
-
</Button>
|
|
45
|
+
<Button type="button" onClick={handlePrimary}>{primaryLabel}</Button>
|
|
33
46
|
<Button type="button" variant="outline" disabled={!canRemoveLast} onClick={onRemoveLast}>
|
|
34
47
|
<Undo2 className="size-4" />
|
|
35
48
|
{t('ui.advancedFilter.empty.removeLast', 'Remove last filter')}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { Inbox, Plus } from 'lucide-react'
|
|
5
|
+
import { Button } from '../../primitives/button'
|
|
6
|
+
import { EmptyState } from '../../primitives/empty-state'
|
|
7
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
|
|
9
|
+
export type ListEmptyStateProps = {
|
|
10
|
+
/** Plural entity label, e.g. "companies". Used to build the default title. */
|
|
11
|
+
entityName?: string
|
|
12
|
+
/** Overrides the generated title. */
|
|
13
|
+
title?: string
|
|
14
|
+
/** Overrides the generated description. */
|
|
15
|
+
description?: string
|
|
16
|
+
/** Create link target; renders the primary "create" action. */
|
|
17
|
+
createHref?: string
|
|
18
|
+
/** Create click handler (alternative to createHref). */
|
|
19
|
+
onCreate?: () => void
|
|
20
|
+
/** Label for the create action (defaults to a generic "Create"). */
|
|
21
|
+
createLabel?: string
|
|
22
|
+
/** Optional leading icon (defaults to a neutral inbox glyph). */
|
|
23
|
+
icon?: React.ReactNode
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Standardized zero-records empty state for list views (DataTable `emptyState`).
|
|
28
|
+
* Use this when a list has no records yet (no active search/filter). For
|
|
29
|
+
* no-results-after-search the DataTable renders SearchEmptyResults, and for
|
|
30
|
+
* no-results-after-filter it renders FilteredEmptyResults.
|
|
31
|
+
*/
|
|
32
|
+
export function ListEmptyState({
|
|
33
|
+
entityName,
|
|
34
|
+
title,
|
|
35
|
+
description,
|
|
36
|
+
createHref,
|
|
37
|
+
onCreate,
|
|
38
|
+
createLabel,
|
|
39
|
+
icon,
|
|
40
|
+
}: ListEmptyStateProps) {
|
|
41
|
+
const t = useT()
|
|
42
|
+
const entity = entityName ?? t('ui.dataTable.empty.genericEntity', 'records')
|
|
43
|
+
const resolvedTitle = title ?? t('ui.dataTable.empty.title', 'No {entity} yet', { entity })
|
|
44
|
+
const resolvedDescription = description ?? t('ui.dataTable.empty.description', 'Items you add will show up here.')
|
|
45
|
+
const resolvedCreateLabel = createLabel ?? t('ui.dataTable.empty.create', 'Create')
|
|
46
|
+
const action = createHref ? (
|
|
47
|
+
<Button asChild>
|
|
48
|
+
<Link href={createHref}>
|
|
49
|
+
<Plus className="size-4" aria-hidden />
|
|
50
|
+
{resolvedCreateLabel}
|
|
51
|
+
</Link>
|
|
52
|
+
</Button>
|
|
53
|
+
) : onCreate ? (
|
|
54
|
+
<Button type="button" onClick={onCreate}>
|
|
55
|
+
<Plus className="size-4" aria-hidden />
|
|
56
|
+
{resolvedCreateLabel}
|
|
57
|
+
</Button>
|
|
58
|
+
) : null
|
|
59
|
+
return (
|
|
60
|
+
<EmptyState
|
|
61
|
+
variant="subtle"
|
|
62
|
+
size="lg"
|
|
63
|
+
icon={icon ?? <Inbox className="size-7" aria-hidden />}
|
|
64
|
+
title={resolvedTitle}
|
|
65
|
+
description={resolvedDescription}
|
|
66
|
+
actions={action}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { SearchX, X } from 'lucide-react'
|
|
4
|
+
import { Button } from '../../primitives/button'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
|
|
7
|
+
export type SearchEmptyResultsProps = {
|
|
8
|
+
/** The active search query, shown back to the user. */
|
|
9
|
+
query: string
|
|
10
|
+
/** Plural entity label (e.g. "companies"); falls back to a generic word. */
|
|
11
|
+
entityNamePlural?: string
|
|
12
|
+
/** Clears the active search. */
|
|
13
|
+
onClearSearch: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SearchEmptyResults({ query, entityNamePlural, onClearSearch }: SearchEmptyResultsProps) {
|
|
17
|
+
const t = useT()
|
|
18
|
+
const entity = entityNamePlural ?? t('ui.dataTable.search.empty.genericEntity', 'results')
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex w-full max-w-md flex-col items-center gap-4 py-10 text-center" data-testid="search-empty-results">
|
|
21
|
+
<div className="flex size-16 items-center justify-center rounded-full bg-muted">
|
|
22
|
+
<SearchX className="size-7 text-muted-foreground" aria-hidden />
|
|
23
|
+
</div>
|
|
24
|
+
<div className="flex flex-col items-center gap-1">
|
|
25
|
+
<div className="text-base font-semibold text-foreground">
|
|
26
|
+
{t('ui.dataTable.search.empty.title', 'No results found')}
|
|
27
|
+
</div>
|
|
28
|
+
<p className="text-sm text-muted-foreground">
|
|
29
|
+
{t(
|
|
30
|
+
'ui.dataTable.search.empty.description',
|
|
31
|
+
'We couldn’t find any {entity} matching “{query}”. Try adjusting your search or filters.',
|
|
32
|
+
{ entity, query },
|
|
33
|
+
)}
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
<Button type="button" onClick={onClearSearch}>
|
|
37
|
+
<X className="size-4" aria-hidden />
|
|
38
|
+
{t('ui.dataTable.search.empty.clear', 'Clear search')}
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -106,6 +106,74 @@ describe('AdvancedFilterPanel', () => {
|
|
|
106
106
|
expect(screen.queryByText(/no filters applied/i)).not.toBeInTheDocument()
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
+
it('closes the panel on Escape even when a closing dropdown layer marked the event prevented', () => {
|
|
110
|
+
const onOpenChange = jest.fn()
|
|
111
|
+
const onFlush = jest.fn()
|
|
112
|
+
render(
|
|
113
|
+
<AdvancedFilterPanel
|
|
114
|
+
fields={fields}
|
|
115
|
+
value={createEmptyTree()}
|
|
116
|
+
onChange={() => {}}
|
|
117
|
+
onApply={() => {}}
|
|
118
|
+
onClear={() => {}}
|
|
119
|
+
onFlush={onFlush}
|
|
120
|
+
pendingErrors={[]}
|
|
121
|
+
userId="u1"
|
|
122
|
+
presets={[]}
|
|
123
|
+
open
|
|
124
|
+
onOpenChange={onOpenChange}
|
|
125
|
+
triggerRef={{ current: null }}
|
|
126
|
+
/>,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// Simulate a just-closed Select whose dismissable layer is still mounted during
|
|
130
|
+
// its exit animation: it consumes Escape (defaultPrevented) but its trigger has
|
|
131
|
+
// already flipped aria-expanded to "false". The panel must still close.
|
|
132
|
+
const swallow = (event: KeyboardEvent) => {
|
|
133
|
+
if (event.key === 'Escape') event.preventDefault()
|
|
134
|
+
}
|
|
135
|
+
window.addEventListener('keydown', swallow, true)
|
|
136
|
+
try {
|
|
137
|
+
fireEvent.keyDown(window, { key: 'Escape' })
|
|
138
|
+
} finally {
|
|
139
|
+
window.removeEventListener('keydown', swallow, true)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
143
|
+
expect(onFlush).toHaveBeenCalled()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('keeps the panel open on Escape while an inner dropdown is actually open', () => {
|
|
147
|
+
const onOpenChange = jest.fn()
|
|
148
|
+
render(
|
|
149
|
+
<AdvancedFilterPanel
|
|
150
|
+
fields={fields}
|
|
151
|
+
value={createEmptyTree()}
|
|
152
|
+
onChange={() => {}}
|
|
153
|
+
onApply={() => {}}
|
|
154
|
+
onClear={() => {}}
|
|
155
|
+
pendingErrors={[]}
|
|
156
|
+
userId="u1"
|
|
157
|
+
presets={[]}
|
|
158
|
+
open
|
|
159
|
+
onOpenChange={onOpenChange}
|
|
160
|
+
triggerRef={{ current: null }}
|
|
161
|
+
/>,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
// A genuinely-open Radix Select keeps its trigger at aria-expanded="true".
|
|
165
|
+
const openSelect = document.createElement('div')
|
|
166
|
+
openSelect.setAttribute('role', 'combobox')
|
|
167
|
+
openSelect.setAttribute('aria-expanded', 'true')
|
|
168
|
+
document.body.appendChild(openSelect)
|
|
169
|
+
try {
|
|
170
|
+
fireEvent.keyDown(window, { key: 'Escape' })
|
|
171
|
+
expect(onOpenChange).not.toHaveBeenCalled()
|
|
172
|
+
} finally {
|
|
173
|
+
document.body.removeChild(openSelect)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
109
177
|
it('saves filters separately from perspectives', async () => {
|
|
110
178
|
const tree = { root: { id: 'r', type: 'group' as const, combinator: 'and' as const, children: [
|
|
111
179
|
{ id: 'a', type: 'rule' as const, field: 'name', operator: 'contains' as const, value: 'Alice' } as any,
|
|
@@ -209,4 +277,34 @@ describe('AdvancedFilterPanel', () => {
|
|
|
209
277
|
}))
|
|
210
278
|
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
211
279
|
})
|
|
280
|
+
|
|
281
|
+
it('closes and flushes the panel when Escape is pressed after manual filter editing', () => {
|
|
282
|
+
const onOpenChange = jest.fn()
|
|
283
|
+
const onFlush = jest.fn()
|
|
284
|
+
const tree = { root: { id: 'r', type: 'group' as const, combinator: 'and' as const, children: [
|
|
285
|
+
{ id: 'a', type: 'rule' as const, field: 'name', operator: 'contains' as const, value: 'Alice' } as any,
|
|
286
|
+
] } }
|
|
287
|
+
|
|
288
|
+
render(
|
|
289
|
+
<AdvancedFilterPanel
|
|
290
|
+
fields={fields}
|
|
291
|
+
value={tree}
|
|
292
|
+
onChange={() => {}}
|
|
293
|
+
onApply={() => {}}
|
|
294
|
+
onClear={() => {}}
|
|
295
|
+
onFlush={onFlush}
|
|
296
|
+
pendingErrors={[]}
|
|
297
|
+
userId="u1"
|
|
298
|
+
presets={[]}
|
|
299
|
+
open
|
|
300
|
+
onOpenChange={onOpenChange}
|
|
301
|
+
triggerRef={{ current: null }}
|
|
302
|
+
/>,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
fireEvent.keyDown(window, { key: 'Escape' })
|
|
306
|
+
|
|
307
|
+
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
308
|
+
expect(onFlush).toHaveBeenCalledTimes(1)
|
|
309
|
+
})
|
|
212
310
|
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { render, screen } from '@testing-library/react'
|
|
7
|
+
import { ListEmptyState } from '../ListEmptyState'
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
10
|
+
useT: () => (_key: string, fallback?: string, params?: Record<string, unknown>) => {
|
|
11
|
+
let out = fallback ?? _key
|
|
12
|
+
if (params) for (const k of Object.keys(params)) out = out.replace(`{${k}}`, String(params[k]))
|
|
13
|
+
return out
|
|
14
|
+
},
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.mock('next/link', () => {
|
|
18
|
+
const React = require('react')
|
|
19
|
+
return React.forwardRef(
|
|
20
|
+
({ children, href, ...rest }: any, ref: React.ForwardedRef<HTMLAnchorElement>) => (
|
|
21
|
+
<a href={typeof href === 'string' ? href : href?.toString?.()} ref={ref} {...rest}>
|
|
22
|
+
{children}
|
|
23
|
+
</a>
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('ListEmptyState', () => {
|
|
29
|
+
it('builds the title from entityName', () => {
|
|
30
|
+
render(<ListEmptyState entityName="companies" />)
|
|
31
|
+
expect(screen.getByText('No companies yet')).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders a create link (role=link) with href and label when createHref is set', () => {
|
|
35
|
+
render(<ListEmptyState entityName="companies" createHref="/backend/customers/companies/create" createLabel="New company" />)
|
|
36
|
+
const link = screen.getByRole('link', { name: /new company/i })
|
|
37
|
+
expect(link).toBeInTheDocument()
|
|
38
|
+
expect(link).toHaveAttribute('href', '/backend/customers/companies/create')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renders a create button when onCreate is provided', () => {
|
|
42
|
+
const onCreate = jest.fn()
|
|
43
|
+
render(<ListEmptyState entityName="users" onCreate={onCreate} createLabel="Add user" />)
|
|
44
|
+
expect(screen.getByRole('button', { name: /add user/i })).toBeInTheDocument()
|
|
45
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders no action when neither createHref nor onCreate is provided', () => {
|
|
49
|
+
render(<ListEmptyState entityName="records" />)
|
|
50
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
|
51
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
BarChart3,
|
|
19
19
|
Bell,
|
|
20
20
|
Bolt,
|
|
21
|
+
Book,
|
|
21
22
|
Bookmark,
|
|
22
23
|
Box,
|
|
23
24
|
Briefcase,
|
|
@@ -83,6 +84,7 @@ import {
|
|
|
83
84
|
Mail,
|
|
84
85
|
MailOpen,
|
|
85
86
|
MapPin,
|
|
87
|
+
MessageCircle,
|
|
86
88
|
MinusCircle,
|
|
87
89
|
Notebook,
|
|
88
90
|
Package,
|
|
@@ -98,6 +100,7 @@ import {
|
|
|
98
100
|
Receipt,
|
|
99
101
|
ReceiptText,
|
|
100
102
|
RefreshCcw,
|
|
103
|
+
RefreshCw,
|
|
101
104
|
Reply,
|
|
102
105
|
RotateCcw,
|
|
103
106
|
Ruler,
|
|
@@ -139,6 +142,7 @@ import {
|
|
|
139
142
|
UserX,
|
|
140
143
|
Wallet,
|
|
141
144
|
Webhook,
|
|
145
|
+
Workflow,
|
|
142
146
|
Wrench,
|
|
143
147
|
X,
|
|
144
148
|
XCircle,
|
|
@@ -158,6 +162,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
158
162
|
'bar-chart-3': BarChart3,
|
|
159
163
|
'bell': Bell,
|
|
160
164
|
'bolt': Bolt,
|
|
165
|
+
'book': Book,
|
|
161
166
|
'bookmark': Bookmark,
|
|
162
167
|
'box': Box,
|
|
163
168
|
'briefcase': Briefcase,
|
|
@@ -223,6 +228,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
223
228
|
'mail': Mail,
|
|
224
229
|
'mail-open': MailOpen,
|
|
225
230
|
'map-pin': MapPin,
|
|
231
|
+
'message-circle': MessageCircle,
|
|
226
232
|
'minus-circle': MinusCircle,
|
|
227
233
|
'notebook': Notebook,
|
|
228
234
|
'package': Package,
|
|
@@ -238,6 +244,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
238
244
|
'receipt': Receipt,
|
|
239
245
|
'receipt-text': ReceiptText,
|
|
240
246
|
'refresh-ccw': RefreshCcw,
|
|
247
|
+
'refresh-cw': RefreshCw,
|
|
241
248
|
'reply': Reply,
|
|
242
249
|
'rotate-ccw': RotateCcw,
|
|
243
250
|
'ruler': Ruler,
|
|
@@ -279,6 +286,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
279
286
|
'users': Users,
|
|
280
287
|
'wallet': Wallet,
|
|
281
288
|
'webhook': Webhook,
|
|
289
|
+
'workflow': Workflow,
|
|
282
290
|
'wrench': Wrench,
|
|
283
291
|
'x': X,
|
|
284
292
|
'x-circle': XCircle,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
4
|
+
import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
5
|
+
|
|
6
|
+
const triggerEventMock = jest.fn()
|
|
7
|
+
|
|
8
|
+
jest.mock('../InjectionSpot', () => ({
|
|
9
|
+
useInjectionSpotEvents: () => ({
|
|
10
|
+
triggerEvent: (...args: unknown[]) => triggerEventMock(...args),
|
|
11
|
+
}),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
15
|
+
useT: () => (key: string, fallback?: string) => fallback ?? key,
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
import { act, renderHook } from '@testing-library/react'
|
|
19
|
+
import { useGuardedMutation } from '../useGuardedMutation'
|
|
20
|
+
import { dismissRecordConflict, getRecordConflictForTest } from '../../conflicts'
|
|
21
|
+
|
|
22
|
+
function setupTriggerEventOk() {
|
|
23
|
+
triggerEventMock.mockImplementation(async () => ({ ok: true, requestHeaders: undefined }))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('useGuardedMutation — surfaces optimistic-lock conflict on the unified bar', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
triggerEventMock.mockClear()
|
|
29
|
+
dismissRecordConflict()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('shows the record-conflict bar when the operation throws CrudHttpError(409, optimistic_lock_conflict)', async () => {
|
|
33
|
+
setupTriggerEventOk()
|
|
34
|
+
const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
|
|
35
|
+
const conflict = new CrudHttpError(409, {
|
|
36
|
+
error: 'record_modified',
|
|
37
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
38
|
+
currentUpdatedAt: '2026-05-25T08:00:01.000Z',
|
|
39
|
+
expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
await act(async () => {
|
|
43
|
+
try {
|
|
44
|
+
await result.current.runMutation({
|
|
45
|
+
operation: async () => { throw conflict },
|
|
46
|
+
context: {},
|
|
47
|
+
mutationPayload: {},
|
|
48
|
+
})
|
|
49
|
+
} catch (err) {
|
|
50
|
+
expect(err).toBe(conflict)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const entry = getRecordConflictForTest()
|
|
55
|
+
expect(entry).not.toBeNull()
|
|
56
|
+
expect(entry?.message).toBe('This record was modified by someone else. Refresh and try again.')
|
|
57
|
+
expect(entry?.currentUpdatedAt).toBe('2026-05-25T08:00:01.000Z')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does NOT show the bar for non-optimistic-lock 409s (e.g. plain conflicts)', async () => {
|
|
61
|
+
setupTriggerEventOk()
|
|
62
|
+
const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
|
|
63
|
+
const otherConflict = new CrudHttpError(409, {
|
|
64
|
+
error: 'duplicate',
|
|
65
|
+
code: 'duplicate_resource',
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await act(async () => {
|
|
69
|
+
try {
|
|
70
|
+
await result.current.runMutation({
|
|
71
|
+
operation: async () => { throw otherConflict },
|
|
72
|
+
context: {},
|
|
73
|
+
mutationPayload: {},
|
|
74
|
+
})
|
|
75
|
+
} catch (err) {
|
|
76
|
+
expect(err).toBe(otherConflict)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('does NOT show the bar for non-409 errors', async () => {
|
|
84
|
+
setupTriggerEventOk()
|
|
85
|
+
const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
|
|
86
|
+
const validationError = new CrudHttpError(422, { error: 'validation_failed' })
|
|
87
|
+
|
|
88
|
+
await act(async () => {
|
|
89
|
+
try {
|
|
90
|
+
await result.current.runMutation({
|
|
91
|
+
operation: async () => { throw validationError },
|
|
92
|
+
context: {},
|
|
93
|
+
mutationPayload: {},
|
|
94
|
+
})
|
|
95
|
+
} catch (err) {
|
|
96
|
+
expect(err).toBe(validationError)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('does NOT show the bar when the operation resolves successfully', async () => {
|
|
104
|
+
setupTriggerEventOk()
|
|
105
|
+
const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
|
|
106
|
+
|
|
107
|
+
await act(async () => {
|
|
108
|
+
const value = await result.current.runMutation({
|
|
109
|
+
operation: async () => 'ok',
|
|
110
|
+
context: {},
|
|
111
|
+
mutationPayload: {},
|
|
112
|
+
})
|
|
113
|
+
expect(value).toBe('ok')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
117
|
+
})
|
|
118
|
+
})
|