@open-mercato/ui 0.6.6-develop.6229.1.ebe6706732 → 0.6.6-develop.6241.1.47c01ad4ed

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.
@@ -7,6 +7,8 @@ const iconButtonVariants = cva(
7
7
  {
8
8
  variants: {
9
9
  variant: {
10
+ primary: "bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover",
11
+ destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90",
10
12
  outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
11
13
  ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
12
14
  white: "bg-background text-muted-foreground shadow-xs hover:bg-accent hover:text-accent-foreground",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/primitives/icon-button.tsx"],
4
- "sourcesContent": ["import * as React from 'react'\nimport { Slot } from '@radix-ui/react-slot'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nconst iconButtonVariants = cva(\n \"inline-flex items-center justify-center cursor-pointer transition-all outline-none disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-disabled disabled:shadow-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:shadow-focus aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary-hover\",\n {\n variants: {\n variant: {\n outline:\n 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',\n ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',\n white:\n 'bg-background text-muted-foreground shadow-xs hover:bg-accent hover:text-accent-foreground',\n modifiable:\n 'bg-transparent text-current hover:bg-foreground/10',\n },\n size: {\n xs: 'size-6',\n sm: 'size-7',\n default: 'size-8',\n lg: 'size-9',\n },\n fullRadius: {\n true: 'rounded-full',\n false: 'rounded-md',\n },\n },\n defaultVariants: {\n variant: 'outline',\n size: 'default',\n fullRadius: false,\n },\n }\n)\n\nexport function IconButton({\n className,\n variant,\n size,\n fullRadius,\n asChild = false,\n ...props\n}: React.ComponentProps<'button'> &\n VariantProps<typeof iconButtonVariants> & { asChild?: boolean }) {\n const Comp = asChild ? Slot : 'button'\n return (\n <Comp\n data-slot=\"icon-button\"\n type={asChild ? undefined : 'button'}\n className={cn(iconButtonVariants({ variant, size, fullRadius, className }))}\n {...props}\n />\n )\n}\n\nexport { iconButtonVariants }\n"],
5
- "mappings": "AAgDI;AA/CJ,SAAS,YAAY;AACrB,SAAS,WAA8B;AACvC,SAAS,UAAU;AAEnB,MAAM,qBAAqB;AAAA,EACzB;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,SAAS;AAAA,QACP,SACE;AAAA,QACF,OAAO;AAAA,QACP,OACE;AAAA,QACF,YACE;AAAA,MACJ;AAAA,MACA,MAAM;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,SAAS;AAAA,QACT,IAAI;AAAA,MACN;AAAA,MACA,YAAY;AAAA,QACV,MAAM;AAAA,QACN,OAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,SAAS;AAAA,MACT,MAAM;AAAA,MACN,YAAY;AAAA,IACd;AAAA,EACF;AACF;AAEO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,GAAG;AACL,GACmE;AACjE,QAAM,OAAO,UAAU,OAAO;AAC9B,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,MAAM,UAAU,SAAY;AAAA,MAC5B,WAAW,GAAG,mBAAmB,EAAE,SAAS,MAAM,YAAY,UAAU,CAAC,CAAC;AAAA,MACzE,GAAG;AAAA;AAAA,EACN;AAEJ;",
4
+ "sourcesContent": ["import * as React from 'react'\nimport { Slot } from '@radix-ui/react-slot'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nconst iconButtonVariants = cva(\n \"inline-flex items-center justify-center cursor-pointer transition-all outline-none disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-disabled disabled:shadow-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:shadow-focus aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary-hover\",\n {\n variants: {\n variant: {\n primary:\n 'bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover',\n destructive:\n 'bg-destructive text-white shadow-xs hover:bg-destructive/90',\n outline:\n 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',\n ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',\n white:\n 'bg-background text-muted-foreground shadow-xs hover:bg-accent hover:text-accent-foreground',\n modifiable:\n 'bg-transparent text-current hover:bg-foreground/10',\n },\n size: {\n xs: 'size-6',\n sm: 'size-7',\n default: 'size-8',\n lg: 'size-9',\n },\n fullRadius: {\n true: 'rounded-full',\n false: 'rounded-md',\n },\n },\n defaultVariants: {\n variant: 'outline',\n size: 'default',\n fullRadius: false,\n },\n }\n)\n\nexport function IconButton({\n className,\n variant,\n size,\n fullRadius,\n asChild = false,\n ...props\n}: React.ComponentProps<'button'> &\n VariantProps<typeof iconButtonVariants> & { asChild?: boolean }) {\n const Comp = asChild ? Slot : 'button'\n return (\n <Comp\n data-slot=\"icon-button\"\n type={asChild ? undefined : 'button'}\n className={cn(iconButtonVariants({ variant, size, fullRadius, className }))}\n {...props}\n />\n )\n}\n\nexport { iconButtonVariants }\n"],
5
+ "mappings": "AAoDI;AAnDJ,SAAS,YAAY;AACrB,SAAS,WAA8B;AACvC,SAAS,UAAU;AAEnB,MAAM,qBAAqB;AAAA,EACzB;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,SAAS;AAAA,QACP,SACE;AAAA,QACF,aACE;AAAA,QACF,SACE;AAAA,QACF,OAAO;AAAA,QACP,OACE;AAAA,QACF,YACE;AAAA,MACJ;AAAA,MACA,MAAM;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,SAAS;AAAA,QACT,IAAI;AAAA,MACN;AAAA,MACA,YAAY;AAAA,QACV,MAAM;AAAA,QACN,OAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,SAAS;AAAA,MACT,MAAM;AAAA,MACN,YAAY;AAAA,IACd;AAAA,EACF;AACF;AAEO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,GAAG;AACL,GACmE;AACjE,QAAM,OAAO,UAAU,OAAO;AAC9B,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,MAAM,UAAU,SAAY;AAAA,MAC5B,WAAW,GAAG,mBAAmB,EAAE,SAAS,MAAM,YAAY,UAAU,CAAC,CAAC;AAAA,MACzE,GAAG;AAAA;AAAA,EACN;AAEJ;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.6-develop.6229.1.ebe6706732",
3
+ "version": "0.6.6-develop.6241.1.47c01ad4ed",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,13 +155,13 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.6-develop.6229.1.ebe6706732",
158
+ "@open-mercato/shared": "0.6.6-develop.6241.1.47c01ad4ed",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
- "@open-mercato/shared": "0.6.6-develop.6229.1.ebe6706732",
164
+ "@open-mercato/shared": "0.6.6-develop.6241.1.47c01ad4ed",
165
165
  "@testing-library/dom": "^10.4.1",
166
166
  "@testing-library/jest-dom": "^6.9.1",
167
167
  "@testing-library/react": "^16.3.1",
@@ -34,7 +34,7 @@ import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
34
34
  import { serializeExport, defaultExportFilename, type PreparedExport } from '@open-mercato/shared/lib/crud/exporters'
35
35
  import { apiCall, withScopedApiRequestHeaders } from './utils/apiCall'
36
36
  import { buildOptimisticLockHeader } from './utils/optimisticLock'
37
- import { surfaceRecordConflict } from './conflicts'
37
+ import { useGuardedMutation } from './injection/useGuardedMutation'
38
38
  import { raiseCrudError } from './utils/serverErrors'
39
39
  import { PerspectiveSidebar } from './PerspectiveSidebar'
40
40
  import { Popover, PopoverTrigger, PopoverContent } from '../primitives/popover'
@@ -1716,6 +1716,27 @@ export function DataTable<T>({
1716
1716
  }
1717
1717
 
1718
1718
  const perspectiveQueryKey: [string, string | null] = ['table-perspectives', perspectiveTableId]
1719
+
1720
+ type PerspectiveMutationContext = {
1721
+ formId: string
1722
+ resourceKind: 'perspective'
1723
+ retryLastMutation: () => Promise<boolean>
1724
+ }
1725
+ const perspectiveMutationContextId = `data-table-perspectives:${perspectiveTableId ?? 'unknown'}`
1726
+ const { runMutation: runPerspectiveMutation, retryLastMutation: retryPerspectiveMutation } =
1727
+ useGuardedMutation<PerspectiveMutationContext>({
1728
+ contextId: perspectiveMutationContextId,
1729
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
1730
+ })
1731
+ const perspectiveMutationContext = React.useMemo<PerspectiveMutationContext>(
1732
+ () => ({
1733
+ formId: perspectiveMutationContextId,
1734
+ resourceKind: 'perspective',
1735
+ retryLastMutation: retryPerspectiveMutation,
1736
+ }),
1737
+ [perspectiveMutationContextId, retryPerspectiveMutation],
1738
+ )
1739
+
1719
1740
  const savePerspectiveMutation = useMutation<PerspectiveSaveResponse, Error, SavePerspectivePayload>({
1720
1741
  mutationFn: async (input) => {
1721
1742
  if (!perspectiveTableId) throw new Error('Missing table id')
@@ -1734,26 +1755,32 @@ export function DataTable<T>({
1734
1755
  const existing = input.perspectiveId
1735
1756
  ? perspectiveData?.perspectives.find((p) => p.id === input.perspectiveId) ?? null
1736
1757
  : null
1737
- const call = await withScopedApiRequestHeaders(
1738
- buildOptimisticLockHeader(existing?.updatedAt ?? null),
1739
- () => apiCall<PerspectiveSaveResponse>(
1740
- `/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
1741
- {
1742
- method: 'POST',
1743
- headers: { 'Content-Type': 'application/json' },
1744
- body: JSON.stringify(payload),
1745
- },
1746
- ),
1747
- )
1748
- if (call.status === 404) {
1749
- throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` to regenerate module routes and restart the dev server.'))
1750
- }
1751
- if (!call.ok) {
1752
- await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.save', 'Failed to save perspective'))
1753
- }
1754
- const result = call.result
1755
- if (!result) throw new Error(t('ui.dataTable.perspectives.error.save', 'Failed to save perspective'))
1756
- return result
1758
+ return runPerspectiveMutation({
1759
+ operation: async () => {
1760
+ const call = await withScopedApiRequestHeaders(
1761
+ buildOptimisticLockHeader(existing?.updatedAt ?? null),
1762
+ () => apiCall<PerspectiveSaveResponse>(
1763
+ `/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
1764
+ {
1765
+ method: 'POST',
1766
+ headers: { 'Content-Type': 'application/json' },
1767
+ body: JSON.stringify(payload),
1768
+ },
1769
+ ),
1770
+ )
1771
+ if (call.status === 404) {
1772
+ throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` to regenerate module routes and restart the dev server.'))
1773
+ }
1774
+ if (!call.ok) {
1775
+ await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.save', 'Failed to save perspective'))
1776
+ }
1777
+ const result = call.result
1778
+ if (!result) throw new Error(t('ui.dataTable.perspectives.error.save', 'Failed to save perspective'))
1779
+ return result
1780
+ },
1781
+ context: perspectiveMutationContext,
1782
+ mutationPayload: payload,
1783
+ })
1757
1784
  },
1758
1785
  onSuccess: (data) => {
1759
1786
  if (perspectiveTableId) {
@@ -1763,11 +1790,12 @@ export function DataTable<T>({
1763
1790
  applyPerspectiveSettings(data.perspective.settings, data.perspective.id)
1764
1791
  }
1765
1792
  },
1766
- onError: (error) => {
1793
+ onError: () => {
1794
+ // Conflict surfacing is handled inside `runPerspectiveMutation`
1795
+ // (surfaceRecordConflict); only the cache refresh remains here.
1767
1796
  if (perspectiveTableId) {
1768
1797
  void queryClient.invalidateQueries({ queryKey: perspectiveQueryKey })
1769
1798
  }
1770
- surfaceRecordConflict(error, t)
1771
1799
  },
1772
1800
  })
1773
1801
 
@@ -1791,14 +1819,20 @@ export function DataTable<T>({
1791
1819
  const deletePerspectiveMutation = useMutation<void, Error, { perspectiveId: string }>({
1792
1820
  mutationFn: async ({ perspectiveId }) => {
1793
1821
  if (!perspectiveTableId) throw new Error('Missing table id')
1794
- const call = await apiCall(
1795
- `/api/perspectives/${encodeURIComponent(perspectiveTableId)}/${encodeURIComponent(perspectiveId)}`,
1796
- { method: 'DELETE' },
1797
- )
1798
- if (call.status === 404) throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` and restart the dev server.'))
1799
- if (!call.ok) {
1800
- await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.delete', 'Failed to delete perspective'))
1801
- }
1822
+ await runPerspectiveMutation({
1823
+ operation: async () => {
1824
+ const call = await apiCall(
1825
+ `/api/perspectives/${encodeURIComponent(perspectiveTableId)}/${encodeURIComponent(perspectiveId)}`,
1826
+ { method: 'DELETE' },
1827
+ )
1828
+ if (call.status === 404) throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` and restart the dev server.'))
1829
+ if (!call.ok) {
1830
+ await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.delete', 'Failed to delete perspective'))
1831
+ }
1832
+ },
1833
+ context: perspectiveMutationContext,
1834
+ mutationPayload: { perspectiveId },
1835
+ })
1802
1836
  },
1803
1837
  onMutate: ({ perspectiveId }) => {
1804
1838
  setDeletingIds((prev) => prev.includes(perspectiveId) ? prev : [...prev, perspectiveId])
@@ -1827,14 +1861,20 @@ export function DataTable<T>({
1827
1861
  const clearRoleMutation = useMutation<void, Error, { roleId: string }>({
1828
1862
  mutationFn: async ({ roleId }) => {
1829
1863
  if (!perspectiveTableId) throw new Error('Missing table id')
1830
- const call = await apiCall(
1831
- `/api/perspectives/${encodeURIComponent(perspectiveTableId)}/roles/${encodeURIComponent(roleId)}`,
1832
- { method: 'DELETE' },
1833
- )
1834
- if (call.status === 404) throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` and restart the dev server.'))
1835
- if (!call.ok) {
1836
- await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.clearRoles', 'Failed to clear role perspectives'))
1837
- }
1864
+ await runPerspectiveMutation({
1865
+ operation: async () => {
1866
+ const call = await apiCall(
1867
+ `/api/perspectives/${encodeURIComponent(perspectiveTableId)}/roles/${encodeURIComponent(roleId)}`,
1868
+ { method: 'DELETE' },
1869
+ )
1870
+ if (call.status === 404) throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` and restart the dev server.'))
1871
+ if (!call.ok) {
1872
+ await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.clearRoles', 'Failed to clear role perspectives'))
1873
+ }
1874
+ },
1875
+ context: perspectiveMutationContext,
1876
+ mutationPayload: { roleId },
1877
+ })
1838
1878
  },
1839
1879
  onMutate: ({ roleId }) => {
1840
1880
  setRoleClearingIds((prev) => prev.includes(roleId) ? prev : [...prev, roleId])
@@ -0,0 +1,263 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { DataTable } from '../DataTable'
4
+ import type { ColumnDef } from '@tanstack/react-table'
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
7
+ import { render, fireEvent, waitFor, screen } from '@testing-library/react'
8
+ import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
9
+ import type { PerspectivesIndexResponse } from '@open-mercato/shared/modules/perspectives/types'
10
+
11
+ jest.mock('next/navigation', () => ({
12
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
13
+ }))
14
+
15
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
16
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
17
+ }))
18
+
19
+ // The guarded-mutation hook is the contract under test: every perspective write
20
+ // must be routed through `runMutation` so global mutation injections, record
21
+ // locks, and conflict handling participate consistently.
22
+ const mockRunMutation = jest.fn(
23
+ async ({ operation }: { operation: () => Promise<unknown> }) => operation(),
24
+ )
25
+ const mockRetryLastMutation = jest.fn(async () => false)
26
+ jest.mock('../injection/useGuardedMutation', () => ({
27
+ useGuardedMutation: () => ({
28
+ runMutation: mockRunMutation,
29
+ retryLastMutation: mockRetryLastMutation,
30
+ }),
31
+ }))
32
+
33
+ // Capture the optimistic-lock headers attached to writes while keeping the call
34
+ // flow real: `apiCall` is recorded, `withScopedApiRequestHeaders` records the
35
+ // header bag and still runs the wrapped operation.
36
+ const mockScopedHeaderCalls: Array<Record<string, string>> = []
37
+ const mockApiCall = jest.fn(async (input: unknown, init?: { method?: string }) => {
38
+ const url = String(input)
39
+ const method = (init?.method ?? 'GET').toUpperCase()
40
+ const ok = (result: unknown) => ({
41
+ ok: true,
42
+ status: 200,
43
+ result,
44
+ response: { ok: true, status: 200 } as Response,
45
+ cacheStatus: null as const,
46
+ })
47
+ if (url.includes('/api/perspectives/') && method === 'POST') {
48
+ return ok({
49
+ perspective: {
50
+ id: 'persp-1',
51
+ name: 'My view',
52
+ tableId: 'test-table',
53
+ settings: {},
54
+ isDefault: false,
55
+ createdAt: 'now',
56
+ updatedAt: '2026-06-19T00:00:00.000Z',
57
+ },
58
+ rolePerspectives: [],
59
+ clearedRoleIds: [],
60
+ })
61
+ }
62
+ return ok(undefined)
63
+ })
64
+ jest.mock('../utils/apiCall', () => ({
65
+ apiCall: (...args: unknown[]) => mockApiCall(...(args as [unknown, { method?: string }?])),
66
+ withScopedApiRequestHeaders: async (
67
+ headers: Record<string, string>,
68
+ run: () => Promise<unknown>,
69
+ ) => {
70
+ mockScopedHeaderCalls.push(headers)
71
+ return run()
72
+ },
73
+ }))
74
+
75
+ type SidebarProps = {
76
+ onSave: (input: {
77
+ name: string
78
+ isDefault: boolean
79
+ applyToRoles: string[]
80
+ setRoleDefault: boolean
81
+ perspectiveId?: string | null
82
+ settings?: unknown
83
+ }) => void | Promise<void>
84
+ onDeletePerspective: (perspectiveId: string) => void | Promise<void>
85
+ onClearRole: (roleId: string) => void | Promise<void>
86
+ }
87
+
88
+ // Replace the heavy drawer UI with a stub exposing the three write handlers as
89
+ // buttons, so the test drives the mutations without rendering the full sidebar.
90
+ jest.mock('../PerspectiveSidebar', () => ({
91
+ PerspectiveSidebar: (props: SidebarProps) => (
92
+ <div>
93
+ <button
94
+ type="button"
95
+ data-testid="save-perspective"
96
+ onClick={() => {
97
+ void props.onSave({
98
+ name: 'My view',
99
+ isDefault: false,
100
+ applyToRoles: [],
101
+ setRoleDefault: false,
102
+ perspectiveId: 'persp-1',
103
+ settings: {
104
+ columnOrder: [],
105
+ columnVisibility: {},
106
+ filters: {},
107
+ sorting: [],
108
+ pageSize: 20,
109
+ searchValue: '',
110
+ },
111
+ })
112
+ }}
113
+ >
114
+ save
115
+ </button>
116
+ <button
117
+ type="button"
118
+ data-testid="delete-perspective"
119
+ onClick={() => {
120
+ void props.onDeletePerspective('persp-1')
121
+ }}
122
+ >
123
+ delete
124
+ </button>
125
+ <button
126
+ type="button"
127
+ data-testid="clear-role"
128
+ onClick={() => {
129
+ void props.onClearRole('role-1')
130
+ }}
131
+ >
132
+ clear
133
+ </button>
134
+ </div>
135
+ ),
136
+ }))
137
+
138
+ type Row = { id: string; name: string }
139
+
140
+ const INDEX_RESPONSE: PerspectivesIndexResponse = {
141
+ tableId: 'test-table',
142
+ perspectives: [
143
+ {
144
+ id: 'persp-1',
145
+ name: 'My view',
146
+ tableId: 'test-table',
147
+ settings: {},
148
+ isDefault: false,
149
+ createdAt: 'now',
150
+ updatedAt: '2026-06-19T00:00:00.000Z',
151
+ },
152
+ ],
153
+ defaultPerspectiveId: null,
154
+ rolePerspectives: [],
155
+ roles: [{ id: 'role-1', name: 'Role 1', hasPerspective: false, hasDefault: false }],
156
+ canApplyToRoles: true,
157
+ }
158
+
159
+ function renderTable() {
160
+ const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
161
+ const queryClient = new QueryClient({
162
+ defaultOptions: {
163
+ queries: { staleTime: Infinity, gcTime: Infinity, retry: false },
164
+ mutations: { retry: false },
165
+ },
166
+ })
167
+ // Seed both perspective queries so the component renders the (mocked) sidebar
168
+ // without issuing any network fetch for them.
169
+ queryClient.setQueryData(['feature-check', 'perspectives'], { use: true, roleDefaults: true })
170
+ queryClient.setQueryData(['table-perspectives', 'test-table'], INDEX_RESPONSE)
171
+ const utils = render(
172
+ <QueryClientProvider client={queryClient}>
173
+ <I18nProvider locale="en" dict={{}}>
174
+ <DataTable
175
+ columns={columns}
176
+ data={[]}
177
+ perspective={{ tableId: 'test-table' }}
178
+ />
179
+ </I18nProvider>
180
+ </QueryClientProvider>,
181
+ )
182
+ return { ...utils, queryClient }
183
+ }
184
+
185
+ describe('DataTable perspective writes go through useGuardedMutation', () => {
186
+ beforeEach(() => {
187
+ mockRunMutation.mockClear()
188
+ mockRetryLastMutation.mockClear()
189
+ mockApiCall.mockClear()
190
+ mockScopedHeaderCalls.length = 0
191
+ })
192
+
193
+ it('routes perspective save through runMutation and keeps the optimistic-lock header', async () => {
194
+ const { queryClient } = renderTable()
195
+ try {
196
+ fireEvent.click(screen.getByTestId('save-perspective'))
197
+ await waitFor(() => expect(mockRunMutation).toHaveBeenCalledTimes(1))
198
+
199
+ const call = mockRunMutation.mock.calls[0][0] as {
200
+ operation: () => Promise<unknown>
201
+ context: Record<string, unknown>
202
+ mutationPayload?: Record<string, unknown>
203
+ }
204
+ expect(call.context.resourceKind).toBe('perspective')
205
+ expect(typeof call.context.retryLastMutation).toBe('function')
206
+
207
+ await waitFor(() =>
208
+ expect(mockApiCall).toHaveBeenCalledWith(
209
+ expect.stringContaining('/api/perspectives/test-table'),
210
+ expect.objectContaining({ method: 'POST' }),
211
+ ),
212
+ )
213
+ // The save still attaches the optimistic-lock header for the edited record.
214
+ expect(mockScopedHeaderCalls).toContainEqual({
215
+ [OPTIMISTIC_LOCK_HEADER_NAME]: '2026-06-19T00:00:00.000Z',
216
+ })
217
+ } finally {
218
+ queryClient.clear()
219
+ }
220
+ })
221
+
222
+ it('routes perspective delete through runMutation', async () => {
223
+ const { queryClient } = renderTable()
224
+ try {
225
+ fireEvent.click(screen.getByTestId('delete-perspective'))
226
+ await waitFor(() => expect(mockRunMutation).toHaveBeenCalledTimes(1))
227
+
228
+ const call = mockRunMutation.mock.calls[0][0] as { context: Record<string, unknown> }
229
+ expect(call.context.resourceKind).toBe('perspective')
230
+ expect(typeof call.context.retryLastMutation).toBe('function')
231
+
232
+ await waitFor(() =>
233
+ expect(mockApiCall).toHaveBeenCalledWith(
234
+ expect.stringContaining('/api/perspectives/test-table/persp-1'),
235
+ expect.objectContaining({ method: 'DELETE' }),
236
+ ),
237
+ )
238
+ } finally {
239
+ queryClient.clear()
240
+ }
241
+ })
242
+
243
+ it('routes role-clear through runMutation', async () => {
244
+ const { queryClient } = renderTable()
245
+ try {
246
+ fireEvent.click(screen.getByTestId('clear-role'))
247
+ await waitFor(() => expect(mockRunMutation).toHaveBeenCalledTimes(1))
248
+
249
+ const call = mockRunMutation.mock.calls[0][0] as { context: Record<string, unknown> }
250
+ expect(call.context.resourceKind).toBe('perspective')
251
+ expect(typeof call.context.retryLastMutation).toBe('function')
252
+
253
+ await waitFor(() =>
254
+ expect(mockApiCall).toHaveBeenCalledWith(
255
+ expect.stringContaining('/api/perspectives/test-table/roles/role-1'),
256
+ expect.objectContaining({ method: 'DELETE' }),
257
+ ),
258
+ )
259
+ } finally {
260
+ queryClient.clear()
261
+ }
262
+ })
263
+ })
@@ -0,0 +1,40 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render } from '@testing-library/react'
5
+ import { IconButton, iconButtonVariants } from '../icon-button'
6
+
7
+ describe('IconButton solid variants', () => {
8
+ // Regression for issue #3507 (BUG-003): the timesheets TimerBar Start/Stop
9
+ // icon buttons used `variant="outline"` and forced their fill via a
10
+ // `bg-primary`/`bg-destructive` className. The outline variant ships a
11
+ // `dark:bg-input/30` override that tailwind-merge cannot reconcile with a
12
+ // base `bg-*` class, so in dark mode the intended solid fill was replaced by
13
+ // the muted input surface and the dark `text-primary-foreground` play icon
14
+ // became near-invisible. The fix is a proper solid `primary`/`destructive`
15
+ // variant with NO `dark:` background override.
16
+
17
+ it('primary variant applies the solid primary surface without a dark override', () => {
18
+ const classes = iconButtonVariants({ variant: 'primary' })
19
+ expect(classes).toContain('bg-primary')
20
+ expect(classes).toContain('text-primary-foreground')
21
+ expect(classes).not.toContain('dark:bg-input')
22
+ })
23
+
24
+ it('destructive variant applies the solid destructive surface without a dark override', () => {
25
+ const classes = iconButtonVariants({ variant: 'destructive' })
26
+ expect(classes).toContain('bg-destructive')
27
+ expect(classes).not.toContain('dark:bg-input')
28
+ })
29
+
30
+ it('renders the primary fill on the element so the icon keeps contrast in both themes', () => {
31
+ const { getByRole } = render(
32
+ <IconButton variant="primary" aria-label="Start timer">
33
+ <svg />
34
+ </IconButton>,
35
+ )
36
+ const className = getByRole('button').className
37
+ expect(className).toContain('bg-primary')
38
+ expect(className).not.toContain('dark:bg-input')
39
+ })
40
+ })
@@ -8,6 +8,10 @@ const iconButtonVariants = cva(
8
8
  {
9
9
  variants: {
10
10
  variant: {
11
+ primary:
12
+ 'bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover',
13
+ destructive:
14
+ 'bg-destructive text-white shadow-xs hover:bg-destructive/90',
11
15
  outline:
12
16
  'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
13
17
  ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',