diff --git a/ROADMAP.md b/ROADMAP.md index e57e93915..1a72d9ef1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1436,6 +1436,41 @@ All 313 `@object-ui/fields` tests pass. --- +## 📦 Detail Page & Related List i18n (P1.15) + +> **Goal:** Salesforce-style detail page enhancements: i18n for all detail page UI elements, improved empty value display, related list actions, and auto-discovery of related lists. + +**i18n Integration:** +- [x] Add `detail.*` translation keys to all 11 locale files (en, zh, ja, de, fr, es, ar, ru, pt, ko) +- [x] `useDetailTranslation` safe wrapper hook with English fallback (follows existing useGridTranslation/useListViewTranslation pattern) +- [x] DetailView fully i18n-integrated (Back, Edit, Share, Delete, Duplicate, Export, View history, Record not found, Related heading, favorites, navigation) +- [x] DetailSection copy tooltip i18n via `useSectionTranslation` +- [x] RelatedList i18n-integrated (record counts, loading, empty state) +- [x] Add `'detail'` to `BUILTIN_KEYS` in `useObjectLabel.ts` to prevent namespace collision + +**Empty Value Display:** +- [x] Replace hardcoded `-` with styled em-dash (`—`) using `text-muted-foreground/50 text-xs italic` for elegant empty state + +**Related List Enhancements:** +- [x] Add `onNew` prop and "New" button to RelatedList header +- [x] Add `onViewAll` prop and "View All" button to RelatedList header +- [x] Record count uses singular/plural i18n keys + +**Tests:** +- [x] 10 new RelatedList tests (title, record counts, empty state, New/View All buttons) +- [x] 2 new DetailView i18n fallback tests (Record not found text, Related heading) +- [x] Updated DetailSection tests for new empty value styling + +**Remaining (future PRs):** +- [ ] Auto-discover related lists from objectSchema reference fields +- [ ] Tab layout (Details/Related/Activity) for detail page +- [ ] Related list row-level Edit/Delete quick actions +- [ ] Related list pagination, sorting, filtering +- [ ] Collapsible section groups +- [ ] Header highlight area with key fields + +--- + ## 📚 Reference - [CONTRIBUTING.md](./CONTRIBUTING.md) — Contribution guidelines diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 7b44db63e..208f630c6 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -131,6 +131,41 @@ const ar = { deleteCard: 'حذف بطاقة', deleteColumn: 'حذف عمود', }, + detail: { + back: 'رجوع', + edit: 'تحرير', + editInline: 'تحرير مباشر', + save: 'حفظ', + saveChanges: 'حفظ التغييرات', + editFieldsInline: 'تحرير الحقول مباشرة', + share: 'مشاركة', + duplicate: 'نسخ', + export: 'تصدير', + viewHistory: 'عرض السجل', + delete: 'حذف', + moreActions: 'المزيد من الإجراءات', + addToFavorites: 'إضافة إلى المفضلة', + removeFromFavorites: 'إزالة من المفضلة', + previousRecord: 'السجل السابق', + nextRecord: 'السجل التالي', + recordOf: '{{current}} من {{total}}', + recordNotFound: 'لم يتم العثور على السجل', + recordNotFoundDescription: 'السجل الذي تبحث عنه غير موجود أو ربما تم حذفه.', + goBack: 'رجوع', + details: 'التفاصيل', + related: 'ذات صلة', + relatedRecords: '{{count}} سجلات', + relatedRecordOne: '{{count}} سجل', + noRelatedRecords: 'لا توجد سجلات ذات صلة', + loading: 'جاري التحميل...', + copyToClipboard: 'نسخ إلى الحافظة', + copied: 'تم النسخ!', + deleteConfirmation: 'هل أنت متأكد أنك تريد حذف هذا السجل؟', + editRecord: 'تحرير السجل', + viewAll: 'عرض الكل', + new: 'جديد', + emptyValue: '—', + }, chart: { noData: 'لا تتوفر بيانات للرسم البياني', loading: 'جاري تحميل الرسم البياني...', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 0ae67cfbc..e4bdd33bf 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -130,6 +130,41 @@ const de = { deleteCard: 'Karte löschen', deleteColumn: 'Spalte löschen', }, + detail: { + back: 'Zurück', + edit: 'Bearbeiten', + editInline: 'Inline bearbeiten', + save: 'Speichern', + saveChanges: 'Änderungen speichern', + editFieldsInline: 'Felder inline bearbeiten', + share: 'Teilen', + duplicate: 'Duplizieren', + export: 'Exportieren', + viewHistory: 'Verlauf anzeigen', + delete: 'Löschen', + moreActions: 'Weitere Aktionen', + addToFavorites: 'Zu Favoriten hinzufügen', + removeFromFavorites: 'Aus Favoriten entfernen', + previousRecord: 'Vorheriger Datensatz', + nextRecord: 'Nächster Datensatz', + recordOf: '{{current}} von {{total}}', + recordNotFound: 'Datensatz nicht gefunden', + recordNotFoundDescription: 'Der gesuchte Datensatz existiert nicht oder wurde möglicherweise gelöscht.', + goBack: 'Zurück', + details: 'Details', + related: 'Verknüpft', + relatedRecords: '{{count}} Datensätze', + relatedRecordOne: '{{count}} Datensatz', + noRelatedRecords: 'Keine verknüpften Datensätze gefunden', + loading: 'Laden...', + copyToClipboard: 'In Zwischenablage kopieren', + copied: 'Kopiert!', + deleteConfirmation: 'Sind Sie sicher, dass Sie diesen Datensatz löschen möchten?', + editRecord: 'Datensatz bearbeiten', + viewAll: 'Alle anzeigen', + new: 'Neu', + emptyValue: '—', + }, chart: { noData: 'Keine Diagrammdaten verfügbar', loading: 'Diagramm wird geladen...', diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 75fca45a1..2247dc148 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -146,6 +146,41 @@ const en = { deleteCard: 'Delete card', deleteColumn: 'Delete column', }, + detail: { + back: 'Back', + edit: 'Edit', + editInline: 'Edit inline', + save: 'Save', + saveChanges: 'Save changes', + editFieldsInline: 'Edit fields inline', + share: 'Share', + duplicate: 'Duplicate', + export: 'Export', + viewHistory: 'View history', + delete: 'Delete', + moreActions: 'More actions', + addToFavorites: 'Add to favorites', + removeFromFavorites: 'Remove from favorites', + previousRecord: 'Previous record', + nextRecord: 'Next record', + recordOf: '{{current}} of {{total}}', + recordNotFound: 'Record not found', + recordNotFoundDescription: 'The record you are looking for does not exist or may have been deleted.', + goBack: 'Go back', + details: 'Details', + related: 'Related', + relatedRecords: '{{count}} records', + relatedRecordOne: '{{count}} record', + noRelatedRecords: 'No related records found', + loading: 'Loading...', + copyToClipboard: 'Copy to clipboard', + copied: 'Copied!', + deleteConfirmation: 'Are you sure you want to delete this record?', + editRecord: 'Edit record', + viewAll: 'View All', + new: 'New', + emptyValue: '—', + }, chart: { noData: 'No chart data available', loading: 'Loading chart...', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 3dc49365b..ce7f9234e 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -130,6 +130,41 @@ const es = { deleteCard: 'Eliminar tarjeta', deleteColumn: 'Eliminar columna', }, + detail: { + back: 'Volver', + edit: 'Editar', + editInline: 'Editar en línea', + save: 'Guardar', + saveChanges: 'Guardar cambios', + editFieldsInline: 'Editar campos en línea', + share: 'Compartir', + duplicate: 'Duplicar', + export: 'Exportar', + viewHistory: 'Ver historial', + delete: 'Eliminar', + moreActions: 'Más acciones', + addToFavorites: 'Añadir a favoritos', + removeFromFavorites: 'Quitar de favoritos', + previousRecord: 'Registro anterior', + nextRecord: 'Siguiente registro', + recordOf: '{{current}} de {{total}}', + recordNotFound: 'Registro no encontrado', + recordNotFoundDescription: 'El registro que busca no existe o puede haber sido eliminado.', + goBack: 'Volver', + details: 'Detalles', + related: 'Relacionados', + relatedRecords: '{{count}} registros', + relatedRecordOne: '{{count}} registro', + noRelatedRecords: 'No se encontraron registros relacionados', + loading: 'Cargando...', + copyToClipboard: 'Copiar al portapapeles', + copied: '¡Copiado!', + deleteConfirmation: '¿Está seguro de que desea eliminar este registro?', + editRecord: 'Editar registro', + viewAll: 'Ver todo', + new: 'Nuevo', + emptyValue: '—', + }, chart: { noData: 'No hay datos de gráfico disponibles', loading: 'Cargando gráfico...', diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 5e9a08cb4..4206eecdd 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -130,6 +130,41 @@ const fr = { deleteCard: 'Supprimer la carte', deleteColumn: 'Supprimer la colonne', }, + detail: { + back: 'Retour', + edit: 'Modifier', + editInline: 'Modifier en ligne', + save: 'Enregistrer', + saveChanges: 'Enregistrer les modifications', + editFieldsInline: 'Modifier les champs en ligne', + share: 'Partager', + duplicate: 'Dupliquer', + export: 'Exporter', + viewHistory: "Voir l'historique", + delete: 'Supprimer', + moreActions: "Plus d'actions", + addToFavorites: 'Ajouter aux favoris', + removeFromFavorites: 'Retirer des favoris', + previousRecord: 'Enregistrement précédent', + nextRecord: 'Enregistrement suivant', + recordOf: '{{current}} sur {{total}}', + recordNotFound: 'Enregistrement introuvable', + recordNotFoundDescription: "L'enregistrement que vous recherchez n'existe pas ou a peut-être été supprimé.", + goBack: 'Retour', + details: 'Détails', + related: 'Associés', + relatedRecords: '{{count}} enregistrements', + relatedRecordOne: '{{count}} enregistrement', + noRelatedRecords: 'Aucun enregistrement associé trouvé', + loading: 'Chargement...', + copyToClipboard: 'Copier dans le presse-papiers', + copied: 'Copié !', + deleteConfirmation: 'Êtes-vous sûr de vouloir supprimer cet enregistrement ?', + editRecord: "Modifier l'enregistrement", + viewAll: 'Tout afficher', + new: 'Nouveau', + emptyValue: '—', + }, chart: { noData: 'Aucune donnée de graphique disponible', loading: 'Chargement du graphique...', diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 7d7e79438..6f4a738f0 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -130,6 +130,41 @@ const ja = { deleteCard: 'カードを削除', deleteColumn: 'カラムを削除', }, + detail: { + back: '戻る', + edit: '編集', + editInline: 'インライン編集', + save: '保存', + saveChanges: '変更を保存', + editFieldsInline: 'フィールドをインライン編集', + share: '共有', + duplicate: '複製', + export: 'エクスポート', + viewHistory: '履歴を表示', + delete: '削除', + moreActions: 'その他の操作', + addToFavorites: 'お気に入りに追加', + removeFromFavorites: 'お気に入りから削除', + previousRecord: '前のレコード', + nextRecord: '次のレコード', + recordOf: '{{current}} / {{total}}', + recordNotFound: 'レコードが見つかりません', + recordNotFoundDescription: 'お探しのレコードは存在しないか、削除された可能性があります。', + goBack: '戻る', + details: '詳細', + related: '関連', + relatedRecords: '{{count}} 件のレコード', + relatedRecordOne: '{{count}} 件のレコード', + noRelatedRecords: '関連レコードが見つかりません', + loading: '読み込み中...', + copyToClipboard: 'クリップボードにコピー', + copied: 'コピーしました!', + deleteConfirmation: 'このレコードを削除してもよろしいですか?', + editRecord: 'レコードを編集', + viewAll: 'すべて表示', + new: '新規', + emptyValue: '—', + }, chart: { noData: 'チャートデータがありません', loading: 'チャート読み込み中...', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index cc0f504d8..0c0a2e278 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -130,6 +130,41 @@ const ko = { deleteCard: '카드 삭제', deleteColumn: '열 삭제', }, + detail: { + back: '뒤로', + edit: '편집', + editInline: '인라인 편집', + save: '저장', + saveChanges: '변경사항 저장', + editFieldsInline: '필드 인라인 편집', + share: '공유', + duplicate: '복제', + export: '내보내기', + viewHistory: '기록 보기', + delete: '삭제', + moreActions: '더 많은 작업', + addToFavorites: '즐겨찾기에 추가', + removeFromFavorites: '즐겨찾기에서 제거', + previousRecord: '이전 레코드', + nextRecord: '다음 레코드', + recordOf: '{{current}} / {{total}}', + recordNotFound: '레코드를 찾을 수 없음', + recordNotFoundDescription: '찾으시는 레코드가 존재하지 않거나 삭제되었을 수 있습니다.', + goBack: '뒤로', + details: '상세정보', + related: '관련', + relatedRecords: '{{count}}개 레코드', + relatedRecordOne: '{{count}}개 레코드', + noRelatedRecords: '관련 레코드를 찾을 수 없습니다', + loading: '로딩 중...', + copyToClipboard: '클립보드에 복사', + copied: '복사됨!', + deleteConfirmation: '이 레코드를 삭제하시겠습니까?', + editRecord: '레코드 편집', + viewAll: '모두 보기', + new: '새로 만들기', + emptyValue: '—', + }, chart: { noData: '차트 데이터가 없습니다', loading: '차트 로딩 중...', diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index c00b1cbd3..5faf0bb4f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -130,6 +130,41 @@ const pt = { deleteCard: 'Excluir cartão', deleteColumn: 'Excluir coluna', }, + detail: { + back: 'Voltar', + edit: 'Editar', + editInline: 'Editar inline', + save: 'Salvar', + saveChanges: 'Salvar alterações', + editFieldsInline: 'Editar campos inline', + share: 'Compartilhar', + duplicate: 'Duplicar', + export: 'Exportar', + viewHistory: 'Ver histórico', + delete: 'Excluir', + moreActions: 'Mais ações', + addToFavorites: 'Adicionar aos favoritos', + removeFromFavorites: 'Remover dos favoritos', + previousRecord: 'Registro anterior', + nextRecord: 'Próximo registro', + recordOf: '{{current}} de {{total}}', + recordNotFound: 'Registro não encontrado', + recordNotFoundDescription: 'O registro que você está procurando não existe ou pode ter sido excluído.', + goBack: 'Voltar', + details: 'Detalhes', + related: 'Relacionados', + relatedRecords: '{{count}} registros', + relatedRecordOne: '{{count}} registro', + noRelatedRecords: 'Nenhum registro relacionado encontrado', + loading: 'Carregando...', + copyToClipboard: 'Copiar para área de transferência', + copied: 'Copiado!', + deleteConfirmation: 'Tem certeza de que deseja excluir este registro?', + editRecord: 'Editar registro', + viewAll: 'Ver tudo', + new: 'Novo', + emptyValue: '—', + }, chart: { noData: 'Nenhum dado de gráfico disponível', loading: 'Carregando gráfico...', diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 67519326c..af6e5b8dd 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -130,6 +130,41 @@ const ru = { deleteCard: 'Удалить карточку', deleteColumn: 'Удалить колонку', }, + detail: { + back: 'Назад', + edit: 'Редактировать', + editInline: 'Редактировать в строке', + save: 'Сохранить', + saveChanges: 'Сохранить изменения', + editFieldsInline: 'Редактировать поля в строке', + share: 'Поделиться', + duplicate: 'Дублировать', + export: 'Экспорт', + viewHistory: 'Просмотр истории', + delete: 'Удалить', + moreActions: 'Другие действия', + addToFavorites: 'Добавить в избранное', + removeFromFavorites: 'Удалить из избранного', + previousRecord: 'Предыдущая запись', + nextRecord: 'Следующая запись', + recordOf: '{{current}} из {{total}}', + recordNotFound: 'Запись не найдена', + recordNotFoundDescription: 'Запись, которую вы ищете, не существует или была удалена.', + goBack: 'Назад', + details: 'Детали', + related: 'Связанные', + relatedRecords: '{{count}} записей', + relatedRecordOne: '{{count}} запись', + noRelatedRecords: 'Связанные записи не найдены', + loading: 'Загрузка...', + copyToClipboard: 'Копировать в буфер обмена', + copied: 'Скопировано!', + deleteConfirmation: 'Вы уверены, что хотите удалить эту запись?', + editRecord: 'Редактировать запись', + viewAll: 'Показать все', + new: 'Создать', + emptyValue: '—', + }, chart: { noData: 'Нет данных для графика', loading: 'Загрузка графика...', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 90b1f0557..950799950 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -146,6 +146,41 @@ const zh = { deleteCard: '删除卡片', deleteColumn: '删除列', }, + detail: { + back: '返回', + edit: '编辑', + editInline: '内联编辑', + save: '保存', + saveChanges: '保存更改', + editFieldsInline: '内联编辑字段', + share: '分享', + duplicate: '复制', + export: '导出', + viewHistory: '查看历史', + delete: '删除', + moreActions: '更多操作', + addToFavorites: '添加到收藏', + removeFromFavorites: '从收藏中移除', + previousRecord: '上一条记录', + nextRecord: '下一条记录', + recordOf: '第 {{current}} 条,共 {{total}} 条', + recordNotFound: '未找到记录', + recordNotFoundDescription: '您查找的记录不存在或已被删除。', + goBack: '返回', + details: '详情', + related: '相关', + relatedRecords: '{{count}} 条记录', + relatedRecordOne: '{{count}} 条记录', + noRelatedRecords: '暂无相关记录', + loading: '加载中...', + copyToClipboard: '复制到剪贴板', + copied: '已复制!', + deleteConfirmation: '确定要删除此记录吗?', + editRecord: '编辑记录', + viewAll: '查看全部', + new: '新建', + emptyValue: '—', + }, chart: { noData: '暂无图表数据', loading: '图表加载中...', diff --git a/packages/i18n/src/useObjectLabel.ts b/packages/i18n/src/useObjectLabel.ts index 993eb4530..1aa5f5a93 100644 --- a/packages/i18n/src/useObjectLabel.ts +++ b/packages/i18n/src/useObjectLabel.ts @@ -28,7 +28,7 @@ import { useObjectTranslation } from './provider'; const BUILTIN_KEYS = new Set([ 'common', 'validation', 'form', 'table', 'grid', 'calendar', 'list', 'kanban', 'chart', 'dashboard', 'configPanel', - 'appDesigner', 'console', 'errors', + 'appDesigner', 'console', 'errors', 'detail', ]); /** diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index 36f1bdded..38106e78a 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -28,6 +28,7 @@ import { SchemaRenderer } from '@object-ui/react'; import { getCellRenderer } from '@object-ui/fields'; import type { DetailViewSection as DetailViewSectionType, DetailViewField, FieldMetadata } from '@object-ui/types'; import { applyDetailAutoLayout } from './autoLayout'; +import { useDetailTranslation } from './useDetailTranslation'; export interface DetailSectionProps { section: DetailViewSectionType; @@ -51,6 +52,7 @@ export const DetailSection: React.FC = ({ }) => { const [isCollapsed, setIsCollapsed] = React.useState(section.defaultCollapsed ?? false); const [copiedField, setCopiedField] = React.useState(null); + const { t } = useDetailTranslation(); const handleCopyField = React.useCallback((fieldName: string, value: any) => { const textValue = value !== null && value !== undefined ? String(value) : ''; @@ -77,7 +79,7 @@ export const DetailSection: React.FC = ({ field.span === 6 ? 'col-span-6' : ''; const displayValue = (() => { - if (value === null || value === undefined) return '-'; + if (value === null || value === undefined) return ; // Enrich field with objectSchema metadata — merge missing properties // even when field.type is explicitly set (e.g., type: 'lookup' without reference_to) const objectDefField = objectSchema?.fields?.[field.name]; @@ -159,7 +161,7 @@ export const DetailSection: React.FC = ({ - {isCopied ? 'Copied!' : 'Copy to clipboard'} + {isCopied ? t('detail.copied') : t('detail.copyToClipboard')} diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx index cd1d16772..e14f2137a 100644 --- a/packages/plugin-detail/src/DetailView.tsx +++ b/packages/plugin-detail/src/DetailView.tsx @@ -45,6 +45,7 @@ import { ActivityTimeline } from './ActivityTimeline'; import { SchemaRenderer } from '@object-ui/react'; import { buildExpandFields } from '@object-ui/core'; import type { DetailViewSchema, DataSource } from '@object-ui/types'; +import { useDetailTranslation } from './useDetailTranslation'; export interface DetailViewProps { schema: DetailViewSchema; @@ -75,6 +76,7 @@ export const DetailView: React.FC = ({ const [isInlineEditing, setIsInlineEditing] = React.useState(false); const [editedValues, setEditedValues] = React.useState>({}); const [objectSchema, setObjectSchema] = React.useState(null); + const { t } = useDetailTranslation(); // Fetch objectSchema + data with $expand when DataSource is provided React.useEffect(() => { @@ -199,7 +201,7 @@ export const DetailView: React.FC = ({ }, [onEdit, schema]); const handleDelete = React.useCallback(() => { - const confirmMessage = schema.deleteConfirmation || 'Are you sure you want to delete this record?'; + const confirmMessage = schema.deleteConfirmation || t('detail.deleteConfirmation'); // Use window.confirm as fallback — the ActionProvider's onConfirm handler // will intercept this if wired up via the action system. if (window.confirm(confirmMessage)) { @@ -215,7 +217,7 @@ export const DetailView: React.FC = ({ // Share functionality - could trigger share dialog or copy link if (navigator.share && schema.objectName && schema.resourceId) { navigator.share({ - title: schema.title || 'Record Details', + title: schema.title || t('detail.details'), text: `${schema.objectName} #${schema.resourceId}`, url: window.location.href, }).catch((err) => console.log('Share failed:', err)); @@ -299,14 +301,14 @@ export const DetailView: React.FC = ({ if (!data && !schema.data) { return (
-

