@open-mercato/ui 0.5.1-develop.2800.bfe2178a4f → 0.5.1-develop.2851.2854b4507f

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 (33) hide show
  1. package/dist/backend/DataTable.js +15 -0
  2. package/dist/backend/DataTable.js.map +2 -2
  3. package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -1
  4. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +1 -1
  5. package/dist/backend/crud/CollapsibleGroup.js +64 -50
  6. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  7. package/dist/backend/crud/CollapsibleZoneLayout.js +23 -3
  8. package/dist/backend/crud/CollapsibleZoneLayout.js.map +2 -2
  9. package/dist/backend/crud/useGroupCollapse.js +2 -2
  10. package/dist/backend/crud/useGroupCollapse.js.map +2 -2
  11. package/dist/backend/crud/usePersistedBooleanFlag.js +57 -16
  12. package/dist/backend/crud/usePersistedBooleanFlag.js.map +2 -2
  13. package/dist/backend/crud/useZoneCollapse.js +2 -2
  14. package/dist/backend/crud/useZoneCollapse.js.map +2 -2
  15. package/dist/backend/messages/SendObjectMessageDialog.js +34 -13
  16. package/dist/backend/messages/SendObjectMessageDialog.js.map +2 -2
  17. package/dist/backend/version-history/VersionHistoryAction.js +3 -3
  18. package/dist/backend/version-history/VersionHistoryAction.js.map +2 -2
  19. package/package.json +3 -3
  20. package/src/backend/DataTable.tsx +16 -0
  21. package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +60 -0
  22. package/src/backend/__tests__/DataTable.extensions.test.tsx +61 -8
  23. package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -1
  24. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +44 -0
  25. package/src/backend/crud/CollapsibleGroup.tsx +12 -2
  26. package/src/backend/crud/CollapsibleZoneLayout.tsx +29 -4
  27. package/src/backend/crud/__tests__/usePersistedBooleanFlag.test.ts +83 -7
  28. package/src/backend/crud/useGroupCollapse.ts +2 -2
  29. package/src/backend/crud/usePersistedBooleanFlag.ts +75 -21
  30. package/src/backend/crud/useZoneCollapse.ts +2 -2
  31. package/src/backend/messages/SendObjectMessageDialog.tsx +37 -7
  32. package/src/backend/messages/__tests__/SendObjectMessageDialog.test.tsx +21 -0
  33. package/src/backend/version-history/VersionHistoryAction.tsx +4 -4
@@ -7,7 +7,7 @@ function getStorageKey(pageType: string, groupId: string) {
7
7
  }
8
8
 
9
9
  export function useGroupCollapse(pageType: string, groupId: string, defaultExpanded = true) {
10
- const { value: expanded, toggle, setValue } = usePersistedBooleanFlag(
10
+ const { value: expanded, toggle, setValue, isHydrated } = usePersistedBooleanFlag(
11
11
  getStorageKey(pageType, groupId),
12
12
  defaultExpanded,
13
13
  )
@@ -18,5 +18,5 @@ export function useGroupCollapse(pageType: string, groupId: string, defaultExpan
18
18
  setValue(next)
19
19
  }
20
20
  }, [setValue])
21
- return { expanded, toggle, setExpanded }
21
+ return { expanded, toggle, setExpanded, isHydrated }
22
22
  }
@@ -1,35 +1,89 @@
1
1
  'use client'
2
- import { useState, useEffect, useCallback, useRef } from 'react'
2
+ import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
3
3
  import {
4
4
  readJsonFromLocalStorage,
5
5
  writeJsonToLocalStorage,
6
6
  } from '@open-mercato/shared/lib/browser/safeLocalStorage'
7
7
 
