@medusajs/dashboard 2.12.3-preview-20251217123836 → 2.12.3-snapshot-20251216185234

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.
Files changed (126) hide show
  1. package/dist/{api-key-management-detail-FRUN2KFK.mjs → api-key-management-detail-6RCDH73M.mjs} +1 -1
  2. package/dist/app.css +0 -19
  3. package/dist/app.js +679 -795
  4. package/dist/app.mjs +2 -2
  5. package/dist/{campaign-detail-HM3GQJLQ.mjs → campaign-detail-5Q4BYCPX.mjs} +1 -1
  6. package/dist/{categories-metadata-WKL3MGD7.mjs → categories-metadata-J7M3XWI7.mjs} +1 -1
  7. package/dist/{category-detail-UTWWDKFP.mjs → category-detail-S5IPXMHX.mjs} +2 -2
  8. package/dist/{category-products-XXBTCXFF.mjs → category-products-KPW6BA5J.mjs} +2 -2
  9. package/dist/{chunk-UMCJYHAD.mjs → chunk-CVHJAKLQ.mjs} +1 -1
  10. package/dist/{chunk-KSDXSKJ7.mjs → chunk-DBXWB3RF.mjs} +1 -1
  11. package/dist/{chunk-GLBHPDR4.mjs → chunk-GRZSG4EP.mjs} +16 -21
  12. package/dist/{chunk-FKNW5MLZ.mjs → chunk-LZFWCKOF.mjs} +4 -21
  13. package/dist/{chunk-5ISRTMYH.mjs → chunk-MJDHVDOW.mjs} +1 -1
  14. package/dist/{chunk-5F427YCP.mjs → chunk-OL24RDYM.mjs} +2 -2
  15. package/dist/{chunk-VFF5WB7C.mjs → chunk-OL6MEUKW.mjs} +104 -108
  16. package/dist/{chunk-OK6NZN2A.mjs → chunk-PHLCT2HA.mjs} +1 -1
  17. package/dist/{chunk-DQUXK4WW.mjs → chunk-ST4P6BQN.mjs} +1 -1
  18. package/dist/{chunk-HNJ65IND.mjs → chunk-WYATCUOM.mjs} +1 -1
  19. package/dist/{chunk-DTCIBQO2.mjs → chunk-YYOPBKME.mjs} +1 -1
  20. package/dist/{chunk-SG2JZPTG.mjs → chunk-ZMG5B4FG.mjs} +1 -1
  21. package/dist/{collection-add-products-42F7H77E.mjs → collection-add-products-FU2BS3D3.mjs} +2 -2
  22. package/dist/{collection-detail-PXIS3G64.mjs → collection-detail-VJE7XHLV.mjs} +2 -2
  23. package/dist/{collection-list-O74CGY24.mjs → collection-list-IGA6SCNF.mjs} +2 -2
  24. package/dist/{collection-metadata-U6FMA4IC.mjs → collection-metadata-QK7MI3D2.mjs} +1 -1
  25. package/dist/{customer-detail-OMTFJ6CE.mjs → customer-detail-MOV2T3LF.mjs} +1 -1
  26. package/dist/{customer-group-detail-ADK3M5LG.mjs → customer-group-detail-6T7OXGQD.mjs} +1 -1
  27. package/dist/{customer-group-list-7ZRQ2HWU.mjs → customer-group-list-AJEAF5D2.mjs} +1 -1
  28. package/dist/{customers-add-customer-group-5U27WHJB.mjs → customers-add-customer-group-QVTVSQYM.mjs} +1 -1
  29. package/dist/{edit-rules-BM2ERGVJ.mjs → edit-rules-SMVRTCUP.mjs} +1 -1
  30. package/dist/en.json +1 -1
  31. package/dist/{inventory-create-7MA7B5N2.mjs → inventory-create-BK52VALF.mjs} +2 -2
  32. package/dist/{inventory-detail-B4PRHZK3.mjs → inventory-detail-ZPSEMYI2.mjs} +1 -1
  33. package/dist/{inventory-metadata-C7MJ3GY5.mjs → inventory-metadata-FNEJ3RAT.mjs} +1 -1
  34. package/dist/{inventory-stock-WVTYPJTX.mjs → inventory-stock-6WYWLWJ7.mjs} +3 -3
  35. package/dist/{location-detail-KO6EBDK5.mjs → location-detail-N3GUZSY7.mjs} +1 -1
  36. package/dist/{location-fulfillment-providers-IORBE3E3.mjs → location-fulfillment-providers-7ZUJAGNY.mjs} +2 -2
  37. package/dist/{location-service-zone-shipping-option-create-2R3ZFLVK.mjs → location-service-zone-shipping-option-create-CNRWYZQC.mjs} +3 -3
  38. package/dist/{location-service-zone-shipping-option-pricing-5HN2Z5RB.mjs → location-service-zone-shipping-option-pricing-OGWI7VPT.mjs} +2 -2
  39. package/dist/{login-XKB6OR7I.mjs → login-VNOLI5YG.mjs} +1 -1
  40. package/dist/{order-create-claim-NKCOGF4A.mjs → order-create-claim-SCDJGM46.mjs} +1 -1
  41. package/dist/{order-create-edit-UNQYXGLL.mjs → order-create-edit-2WALBPXS.mjs} +1 -1
  42. package/dist/{order-create-exchange-WI7OA2WO.mjs → order-create-exchange-LQU4YN7F.mjs} +1 -1
  43. package/dist/{order-create-fulfillment-2LJTEWDY.mjs → order-create-fulfillment-OWUVTZXW.mjs} +1 -1
  44. package/dist/{order-create-refund-7K6UJXGP.mjs → order-create-refund-Q6HQY42R.mjs} +1 -1
  45. package/dist/{order-create-shipment-ZTDLLUBY.mjs → order-create-shipment-WAGGEPRW.mjs} +1 -1
  46. package/dist/{order-detail-JTRUMRLO.mjs → order-detail-PVPGEWGY.mjs} +1 -1
  47. package/dist/{order-edit-billing-address-YHYNVLOE.mjs → order-edit-billing-address-UM76J4KX.mjs} +1 -1
  48. package/dist/{order-edit-email-TCQPEVZY.mjs → order-edit-email-CL3KNOCM.mjs} +1 -1
  49. package/dist/{order-edit-shipping-address-CFSYQLKD.mjs → order-edit-shipping-address-PIESTGVL.mjs} +1 -1
  50. package/dist/{order-export-G4SBNEJ7.mjs → order-export-LE363ZLB.mjs} +1 -1
  51. package/dist/{order-metadata-KGPB37VL.mjs → order-metadata-FHBB7MTG.mjs} +1 -1
  52. package/dist/{order-receive-return-JER24SEV.mjs → order-receive-return-PRVKP6J2.mjs} +1 -1
  53. package/dist/{order-request-transfer-3FBUYZNT.mjs → order-request-transfer-XSAGRUMT.mjs} +1 -1
  54. package/dist/{price-list-create-CXZCFFTP.mjs → price-list-create-K5JEZT57.mjs} +4 -4
  55. package/dist/{price-list-detail-XOMU6U5J.mjs → price-list-detail-Q5VG5VGW.mjs} +2 -2
  56. package/dist/{price-list-prices-add-SDX5CQME.mjs → price-list-prices-add-2MQ226U4.mjs} +4 -4
  57. package/dist/{price-list-prices-edit-EKB6NI5D.mjs → price-list-prices-edit-OJZLV7OS.mjs} +2 -2
  58. package/dist/{product-attributes-MXDPSOWM.mjs → product-attributes-YF4TZOIO.mjs} +2 -2
  59. package/dist/{product-create-3O34JJLS.mjs → product-create-KJML2332.mjs} +3 -3
  60. package/dist/{product-create-variant-OTJKT6WI.mjs → product-create-variant-5EBCLM54.mjs} +2 -2
  61. package/dist/{product-detail-SYTLG5D3.mjs → product-detail-QG72542C.mjs} +2 -2
  62. package/dist/{product-edit-W72S22NM.mjs → product-edit-DZZR775Q.mjs} +2 -2
  63. package/dist/{product-export-57UUAGXF.mjs → product-export-5AD7NELI.mjs} +2 -2
  64. package/dist/{product-image-variants-edit-2BW5BJON.mjs → product-image-variants-edit-M6QF2RLE.mjs} +1 -1
  65. package/dist/{product-import-6EM4VUXP.mjs → product-import-V3KQN4TV.mjs} +1 -1
  66. package/dist/{product-list-5V5GEH5K.mjs → product-list-EUWZIFTM.mjs} +2 -2
  67. package/dist/{product-metadata-JZLHBLZQ.mjs → product-metadata-GL2MVPDI.mjs} +1 -1
  68. package/dist/{product-organization-SVXTCWIF.mjs → product-organization-O7RHELMQ.mjs} +2 -2
  69. package/dist/{product-prices-5ZL2RP7A.mjs → product-prices-YWV6MSM6.mjs} +1 -1
  70. package/dist/{product-stock-SJJABF6I.mjs → product-stock-AKEFMK5O.mjs} +3 -3
  71. package/dist/{product-tag-create-XXO4AQEC.mjs → product-tag-create-PQMDDKWH.mjs} +1 -1
  72. package/dist/{product-tag-detail-BSK64HXL.mjs → product-tag-detail-I3MBZX7U.mjs} +3 -3
  73. package/dist/{product-tag-edit-ENCGDT7E.mjs → product-tag-edit-K3BBQLJR.mjs} +1 -1
  74. package/dist/{product-tag-list-SLQGCNDZ.mjs → product-tag-list-JUWSOMB7.mjs} +3 -3
  75. package/dist/{product-tag-metadata-EPXHMU2K.mjs → product-tag-metadata-MJH5LH7E.mjs} +1 -1
  76. package/dist/{product-type-detail-4CRRU7YK.mjs → product-type-detail-RKHT5NBL.mjs} +2 -2
  77. package/dist/{product-type-metadata-73OKOGPP.mjs → product-type-metadata-CDJDFFGQ.mjs} +1 -1
  78. package/dist/{product-variant-detail-RPHLG4HU.mjs → product-variant-detail-XAYG5CKE.mjs} +1 -1
  79. package/dist/{product-variant-edit-JF7NN64Y.mjs → product-variant-edit-DEZEY2H2.mjs} +1 -1
  80. package/dist/{product-variant-metadata-HU2CXGPO.mjs → product-variant-metadata-VTZDNWUT.mjs} +1 -1
  81. package/dist/{promotion-create-BHA3FQG2.mjs → promotion-create-HWFNUQXG.mjs} +1 -1
  82. package/dist/{promotion-detail-F3QSR52W.mjs → promotion-detail-QC36KXB3.mjs} +1 -1
  83. package/dist/{refund-reason-create-ZA5TKW2Z.mjs → refund-reason-create-YHCDEHGQ.mjs} +1 -1
  84. package/dist/{refund-reason-edit-N2CRCLKZ.mjs → refund-reason-edit-CZ5QZ2SZ.mjs} +1 -1
  85. package/dist/{refund-reason-list-SE4TMGMT.mjs → refund-reason-list-OJYYEYJE.mjs} +1 -1
  86. package/dist/{region-metadata-O5NZBWXP.mjs → region-metadata-H6XXUQ4S.mjs} +1 -1
  87. package/dist/{reservation-detail-UFK6XIXE.mjs → reservation-detail-LZAQL4XA.mjs} +1 -1
  88. package/dist/{reservation-metadata-AEJEKGLV.mjs → reservation-metadata-5HZSDDOK.mjs} +1 -1
  89. package/dist/{sales-channel-add-products-2LMB7EF5.mjs → sales-channel-add-products-F7YV4MO5.mjs} +2 -2
  90. package/dist/{sales-channel-detail-EUQ4STQI.mjs → sales-channel-detail-MXIPZCGA.mjs} +2 -2
  91. package/dist/{sales-channel-list-JXKGHX4G.mjs → sales-channel-list-RLGL7FM3.mjs} +1 -1
  92. package/dist/{sales-channel-metadata-AJMQ5SQ2.mjs → sales-channel-metadata-M364R4RJ.mjs} +1 -1
  93. package/dist/{shipping-option-type-create-YVVIA2XC.mjs → shipping-option-type-create-C5WUWON7.mjs} +1 -1
  94. package/dist/{shipping-option-type-detail-ZZW36XLK.mjs → shipping-option-type-detail-PENS2K73.mjs} +2 -2
  95. package/dist/{shipping-option-type-edit-O6F74T3A.mjs → shipping-option-type-edit-CIU5EHRP.mjs} +1 -1
  96. package/dist/{shipping-option-type-list-SPTE7MT6.mjs → shipping-option-type-list-DIOX7VG7.mjs} +2 -2
  97. package/dist/{shipping-profile-metadata-7WFE55VG.mjs → shipping-profile-metadata-75G2NNMA.mjs} +1 -1
  98. package/dist/{chunk-IKTGFXWR.mjs → store-add-locales-VJ4RJ7UI.mjs} +67 -2
  99. package/dist/{store-detail-YLJLBBZE.mjs → store-detail-JSNPOB2F.mjs} +1 -1
  100. package/dist/{store-metadata-BZ57I2E6.mjs → store-metadata-CYXTVJUE.mjs} +1 -1
  101. package/dist/{tax-region-create-FGTV7VJL.mjs → tax-region-create-DWGL4EUT.mjs} +1 -1
  102. package/dist/{tax-region-detail-PPIMD7OX.mjs → tax-region-detail-2AE2EFI3.mjs} +5 -5
  103. package/dist/{tax-region-edit-ELZKA7YH.mjs → tax-region-edit-EEVEEU2Q.mjs} +1 -1
  104. package/dist/{tax-region-province-detail-FV2NDT3E.mjs → tax-region-province-detail-4ERSEQFF.mjs} +5 -5
  105. package/dist/{tax-region-tax-override-create-N572MQPZ.mjs → tax-region-tax-override-create-PHCGEF7V.mjs} +3 -3
  106. package/dist/{tax-region-tax-override-edit-5DCSJW6D.mjs → tax-region-tax-override-edit-SMRPSILC.mjs} +4 -4
  107. package/dist/{translation-list-FK7XYLHX.mjs → translation-list-UF7FLXOW.mjs} +141 -227
  108. package/dist/{translations-edit-VRXZI5KW.mjs → translations-edit-USQJNMAY.mjs} +253 -224
  109. package/dist/{user-metadata-GRJZZ524.mjs → user-metadata-2WPJOEJA.mjs} +1 -1
  110. package/dist/{workflow-execution-detail-HXTFWGKG.mjs → workflow-execution-detail-H2AKEZJX.mjs} +1 -1
  111. package/package.json +9 -9
  112. package/src/components/data-grid/hooks/use-data-grid-cell.tsx +0 -1
  113. package/src/components/data-grid/hooks/use-data-grid-form-handlers.tsx +0 -1
  114. package/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx +4 -22
  115. package/src/dashboard-app/routes/get-route.map.tsx +0 -4
  116. package/src/hooks/api/translations.tsx +17 -26
  117. package/src/i18n/translations/en.json +1 -1
  118. package/src/i18n/translations/es.json +1 -1
  119. package/src/routes/translations/translation-list/components/active-locales-section/active-locales-section.tsx +17 -42
  120. package/src/routes/translations/translation-list/components/translation-list-section/translation-list-section.tsx +1 -5
  121. package/src/routes/translations/translation-list/components/translations-completion-section/translations-completion-section.tsx +121 -182
  122. package/src/routes/translations/translations-edit/components/translations-edit-form/translations-edit-form.tsx +330 -285
  123. package/dist/add-locales-GGNZCABB.mjs +0 -81
  124. package/dist/store-add-locales-GWCGIXHU.mjs +0 -81
  125. package/src/routes/translations/add-locales/add-locales.tsx +0 -29
  126. package/src/routes/translations/add-locales/index.tsx +0 -1