Record not found

+

{t('detail.recordNotFound')}

- The record you are looking for does not exist or may have been deleted. + {t('detail.recordNotFoundDescription')}

{(schema.showBack ?? true) && ( )}
@@ -326,13 +328,13 @@ export const DetailView: React.FC = ({ - Back + {t('detail.back')} )}

- {(schema.primaryField && data?.[schema.primaryField]) || schema.title || 'Details'} + {(schema.primaryField && data?.[schema.primaryField]) || schema.title || t('detail.details')}

{schema.summaryFields?.map((fieldName) => { const val = data?.[fieldName]; @@ -359,7 +361,7 @@ export const DetailView: React.FC = ({ - {isFavorite ? 'Remove from favorites' : 'Add to favorites'} + {isFavorite ? t('detail.removeFromFavorites') : t('detail.addToFavorites')}
@@ -394,10 +396,10 @@ export const DetailView: React.FC = ({ - Previous record + {t('detail.previousRecord')} - {schema.recordNavigation.currentIndex + 1} of {schema.recordNavigation.recordIds.length} + {t('detail.recordOf', { current: schema.recordNavigation.currentIndex + 1, total: schema.recordNavigation.recordIds.length })} @@ -416,7 +418,7 @@ export const DetailView: React.FC = ({ - Next record + {t('detail.nextRecord')}
)} @@ -438,18 +440,18 @@ export const DetailView: React.FC = ({ {isInlineEditing ? ( <> - Save + {t('detail.save')} ) : ( <> - Edit inline + {t('detail.editInline')} )} - {isInlineEditing ? 'Save changes' : 'Edit fields inline'} + {isInlineEditing ? t('detail.saveChanges') : t('detail.editFieldsInline')} )} @@ -461,7 +463,7 @@ export const DetailView: React.FC = ({ - Share + {t('detail.share')} {/* Edit Button */} @@ -470,10 +472,10 @@ export const DetailView: React.FC = ({ - Edit record + {t('detail.editRecord')} )} @@ -487,20 +489,20 @@ export const DetailView: React.FC = ({ - More actions + {t('detail.moreActions')} - Duplicate + {t('detail.duplicate')} - Export + {t('detail.export')} - View history + {t('detail.viewHistory')} {schema.showDelete && ( <> @@ -510,7 +512,7 @@ export const DetailView: React.FC = ({ className="text-destructive focus:text-destructive" > - Delete + {t('detail.delete')} )} @@ -564,7 +566,7 @@ export const DetailView: React.FC = ({ {/* Related Lists */} {schema.related && schema.related.length > 0 && (
-

Related

+

{t('detail.related')}

{schema.related.map((related, index) => ( void; + /** Callback when "View All" button is clicked */ + onViewAll?: () => void; } export const RelatedList: React.FC = ({ @@ -31,9 +37,12 @@ export const RelatedList: React.FC = ({ columns, className, dataSource, + onNew, + onViewAll, }) => { const [relatedData, setRelatedData] = React.useState(data); const [loading, setLoading] = React.useState(false); + const { t } = useDetailTranslation(); React.useEffect(() => { if (api && !data.length) { @@ -90,24 +99,44 @@ export const RelatedList: React.FC = ({ } }, [type, relatedData, columns, schema]); + const recordCountText = relatedData.length === 1 + ? t('detail.relatedRecordOne', { count: relatedData.length }) + : t('detail.relatedRecords', { count: relatedData.length }); + return ( - {title} - - {relatedData.length} record{relatedData.length !== 1 ? 's' : ''} - +
+ {title} + + {recordCountText} + +
+
+ {onNew && ( + + )} + {onViewAll && ( + + )} +
{loading ? (
- Loading... + {t('detail.loading')}
) : relatedData.length === 0 ? (
- No related records found + {t('detail.noRelatedRecords')}
) : ( diff --git a/packages/plugin-detail/src/__tests__/DetailSection.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.test.tsx index bc6d1f071..56a26414a 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.test.tsx @@ -87,7 +87,7 @@ describe('DetailSection', () => { columns: 1, }; render(); - expect(screen.getByText('-')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); }); it('should render section title', () => { @@ -214,7 +214,7 @@ describe('DetailSection', () => { }; render(); expect(screen.getByText('Alice')).toBeInTheDocument(); - expect(screen.getByText('-')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); }); it('should use md: breakpoint for 2-column layouts', () => { diff --git a/packages/plugin-detail/src/__tests__/DetailView.test.tsx b/packages/plugin-detail/src/__tests__/DetailView.test.tsx index 35904cea2..a22432d25 100644 --- a/packages/plugin-detail/src/__tests__/DetailView.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailView.test.tsx @@ -621,4 +621,43 @@ describe('DetailView', () => { const { findByText } = render(); expect(await findByText('Bob')).toBeInTheDocument(); }); + + it('should use i18n fallback for "Record not found" text', async () => { + const mockDataSource = { + findOne: vi.fn().mockResolvedValue(null), + } as any; + + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Contact Details', + objectName: 'contact', + resourceId: 'nonexistent-id', + fields: [{ name: 'name', label: 'Name' }], + }; + + const { findByText } = render(); + // These use the default English translations from useDetailTranslation fallback + expect(await findByText('Record not found')).toBeInTheDocument(); + expect(await findByText('Go back')).toBeInTheDocument(); + }); + + it('should use i18n fallback for related section heading', () => { + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Account Details', + data: { name: 'Acme Corp' }, + fields: [{ name: 'name', label: 'Name' }], + related: [ + { + title: 'Contacts', + type: 'table', + data: [], + }, + ], + }; + + render(); + // The "Related" heading uses t('detail.related') + expect(screen.getByText('Related')).toBeInTheDocument(); + }); }); diff --git a/packages/plugin-detail/src/__tests__/RelatedList.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.test.tsx new file mode 100644 index 000000000..1e8aba0e3 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RelatedList.test.tsx @@ -0,0 +1,66 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RelatedList } from '../RelatedList'; + +describe('RelatedList', () => { + it('should render title', () => { + render(); + expect(screen.getByText('Contacts')).toBeInTheDocument(); + }); + + it('should show record count for empty list', () => { + render(); + expect(screen.getByText('0 records')).toBeInTheDocument(); + }); + + it('should show singular record count for one item', () => { + render(); + expect(screen.getByText('1 record')).toBeInTheDocument(); + }); + + it('should show plural record count for multiple items', () => { + const data = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + render(); + expect(screen.getByText('2 records')).toBeInTheDocument(); + }); + + it('should show "No related records found" for empty data', () => { + render(); + expect(screen.getByText('No related records found')).toBeInTheDocument(); + }); + + it('should render New button when onNew callback is provided', () => { + const onNew = vi.fn(); + render(); + const newButton = screen.getByText('New'); + expect(newButton).toBeInTheDocument(); + fireEvent.click(newButton); + expect(onNew).toHaveBeenCalledTimes(1); + }); + + it('should render View All button when onViewAll callback is provided', () => { + const onViewAll = vi.fn(); + render(); + const viewAllButton = screen.getByText('View All'); + expect(viewAllButton).toBeInTheDocument(); + fireEvent.click(viewAllButton); + expect(onViewAll).toHaveBeenCalledTimes(1); + }); + + it('should not render New or View All buttons when callbacks are not provided', () => { + render(); + expect(screen.queryByText('New')).not.toBeInTheDocument(); + expect(screen.queryByText('View All')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 6b1323817..85e4bffd7 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -15,6 +15,7 @@ import type { DetailViewSchema } from '@object-ui/types'; export { DetailView, DetailSection, DetailTabs, RelatedList }; export { inferDetailColumns, isWideFieldType, applyAutoSpan, applyDetailAutoLayout } from './autoLayout'; +export { useDetailTranslation, DETAIL_DEFAULT_TRANSLATIONS, createSafeTranslationHook } from './useDetailTranslation'; export { RecordComments } from './RecordComments'; export { ActivityTimeline } from './ActivityTimeline'; export { InlineCreateRelated } from './InlineCreateRelated'; diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts new file mode 100644 index 000000000..ddaf79643 --- /dev/null +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -0,0 +1,103 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useObjectTranslation } from '@object-ui/react'; + +/** + * Create a safe translation hook with fallback to defaults. + * Follows the same pattern as useGridTranslation / useListViewTranslation. + * + * @param defaults - Fallback English translations keyed by i18n key + * @param testKey - A key to test if i18n is properly configured + */ +export function createSafeTranslationHook( + defaults: Record, + testKey: string, +) { + return function useSafeTranslation() { + try { + const result = useObjectTranslation(); + const testValue = result.t(testKey); + if (testValue === testKey) { + return { + t: (key: string, options?: Record) => { + let value = defaults[key] || key; + if (options) { + for (const [k, v] of Object.entries(options)) { + value = value.replace(`{{${k}}}`, String(v)); + } + } + return value; + }, + }; + } + return { t: result.t }; + } catch { + return { + t: (key: string, options?: Record) => { + let value = defaults[key] || key; + if (options) { + for (const [k, v] of Object.entries(options)) { + value = value.replace(`{{${k}}}`, String(v)); + } + } + return value; + }, + }; + } + }; +} + +/** + * Default English translations for detail view components. + * Used as fallback when no I18nProvider is available. + */ +export const DETAIL_DEFAULT_TRANSLATIONS: Record = { + 'detail.back': 'Back', + 'detail.edit': 'Edit', + 'detail.editInline': 'Edit inline', + 'detail.save': 'Save', + 'detail.saveChanges': 'Save changes', + 'detail.editFieldsInline': 'Edit fields inline', + 'detail.share': 'Share', + 'detail.duplicate': 'Duplicate', + 'detail.export': 'Export', + 'detail.viewHistory': 'View history', + 'detail.delete': 'Delete', + 'detail.moreActions': 'More actions', + 'detail.addToFavorites': 'Add to favorites', + 'detail.removeFromFavorites': 'Remove from favorites', + 'detail.previousRecord': 'Previous record', + 'detail.nextRecord': 'Next record', + 'detail.recordOf': '{{current}} of {{total}}', + 'detail.recordNotFound': 'Record not found', + 'detail.recordNotFoundDescription': 'The record you are looking for does not exist or may have been deleted.', + 'detail.goBack': 'Go back', + 'detail.details': 'Details', + 'detail.related': 'Related', + 'detail.relatedRecords': '{{count}} records', + 'detail.relatedRecordOne': '{{count}} record', + 'detail.noRelatedRecords': 'No related records found', + 'detail.loading': 'Loading...', + 'detail.copyToClipboard': 'Copy to clipboard', + 'detail.copied': 'Copied!', + 'detail.deleteConfirmation': 'Are you sure you want to delete this record?', + 'detail.editRecord': 'Edit record', + 'detail.viewAll': 'View All', + 'detail.new': 'New', + 'detail.emptyValue': '—', +}; + +/** + * Translation hook for detail view components. + * Falls back to DETAIL_DEFAULT_TRANSLATIONS when no I18nProvider is available. + */ +export const useDetailTranslation = createSafeTranslationHook( + DETAIL_DEFAULT_TRANSLATIONS, + 'detail.back', +);