8
- /**
9
- * Persists a boolean flag in localStorage under a given key.
10
- * Reads once on mount; writes on every change after mount.
11
- * Designed to back collapse/expand state for crud form groups and zones.
12
- */
8
+ const LOCAL_BROADCAST_EVENT = 'om:persisted-boolean-flag:change'
9
+
10
+ type SafeEventTarget = {
11
+ addEventListener: EventTarget['addEventListener']
12
+ removeEventListener: EventTarget['removeEventListener']
13
+ dispatchEvent: EventTarget['dispatchEvent']
14
+ }
15
+
16
+ const localEmitter: SafeEventTarget | null =
17
+ typeof window !== 'undefined' ? new EventTarget() : null
18
+
19
+ function readCurrentValue(storageKey: string, defaultValue: boolean): boolean {
20
+ const saved = readJsonFromLocalStorage<string | null>(storageKey, null)
21
+ if (saved === '1') return true
22
+ if (saved === '0') return false
23
+ return defaultValue
24
+ }
25
+
26
+ function persistValue(storageKey: string, next: boolean): void {
27
+ writeJsonToLocalStorage(storageKey, next ? '1' : '0')
28
+ localEmitter?.dispatchEvent(
29
+ new CustomEvent(LOCAL_BROADCAST_EVENT, { detail: storageKey }),
30
+ )
31
+ }
32
+
33
+ function subscribe(storageKey: string, onChange: () => void): () => void {
34
+ if (typeof window === 'undefined') return () => {}
35
+
36
+ const handleStorage = (event: StorageEvent) => {
37
+ if (event.key === storageKey) onChange()
38
+ }
39
+ const handleLocal = (event: Event) => {
40
+ const detail = (event as CustomEvent<string>).detail
41
+ if (detail === storageKey) onChange()
42
+ }
43
+
44
+ window.addEventListener('storage', handleStorage)
45
+ localEmitter?.addEventListener(LOCAL_BROADCAST_EVENT, handleLocal)
46
+
47
+ return () => {
48
+ window.removeEventListener('storage', handleStorage)
49
+ localEmitter?.removeEventListener(LOCAL_BROADCAST_EVENT, handleLocal)
50
+ }
51
+ }
52
+
13
53
  export function usePersistedBooleanFlag(storageKey: string, defaultValue: boolean) {
14
- const [value, setValue] = useState(defaultValue)
15
- const mounted = useRef(false)
54
+ const [isHydrated, setIsHydrated] = useState(false)
55
+ const getSnapshot = useCallback(
56
+ () => readCurrentValue(storageKey, defaultValue),
57
+ [storageKey, defaultValue],
58
+ )
59
+ const getServerSnapshot = useCallback(() => defaultValue, [defaultValue])
60
+ const subscribeKey = useCallback(
61
+ (onChange: () => void) => subscribe(storageKey, onChange),
62
+ [storageKey],
63
+ )
16
64
 
17
- useEffect(() => {
18
- mounted.current = true
19
- const saved = readJsonFromLocalStorage<string | null>(storageKey, null)
20
- if (saved !== null) {
21
- setValue(saved === '1')
22
- }
23
- }, [storageKey])
65
+ const value = useSyncExternalStore(subscribeKey, getSnapshot, getServerSnapshot)
24
66
 
25
67
  useEffect(() => {
26
- if (!mounted.current) return
27
- writeJsonToLocalStorage(storageKey, value ? '1' : '0')
28
- }, [storageKey, value])
68
+ setIsHydrated(true)
69
+ }, [])
70
+
71
+ const setValue = useCallback(
72
+ (next: boolean | ((prev: boolean) => boolean)) => {
73
+ const current = readCurrentValue(storageKey, defaultValue)
74
+ const nextValue =
75
+ typeof next === 'function'
76
+ ? (next as (prev: boolean) => boolean)(current)
77
+ : next
78
+ persistValue(storageKey, nextValue)
79
+ },
80
+ [storageKey, defaultValue],
81
+ )
29
82
 
30
83
  const toggle = useCallback(() => {
31
- setValue((prev) => !prev)
32
- }, [])
84
+ const current = readCurrentValue(storageKey, defaultValue)
85
+ persistValue(storageKey, !current)
86
+ }, [storageKey, defaultValue])
33
87
 
34
- return { value, toggle, setValue }
88
+ return { value, toggle, setValue, isHydrated }
35
89
  }
@@ -7,7 +7,7 @@ function getStorageKey(pageType: string) {
7
7
  }
8
8
 
9
9
  export function useZoneCollapse(pageType: string) {
10
- const { value: collapsed, toggle, setValue } = usePersistedBooleanFlag(
10
+ const { value: collapsed, toggle, setValue, isHydrated } = usePersistedBooleanFlag(
11
11
  getStorageKey(pageType),
12
12
  false,
13
13
  )
@@ -18,5 +18,5 @@ export function useZoneCollapse(pageType: string) {
18
18
  setValue(next)
19
19
  }
20
20
  }, [setValue])
