@object-ui/plugin-detail 3.0.2 → 3.1.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/.turbo/turbo-build.log +45 -8
- package/CHANGELOG.md +9 -0
- package/dist/AddressField-C07oUOY6.js +96 -0
- package/dist/AutoNumberField-BxnFqllo.js +8 -0
- package/dist/AvatarField-VThNABzo.js +82 -0
- package/dist/BooleanField-CGHKBzAi.js +37 -0
- package/dist/CodeField-Co_muhRR.js +21 -0
- package/dist/ColorField-DLid_tFz.js +42 -0
- package/dist/CurrencyField-Bw-LqANM.js +43 -0
- package/dist/DateField-BNHAzMB2.js +21 -0
- package/dist/DateTimeField-DjAyn_DQ.js +28 -0
- package/dist/EmailField-xoNcSppb.js +31 -0
- package/dist/FileField-DbNJwjU2.js +133 -0
- package/dist/FormulaField-CJkkwIK8.js +9 -0
- package/dist/GeolocationField-C1AnS6VV.js +123 -0
- package/dist/GridField-DATAHIKf.js +30 -0
- package/dist/ImageField-CEKJpyJp.js +90 -0
- package/dist/LocationField-jDWXjlpx.js +31 -0
- package/dist/LookupField-DQ08L9UQ.js +96 -0
- package/dist/MasterDetailField-Dbk529Ea.js +108 -0
- package/dist/NumberField-BVroN9aV.js +26 -0
- package/dist/ObjectField-CT3l_IHW.js +48 -0
- package/dist/PasswordField-DweVLEE0.js +38 -0
- package/dist/PercentField-ZpWUK97K.js +63 -0
- package/dist/PhoneField-mw-9fqZ_.js +31 -0
- package/dist/QRCodeField-Cbb9ck59.js +77 -0
- package/dist/RatingField-CSqgLS6t.js +47 -0
- package/dist/RichTextField-BpfBOd99.js +38 -0
- package/dist/SelectField-B9Ei-5jl.js +26 -0
- package/dist/SignatureField-DgGpHnQ8.js +85 -0
- package/dist/SliderField-C6HvOHd8.js +30 -0
- package/dist/SummaryField-ugYPYxjP.js +9 -0
- package/dist/TextAreaField-BK3RgzY3.js +39 -0
- package/dist/TextField-Bvzx3atT.js +32 -0
- package/dist/TimeField-Cuz9-Uai.js +21 -0
- package/dist/UrlField-B6XHTV73.js +33 -0
- package/dist/UserField-ooTul2d6.js +49 -0
- package/dist/VectorField-CKg9jdGa.js +25 -0
- package/dist/index-CnlyRfY_.js +59461 -0
- package/dist/index.js +30 -55026
- package/dist/index.umd.cjs +41 -30
- package/dist/plugin-detail.css +1 -1
- package/dist/src/ActivityTimeline.d.ts +20 -0
- package/dist/src/ActivityTimeline.d.ts.map +1 -0
- package/dist/src/CommentAttachment.d.ts +25 -0
- package/dist/src/CommentAttachment.d.ts.map +1 -0
- package/dist/src/CommentInput.d.ts +24 -0
- package/dist/src/CommentInput.d.ts.map +1 -0
- package/dist/src/DetailSection.d.ts +6 -0
- package/dist/src/DetailSection.d.ts.map +1 -1
- package/dist/src/DetailView.d.ts +4 -0
- package/dist/src/DetailView.d.ts.map +1 -1
- package/dist/src/DetailView.stories.d.ts +8 -0
- package/dist/src/DetailView.stories.d.ts.map +1 -1
- package/dist/src/DiffView.d.ts +24 -0
- package/dist/src/DiffView.d.ts.map +1 -0
- package/dist/src/FieldChangeItem.d.ts +21 -0
- package/dist/src/FieldChangeItem.d.ts.map +1 -0
- package/dist/src/InlineCreateRelated.d.ts +32 -0
- package/dist/src/InlineCreateRelated.d.ts.map +1 -0
- package/dist/src/MentionAutocomplete.d.ts +43 -0
- package/dist/src/MentionAutocomplete.d.ts.map +1 -0
- package/dist/src/PointInTimeRestore.d.ts +28 -0
- package/dist/src/PointInTimeRestore.d.ts.map +1 -0
- package/dist/src/ReactionPicker.d.ts +25 -0
- package/dist/src/ReactionPicker.d.ts.map +1 -0
- package/dist/src/RecordActivityTimeline.d.ts +49 -0
- package/dist/src/RecordActivityTimeline.d.ts.map +1 -0
- package/dist/src/RecordChatterPanel.d.ts +48 -0
- package/dist/src/RecordChatterPanel.d.ts.map +1 -0
- package/dist/src/RecordComments.d.ts +20 -0
- package/dist/src/RecordComments.d.ts.map +1 -0
- package/dist/src/RecordNavigationEnhanced.d.ts +18 -0
- package/dist/src/RecordNavigationEnhanced.d.ts.map +1 -0
- package/dist/src/RelatedList.d.ts +4 -0
- package/dist/src/RelatedList.d.ts.map +1 -1
- package/dist/src/RelationshipGraph.d.ts +23 -0
- package/dist/src/RelationshipGraph.d.ts.map +1 -0
- package/dist/src/RichTextCommentInput.d.ts +24 -0
- package/dist/src/RichTextCommentInput.d.ts.map +1 -0
- package/dist/src/SubscriptionToggle.d.ts +22 -0
- package/dist/src/SubscriptionToggle.d.ts.map +1 -0
- package/dist/src/ThreadedReplies.d.ts +26 -0
- package/dist/src/ThreadedReplies.d.ts.map +1 -0
- package/dist/src/autoLayout.d.ts +34 -0
- package/dist/src/autoLayout.d.ts.map +1 -0
- package/dist/src/index.d.ts +36 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/useDetailTranslation.d.ts +34 -0
- package/dist/src/useDetailTranslation.d.ts.map +1 -0
- package/package.json +8 -7
- package/src/ActivityTimeline.tsx +184 -0
- package/src/CommentAttachment.tsx +192 -0
- package/src/CommentInput.tsx +81 -0
- package/src/DetailSection.tsx +74 -9
- package/src/DetailView.stories.tsx +76 -0
- package/src/DetailView.tsx +270 -27
- package/src/DiffView.tsx +231 -0
- package/src/FieldChangeItem.tsx +46 -0
- package/src/InlineCreateRelated.tsx +291 -0
- package/src/MentionAutocomplete.tsx +123 -0
- package/src/PointInTimeRestore.tsx +261 -0
- package/src/ReactionPicker.tsx +106 -0
- package/src/RecordActivityTimeline.tsx +429 -0
- package/src/RecordChatterPanel.tsx +202 -0
- package/src/RecordComments.tsx +215 -0
- package/src/RecordNavigationEnhanced.tsx +211 -0
- package/src/RelatedList.tsx +37 -8
- package/src/RelationshipGraph.tsx +286 -0
- package/src/RichTextCommentInput.tsx +348 -0
- package/src/SubscriptionToggle.tsx +60 -0
- package/src/ThreadedReplies.tsx +161 -0
- package/src/__tests__/ActivityTimeline.test.tsx +119 -0
- package/src/__tests__/ActivityTimelineFiltering.test.tsx +143 -0
- package/src/__tests__/CommentInput.test.tsx +57 -0
- package/src/__tests__/DetailSection.test.tsx +320 -0
- package/src/__tests__/DetailView.test.tsx +415 -1
- package/src/__tests__/FieldChangeItem.test.tsx +119 -0
- package/src/__tests__/MentionAutocomplete.test.tsx +97 -0
- package/src/__tests__/ReactionPicker.test.tsx +113 -0
- package/src/__tests__/RecordActivityTimeline.test.tsx +395 -0
- package/src/__tests__/RecordChatterPanel.test.tsx +227 -0
- package/src/__tests__/RecordComments.test.tsx +96 -0
- package/src/__tests__/RecordCommentsPinSearch.test.tsx +133 -0
- package/src/__tests__/RelatedList.test.tsx +66 -0
- package/src/__tests__/SubscriptionToggle.test.tsx +84 -0
- package/src/__tests__/ThreadedReplies.test.tsx +212 -0
- package/src/__tests__/autoLayout.test.ts +184 -0
- package/src/__tests__/phase12-features.test.tsx +583 -0
- package/src/autoLayout.ts +111 -0
- package/src/index.tsx +46 -0
- package/src/useDetailTranslation.ts +103 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
-
import { render, screen, fireEvent } from '@testing-library/react';
|
|
10
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
11
11
|
import { DetailView } from '../DetailView';
|
|
12
12
|
import type { DetailViewSchema } from '@object-ui/types';
|
|
13
13
|
|
|
@@ -246,4 +246,418 @@ describe('DetailView', () => {
|
|
|
246
246
|
const skeletons = container.querySelectorAll('.animate-pulse');
|
|
247
247
|
expect(skeletons.length).toBeGreaterThan(0);
|
|
248
248
|
});
|
|
249
|
+
|
|
250
|
+
it('should render prev/next navigation when recordNavigation is provided', () => {
|
|
251
|
+
const onNavigate = vi.fn();
|
|
252
|
+
const schema: DetailViewSchema = {
|
|
253
|
+
type: 'detail-view',
|
|
254
|
+
title: 'Contact Details',
|
|
255
|
+
data: { name: 'John Doe' },
|
|
256
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
257
|
+
showBack: false,
|
|
258
|
+
recordNavigation: {
|
|
259
|
+
recordIds: ['id1', 'id2', 'id3'],
|
|
260
|
+
currentIndex: 1,
|
|
261
|
+
onNavigate,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
render(<DetailView schema={schema} />);
|
|
266
|
+
|
|
267
|
+
// Should show position indicator
|
|
268
|
+
expect(screen.getByText('2 of 3')).toBeInTheDocument();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should call onNavigate with previous record id', () => {
|
|
272
|
+
const onNavigate = vi.fn();
|
|
273
|
+
const schema: DetailViewSchema = {
|
|
274
|
+
type: 'detail-view',
|
|
275
|
+
title: 'Contact Details',
|
|
276
|
+
data: { name: 'John Doe' },
|
|
277
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
278
|
+
showBack: false,
|
|
279
|
+
recordNavigation: {
|
|
280
|
+
recordIds: ['id1', 'id2', 'id3'],
|
|
281
|
+
currentIndex: 1,
|
|
282
|
+
onNavigate,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const { container } = render(<DetailView schema={schema} />);
|
|
287
|
+
|
|
288
|
+
// Find the prev button (first in the navigation group)
|
|
289
|
+
const navButtons = container.querySelectorAll('button');
|
|
290
|
+
// The prev button is the one that contains a chevron-left icon and is not disabled
|
|
291
|
+
const prevButton = Array.from(navButtons).find(btn =>
|
|
292
|
+
btn.querySelector('.lucide-chevron-left')
|
|
293
|
+
);
|
|
294
|
+
expect(prevButton).toBeTruthy();
|
|
295
|
+
fireEvent.click(prevButton!);
|
|
296
|
+
expect(onNavigate).toHaveBeenCalledWith('id1');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should call onNavigate with next record id', () => {
|
|
300
|
+
const onNavigate = vi.fn();
|
|
301
|
+
const schema: DetailViewSchema = {
|
|
302
|
+
type: 'detail-view',
|
|
303
|
+
title: 'Contact Details',
|
|
304
|
+
data: { name: 'John Doe' },
|
|
305
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
306
|
+
showBack: false,
|
|
307
|
+
recordNavigation: {
|
|
308
|
+
recordIds: ['id1', 'id2', 'id3'],
|
|
309
|
+
currentIndex: 1,
|
|
310
|
+
onNavigate,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const { container } = render(<DetailView schema={schema} />);
|
|
315
|
+
|
|
316
|
+
const nextButton = Array.from(container.querySelectorAll('button')).find(btn =>
|
|
317
|
+
btn.querySelector('.lucide-chevron-right')
|
|
318
|
+
);
|
|
319
|
+
expect(nextButton).toBeTruthy();
|
|
320
|
+
fireEvent.click(nextButton!);
|
|
321
|
+
expect(onNavigate).toHaveBeenCalledWith('id3');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should disable prev button at first record', () => {
|
|
325
|
+
const schema: DetailViewSchema = {
|
|
326
|
+
type: 'detail-view',
|
|
327
|
+
title: 'Contact Details',
|
|
328
|
+
data: { name: 'John Doe' },
|
|
329
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
330
|
+
showBack: false,
|
|
331
|
+
recordNavigation: {
|
|
332
|
+
recordIds: ['id1', 'id2'],
|
|
333
|
+
currentIndex: 0,
|
|
334
|
+
onNavigate: vi.fn(),
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const { container } = render(<DetailView schema={schema} />);
|
|
339
|
+
|
|
340
|
+
const prevButton = Array.from(container.querySelectorAll('button')).find(btn =>
|
|
341
|
+
btn.querySelector('.lucide-chevron-left')
|
|
342
|
+
);
|
|
343
|
+
expect(prevButton).toBeTruthy();
|
|
344
|
+
expect(prevButton!).toBeDisabled();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should disable next button at last record', () => {
|
|
348
|
+
const schema: DetailViewSchema = {
|
|
349
|
+
type: 'detail-view',
|
|
350
|
+
title: 'Contact Details',
|
|
351
|
+
data: { name: 'John Doe' },
|
|
352
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
353
|
+
showBack: false,
|
|
354
|
+
recordNavigation: {
|
|
355
|
+
recordIds: ['id1', 'id2'],
|
|
356
|
+
currentIndex: 1,
|
|
357
|
+
onNavigate: vi.fn(),
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const { container } = render(<DetailView schema={schema} />);
|
|
362
|
+
|
|
363
|
+
const nextButton = Array.from(container.querySelectorAll('button')).find(btn =>
|
|
364
|
+
btn.querySelector('.lucide-chevron-right')
|
|
365
|
+
);
|
|
366
|
+
expect(nextButton).toBeTruthy();
|
|
367
|
+
expect(nextButton!).toBeDisabled();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should render comments section when comments are provided', () => {
|
|
371
|
+
const schema: DetailViewSchema = {
|
|
372
|
+
type: 'detail-view',
|
|
373
|
+
title: 'Contact Details',
|
|
374
|
+
data: { name: 'John Doe' },
|
|
375
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
376
|
+
comments: [
|
|
377
|
+
{
|
|
378
|
+
id: '1',
|
|
379
|
+
text: 'Great contact!',
|
|
380
|
+
author: 'Alice',
|
|
381
|
+
createdAt: '2026-02-16T08:00:00Z',
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
render(<DetailView schema={schema} />);
|
|
387
|
+
|
|
388
|
+
expect(screen.getByText('Comments')).toBeInTheDocument();
|
|
389
|
+
expect(screen.getByText('Great contact!')).toBeInTheDocument();
|
|
390
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('should render activity timeline when activities are provided', () => {
|
|
394
|
+
const schema: DetailViewSchema = {
|
|
395
|
+
type: 'detail-view',
|
|
396
|
+
title: 'Contact Details',
|
|
397
|
+
data: { name: 'John Doe' },
|
|
398
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
399
|
+
activities: [
|
|
400
|
+
{
|
|
401
|
+
id: '1',
|
|
402
|
+
type: 'create',
|
|
403
|
+
user: 'Bob',
|
|
404
|
+
timestamp: '2026-02-15T10:00:00Z',
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
id: '2',
|
|
408
|
+
type: 'field_change',
|
|
409
|
+
field: 'email',
|
|
410
|
+
oldValue: 'old@test.com',
|
|
411
|
+
newValue: 'new@test.com',
|
|
412
|
+
user: 'Alice',
|
|
413
|
+
timestamp: '2026-02-16T09:00:00Z',
|
|
414
|
+
},
|
|
415
|
+
],
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
render(<DetailView schema={schema} />);
|
|
419
|
+
|
|
420
|
+
expect(screen.getByText('Activity')).toBeInTheDocument();
|
|
421
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('should render primaryField value as header title', () => {
|
|
425
|
+
const schema: DetailViewSchema = {
|
|
426
|
+
type: 'detail-view',
|
|
427
|
+
title: 'Contact',
|
|
428
|
+
primaryField: 'name',
|
|
429
|
+
data: { name: 'John Doe', email: 'john@example.com' },
|
|
430
|
+
fields: [
|
|
431
|
+
{ name: 'name', label: 'Name' },
|
|
432
|
+
{ name: 'email', label: 'Email' },
|
|
433
|
+
],
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
render(<DetailView schema={schema} />);
|
|
437
|
+
// The h1 heading should show the primary field value
|
|
438
|
+
const heading = screen.getByRole('heading', { level: 1 });
|
|
439
|
+
expect(heading.textContent).toBe('John Doe');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should fall back to title when primaryField value is empty', () => {
|
|
443
|
+
const schema: DetailViewSchema = {
|
|
444
|
+
type: 'detail-view',
|
|
445
|
+
title: 'Contact',
|
|
446
|
+
primaryField: 'name',
|
|
447
|
+
data: { email: 'john@example.com' },
|
|
448
|
+
fields: [
|
|
449
|
+
{ name: 'name', label: 'Name' },
|
|
450
|
+
{ name: 'email', label: 'Email' },
|
|
451
|
+
],
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
render(<DetailView schema={schema} />);
|
|
455
|
+
const heading = screen.getByRole('heading', { level: 1 });
|
|
456
|
+
expect(heading.textContent).toBe('Contact');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('should render summaryFields as badges', () => {
|
|
460
|
+
const schema: DetailViewSchema = {
|
|
461
|
+
type: 'detail-view',
|
|
462
|
+
title: 'Contact',
|
|
463
|
+
primaryField: 'name',
|
|
464
|
+
summaryFields: ['status', 'department'],
|
|
465
|
+
data: { name: 'Jane Doe', status: 'Active', department: 'Engineering' },
|
|
466
|
+
fields: [
|
|
467
|
+
{ name: 'name', label: 'Name' },
|
|
468
|
+
{ name: 'status', label: 'Status' },
|
|
469
|
+
{ name: 'department', label: 'Department' },
|
|
470
|
+
],
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
render(<DetailView schema={schema} />);
|
|
474
|
+
const heading = screen.getByRole('heading', { level: 1 });
|
|
475
|
+
expect(heading.textContent).toBe('Jane Doe');
|
|
476
|
+
// Summary badges should be present (they appear both as badges and as field values)
|
|
477
|
+
const activeElements = screen.getAllByText('Active');
|
|
478
|
+
expect(activeElements.length).toBeGreaterThanOrEqual(1);
|
|
479
|
+
const engElements = screen.getAllByText('Engineering');
|
|
480
|
+
expect(engElements.length).toBeGreaterThanOrEqual(1);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('should not render summary badge for empty values', () => {
|
|
484
|
+
const schema: DetailViewSchema = {
|
|
485
|
+
type: 'detail-view',
|
|
486
|
+
title: 'Contact',
|
|
487
|
+
summaryFields: ['status', 'department'],
|
|
488
|
+
data: { name: 'Jane Doe', status: 'Active', department: null },
|
|
489
|
+
fields: [
|
|
490
|
+
{ name: 'name', label: 'Name' },
|
|
491
|
+
{ name: 'status', label: 'Status' },
|
|
492
|
+
],
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const { container } = render(<DetailView schema={schema} />);
|
|
496
|
+
// The header area should have a badge for 'Active' but not 'department'
|
|
497
|
+
// Find badges within the header
|
|
498
|
+
const headerBadges = container.querySelectorAll('.border-b .rounded-full');
|
|
499
|
+
const badgeTexts = Array.from(headerBadges).map(b => b.textContent);
|
|
500
|
+
expect(badgeTexts).toContain('Active');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('should show "Record not found" when data is null after loading', async () => {
|
|
504
|
+
const mockDataSource = {
|
|
505
|
+
findOne: vi.fn().mockResolvedValue(null),
|
|
506
|
+
} as any;
|
|
507
|
+
|
|
508
|
+
const schema: DetailViewSchema = {
|
|
509
|
+
type: 'detail-view',
|
|
510
|
+
title: 'Contact Details',
|
|
511
|
+
objectName: 'contact',
|
|
512
|
+
resourceId: 'nonexistent-id',
|
|
513
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const { findByText } = render(<DetailView schema={schema} dataSource={mockDataSource} />);
|
|
517
|
+
expect(await findByText('Record not found')).toBeInTheDocument();
|
|
518
|
+
expect(await findByText(/does not exist or may have been deleted/)).toBeInTheDocument();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('should show "Go back" button in "Record not found" state when showBack is true', async () => {
|
|
522
|
+
const mockDataSource = {
|
|
523
|
+
findOne: vi.fn().mockResolvedValue(null),
|
|
524
|
+
} as any;
|
|
525
|
+
const onBack = vi.fn();
|
|
526
|
+
|
|
527
|
+
const schema: DetailViewSchema = {
|
|
528
|
+
type: 'detail-view',
|
|
529
|
+
title: 'Contact Details',
|
|
530
|
+
objectName: 'contact',
|
|
531
|
+
resourceId: 'nonexistent-id',
|
|
532
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
533
|
+
showBack: true,
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const { findByText } = render(<DetailView schema={schema} dataSource={mockDataSource} onBack={onBack} />);
|
|
537
|
+
const goBackBtn = await findByText('Go back');
|
|
538
|
+
fireEvent.click(goBackBtn);
|
|
539
|
+
expect(onBack).toHaveBeenCalled();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should call findOne with $expand when objectSchema has lookup fields', async () => {
|
|
543
|
+
const mockDataSource = {
|
|
544
|
+
getObjectSchema: vi.fn().mockResolvedValue({
|
|
545
|
+
fields: {
|
|
546
|
+
name: { type: 'text' },
|
|
547
|
+
customer: { type: 'lookup', reference_to: 'contact' },
|
|
548
|
+
account: { type: 'master_detail', reference_to: 'account' },
|
|
549
|
+
},
|
|
550
|
+
}),
|
|
551
|
+
findOne: vi.fn().mockResolvedValue({ name: 'Order 1', customer: { name: 'Alice' }, account: { name: 'Acme' } }),
|
|
552
|
+
} as any;
|
|
553
|
+
|
|
554
|
+
const schema: DetailViewSchema = {
|
|
555
|
+
type: 'detail-view',
|
|
556
|
+
title: 'Order Details',
|
|
557
|
+
objectName: 'order',
|
|
558
|
+
resourceId: 'order-1',
|
|
559
|
+
fields: [
|
|
560
|
+
{ name: 'name', label: 'Name' },
|
|
561
|
+
{ name: 'customer', label: 'Customer' },
|
|
562
|
+
{ name: 'account', label: 'Account' },
|
|
563
|
+
],
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
render(<DetailView schema={schema} dataSource={mockDataSource} />);
|
|
567
|
+
|
|
568
|
+
await waitFor(() => {
|
|
569
|
+
expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('order');
|
|
570
|
+
expect(mockDataSource.findOne).toHaveBeenCalledWith(
|
|
571
|
+
'order',
|
|
572
|
+
'order-1',
|
|
573
|
+
expect.objectContaining({ $expand: expect.arrayContaining(['customer', 'account']) }),
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('should call findOne without $expand when objectSchema has no lookup fields', async () => {
|
|
579
|
+
const mockDataSource = {
|
|
580
|
+
getObjectSchema: vi.fn().mockResolvedValue({
|
|
581
|
+
fields: {
|
|
582
|
+
name: { type: 'text' },
|
|
583
|
+
email: { type: 'text' },
|
|
584
|
+
},
|
|
585
|
+
}),
|
|
586
|
+
findOne: vi.fn().mockResolvedValue({ name: 'Alice', email: 'alice@example.com' }),
|
|
587
|
+
} as any;
|
|
588
|
+
|
|
589
|
+
const schema: DetailViewSchema = {
|
|
590
|
+
type: 'detail-view',
|
|
591
|
+
title: 'Contact Details',
|
|
592
|
+
objectName: 'contact',
|
|
593
|
+
resourceId: 'c1',
|
|
594
|
+
fields: [
|
|
595
|
+
{ name: 'name', label: 'Name' },
|
|
596
|
+
{ name: 'email', label: 'Email' },
|
|
597
|
+
],
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
render(<DetailView schema={schema} dataSource={mockDataSource} />);
|
|
601
|
+
|
|
602
|
+
await waitFor(() => {
|
|
603
|
+
// When no lookup fields exist, findOne should be called without $expand params
|
|
604
|
+
expect(mockDataSource.findOne).toHaveBeenCalledWith('contact', 'c1');
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should still work when getObjectSchema is not available on dataSource', async () => {
|
|
609
|
+
const mockDataSource = {
|
|
610
|
+
findOne: vi.fn().mockResolvedValue({ name: 'Bob' }),
|
|
611
|
+
} as any;
|
|
612
|
+
|
|
613
|
+
const schema: DetailViewSchema = {
|
|
614
|
+
type: 'detail-view',
|
|
615
|
+
title: 'Contact Details',
|
|
616
|
+
objectName: 'contact',
|
|
617
|
+
resourceId: 'c1',
|
|
618
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const { findByText } = render(<DetailView schema={schema} dataSource={mockDataSource} />);
|
|
622
|
+
expect(await findByText('Bob')).toBeInTheDocument();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('should use i18n fallback for "Record not found" text', async () => {
|
|
626
|
+
const mockDataSource = {
|
|
627
|
+
findOne: vi.fn().mockResolvedValue(null),
|
|
628
|
+
} as any;
|
|
629
|
+
|
|
630
|
+
const schema: DetailViewSchema = {
|
|
631
|
+
type: 'detail-view',
|
|
632
|
+
title: 'Contact Details',
|
|
633
|
+
objectName: 'contact',
|
|
634
|
+
resourceId: 'nonexistent-id',
|
|
635
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const { findByText } = render(<DetailView schema={schema} dataSource={mockDataSource} />);
|
|
639
|
+
// These use the default English translations from useDetailTranslation fallback
|
|
640
|
+
expect(await findByText('Record not found')).toBeInTheDocument();
|
|
641
|
+
expect(await findByText('Go back')).toBeInTheDocument();
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('should use i18n fallback for related section heading', () => {
|
|
645
|
+
const schema: DetailViewSchema = {
|
|
646
|
+
type: 'detail-view',
|
|
647
|
+
title: 'Account Details',
|
|
648
|
+
data: { name: 'Acme Corp' },
|
|
649
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
650
|
+
related: [
|
|
651
|
+
{
|
|
652
|
+
title: 'Contacts',
|
|
653
|
+
type: 'table',
|
|
654
|
+
data: [],
|
|
655
|
+
},
|
|
656
|
+
],
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
render(<DetailView schema={schema} />);
|
|
660
|
+
// The "Related" heading uses t('detail.related')
|
|
661
|
+
expect(screen.getByText('Related')).toBeInTheDocument();
|
|
662
|
+
});
|
|
249
663
|
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { render, screen } from '@testing-library/react';
|
|
11
|
+
import '@testing-library/jest-dom';
|
|
12
|
+
import { FieldChangeItem } from '../FieldChangeItem';
|
|
13
|
+
import type { FieldChangeEntry } from '@object-ui/types';
|
|
14
|
+
|
|
15
|
+
describe('FieldChangeItem', () => {
|
|
16
|
+
it('should render field label with old and new display values', () => {
|
|
17
|
+
const change: FieldChangeEntry = {
|
|
18
|
+
field: 'status',
|
|
19
|
+
fieldLabel: 'Status',
|
|
20
|
+
oldDisplayValue: 'Open',
|
|
21
|
+
newDisplayValue: 'Closed',
|
|
22
|
+
};
|
|
23
|
+
render(<FieldChangeItem change={change} />);
|
|
24
|
+
expect(screen.getByText('Status')).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByText('Open')).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText('Closed')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should derive field label from field name when fieldLabel is not set', () => {
|
|
30
|
+
const change: FieldChangeEntry = {
|
|
31
|
+
field: 'first_name',
|
|
32
|
+
oldValue: 'John',
|
|
33
|
+
newValue: 'Jane',
|
|
34
|
+
};
|
|
35
|
+
render(<FieldChangeItem change={change} />);
|
|
36
|
+
expect(screen.getByText('First name')).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should use raw values when display values are not set', () => {
|
|
40
|
+
const change: FieldChangeEntry = {
|
|
41
|
+
field: 'priority',
|
|
42
|
+
fieldLabel: 'Priority',
|
|
43
|
+
oldValue: 'low',
|
|
44
|
+
newValue: 'high',
|
|
45
|
+
};
|
|
46
|
+
render(<FieldChangeItem change={change} />);
|
|
47
|
+
expect(screen.getByText('low')).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText('high')).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should show (empty) when value is null/undefined', () => {
|
|
52
|
+
const change: FieldChangeEntry = {
|
|
53
|
+
field: 'notes',
|
|
54
|
+
fieldLabel: 'Notes',
|
|
55
|
+
newValue: 'Some text',
|
|
56
|
+
};
|
|
57
|
+
render(<FieldChangeItem change={change} />);
|
|
58
|
+
expect(screen.getByText('(empty)')).toBeInTheDocument();
|
|
59
|
+
expect(screen.getByText('Some text')).toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should apply custom className', () => {
|
|
63
|
+
const change: FieldChangeEntry = {
|
|
64
|
+
field: 'name',
|
|
65
|
+
fieldLabel: 'Name',
|
|
66
|
+
oldValue: 'A',
|
|
67
|
+
newValue: 'B',
|
|
68
|
+
};
|
|
69
|
+
const { container } = render(<FieldChangeItem change={change} className="custom-class" />);
|
|
70
|
+
expect(container.firstChild).toHaveClass('custom-class');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should render arrow icon between old and new values', () => {
|
|
74
|
+
const change: FieldChangeEntry = {
|
|
75
|
+
field: 'status',
|
|
76
|
+
fieldLabel: 'Status',
|
|
77
|
+
oldValue: 'Open',
|
|
78
|
+
newValue: 'Closed',
|
|
79
|
+
};
|
|
80
|
+
const { container } = render(<FieldChangeItem change={change} />);
|
|
81
|
+
// ArrowRight renders as an SVG with lucide classes
|
|
82
|
+
const svg = container.querySelector('svg');
|
|
83
|
+
expect(svg).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should render old value with line-through style', () => {
|
|
87
|
+
const change: FieldChangeEntry = {
|
|
88
|
+
field: 'status',
|
|
89
|
+
fieldLabel: 'Status',
|
|
90
|
+
oldDisplayValue: 'Open',
|
|
91
|
+
newDisplayValue: 'Closed',
|
|
92
|
+
};
|
|
93
|
+
render(<FieldChangeItem change={change} />);
|
|
94
|
+
const oldEl = screen.getByText('Open');
|
|
95
|
+
expect(oldEl).toHaveClass('line-through');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should use fieldLabel priority over auto-generated label', () => {
|
|
99
|
+
const change: FieldChangeEntry = {
|
|
100
|
+
field: 'first_name',
|
|
101
|
+
fieldLabel: 'Custom Label',
|
|
102
|
+
oldValue: 'A',
|
|
103
|
+
newValue: 'B',
|
|
104
|
+
};
|
|
105
|
+
render(<FieldChangeItem change={change} />);
|
|
106
|
+
expect(screen.getByText('Custom Label')).toBeInTheDocument();
|
|
107
|
+
expect(screen.queryByText('First name')).not.toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should show (empty) for both null old and new values', () => {
|
|
111
|
+
const change: FieldChangeEntry = {
|
|
112
|
+
field: 'notes',
|
|
113
|
+
fieldLabel: 'Notes',
|
|
114
|
+
};
|
|
115
|
+
render(<FieldChangeItem change={change} />);
|
|
116
|
+
const emptyTexts = screen.getAllByText('(empty)');
|
|
117
|
+
expect(emptyTexts).toHaveLength(2);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
11
|
+
import '@testing-library/jest-dom';
|
|
12
|
+
import { MentionAutocomplete, createMentionFromSuggestion } from '../MentionAutocomplete';
|
|
13
|
+
import type { MentionSuggestionItem } from '../MentionAutocomplete';
|
|
14
|
+
|
|
15
|
+
const mockSuggestions: MentionSuggestionItem[] = [
|
|
16
|
+
{ id: 'u1', name: 'Alice Smith', type: 'user' },
|
|
17
|
+
{ id: 'u2', name: 'Bob Johnson', type: 'user', avatarUrl: 'https://example.com/bob.jpg' },
|
|
18
|
+
{ id: 't1', name: 'Engineering', type: 'team' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe('MentionAutocomplete', () => {
|
|
22
|
+
it('should render suggestions when visible', () => {
|
|
23
|
+
const onSelect = vi.fn();
|
|
24
|
+
render(
|
|
25
|
+
<MentionAutocomplete query="" suggestions={mockSuggestions} onSelect={onSelect} visible />,
|
|
26
|
+
);
|
|
27
|
+
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByText('Engineering')).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should not render when not visible', () => {
|
|
33
|
+
const onSelect = vi.fn();
|
|
34
|
+
const { container } = render(
|
|
35
|
+
<MentionAutocomplete query="" suggestions={mockSuggestions} onSelect={onSelect} visible={false} />,
|
|
36
|
+
);
|
|
37
|
+
expect(container.firstChild).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should filter suggestions by query', () => {
|
|
41
|
+
const onSelect = vi.fn();
|
|
42
|
+
render(
|
|
43
|
+
<MentionAutocomplete query="Ali" suggestions={mockSuggestions} onSelect={onSelect} visible />,
|
|
44
|
+
);
|
|
45
|
+
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
|
46
|
+
expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should show type label for non-user types', () => {
|
|
50
|
+
const onSelect = vi.fn();
|
|
51
|
+
render(
|
|
52
|
+
<MentionAutocomplete query="Eng" suggestions={mockSuggestions} onSelect={onSelect} visible />,
|
|
53
|
+
);
|
|
54
|
+
expect(screen.getByText('(team)')).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should call onSelect when a suggestion is clicked', () => {
|
|
58
|
+
const onSelect = vi.fn();
|
|
59
|
+
render(
|
|
60
|
+
<MentionAutocomplete query="" suggestions={mockSuggestions} onSelect={onSelect} visible />,
|
|
61
|
+
);
|
|
62
|
+
fireEvent.mouseDown(screen.getByText('Alice Smith'));
|
|
63
|
+
expect(onSelect).toHaveBeenCalledWith(mockSuggestions[0]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should render avatar image when avatarUrl is present', () => {
|
|
67
|
+
const onSelect = vi.fn();
|
|
68
|
+
render(
|
|
69
|
+
<MentionAutocomplete query="" suggestions={mockSuggestions} onSelect={onSelect} visible />,
|
|
70
|
+
);
|
|
71
|
+
const img = screen.getByAltText('Bob Johnson');
|
|
72
|
+
expect(img).toBeInTheDocument();
|
|
73
|
+
expect(img).toHaveAttribute('src', 'https://example.com/bob.jpg');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should not render when no matching suggestions', () => {
|
|
77
|
+
const onSelect = vi.fn();
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<MentionAutocomplete query="xyz" suggestions={mockSuggestions} onSelect={onSelect} visible />,
|
|
80
|
+
);
|
|
81
|
+
expect(container.firstChild).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('createMentionFromSuggestion', () => {
|
|
86
|
+
it('should create a Mention object from a suggestion item', () => {
|
|
87
|
+
const item: MentionSuggestionItem = { id: 'u1', name: 'Alice', type: 'user' };
|
|
88
|
+
const mention = createMentionFromSuggestion(item, 5, 6);
|
|
89
|
+
expect(mention).toEqual({
|
|
90
|
+
type: 'user',
|
|
91
|
+
id: 'u1',
|
|
92
|
+
name: 'Alice',
|
|
93
|
+
offset: 5,
|
|
94
|
+
length: 6,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|