@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.
- package/dist/backend/DataTable.js +68 -38
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/primitives/icon-button.js +2 -0
- package/dist/primitives/icon-button.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +79 -39
- package/src/backend/__tests__/DataTable.perspectiveGuardedMutation.test.tsx +263 -0
- package/src/primitives/__tests__/icon-button.test.tsx +40 -0
- package/src/primitives/icon-button.tsx +4 -0
|
@@ -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": "
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
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: (
|
|
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
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
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
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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',
|