@open-mercato/ui 0.6.4-develop.4382.1.6b4f656b77 → 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.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/AGENTS.md +2 -1
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +212 -85
  6. package/dist/backend/CrudForm.js.map +3 -3
  7. package/dist/backend/DataTable.js +77 -26
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/FlashMessages.js +21 -2
  10. package/dist/backend/FlashMessages.js.map +2 -2
  11. package/dist/backend/JsonBuilder.js +64 -41
  12. package/dist/backend/JsonBuilder.js.map +2 -2
  13. package/dist/backend/PerspectiveSidebar.js +8 -1
  14. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  15. package/dist/backend/RowActions.js +1 -1
  16. package/dist/backend/RowActions.js.map +2 -2
  17. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  18. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  19. package/dist/backend/conflicts/index.js +32 -0
  20. package/dist/backend/conflicts/index.js.map +7 -0
  21. package/dist/backend/conflicts/store.js +50 -0
  22. package/dist/backend/conflicts/store.js.map +7 -0
  23. package/dist/backend/detail/ActivitiesSection.js +1 -0
  24. package/dist/backend/detail/ActivitiesSection.js.map +2 -2
  25. package/dist/backend/detail/InlineEditors.js +2 -51
  26. package/dist/backend/detail/InlineEditors.js.map +3 -3
  27. package/dist/backend/detail/RecordNotFoundState.js +10 -5
  28. package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
  29. package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
  30. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  31. package/dist/backend/filters/FilteredEmptyResults.js +12 -4
  32. package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
  33. package/dist/backend/filters/ListEmptyState.js +44 -0
  34. package/dist/backend/filters/ListEmptyState.js.map +7 -0
  35. package/dist/backend/filters/SearchEmptyResults.js +28 -0
  36. package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
  37. package/dist/backend/icons/lucideRegistry.generated.js +8 -0
  38. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  39. package/dist/backend/injection/useGuardedMutation.js +8 -1
  40. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  41. package/dist/backend/inputs/MarkdownField.js +20 -0
  42. package/dist/backend/inputs/MarkdownField.js.map +7 -0
  43. package/dist/backend/inputs/MdxEditorImpl.js +98 -0
  44. package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
  45. package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
  46. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  47. package/dist/backend/markdown/markdownToPlainText.js +29 -0
  48. package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
  49. package/dist/backend/messages/EmailThreadsPanel.js +212 -0
  50. package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
  51. package/dist/backend/messages/index.js +4 -0
  52. package/dist/backend/messages/index.js.map +2 -2
  53. package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
  54. package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
  55. package/dist/backend/notifications/NotificationDispatcher.js +1 -0
  56. package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
  57. package/dist/backend/notifications/useNotificationsPoll.js +7 -0
  58. package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
  59. package/dist/backend/notifications/useNotificationsSse.js +8 -0
  60. package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
  61. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  62. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  63. package/dist/backend/utils/optimisticLock.js +34 -0
  64. package/dist/backend/utils/optimisticLock.js.map +7 -0
  65. package/dist/portal/hooks/useTenantContext.js +1 -1
  66. package/dist/portal/hooks/useTenantContext.js.map +2 -2
  67. package/dist/primitives/alert.js.map +1 -1
  68. package/dist/primitives/checkbox.js +2 -1
  69. package/dist/primitives/checkbox.js.map +2 -2
  70. package/dist/types/css.d.js +1 -0
  71. package/dist/types/css.d.js.map +7 -0
  72. package/package.json +17 -13
  73. package/src/backend/AppShell.tsx +10 -0
  74. package/src/backend/CrudForm.tsx +320 -97
  75. package/src/backend/DataTable.tsx +113 -34
  76. package/src/backend/FlashMessages.tsx +31 -2
  77. package/src/backend/JsonBuilder.tsx +79 -26
  78. package/src/backend/PerspectiveSidebar.tsx +8 -1
  79. package/src/backend/RowActions.tsx +1 -1
  80. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
  81. package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
  82. package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
  83. package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
  84. package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
  85. package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
  86. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
  87. package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
  88. package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
  89. package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
  90. package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
  91. package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
  92. package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
  93. package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
  94. package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
  95. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
  96. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  97. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  98. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  99. package/src/backend/conflicts/index.ts +49 -0
  100. package/src/backend/conflicts/store.ts +94 -0
  101. package/src/backend/detail/ActivitiesSection.tsx +1 -0
  102. package/src/backend/detail/InlineEditors.tsx +14 -76
  103. package/src/backend/detail/RecordNotFoundState.tsx +13 -6
  104. package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
  105. package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
  106. package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
  107. package/src/backend/filters/ListEmptyState.tsx +69 -0
  108. package/src/backend/filters/SearchEmptyResults.tsx +42 -0
  109. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
  110. package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
  111. package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
  112. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  113. package/src/backend/injection/useGuardedMutation.ts +16 -1
  114. package/src/backend/inputs/MarkdownField.tsx +34 -0
  115. package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
  116. package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
  117. package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
  118. package/src/backend/markdown/markdownToPlainText.ts +41 -0
  119. package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
  120. package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
  121. package/src/backend/messages/index.ts +12 -0
  122. package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
  123. package/src/backend/notifications/NotificationDispatcher.ts +2 -0
  124. package/src/backend/notifications/useNotificationsPoll.ts +5 -0
  125. package/src/backend/notifications/useNotificationsSse.ts +6 -0
  126. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  127. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  128. package/src/backend/utils/optimisticLock.ts +70 -0
  129. package/src/portal/hooks/useTenantContext.ts +7 -2
  130. package/src/primitives/__tests__/checkbox.test.tsx +28 -0
  131. package/src/primitives/alert.tsx +1 -1
  132. package/src/primitives/checkbox.tsx +2 -1
  133. 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 { ErrorMessage } from "./ErrorMessage";
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
- <ErrorMessage
42
- label={label}
43
- description={description}
44
- action={action ?? defaultAction}
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
- {t('ui.advancedFilter.empty.noMatches', 'No {entity} match these filters', { entity: entityNamePlural })}
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={onClearAll}>
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
+ })