21
- return { collapsed, toggle, setCollapsed }
21
+ return { collapsed, toggle, setCollapsed, isHydrated }
22
22
  }
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { Send } from 'lucide-react'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { Button } from '../../primitives/button'
7
+ import { IconButton } from '../../primitives/icon-button'
7
8
  import {
8
9
  MessageComposer,
9
10
  type MessageComposerContextObject,
@@ -17,6 +18,10 @@ export type SendObjectMessageDialogProps = {
17
18
  lockedType?: string | null
18
19
  requiredActionConfig?: MessageComposerRequiredActionConfig | null
19
20
  disabled?: boolean
21
+ buttonVariant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'muted' | 'link'
22
+ buttonSize?: 'default' | 'sm' | 'lg' | 'icon'
23
+ buttonClassName?: string
24
+ buttonLabel?: string
20
25
  viewHref?: string | null
21
26
  onSuccess?: MessageComposerProps['onSuccess']
22
27
  }
@@ -27,11 +32,16 @@ export function SendObjectMessageDialog({
27
32
  lockedType = 'messages.defaultWithObjects',
28
33
  requiredActionConfig = null,
29
34
  disabled = false,
35
+ buttonVariant = 'ghost',
36
+ buttonSize = 'icon',
37
+ buttonClassName,
38
+ buttonLabel,
30
39
  viewHref: _viewHref = null,
31
40
  onSuccess,
32
41
  }: SendObjectMessageDialogProps) {
33
42
  const t = useT()
34
43
  const [open, setOpen] = React.useState(false)
44
+ const label = buttonLabel ?? t('messages.compose', 'Compose message')
35
45
 
36
46
  const openComposer = React.useCallback(() => {
37
47
  if (disabled) return
@@ -46,19 +56,39 @@ export function SendObjectMessageDialog({
46
56
  previewData: object.previewData ?? null,
47
57
  }), [object.entityId, object.entityModule, object.entityType, object.sourceEntityId, object.sourceEntityType, object.previewData])
48
58
 
49
- return (
50
- <>
59
+ const trigger = buttonSize === 'icon' && (buttonVariant === 'outline' || buttonVariant === 'ghost')
60
+ ? (
61
+ <IconButton
62
+ type="button"
63
+ size="default"
64
+ variant={buttonVariant}
65
+ className={buttonClassName}
66
+ disabled={disabled}
67
+ onClick={openComposer}
68
+ aria-label={label}
69
+ title={label}
70
+ >
71
+ <Send className="size-4" />
72
+ </IconButton>
73
+ )
74
+ : (
51
75
  <Button
52
76
  type="button"
53
- size="icon"
54
- variant="ghost"
77
+ size={buttonSize}
78
+ variant={buttonVariant}
79
+ className={buttonClassName}
55
80
  disabled={disabled}
56
81
  onClick={openComposer}
57
- aria-label={t('messages.compose', 'Compose message')}
58
- title={t('messages.compose', 'Compose message')}
82
+ aria-label={label}
83
+ title={label}
59
84
  >
60
- <Send className="h-4 w-4" />
85
+ <Send className="size-4" />
61
86
  </Button>
87
+ )
88
+
89
+ return (
90
+ <>
91
+ {trigger}
62
92
  <MessageComposer
63
93
  variant="compose"
64
94
  open={open}
@@ -74,4 +74,25 @@ describe('SendObjectMessageDialog', () => {
74
74
  open: true,
75
75
  }))
76
76
  })
77
+
78
+ it('allows callers to style and label the trigger button', () => {
79
+ renderWithProviders(
80
+ <SendObjectMessageDialog
81
+ object={{
82
+ entityModule: 'customers',
83
+ entityType: 'person',
84
+ entityId: '11111111-1111-4111-8111-111111111111',
85
+ }}
86
+ buttonVariant="outline"
87
+ buttonSize="icon"
88
+ buttonClassName="size-8"
89
+ buttonLabel="Send message"
90
+ />,
91
+ { dict: {} },
92
+ )
93
+
94
+ const button = screen.getByRole('button', { name: 'Send message' })
95
+ expect(button.className).toEqual(expect.stringContaining('size-8'))
96
+ expect(button.className).toEqual(expect.stringContaining('border'))
97
+ })
77
98
  })
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { Clock } from 'lucide-react'
5
- import { Button } from '../../primitives/button'
5
+ import { IconButton } from '../../primitives/icon-button'
6
6
  import { cn } from '@open-mercato/shared/lib/utils'
7
7
  import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
8
8
  import type { VersionHistoryConfig } from './types'
@@ -48,17 +48,17 @@ export function VersionHistoryAction({
48
48
 
49
49
  return (
50
50
  <>
51
- <Button
51
+ <IconButton
52
52
  type="button"
53
53
  variant="ghost"
54
- size="icon"
54
+ size="default"
55
55
  onClick={() => setOpen(true)}
56
56
  aria-label={t('audit_logs.version_history.title')}
57
57
  title={t('audit_logs.version_history.title')}
58
58
  className={buttonClassName}
59
59
  >
60
60
  <Clock className={cn('size-4', iconClassName)} />
61
- </Button>
61
+ </IconButton>
62
62
  <VersionHistoryPanel
63
63
  open={open}
64
64
  onOpenChange={setOpen}