@open-mercato/ui 0.6.7-develop.6828.1.ab1620a63e → 0.6.7-develop.6842.1.8cd8a8883c
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/JsonBuilder.js +3 -3
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +2 -1
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/RowActions.js +2 -2
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -1
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
- package/dist/backend/detail/AttachmentsSection.js +1 -1
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +1 -1
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/detail/TagsSection.js +1 -1
- package/dist/backend/detail/TagsSection.js.map +2 -2
- package/dist/backend/forms/FormHeader.js +1 -2
- package/dist/backend/forms/FormHeader.js.map +2 -2
- package/dist/backend/schedule/ScheduleAgenda.js +3 -3
- package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
- package/dist/backend/schedule/ScheduleGrid.js +3 -3
- package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
- package/dist/backend/utils/apiCall.js +21 -1
- package/dist/backend/utils/apiCall.js.map +2 -2
- package/dist/frontend/LanguageSwitcher.js +2 -1
- package/dist/frontend/LanguageSwitcher.js.map +2 -2
- package/dist/primitives/button.js +9 -4
- package/dist/primitives/button.js.map +2 -2
- package/package.json +4 -4
- package/src/backend/JsonBuilder.tsx +3 -3
- package/src/backend/ProfileDropdown.tsx +1 -0
- package/src/backend/RowActions.tsx +2 -2
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -1
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +35 -0
- package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
- package/src/backend/detail/AttachmentsSection.tsx +1 -1
- package/src/backend/detail/NotesSection.tsx +1 -1
- package/src/backend/detail/TagsSection.tsx +1 -1
- package/src/backend/forms/FormHeader.tsx +1 -2
- package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
- package/src/backend/schedule/ScheduleGrid.tsx +3 -3
- package/src/backend/utils/__tests__/apiCall.test.ts +61 -0
- package/src/backend/utils/apiCall.ts +29 -1
- package/src/frontend/LanguageSwitcher.tsx +1 -0
- package/src/primitives/__tests__/button.test.tsx +70 -0
- package/src/primitives/button.tsx +10 -4
|
@@ -61,6 +61,41 @@ describe('ConfirmDialog', () => {
|
|
|
61
61
|
expect(dialog.parentElement).toBe(document.body)
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
+
it('renders the destructive confirmation as the solid variant', () => {
|
|
65
|
+
renderWithProviders(
|
|
66
|
+
<ConfirmDialog
|
|
67
|
+
open
|
|
68
|
+
variant="destructive"
|
|
69
|
+
onOpenChange={() => undefined}
|
|
70
|
+
onConfirm={() => undefined}
|
|
71
|
+
title="Delete customer?"
|
|
72
|
+
confirmText="Delete"
|
|
73
|
+
cancelText="Cancel"
|
|
74
|
+
/>,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const classNames = screen.getByRole('button', { name: 'Delete' }).className.split(/\s+/)
|
|
78
|
+
expect(classNames).toContain('bg-destructive')
|
|
79
|
+
expect(classNames).toContain('text-white')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('keeps the default confirmation on the primary variant', () => {
|
|
83
|
+
renderWithProviders(
|
|
84
|
+
<ConfirmDialog
|
|
85
|
+
open
|
|
86
|
+
onOpenChange={() => undefined}
|
|
87
|
+
onConfirm={() => undefined}
|
|
88
|
+
title="Publish article?"
|
|
89
|
+
confirmText="Publish"
|
|
90
|
+
cancelText="Cancel"
|
|
91
|
+
/>,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const classNames = screen.getByRole('button', { name: 'Publish' }).className.split(/\s+/)
|
|
95
|
+
expect(classNames).toContain('bg-primary')
|
|
96
|
+
expect(classNames).not.toContain('bg-destructive')
|
|
97
|
+
})
|
|
98
|
+
|
|
64
99
|
it('closes only the confirmation that owns an Escape event', () => {
|
|
65
100
|
const firstOpenChange = jest.fn()
|
|
66
101
|
const secondOpenChange = jest.fn()
|
|
@@ -38,7 +38,7 @@ export function AttachmentDeleteDialog({ open, onOpenChange, fileName, onConfirm
|
|
|
38
38
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isDeleting}>
|
|
39
39
|
{t('attachments.library.metadata.cancel', 'Cancel')}
|
|
40
40
|
</Button>
|
|
41
|
-
<Button type="button" variant="destructive" onClick={onConfirm} disabled={isDeleting}>
|
|
41
|
+
<Button type="button" variant="destructive-solid" onClick={onConfirm} disabled={isDeleting}>
|
|
42
42
|
{t('attachments.library.actions.delete', 'Delete')}
|
|
43
43
|
</Button>
|
|
44
44
|
</DialogFooter>
|
|
@@ -256,7 +256,7 @@ function AttachmentsSectionImpl({
|
|
|
256
256
|
</div>
|
|
257
257
|
)}
|
|
258
258
|
|
|
259
|
-
{error ? <p className="text-xs font-medium text-
|
|
259
|
+
{error ? <p className="text-xs font-medium text-status-error-text">{error}</p> : null}
|
|
260
260
|
|
|
261
261
|
{loading ? (
|
|
262
262
|
<div className="text-sm text-muted-foreground">{t('attachments.library.loading', 'Loading attachments…')}</div>
|
|
@@ -1256,7 +1256,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
1256
1256
|
editorWrapperClassName="w-full rounded-md border border-muted-foreground/20 bg-background p-2"
|
|
1257
1257
|
remarkPlugins={markdownPlugins}
|
|
1258
1258
|
/>
|
|
1259
|
-
{contentError ? <p className="text-xs text-
|
|
1259
|
+
{contentError ? <p className="text-xs text-status-error-text">{contentError}</p> : null}
|
|
1260
1260
|
<div className="flex flex-wrap items-center gap-2">
|
|
1261
1261
|
<Button type="button" size="sm" onClick={handleContentSave} disabled={contentSavingId === note.id}>
|
|
1262
1262
|
{contentSavingId === note.id ? (
|
|
@@ -387,7 +387,7 @@ function TagsSectionImpl({
|
|
|
387
387
|
loadSuggestions={loadSuggestions}
|
|
388
388
|
autoFocus={!autoSave}
|
|
389
389
|
/>
|
|
390
|
-
{error ? <p className="text-xs text-
|
|
390
|
+
{error ? <p className="text-xs text-status-error-text">{error}</p> : null}
|
|
391
391
|
{autoSave ? null : (
|
|
392
392
|
<div className="flex items-center gap-2 mt-3 mb-2">
|
|
393
393
|
<Button type="button" size="sm" onClick={handleSave} disabled={saving || isSubmitting}>
|
|
@@ -181,11 +181,10 @@ function DetailHeader({
|
|
|
181
181
|
{onDelete ? (
|
|
182
182
|
<Button
|
|
183
183
|
type="button"
|
|
184
|
-
variant="
|
|
184
|
+
variant="destructive"
|
|
185
185
|
size="sm"
|
|
186
186
|
onClick={onDelete}
|
|
187
187
|
disabled={isDeleting}
|
|
188
|
-
className="text-red-600 border-red-200 hover:bg-red-50 rounded"
|
|
189
188
|
>
|
|
190
189
|
{isDeleting ? (
|
|
191
190
|
<Loader2 className="size-4 mr-2 animate-spin" />
|
|
@@ -57,9 +57,9 @@ function getStatusLabel(status: ScheduleItem['status'], t: (key: string, fallbac
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function getKindStyles(kind: ScheduleItem['kind']): string {
|
|
60
|
-
if (kind === 'event') return 'border-
|
|
61
|
-
if (kind === 'exception') return 'border-
|
|
62
|
-
return 'border-
|
|
60
|
+
if (kind === 'event') return 'border-status-info-border bg-status-info-bg text-status-info-text'
|
|
61
|
+
if (kind === 'exception') return 'border-status-warning-border bg-status-warning-bg text-status-warning-text'
|
|
62
|
+
return 'border-status-success-border bg-status-success-bg text-status-success-text'
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export type ScheduleAgendaProps = {
|
|
@@ -57,9 +57,9 @@ function getStatusLabel(status: ScheduleItem['status'], t: (key: string, fallbac
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function getKindStyles(kind: ScheduleItem['kind']): string {
|
|
60
|
-
if (kind === 'event') return 'border-
|
|
61
|
-
if (kind === 'exception') return 'border-
|
|
62
|
-
return 'border-
|
|
60
|
+
if (kind === 'event') return 'border-status-info-border bg-status-info-bg text-status-info-text'
|
|
61
|
+
if (kind === 'exception') return 'border-status-warning-border bg-status-warning-bg text-status-warning-text'
|
|
62
|
+
return 'border-status-success-border bg-status-success-bg text-status-success-text'
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export type ScheduleGridProps = {
|
|
@@ -107,3 +107,64 @@ describe('readApiResultOrThrow', () => {
|
|
|
107
107
|
expect(result).toBeNull()
|
|
108
108
|
})
|
|
109
109
|
})
|
|
110
|
+
|
|
111
|
+
describe('aborted requests', () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
jest.resetAllMocks()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
function createAbortedBodyResponse(): Response {
|
|
117
|
+
const abortError = new Error('The operation was aborted.')
|
|
118
|
+
abortError.name = 'AbortError'
|
|
119
|
+
const response = {
|
|
120
|
+
ok: true,
|
|
121
|
+
status: 200,
|
|
122
|
+
headers: new Map<string, string>(),
|
|
123
|
+
text: jest.fn(async () => { throw abortError }),
|
|
124
|
+
clone: () => response,
|
|
125
|
+
} as unknown as Response
|
|
126
|
+
return response
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it('rejects with AbortError instead of reporting an empty payload', async () => {
|
|
130
|
+
;(apiFetch as jest.Mock).mockResolvedValue(createAbortedBodyResponse())
|
|
131
|
+
const controller = new AbortController()
|
|
132
|
+
controller.abort()
|
|
133
|
+
|
|
134
|
+
await expect(
|
|
135
|
+
readApiResultOrThrow('/api/interactions', { signal: controller.signal }),
|
|
136
|
+
).rejects.toMatchObject({ name: 'AbortError' })
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('does not mask a genuinely empty payload when the request was not aborted', async () => {
|
|
140
|
+
;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse('', { status: 200 }))
|
|
141
|
+
const controller = new AbortController()
|
|
142
|
+
|
|
143
|
+
await expect(
|
|
144
|
+
readApiResultOrThrow('/api/interactions', { signal: controller.signal }),
|
|
145
|
+
).rejects.toThrow('Missing response payload (200)')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('keeps the caller-provided fallback when one is configured', async () => {
|
|
149
|
+
;(apiFetch as jest.Mock).mockResolvedValue(createAbortedBodyResponse())
|
|
150
|
+
const controller = new AbortController()
|
|
151
|
+
controller.abort()
|
|
152
|
+
|
|
153
|
+
const call = await apiCall<{ items: string[] }>(
|
|
154
|
+
'/api/interactions',
|
|
155
|
+
{ signal: controller.signal },
|
|
156
|
+
{ fallback: { items: [] } },
|
|
157
|
+
)
|
|
158
|
+
expect(call.result).toEqual({ items: [] })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('propagates AbortError raised by a custom parser', async () => {
|
|
162
|
+
;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse('{}', { status: 200 }))
|
|
163
|
+
const abortError = new Error('The operation was aborted.')
|
|
164
|
+
abortError.name = 'AbortError'
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
apiCall('/api/interactions', undefined, { parse: async () => { throw abortError } }),
|
|
168
|
+
).rejects.toMatchObject({ name: 'AbortError' })
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -19,6 +19,27 @@ export type ApiCallResult<TReturn> = {
|
|
|
19
19
|
|
|
20
20
|
const scopedRequestHeaders = createScopedHeaderStack()
|
|
21
21
|
|
|
22
|
+
function isAbortError(error: unknown): boolean {
|
|
23
|
+
return typeof error === 'object'
|
|
24
|
+
&& error !== null
|
|
25
|
+
&& (error as { name?: unknown }).name === 'AbortError'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createAbortError(): Error {
|
|
29
|
+
if (typeof DOMException === 'function') {
|
|
30
|
+
return new DOMException('[internal] The operation was aborted.', 'AbortError')
|
|
31
|
+
}
|
|
32
|
+
const error = new Error('[internal] The operation was aborted.')
|
|
33
|
+
error.name = 'AbortError'
|
|
34
|
+
return error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveAbortSignal(input: RequestInfo | URL, init?: RequestInit): AbortSignal | null {
|
|
38
|
+
if (init?.signal) return init.signal
|
|
39
|
+
if (typeof Request !== 'undefined' && input instanceof Request) return input.signal ?? null
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
function mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string>): HeadersInit {
|
|
23
44
|
if (!base) return extra
|
|
24
45
|
if (typeof Headers !== 'undefined' && base instanceof Headers) {
|
|
@@ -63,9 +84,16 @@ export async function apiCall<TReturn = Record<string, unknown>>(
|
|
|
63
84
|
: response
|
|
64
85
|
if (parser) result = await parser(source)
|
|
65
86
|
else result = await readJsonSafe<TReturn>(source, fallback)
|
|
66
|
-
} catch {
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (isAbortError(err)) throw err
|
|
67
89
|
result = fallback
|
|
68
90
|
}
|
|
91
|
+
// `readJsonSafe` swallows the abort that cancels an in-flight body read, so a
|
|
92
|
+
// request aborted after its response headers arrived would otherwise look like
|
|
93
|
+
// a successful call that returned an empty payload.
|
|
94
|
+
if (result == null && resolveAbortSignal(input, init)?.aborted) {
|
|
95
|
+
throw createAbortError()
|
|
96
|
+
}
|
|
69
97
|
return {
|
|
70
98
|
ok: response.ok,
|
|
71
99
|
status: response.status,
|
|
@@ -24,6 +24,7 @@ export function LanguageSwitcher() {
|
|
|
24
24
|
pl: t('common.languages.polish', 'Polski'),
|
|
25
25
|
es: t('common.languages.spanish', 'Español'),
|
|
26
26
|
de: t('common.languages.german', 'Deutsch'),
|
|
27
|
+
ko: t('common.languages.korean', '한국어'),
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
async function setLocale(locale: Locale) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { render } from '@testing-library/react'
|
|
5
|
+
import { Button, buttonVariants } from '../button'
|
|
6
|
+
|
|
7
|
+
type ButtonVariant = NonNullable<Parameters<typeof buttonVariants>[0]>['variant']
|
|
8
|
+
|
|
9
|
+
function classesOf(variant: ButtonVariant) {
|
|
10
|
+
return buttonVariants({ variant }).split(/\s+/).filter(Boolean)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('Button destructive variants', () => {
|
|
14
|
+
// The DS policy: `destructive` is the quiet trigger (red text on a calm
|
|
15
|
+
// surface) so a page full of delete buttons does not train users to ignore
|
|
16
|
+
// red; `destructive-solid` is the filled form, reserved for the single
|
|
17
|
+
// point-of-no-return confirmation inside a dialog. These tests pin both
|
|
18
|
+
// halves of that contract — swapping them back is a visual regression that
|
|
19
|
+
// no other test would catch.
|
|
20
|
+
|
|
21
|
+
it('destructive is quiet: destructive border and text on the page surface', () => {
|
|
22
|
+
const classes = classesOf('destructive')
|
|
23
|
+
expect(classes).toContain('border')
|
|
24
|
+
expect(classes).toContain('border-destructive')
|
|
25
|
+
expect(classes).toContain('bg-background')
|
|
26
|
+
expect(classes).toContain('text-destructive')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('destructive does not fill with the destructive surface', () => {
|
|
30
|
+
const classes = classesOf('destructive')
|
|
31
|
+
expect(classes).not.toContain('bg-destructive')
|
|
32
|
+
expect(classes).not.toContain('text-white')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('destructive-solid fills with the destructive surface and white glyph', () => {
|
|
36
|
+
const classes = classesOf('destructive-solid')
|
|
37
|
+
expect(classes).toContain('bg-destructive')
|
|
38
|
+
expect(classes).toContain('text-white')
|
|
39
|
+
expect(classes).not.toContain('border-destructive')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('destructive-solid ships no dark background override', () => {
|
|
43
|
+
// `--destructive` is a semantic token that already resolves per theme; a
|
|
44
|
+
// `dark:bg-*` override on top of it is what broke IconButton in #3507.
|
|
45
|
+
const classes = classesOf('destructive-solid')
|
|
46
|
+
expect(classes.some((cls) => cls.startsWith('dark:bg-'))).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('renders the quiet treatment on the element for variant="destructive"', () => {
|
|
50
|
+
const { getByRole } = render(<Button variant="destructive">Delete</Button>)
|
|
51
|
+
const className = getByRole('button').className
|
|
52
|
+
expect(className).toContain('text-destructive')
|
|
53
|
+
expect(className).toContain('border-destructive')
|
|
54
|
+
expect(className.split(/\s+/)).not.toContain('bg-destructive')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders the filled treatment on the element for variant="destructive-solid"', () => {
|
|
58
|
+
const { getByRole } = render(<Button variant="destructive-solid">Delete permanently</Button>)
|
|
59
|
+
const classNames = getByRole('button').className.split(/\s+/)
|
|
60
|
+
expect(classNames).toContain('bg-destructive')
|
|
61
|
+
expect(classNames).toContain('text-white')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('defaults to the primary variant when none is given', () => {
|
|
65
|
+
const { getByRole } = render(<Button>Save</Button>)
|
|
66
|
+
const classNames = getByRole('button').className.split(/\s+/)
|
|
67
|
+
expect(classNames).toContain('bg-primary')
|
|
68
|
+
expect(classNames).not.toContain('text-destructive')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -9,14 +9,20 @@ const buttonVariants = cva(
|
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
11
11
|
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover',
|
|
12
|
+
/* Destructive is quiet by design: red text on a calm surface. A wall
|
|
13
|
+
of solid red buttons trains users to ignore red. The filled form is
|
|
14
|
+
`destructive-solid`, reserved for the single point-of-no-return
|
|
15
|
+
confirmation inside a dialog — never for the button that opens it. */
|
|
12
16
|
destructive:
|
|
13
|
-
'
|
|
17
|
+
'border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive',
|
|
18
|
+
'destructive-solid':
|
|
19
|
+
'bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive',
|
|
14
20
|
'destructive-outline':
|
|
15
|
-
'border border-destructive
|
|
21
|
+
'border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10',
|
|
16
22
|
'destructive-soft':
|
|
17
|
-
'bg-destructive/10 text-destructive hover:bg-destructive/15
|
|
23
|
+
'bg-destructive/10 text-destructive hover:bg-destructive/15',
|
|
18
24
|
'destructive-ghost':
|
|
19
|
-
'text-destructive hover:bg-destructive/10
|
|
25
|
+
'text-destructive hover:bg-destructive/10',
|
|
20
26
|
outline:
|
|
21
27
|
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
|
22
28
|
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|