@@ -18,17 +18,38 @@ import {
18
18
  import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
19
19
  import { useBatchTranslations } from "../../../../../hooks/api/translations"
20
20
 
21
- const EntityTranslationsSchema = z.object({
21
+ /**
22
+ * Schema for a single locale translation.
23
+ */
24
+ const LocaleTranslationSchema = z.object({
22
25
  id: z.string().nullish(),
26
+ locale_code: z.string(),
23
27
  fields: z.record(z.string().optional()),
24
28
  })
29
+ export type LocaleTranslationSchema = z.infer<typeof LocaleTranslationSchema>
30
+
31
+ /**
32
+ * Schema for an entity's translations (parent row in DataGrid).
33
+ * Contains all locale translations for that entity.
34
+ */
35
+ const EntityTranslationsSchema = z.object({
36
+ locales: z.record(LocaleTranslationSchema),
37
+ })
25
38
  export type EntityTranslationsSchema = z.infer<typeof EntityTranslationsSchema>
26
39
 
40
+ /**
41
+ * Form schema
42
+ * Maps each reference_id to their corresponding translations for each locale
43
+ */
27
44
  export const TranslationsFormSchema = z.object({
28
45
  entities: z.record(EntityTranslationsSchema),
29
46
  })
30
47
  export type TranslationsFormSchema = z.infer<typeof TranslationsFormSchema>
31
48
 
49
+ /**
50
+ * Row types for the DataGrid.
51
+ * Parent rows are entities, subrows are translatable fields.
52
+ */
32
53
  export type TranslationRow = EntityRow | FieldRow
33
54
 
34
55
  export type EntityRow = {
@@ -51,94 +72,150 @@ export function isFieldRow(row: TranslationRow): row is FieldRow {
51
72
  return row._type === "field"
52
73
  }
53
74
 
54
- type LocaleSnapshot = {
55
- localeCode: string
56
- entities: Record<string, EntityTranslationsSchema>
57
- }
58
-
59
- function buildLocaleSnapshot(
75
+ function initTranslationsFormState(
60
76
  translations: HttpTypes.AdminTranslation[],
61
77
  references: { id: string; [key: string]: string }[],
62
- localeCode: string,
78
+ availableLocales: AdminStoreLocale[],
63
79
  translatableFields: string[]
64
- ): LocaleSnapshot {
65
- const referenceTranslations = new Map<string, HttpTypes.AdminTranslation>()
80
+ ): TranslationsFormSchema {
81
+ const existingMap = new Map<string, HttpTypes.AdminTranslation>()
66
82
  for (const t of translations) {
67
- if (t.locale_code === localeCode) {
68
- referenceTranslations.set(t.reference_id, t)
69
- }
83
+ existingMap.set(`${t.reference_id}:${t.locale_code}`, t)
70
84
  }
71
85
 
72
- const entities: Record<string, EntityTranslationsSchema> = {}
73
- for (const ref of references) {
74
- const existing = referenceTranslations.get(ref.id)
75
- const fields: Record<string, string> = {}
86
+ const entitiesTranslationState: Record<string, EntityTranslationsSchema> = {}
76
87
 
77
- for (const fieldName of translatableFields) {
78
- fields[fieldName] = (existing?.translations?.[fieldName] as string) ?? ""
79
- }
88
+ for (const reference of references) {
89
+ const locales: Record<string, LocaleTranslationSchema> = {}
90
+
91
+ for (const locale of availableLocales) {
92
+ const key = `${reference.id}:${locale.locale_code}`
93
+ const existing = existingMap.get(key)
94
+
95
+ const fields: Record<string, string> = {}
96
+ for (const fieldName of translatableFields) {
97
+ const fieldValue = (existing?.translations?.[fieldName] as string) ?? ""
98
+ fields[fieldName] = fieldValue
99
+ }
80
100
 
81
- entities[ref.id] = {
82
- id: existing?.id ?? null,
83
- fields,
101
+ locales[locale.locale_code] = {
102
+ id: existing?.id ?? null,
103
+ locale_code: locale.locale_code,
104
+ fields,
105
+ }
84
106
  }
107
+
108
+ entitiesTranslationState[reference.id] = { locales }
85
109
  }
86
110
 
87
- return { localeCode, entities }
111
+ return {
112
+ entities: entitiesTranslationState,
113
+ }
88
114
  }
89
115
 
90
- function extendSnapshot(
91
- snapshot: LocaleSnapshot,
92
- translations: HttpTypes.AdminTranslation[],
93
- newReferences: { id: string; [key: string]: string }[],
116
+ function buildTranslationRows(
117
+ references: { id: string; [key: string]: string }[],
94
118
  translatableFields: string[]
95
- ): LocaleSnapshot {
96
- const referenceTranslations = new Map<string, HttpTypes.AdminTranslation>()
97
- for (const t of translations) {
98
- if (t.locale_code === snapshot.localeCode) {
99
- referenceTranslations.set(t.reference_id, t)
100
- }
101
- }
102
-
103
- const extendedEntities = { ...snapshot.entities }
119
+ ): TranslationRow[] {
120
+ return references.map((reference) => ({
121
+ _type: "entity" as const,
122
+ reference_id: reference.id,
123
+ subRows: translatableFields.map((fieldName) => ({
124
+ _type: "field" as const,
125
+ reference_id: reference.id,
126
+ field_name: fieldName,
127
+ })),
128
+ }))
129
+ }
104
130
 
105
- for (const ref of newReferences) {
106
- if (!extendedEntities[ref.id]) {
107
- const existing = referenceTranslations.get(ref.id)
108
- const fields: Record<string, string> = {}
131
+ function transformToBatchPayload(
132
+ currentState: TranslationsFormSchema,
133
+ initialState: TranslationsFormSchema,
134
+ entityType: string
135
+ ): Required<HttpTypes.AdminBatchTranslations> {
136
+ const payload: Required<HttpTypes.AdminBatchTranslations> = {
137
+ create: [],
138
+ update: [],
139
+ delete: [],
140
+ }
109
141
 
110
- for (const fieldName of translatableFields) {
111
- fields[fieldName] =
112
- (existing?.translations?.[fieldName] as string) ?? ""
113
- }
142
+ for (const [entityId, entityData] of Object.entries(currentState.entities)) {
143
+ for (const [localeCode, localeTranslations] of Object.entries(
144
+ entityData.locales
145
+ )) {
146
+ const initial = initialState.entities[entityId]?.locales[localeCode]
147
+ const hasContent = Object.values(localeTranslations.fields).some(
148
+ (v) => v !== undefined && v.trim() !== ""
149
+ )
150
+ const hadContent =
151
+ initial &&
152
+ Object.values(initial.fields).some(
153
+ (v) => v !== undefined && v.trim() !== ""
154
+ )
114
155
 
115
- extendedEntities[ref.id] = {
116
- id: existing?.id ?? null,
117
- fields,
156
+ if (!localeTranslations.id && hasContent) {
157
+ payload.create.push({
158
+ reference_id: entityId,
159
+ reference: entityType,
160
+ locale_code: localeTranslations.locale_code,
161
+ translations: localeTranslations.fields,
162
+ })
163
+ } else if (localeTranslations.id && hasContent) {
164
+ // UPDATE: Has ID and has content - check if changed
165
+ const hasChanged =
166
+ !initial ||
167
+ JSON.stringify(localeTranslations.fields) !==
168
+ JSON.stringify(initial.fields)
169
+
170
+ if (hasChanged) {
171
+ payload.update.push({
172
+ id: localeTranslations.id,
173
+ translations: localeTranslations.fields,
174
+ })
175
+ }
176
+ } else if (localeTranslations.id && !hasContent && hadContent) {
177
+ payload.delete.push(localeTranslations.id)
118
178
  }
119
179
  }
120
180
  }
121
181
 
122
- return { ...snapshot, entities: extendedEntities }
182
+ return payload
123
183
  }
124
184
 
125
- function snapshotToFormValues(
126
- snapshot: LocaleSnapshot
127
- ): TranslationsFormSchema {
128
- return { entities: snapshot.entities }
129
- }
185
+ function hasLocaleChanges(
186
+ currentState: TranslationsFormSchema,
187
+ initialState: TranslationsFormSchema,
188
+ localeCode: string
189
+ ): boolean {
190
+ for (const [entityId, entityData] of Object.entries(currentState.entities)) {
191
+ const currentLocale = entityData.locales[localeCode]
192
+ const initialLocale = initialState.entities[entityId]?.locales[localeCode]
193
+
194
+ if (!currentLocale || !initialLocale) {
195
+ continue
196
+ }
197
+
198
+ for (const [fieldName, fieldValue] of Object.entries(
199
+ currentLocale.fields
200
+ )) {
201
+ const initialValue = initialLocale.fields[fieldName] ?? ""
202
+ const currentValue = fieldValue ?? ""
130
203
 
131
- type ChangeDetectionResult = {
132
- hasChanges: boolean
133
- payload: Required<HttpTypes.AdminBatchTranslations>
204
+ if (currentValue !== initialValue) {
205
+ return true
206
+ }
207
+ }
208
+ }
209
+
210
+ return false
134
211
  }
135
212
 
136
- function computeChanges(
213
+ function transformSingleLocaleToBatchPayload(
137
214
  currentState: TranslationsFormSchema,
138
- snapshot: LocaleSnapshot,
215
+ initialState: TranslationsFormSchema,
139
216
  entityType: string,
140
217
  localeCode: string
141
- ): ChangeDetectionResult {
218
+ ): Required<HttpTypes.AdminBatchTranslations> {
142
219
  const payload: Required<HttpTypes.AdminBatchTranslations> = {
143
220
  create: [],
144
221
  update: [],
@@ -146,43 +223,44 @@ function computeChanges(
146
223
  }
147
224
 
148
225
  for (const [entityId, entityData] of Object.entries(currentState.entities)) {
149
- const baseline = snapshot.entities[entityId]
150
- if (!baseline) {
151
- continue
152
- }
226
+ const localeTranslations = entityData.locales[localeCode]
227
+ if (!localeTranslations) continue
153
228
 
154
- const hasContent = Object.values(entityData.fields).some(
155
- (v) => v !== undefined && v.trim() !== ""
156
- )
157
- const hadContent = Object.values(baseline.fields).some(
229
+ const initial = initialState.entities[entityId]?.locales[localeCode]
230
+ const hasContent = Object.values(localeTranslations.fields).some(
158
231
  (v) => v !== undefined && v.trim() !== ""
159
232
  )
160
- const hasChanged =
161
- JSON.stringify(entityData.fields) !== JSON.stringify(baseline.fields)
233
+ const hadContent =
234
+ initial &&
235
+ Object.values(initial.fields).some(
236
+ (v) => v !== undefined && v.trim() !== ""
237
+ )
162
238
 
163
- if (!entityData.id && hasContent) {
239
+ if (!localeTranslations.id && hasContent) {
164
240
  payload.create.push({
165
241
  reference_id: entityId,
166
242
  reference: entityType,
167
- locale_code: localeCode,
168
- translations: entityData.fields,
243
+ locale_code: localeTranslations.locale_code,
244
+ translations: localeTranslations.fields,
169
245
  })
170
- } else if (entityData.id && hasContent && hasChanged) {
171
- payload.update.push({
172
- id: entityData.id,
173
- translations: entityData.fields,
174
- })
175
- } else if (entityData.id && !hasContent && hadContent) {
176
- payload.delete.push(entityData.id)
246
+ } else if (localeTranslations.id && hasContent) {
247
+ const hasChanged =
248
+ !initial ||
249
+ JSON.stringify(localeTranslations.fields) !==
250
+ JSON.stringify(initial.fields)
251
+
252
+ if (hasChanged) {
253
+ payload.update.push({
254
+ id: localeTranslations.id,
255
+ translations: localeTranslations.fields,
256
+ })
257
+ }
258
+ } else if (localeTranslations.id && !hasContent && hadContent) {
259
+ payload.delete.push(localeTranslations.id)
177
260
  }
178
261
  }
179
262
 
180
- const hasChanges =
181
- payload.create.length > 0 ||
182
- payload.update.length > 0 ||
183
- payload.delete.length > 0
184
-
185
- return { hasChanges, payload }
263
+ return payload
186
264
  }
187
265
 
188
266
  const columnHelper = createDataGridHelper<
@@ -190,42 +268,29 @@ const columnHelper = createDataGridHelper<
190
268
  TranslationsFormSchema
191
269
  >()
192
270
 
193
- const FIELD_COLUMN_WIDTH = 350
194
-
195
- function buildTranslationRows(
196
- references: { id: string; [key: string]: string }[],
197
- translatableFields: string[]
198
- ): TranslationRow[] {
199
- return references.map((reference) => ({
200
- _type: "entity" as const,
201
- reference_id: reference.id,
202
- subRows: translatableFields.map((fieldName) => ({
203
- _type: "field" as const,
204
- reference_id: reference.id,
205
- field_name: fieldName,
206
- })),
207
- }))
208
- }
271
+ const FIELD_COLUMN_WIDTH = 150
209
272
 
210
273
  function useTranslationsGridColumns({
211
274
  entities,
275
+ translatableFields,
212
276
  availableLocales,
213
277
  selectedLocale,
214
278
  dynamicColumnWidth,
215
279
  }: {
216
280
  entities: { id: string; [key: string]: string }[]
281
+ translatableFields: string[]
217
282
  availableLocales: AdminStoreLocale[]
218
283
  selectedLocale: string
219
284
  dynamicColumnWidth: number
220
285
  }) {
221
286
  const { t } = useTranslation()
222
287
 
223
- return useMemo(() => {
288
+ const columns: ColumnDef<TranslationRow>[] = useMemo(() => {
224
289
  const selectedLocaleData = availableLocales.find(
225
290
  (l) => l.locale_code === selectedLocale
226
291
  )
227
292
 
228
- const columns: ColumnDef<TranslationRow>[] = [
293
+ const baseColumns = [
229
294
  columnHelper.column({
230
295
  id: "field",
231
296
  name: "field",
@@ -235,7 +300,9 @@ function useTranslationsGridColumns({
235
300
  const row = context.row.original
236
301
 
237
302
  if (isEntityRow(row)) {
238
- return <DataGrid.ReadonlyCell context={context} />
303
+ return (
304
+ <DataGrid.ReadonlyCell context={context}></DataGrid.ReadonlyCell>
305
+ )
239
306
  }
240
307
 
241
308
  return (
@@ -270,10 +337,14 @@ function useTranslationsGridColumns({
270
337
  const row = context.row.original
271
338
 
272
339
  if (isEntityRow(row)) {
273
- return <DataGrid.ReadonlyCell context={context} />
340
+ return (
341
+ <DataGrid.ReadonlyCell context={context}></DataGrid.ReadonlyCell>
342
+ )
274
343
  }
275
344
 
276
- const entity = entities.find((e) => e.id === row.reference_id)
345
+ const entity = entities.find(
346
+ (entity) => entity.id === row.reference_id
347
+ )
277
348
  if (!entity) {
278
349
  return null
279
350
  }
@@ -290,7 +361,7 @@ function useTranslationsGridColumns({
290
361
  ]
291
362
 
292
363
  if (selectedLocaleData) {
293
- columns.push(
364
+ baseColumns.push(
294
365
  columnHelper.column({
295
366
  id: selectedLocaleData.locale_code,
296
367
  name: selectedLocaleData.locale.name,
@@ -316,15 +387,24 @@ function useTranslationsGridColumns({
316
387
  return null
317
388
  }
318
389
 
319
- return `entities.${row.reference_id}.fields.${row.field_name}`
390
+ return `entities.${row.reference_id}.locales.${selectedLocaleData.locale_code}.fields.${row.field_name}`
320
391
  },
321
392
  type: "multiline-text",
322
393
  })
323
394
  )
324
395
  }
325
396
 
326
- return columns
327
- }, [t, availableLocales, selectedLocale, entities, dynamicColumnWidth])
397
+ return baseColumns
398
+ }, [
399
+ t,
400
+ translatableFields,
401
+ availableLocales,
402
+ selectedLocale,
403
+ entities,
404
+ dynamicColumnWidth,
405
+ ])
406
+
407
+ return columns
328
408
  }
329
409
 
330
410
  type TranslationsEditFormProps = {
@@ -379,105 +459,74 @@ export const TranslationsEditForm = ({
379
459
  const [selectedLocale, setSelectedLocale] = useState<string>(
380
460
  availableLocales[0]?.locale_code ?? ""
381
461
  )
462
+
382
463
  const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false)
383
464
  const [pendingLocale, setPendingLocale] = useState<string | null>(null)
384
- const [isSwitchingLocale, setIsSwitchingLocale] = useState(false)
385
465
 
386
- const snapshotRef = useRef<LocaleSnapshot>(
387
- buildLocaleSnapshot(
466
+ const entities = useMemo(() => references, [references])
467
+ const totalCount = useMemo(
468
+ () => referenceCount * (translatableFields.length + 1),
469
+ [referenceCount, translatableFields]
470
+ )
471
+
472
+ const initialState = useRef(
473
+ initTranslationsFormState(
388
474
  translations,
389
- references,
390
- selectedLocale,
475
+ entities,
476
+ availableLocales,
391
477
  translatableFields
392
478
  )
393
479
  )
394
480
 
395
- const knownEntityIdsRef = useRef<Set<string>>(
396
- new Set(references.map((r) => r.id))
397
- )
398
-
399
- const latestPropsRef = useRef({ translations, references })
400
- useEffect(() => {
401
- latestPropsRef.current = { translations, references }
402
- }, [translations, references])
403
-
404
481
  const form = useForm<TranslationsFormSchema>({
405
482
  resolver: zodResolver(TranslationsFormSchema),
406
- defaultValues: snapshotToFormValues(snapshotRef.current),
483
+ defaultValues: initialState.current,
407
484
  })
408
485
 
409
- useEffect(() => {
410
- const currentIds = new Set(references.map((r) => r.id))
411
- const newReferences = references.filter(
412
- (r) => !knownEntityIdsRef.current.has(r.id)
413
- )
414
-
415
- if (newReferences.length === 0) {
416
- return
417
- }
418
-
419
- knownEntityIdsRef.current = currentIds
420
- snapshotRef.current = extendSnapshot(
421
- snapshotRef.current,
422
- translations,
423
- newReferences,
424
- translatableFields
425
- )
426
-
427
- const currentValues = form.getValues()
428
- const newFormValues: TranslationsFormSchema = {
429
- entities: { ...currentValues.entities },
430
- }
431
-
432
- for (const ref of newReferences) {
433
- if (!newFormValues.entities[ref.id]) {
434
- newFormValues.entities[ref.id] = snapshotRef.current.entities[ref.id]
435
- }
436
- }
437
-
438
- form.reset(newFormValues, {
439
- keepDirty: true,
440
- keepDirtyValues: true,
441
- })
442
- }, [references, translations, translatableFields, form])
443
-
444
486
  const rows = useMemo(
445
- () => buildTranslationRows(references, translatableFields),
446
- [references, translatableFields]
487
+ () => buildTranslationRows(entities, translatableFields),
488
+ [entities, translatableFields]
447
489
  )
448
490
 
449
- const totalRowCount = useMemo(
450
- () => referenceCount * (translatableFields.length + 1),
451
- [referenceCount, translatableFields]
452
- )
491
+ const { mutateAsync, isPending } = useBatchTranslations(entityType)
453
492
 
454
- const selectedLocaleDisplay = useMemo(
455
- () =>
456
- availableLocales.find((l) => l.locale_code === selectedLocale)?.locale
457
- .name,
458
- [availableLocales, selectedLocale]
459
- )
493
+ const handleLocaleChange = useCallback(
494
+ (newLocale: string) => {
495
+ if (newLocale === selectedLocale) {
496
+ return
497
+ }
460
498
 
461
- const columns = useTranslationsGridColumns({
462
- entities: references,
463
- availableLocales,
464
- selectedLocale,
465
- dynamicColumnWidth,
466
- })
499
+ const currentValues = form.getValues()
500
+ const hasChanges = hasLocaleChanges(
501
+ currentValues,
502
+ initialState.current,
503
+ selectedLocale
504
+ )
467
505
 
468
- const { mutateAsync, isPending, invalidateQueries } =
469
- useBatchTranslations(entityType)
506
+ if (hasChanges) {
507
+ setPendingLocale(newLocale)
508
+ setShowUnsavedPrompt(true)
509
+ } else {
510
+ setSelectedLocale(newLocale)
511
+ }
512
+ },
513
+ [selectedLocale, form]
514
+ )
470
515
 
471
516
  const saveCurrentLocale = useCallback(async () => {
472
517
  const currentValues = form.getValues()
473
- const { hasChanges, payload } = computeChanges(
518
+ const payload = transformSingleLocaleToBatchPayload(
474
519
  currentValues,
475
- snapshotRef.current,
520
+ initialState.current,
476
521
  entityType,
477
522
  selectedLocale
478
523
  )
479
524
 
480
- if (!hasChanges) {
525
+ if (
526
+ payload.create.length === 0 &&
527
+ payload.update.length === 0 &&
528
+ payload.delete.length === 0
529
+ ) {
481
530
  return true
482
531
  }
483
532
 
@@ -489,13 +538,11 @@ export const TranslationsEditForm = ({
489
538
 
490
539
  for (let i = 0; i < batchCount; i++) {
491
540
  let currentBatchAvailable = BATCH_SIZE
492
-
493
541
  const currentBatch: HttpTypes.AdminBatchTranslations = {
494
542
  create: [],
495
543
  update: [],
496
544
  delete: [],
497
545
  }
498
-
499
546
  if (payload.create.length > 0) {
500
547
  currentBatch.create = payload.create.splice(0, currentBatchAvailable)
501
548
  currentBatchAvailable -= currentBatch.create.length
@@ -506,36 +553,22 @@ export const TranslationsEditForm = ({
506
553
  }
507
554
  if (payload.delete.length > 0) {
508
555
  currentBatch.delete = payload.delete.splice(0, currentBatchAvailable)
556
+ currentBatchAvailable -= currentBatch.delete.length
509
557
  }
510
558
 
511
- const response = await mutateAsync(currentBatch, {
512
- onError: (error) => {
513
- toast.error(error.message)
514
- },
515
- })
516
-
517
- if (response.created) {
518
- for (const created of response.created) {
519
- form.setValue(`entities.${created.reference_id}.id`, created.id, {
520
- shouldDirty: false,
521
- })
522
- if (snapshotRef.current.entities[created.reference_id]) {
523
- snapshotRef.current.entities[created.reference_id].id = created.id
524
- }
525
- }
526
- }
559
+ await mutateAsync(currentBatch)
527
560
  }
528
561
 
529
- const savedValues = form.getValues()
530
- for (const entityId of Object.keys(savedValues.entities)) {
531
- if (snapshotRef.current.entities[entityId]) {
532
- snapshotRef.current.entities[entityId] = {
533
- ...savedValues.entities[entityId],
562
+ const updatedInitialState = { ...initialState.current }
563
+ for (const entityId of Object.keys(currentValues.entities)) {
564
+ if (updatedInitialState.entities[entityId]?.locales[selectedLocale]) {
565
+ updatedInitialState.entities[entityId].locales[selectedLocale] = {
566
+ ...currentValues.entities[entityId].locales[selectedLocale],
534
567
  }
535
568
  }
536
569
  }
537
-
538
- form.reset(savedValues)
570
+ initialState.current = updatedInitialState
571
+ form.reset(currentValues)
539
572
 
540
573
  return true
541
574
  } catch (error) {
@@ -546,70 +579,19 @@ export const TranslationsEditForm = ({
546
579
  }
547
580
  }, [form, entityType, selectedLocale, mutateAsync])
548
581
 
549
- const switchToLocale = useCallback(
550
- async (newLocale: string) => {
551
- setIsSwitchingLocale(true)
552
-
553
- try {
554
- await invalidateQueries()
555
-
556
- await new Promise((resolve) => requestAnimationFrame(resolve))
557
-
558
- const { translations, references } = latestPropsRef.current
559
-
560
- const newSnapshot = buildLocaleSnapshot(
561
- translations,
562
- references,
563
- newLocale,
564
- translatableFields
565
- )
566
-
567
- snapshotRef.current = newSnapshot
568
- knownEntityIdsRef.current = new Set(references.map((r) => r.id))
569
-
570
- form.reset(snapshotToFormValues(newSnapshot))
571
-
572
- setSelectedLocale(newLocale)
573
- } finally {
574
- setIsSwitchingLocale(false)
575
- }
576
- },
577
- [translatableFields, form, invalidateQueries]
578
- )
579
-
580
- const handleLocaleChange = useCallback(
581
- (newLocale: string) => {
582
- if (newLocale === selectedLocale) {
583
- return
584
- }
585
-
586
- const currentValues = form.getValues()
587
- const { hasChanges } = computeChanges(
588
- currentValues,
589
- snapshotRef.current,
590
- entityType,
591
- selectedLocale
592
- )
593
-
594
- if (hasChanges) {
595
- setPendingLocale(newLocale)
596
- setShowUnsavedPrompt(true)
597
- } else {
598
- switchToLocale(newLocale)
599
- }
600
- },
601
- [selectedLocale, form, entityType, switchToLocale]
602
- )
603
-
604
582
  const handleSaveAndSwitch = useCallback(async () => {
605
583
  const success = await saveCurrentLocale()
606
584
  if (success && pendingLocale) {
607
- toast.success(t("translations.edit.successToast"))
608
- await switchToLocale(pendingLocale)
585
+ toast.success(
586
+ t("translations.edit.localeChangesSaved", {
587
+ defaultValue: "Changes saved successfully",
588
+ })
589
+ )
590
+ setSelectedLocale(pendingLocale)
609
591
  }
610
592
  setShowUnsavedPrompt(false)
611
593
  setPendingLocale(null)
612
- }, [saveCurrentLocale, pendingLocale, t, switchToLocale])
594
+ }, [saveCurrentLocale, pendingLocale, t])
613
595
 
614
596
  const handleCancelSwitch = useCallback(() => {
615
597
  setShowUnsavedPrompt(false)
@@ -629,19 +611,83 @@ export const TranslationsEditForm = ({
629
611
  [saveCurrentLocale, t, handleSuccess]
630
612
  )
631
613
 
632
- const handleClose = useCallback(() => {
633
- invalidateQueries()
634
- }, [invalidateQueries])
614
+ const handleSubmit = form.handleSubmit(async (values) => {
615
+ const payload = transformToBatchPayload(
616
+ values,
617
+ initialState.current,
618
+ entityType
619
+ )
635
620
 
636
- const isLoading = isPending || isSwitchingLocale
621
+ if (
622
+ payload.create.length === 0 &&
623
+ payload.update.length === 0 &&
624
+ payload.delete.length === 0
625
+ ) {
626
+ return
627
+ }
628
+
629
+ const BATCH_SIZE = 150
630
+ const totalItems =
631
+ payload.create.length + payload.update.length + payload.delete.length
632
+ const batchCount = Math.ceil(totalItems / BATCH_SIZE)
633
+
634
+ for (let i = 0; i < batchCount; i++) {
635
+ let currentBatchAvailable = BATCH_SIZE
636
+ const currentBatch: HttpTypes.AdminBatchTranslations = {
637
+ create: [],
638
+ update: [],
639
+ delete: [],
640
+ }
641
+ if (payload.create.length > 0) {
642
+ currentBatch.create = payload.create.splice(0, currentBatchAvailable)
643
+ currentBatchAvailable -= currentBatch.create.length
644
+ }
645
+ if (payload.update.length > 0) {
646
+ currentBatch.update = payload.update.splice(0, currentBatchAvailable)
647
+ currentBatchAvailable -= currentBatch.update.length
648
+ }
649
+ if (payload.delete.length > 0) {
650
+ currentBatch.delete = payload.delete.splice(0, currentBatchAvailable)
651
+ currentBatchAvailable -= currentBatch.delete.length
652
+ }
653
+
654
+ await mutateAsync(currentBatch, {
655
+ onSuccess: () => {
656
+ if (i === batchCount - 1) {
657
+ toast.success(
658
+ t("translations.edit.successToast", {
659
+ defaultValue: "Translations updated successfully",
660
+ })
661
+ )
662
+ handleSuccess()
663
+ }
664
+ },
665
+ onError: (error) => {
666
+ toast.error(error.message)
667
+ },
668
+ })
669
+ }
670
+ })
671
+
672
+ const columns = useTranslationsGridColumns({
673
+ entities,
674
+ translatableFields,
675
+ availableLocales,
676
+ selectedLocale,
677
+ dynamicColumnWidth,
678
+ })
679
+
680
+ const selectedLocaleDisplay = availableLocales.find(
681
+ (l) => l.locale_code === selectedLocale
682
+ )?.locale.name
637
683
 
638
684
  return (
639
- <RouteFocusModal.Form form={form} onClose={handleClose}>
685
+ <RouteFocusModal.Form form={form}>
640
686
  <KeyboundForm
641
- onSubmit={() => handleSave(true)}
687
+ onSubmit={handleSubmit}
642
688
  className="flex h-full flex-col overflow-hidden"
643
689
  >
644
- <RouteFocusModal.Header />
690
+ <RouteFocusModal.Header></RouteFocusModal.Header>
645
691
  <RouteFocusModal.Body className="size-full overflow-hidden">
646
692
  <div ref={containerRef} className="size-full">
647
693
  <DataGrid
@@ -655,13 +701,12 @@ export const TranslationsEditForm = ({
655
701
  }}
656
702
  state={form}
657
703
  onEditingChange={(editing) => setCloseOnEscape(!editing)}
658
- totalRowCount={totalRowCount}
704
+ totalRowCount={totalCount}
659
705
  onFetchMore={fetchNextPage}
660
706
  isFetchingMore={isFetchingNextPage}
661
707
  hasNextPage={hasNextPage}
662
708
  headerContent={
663
709
  <Select
664
- disabled={isLoading}
665
710
  value={selectedLocale}
666
711
  onValueChange={handleLocaleChange}
667
712
  size="small"
@@ -687,12 +732,7 @@ export const TranslationsEditForm = ({
687
732
  <RouteFocusModal.Footer>
688
733
  <div className="flex items-center justify-end gap-x-2">
689
734
  <RouteFocusModal.Close asChild>
690
- <Button
691
- type="button"
692
- size="small"
693
- variant="secondary"
694
- isLoading={isLoading}
695
- >
735
+ <Button size="small" variant="secondary">
696
736
  {t("actions.cancel")}
697
737
  </Button>
698
738
  </RouteFocusModal.Close>
@@ -701,11 +741,16 @@ export const TranslationsEditForm = ({
701
741
  type="button"
702
742
  variant="secondary"
703
743
  onClick={() => handleSave(false)}
704
- isLoading={isLoading}
744
+ isLoading={isPending}
705
745
  >
706
746
  {t("actions.saveChanges")}
707
747
  </Button>
708
- <Button size="small" type="submit" isLoading={isLoading}>
748
+ <Button
749
+ size="small"
750
+ type="button"
751
+ onClick={() => handleSave(true)}
752
+ isLoading={isPending}
753
+ >
709
754
  {t("actions.saveAndClose")}
710
755
  </Button>
711
756
  </div>
@@ -735,7 +780,7 @@ export const TranslationsEditForm = ({
735
780
  size="small"
736
781
  onClick={handleSaveAndSwitch}
737
782
  type="button"
738
- isLoading={isLoading}
783
+ isLoading={isPending}
739
784
  >
740
785
  {t("actions.saveChanges")}
741
786
  </Button>