@asteby/metacore-runtime-react 23.2.0 → 23.4.0
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/CHANGELOG.md +33 -0
- package/dist/action-modal-dispatcher.js +4 -4
- package/dist/dialogs/dynamic-record.d.ts +5 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +155 -34
- package/dist/display-value.d.ts +56 -0
- package/dist/display-value.d.ts.map +1 -0
- package/dist/display-value.js +74 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +13 -72
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +5 -3
- package/dist/dynamic-table.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/rich-url.d.ts +119 -0
- package/dist/rich-url.d.ts.map +1 -0
- package/dist/rich-url.js +224 -0
- package/package.json +1 -1
- package/src/__tests__/readonly-fields.test.tsx +97 -0
- package/src/__tests__/record-detail-display.test.tsx +113 -0
- package/src/__tests__/rich-url.test.tsx +196 -0
- package/src/action-modal-dispatcher.tsx +4 -4
- package/src/dialogs/dynamic-record.tsx +244 -48
- package/src/display-value.tsx +134 -0
- package/src/dynamic-columns.tsx +24 -128
- package/src/dynamic-row-actions.tsx +5 -3
- package/src/dynamic-table.tsx +2 -2
- package/src/index.ts +17 -0
- package/src/rich-url.tsx +359 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// `readonly` field handling in the generic record dialog + action modal.
|
|
4
|
+
//
|
|
5
|
+
// A `readonly` field is server/system-generated (e.g. the GitHub addon's
|
|
6
|
+
// `number`/`github_url`, filled by the API after the outbound create). Kernel
|
|
7
|
+
// v0.64.0 stamps `FieldDef.readonly` on such fields served by DeriveFormFields.
|
|
8
|
+
// UI semantics:
|
|
9
|
+
// - CREATE: the field is excluded from the form entirely (`filterVisibleFields`)
|
|
10
|
+
// — the user can't set a value the server will overwrite.
|
|
11
|
+
// - EDIT: the field is shown, but as a disabled/muted input
|
|
12
|
+
// (`ReadonlyEditField`) — the value is visible, not editable.
|
|
13
|
+
// - VIEW: unchanged (rich read-only renderer).
|
|
14
|
+
//
|
|
15
|
+
// The last block covers action fields with a materialized `options_source`: ops
|
|
16
|
+
// turns them into `type:'select'` with server-populated `options`, and the modal
|
|
17
|
+
// must render a <Select> for them. That decision lives in `resolveWidget`.
|
|
18
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
19
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
20
|
+
|
|
21
|
+
vi.mock('react-i18next', () => ({
|
|
22
|
+
useTranslation: () => ({
|
|
23
|
+
t: (k: string, o?: any) => o?.defaultValue ?? k,
|
|
24
|
+
i18n: { language: 'es' },
|
|
25
|
+
}),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
import { filterVisibleFields, ReadonlyEditField } from '../dialogs/dynamic-record'
|
|
29
|
+
import { resolveWidget } from '../dynamic-form-schema'
|
|
30
|
+
|
|
31
|
+
afterEach(cleanup)
|
|
32
|
+
|
|
33
|
+
const fields = [
|
|
34
|
+
{ key: 'name', label: 'Nombre', type: 'text' },
|
|
35
|
+
// System-generated: filled by the API after the outbound create.
|
|
36
|
+
{ key: 'number', label: 'Número', type: 'text', readonly: true },
|
|
37
|
+
{ key: 'secret', label: 'Secret', type: 'text', hidden: true },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
describe('filterVisibleFields — readonly exclusion per mode', () => {
|
|
41
|
+
it('excludes a readonly field on CREATE (and always excludes hidden)', () => {
|
|
42
|
+
const keys = filterVisibleFields(fields as any, 'create').map(f => f.key)
|
|
43
|
+
expect(keys).toEqual(['name'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('keeps a readonly field visible on EDIT and VIEW (still drops hidden)', () => {
|
|
47
|
+
expect(filterVisibleFields(fields as any, 'edit').map(f => f.key)).toEqual(['name', 'number'])
|
|
48
|
+
expect(filterVisibleFields(fields as any, 'view').map(f => f.key)).toEqual(['name', 'number'])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('ReadonlyEditField — edit-mode rendering of a readonly field', () => {
|
|
53
|
+
it('renders a disabled, muted input with the value visible', () => {
|
|
54
|
+
render(
|
|
55
|
+
<ReadonlyEditField
|
|
56
|
+
field={{ key: 'number', label: 'Número', type: 'text', readonly: true }}
|
|
57
|
+
value="GH-42"
|
|
58
|
+
/>,
|
|
59
|
+
)
|
|
60
|
+
const input = screen.getByDisplayValue('GH-42') as HTMLInputElement
|
|
61
|
+
expect(input).toBeTruthy()
|
|
62
|
+
expect(input.disabled).toBe(true)
|
|
63
|
+
expect(input.className).toContain('text-muted-foreground')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('renders a boolean readonly field as a disabled switch', () => {
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<ReadonlyEditField
|
|
69
|
+
field={{ key: 'active', label: 'Activo', type: 'boolean', readonly: true }}
|
|
70
|
+
value={true}
|
|
71
|
+
/>,
|
|
72
|
+
)
|
|
73
|
+
const sw = container.querySelector('[role="switch"]') as HTMLElement
|
|
74
|
+
expect(sw).toBeTruthy()
|
|
75
|
+
expect(sw.getAttribute('data-disabled') != null || sw.hasAttribute('disabled')).toBe(true)
|
|
76
|
+
expect(screen.getByText('Sí')).toBeTruthy()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('action fields — options_source materialized to a select', () => {
|
|
81
|
+
it('resolves a select-typed field (server-populated options) to the select widget', () => {
|
|
82
|
+
// ops materializes an `options_source` action field into `type:'select'`
|
|
83
|
+
// with the options populated server-side; the modal's renderField keys off
|
|
84
|
+
// resolveWidget to render a <Select> for it.
|
|
85
|
+
expect(
|
|
86
|
+
resolveWidget({
|
|
87
|
+
key: 'status',
|
|
88
|
+
label: 'Estado',
|
|
89
|
+
type: 'select',
|
|
90
|
+
options: [
|
|
91
|
+
{ value: 'open', label: 'Abierto' },
|
|
92
|
+
{ value: 'closed', label: 'Cerrado' },
|
|
93
|
+
],
|
|
94
|
+
} as any),
|
|
95
|
+
).toBe('select')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Detail-dialog display mapping: the read-only `ViewValue` must render values
|
|
4
|
+
// with the SAME "pro" display logic as the table (shared `display-value.tsx`
|
|
5
|
+
// primitives), keyed off the stamped display type (`cellStyle ?? type`):
|
|
6
|
+
// - served option → colored badge with the localized label,
|
|
7
|
+
// - `cellStyle:'url'` on a text column → clickable external link (new tab),
|
|
8
|
+
// - `cellStyle:'status'` bare token (kanban stage) → colored, translated pill,
|
|
9
|
+
// - a `labels`/`tags` array → a row of badges (never a raw mini-table / JSON).
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
12
|
+
|
|
13
|
+
// Identity-ish translator with a tiny stage dictionary, matching how the host
|
|
14
|
+
// resolves an addon's i18n keys (t(token, { defaultValue })).
|
|
15
|
+
vi.mock('react-i18next', () => ({
|
|
16
|
+
useTranslation: () => ({
|
|
17
|
+
t: (k: string, opts?: any) =>
|
|
18
|
+
k === 'backlog' ? 'Pendiente' : opts?.defaultValue ?? k,
|
|
19
|
+
i18n: { language: 'es' },
|
|
20
|
+
}),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
import { ViewValue } from '../dialogs/dynamic-record'
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup)
|
|
26
|
+
|
|
27
|
+
describe('ViewValue — detail dialog display mapping', () => {
|
|
28
|
+
it('renders a served option as a colored badge with the localized label', () => {
|
|
29
|
+
render(
|
|
30
|
+
<ViewValue
|
|
31
|
+
field={{
|
|
32
|
+
key: 'product_type',
|
|
33
|
+
label: 'Tipo',
|
|
34
|
+
type: 'select',
|
|
35
|
+
options: [
|
|
36
|
+
{ value: 'storable', label: 'Almacenable', color: '#22c55e' },
|
|
37
|
+
],
|
|
38
|
+
}}
|
|
39
|
+
value="storable"
|
|
40
|
+
record={{}}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
const badge = screen.getByText('Almacenable')
|
|
44
|
+
expect(badge).toBeTruthy()
|
|
45
|
+
// Never the raw enum token.
|
|
46
|
+
expect(screen.queryByText('storable')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('renders a cellStyle:url text column as a clickable external link', () => {
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<ViewValue
|
|
52
|
+
field={{ key: 'github_url', label: 'GitHub', type: 'text', cellStyle: 'url' }}
|
|
53
|
+
value="https://github.com/asteby/x"
|
|
54
|
+
record={{}}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
const link = container.querySelector('a') as HTMLAnchorElement
|
|
58
|
+
expect(link).toBeTruthy()
|
|
59
|
+
expect(link.getAttribute('href')).toBe('https://github.com/asteby/x')
|
|
60
|
+
expect(link.getAttribute('target')).toBe('_blank')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('renders a bare stage token (cellStyle:status) as a translated colored pill', () => {
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<ViewValue
|
|
66
|
+
field={{ key: 'stage', label: 'Etapa', type: 'text', cellStyle: 'status' }}
|
|
67
|
+
value="backlog"
|
|
68
|
+
record={{}}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
// Localized via the manifest i18n key, not the raw "backlog".
|
|
72
|
+
expect(screen.getByText('Pendiente')).toBeTruthy()
|
|
73
|
+
expect(container.textContent).not.toContain('backlog')
|
|
74
|
+
// Colored: the badge carries an inline background style.
|
|
75
|
+
const styled = container.querySelector('[style*="background"]')
|
|
76
|
+
expect(styled).toBeTruthy()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('renders a labels array (cellStyle:tags) as a row of badges', () => {
|
|
80
|
+
render(
|
|
81
|
+
<ViewValue
|
|
82
|
+
field={{ key: 'labels', label: 'Labels', type: 'json', cellStyle: 'tags' }}
|
|
83
|
+
value={['bug', 'urgent']}
|
|
84
|
+
record={{}}
|
|
85
|
+
/>
|
|
86
|
+
)
|
|
87
|
+
expect(screen.getByText('bug')).toBeTruthy()
|
|
88
|
+
expect(screen.getByText('urgent')).toBeTruthy()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('renders label objects with color as colored badges', () => {
|
|
92
|
+
render(
|
|
93
|
+
<ViewValue
|
|
94
|
+
field={{ key: 'labels', label: 'Labels', type: 'json', cellStyle: 'relation-badge-list' }}
|
|
95
|
+
value={[{ label: 'Bug', color: '#ef4444' }, { name: 'P1' }]}
|
|
96
|
+
record={{}}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
expect(screen.getByText('Bug')).toBeTruthy()
|
|
100
|
+
expect(screen.getByText('P1')).toBeTruthy()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('keeps the "—" empty marker for an empty labels array', () => {
|
|
104
|
+
const { container } = render(
|
|
105
|
+
<ViewValue
|
|
106
|
+
field={{ key: 'labels', label: 'Labels', type: 'json', cellStyle: 'tags' }}
|
|
107
|
+
value={[]}
|
|
108
|
+
record={{}}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
expect(container.textContent).toContain('—')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Rich URL & media rendering: URLs are never shown raw. The shared primitives
|
|
4
|
+
// classify a URL (image / file / link), derive a smart short label, and
|
|
5
|
+
// linkify URLs embedded in free text (bare or markdown), rendering images as
|
|
6
|
+
// inline thumbnails, files as chips, and everything else as a compact link.
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
9
|
+
import {
|
|
10
|
+
classifyUrl,
|
|
11
|
+
isImageUrl,
|
|
12
|
+
isFileUrl,
|
|
13
|
+
smartUrlLabel,
|
|
14
|
+
fileNameFromUrl,
|
|
15
|
+
ensureHref,
|
|
16
|
+
splitTrailingPunct,
|
|
17
|
+
linkifyText,
|
|
18
|
+
UrlChip,
|
|
19
|
+
FileChip,
|
|
20
|
+
ImageThumbnail,
|
|
21
|
+
MediaValue,
|
|
22
|
+
RichText,
|
|
23
|
+
} from '../rich-url'
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup)
|
|
26
|
+
|
|
27
|
+
describe('classifyUrl', () => {
|
|
28
|
+
it('detects images by extension', () => {
|
|
29
|
+
for (const u of [
|
|
30
|
+
'https://cdn.example.com/a.jpg',
|
|
31
|
+
'https://cdn.example.com/a.JPEG',
|
|
32
|
+
'http://x/y/z.png?w=200',
|
|
33
|
+
'https://x/pic.webp#frag',
|
|
34
|
+
'https://x/i.avif',
|
|
35
|
+
'https://x/logo.svg',
|
|
36
|
+
]) {
|
|
37
|
+
expect(isImageUrl(u)).toBe(true)
|
|
38
|
+
expect(classifyUrl(u)).toBe('image')
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('detects images served from a storage media path without extension', () => {
|
|
43
|
+
const u = 'https://link.asteby.com/storage/media/9f3ac2b1'
|
|
44
|
+
expect(isImageUrl(u)).toBe(true)
|
|
45
|
+
expect(classifyUrl(u)).toBe('image')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('detects non-image files by extension', () => {
|
|
49
|
+
for (const u of [
|
|
50
|
+
'https://x/report.pdf',
|
|
51
|
+
'https://x/archive.zip',
|
|
52
|
+
'https://x/sheet.xlsx',
|
|
53
|
+
'https://x/doc.docx?dl=1',
|
|
54
|
+
'https://x/clip.mp4',
|
|
55
|
+
]) {
|
|
56
|
+
expect(isFileUrl(u)).toBe(true)
|
|
57
|
+
expect(classifyUrl(u)).toBe('file')
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('treats a plain page URL as a link', () => {
|
|
62
|
+
const u = 'https://github.com/asteby/doctores.lat/issues/212'
|
|
63
|
+
expect(isImageUrl(u)).toBe(false)
|
|
64
|
+
expect(isFileUrl(u)).toBe(false)
|
|
65
|
+
expect(classifyUrl(u)).toBe('link')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('smartUrlLabel / fileNameFromUrl / ensureHref', () => {
|
|
70
|
+
it('uses the bare hostname for a page URL (no raw long URL)', () => {
|
|
71
|
+
expect(
|
|
72
|
+
smartUrlLabel('https://github.com/asteby/doctores.lat/issues/212')
|
|
73
|
+
).toBe('github.com')
|
|
74
|
+
expect(smartUrlLabel('https://www.example.com/path/to/thing')).toBe(
|
|
75
|
+
'example.com'
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('uses the file name for a file/image URL', () => {
|
|
80
|
+
expect(smartUrlLabel('https://x/y/report.pdf')).toBe('report.pdf')
|
|
81
|
+
expect(fileNameFromUrl('https://x/y/My%20File.pdf')).toBe('My File.pdf')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('makes a schemeless URL absolute', () => {
|
|
85
|
+
expect(ensureHref('github.com/x')).toBe('https://github.com/x')
|
|
86
|
+
expect(ensureHref('https://a.com')).toBe('https://a.com')
|
|
87
|
+
expect(ensureHref('mailto:a@b.com')).toBe('mailto:a@b.com')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('splitTrailingPunct', () => {
|
|
92
|
+
it('peels a trailing sentence period', () => {
|
|
93
|
+
expect(splitTrailingPunct('https://a.com/x.')).toEqual([
|
|
94
|
+
'https://a.com/x',
|
|
95
|
+
'.',
|
|
96
|
+
])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('keeps a paren the URL itself opened', () => {
|
|
100
|
+
expect(
|
|
101
|
+
splitTrailingPunct('https://en.wikipedia.org/wiki/Foo_(bar)')
|
|
102
|
+
).toEqual(['https://en.wikipedia.org/wiki/Foo_(bar)', ''])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('peels a closing paren the URL did not open', () => {
|
|
106
|
+
expect(splitTrailingPunct('https://a.com/x)')).toEqual([
|
|
107
|
+
'https://a.com/x',
|
|
108
|
+
')',
|
|
109
|
+
])
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('linkifyText', () => {
|
|
114
|
+
it('linkifies a bare URL and keeps surrounding text', () => {
|
|
115
|
+
const nodes = linkifyText('see https://github.com/a/b for details')
|
|
116
|
+
render(<p>{nodes}</p>)
|
|
117
|
+
const link = screen.getByRole('link')
|
|
118
|
+
expect(link.getAttribute('href')).toBe('https://github.com/a/b')
|
|
119
|
+
expect(link.getAttribute('target')).toBe('_blank')
|
|
120
|
+
// short label, not the raw URL
|
|
121
|
+
expect(link.textContent).toContain('github.com')
|
|
122
|
+
expect(screen.getByText(/see/)).toBeTruthy()
|
|
123
|
+
expect(screen.getByText(/for details/)).toBeTruthy()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does not swallow the trailing period into the href', () => {
|
|
127
|
+
const nodes = linkifyText('go to https://a.com/x.')
|
|
128
|
+
render(<p>{nodes}</p>)
|
|
129
|
+
expect(screen.getByRole('link').getAttribute('href')).toBe('https://a.com/x')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('renders an embedded image URL as an inline thumbnail', () => {
|
|
133
|
+
const nodes = linkifyText('pic https://cdn.x/p.png here')
|
|
134
|
+
const { container } = render(<p>{nodes}</p>)
|
|
135
|
+
const img = container.querySelector('img')
|
|
136
|
+
expect(img).toBeTruthy()
|
|
137
|
+
expect(img!.getAttribute('src')).toBe('https://cdn.x/p.png')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('honors a markdown link label', () => {
|
|
141
|
+
const nodes = linkifyText('[the issue](https://github.com/a/b/issues/1)')
|
|
142
|
+
render(<p>{nodes}</p>)
|
|
143
|
+
const link = screen.getByRole('link')
|
|
144
|
+
expect(link.getAttribute('href')).toBe('https://github.com/a/b/issues/1')
|
|
145
|
+
expect(link.textContent).toContain('the issue')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('primitives render', () => {
|
|
150
|
+
it('UrlChip opens in a new tab with the full URL in the title', () => {
|
|
151
|
+
render(<UrlChip url="https://github.com/asteby/x/issues/9" />)
|
|
152
|
+
const link = screen.getByRole('link')
|
|
153
|
+
expect(link.getAttribute('href')).toBe('https://github.com/asteby/x/issues/9')
|
|
154
|
+
expect(link.getAttribute('title')).toBe('https://github.com/asteby/x/issues/9')
|
|
155
|
+
expect(link.getAttribute('rel')).toBe('noopener noreferrer')
|
|
156
|
+
expect(link.textContent).toContain('github.com')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('FileChip shows the file name', () => {
|
|
160
|
+
render(<FileChip url="https://x/y/quarterly-report.pdf" />)
|
|
161
|
+
expect(screen.getByText('quarterly-report.pdf')).toBeTruthy()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('ImageThumbnail falls back to a link chip on load error', () => {
|
|
165
|
+
const { container } = render(
|
|
166
|
+
<ImageThumbnail url="https://cdn.x/broken.png" />
|
|
167
|
+
)
|
|
168
|
+
const img = container.querySelector('img') as HTMLImageElement
|
|
169
|
+
expect(img).toBeTruthy()
|
|
170
|
+
// simulate a broken image (React synthetic onError)
|
|
171
|
+
fireEvent.error(img)
|
|
172
|
+
// after error, a link chip is rendered instead
|
|
173
|
+
expect(screen.queryByRole('link')).toBeTruthy()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('MediaValue dispatches by kind', () => {
|
|
177
|
+
const { rerender, container } = render(
|
|
178
|
+
<MediaValue url="https://cdn.x/a.png" />
|
|
179
|
+
)
|
|
180
|
+
expect(container.querySelector('img')).toBeTruthy()
|
|
181
|
+
|
|
182
|
+
rerender(<MediaValue url="https://x/a.pdf" />)
|
|
183
|
+
expect(screen.getByText('a.pdf')).toBeTruthy()
|
|
184
|
+
|
|
185
|
+
rerender(<MediaValue url="https://github.com/a/b" />)
|
|
186
|
+
expect(screen.getByRole('link').textContent).toContain('github.com')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('RichText linkifies a body while preserving text', () => {
|
|
190
|
+
render(
|
|
191
|
+
<RichText text={'Fixes the bug. Ref https://github.com/a/b/pull/3'} />
|
|
192
|
+
)
|
|
193
|
+
expect(screen.getByText(/Fixes the bug/)).toBeTruthy()
|
|
194
|
+
expect(screen.getByRole('link').getAttribute('href')).toBe('https://github.com/a/b/pull/3')
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -225,11 +225,11 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
225
225
|
const url = buildActionUrl(endpoint, model, record.id, action.key)
|
|
226
226
|
const res = await api.post(url, {})
|
|
227
227
|
if (res.data.success) {
|
|
228
|
-
toast.success(res.data.message
|
|
228
|
+
toast.success(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.success'))
|
|
229
229
|
onOpenChange(false)
|
|
230
230
|
onSuccess()
|
|
231
231
|
} else {
|
|
232
|
-
toast.error(res.data.message
|
|
232
|
+
toast.error(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.error'))
|
|
233
233
|
}
|
|
234
234
|
} catch (err: any) {
|
|
235
235
|
toast.error(err?.response?.data?.message || t('common.error'))
|
|
@@ -342,11 +342,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
342
342
|
const url = buildActionUrl(endpoint, model, record.id, action.key)
|
|
343
343
|
const res = await api.post(url, formData)
|
|
344
344
|
if (res.data.success) {
|
|
345
|
-
toast.success(res.data.message
|
|
345
|
+
toast.success(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.success'))
|
|
346
346
|
onOpenChange(false)
|
|
347
347
|
onSuccess()
|
|
348
348
|
} else {
|
|
349
|
-
toast.error(res.data.message
|
|
349
|
+
toast.error(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.error'))
|
|
350
350
|
}
|
|
351
351
|
} catch (err: any) {
|
|
352
352
|
toast.error(err?.response?.data?.message || t('common.error'))
|