@tinya-code/gas-admin-ui 0.2.0-alpha.12 → 0.2.0-alpha.15

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 (369) hide show
  1. package/README.md +222 -0
  2. package/ng-package.json +7 -0
  3. package/package.json +3 -15
  4. package/src/lib/actions/action-registry.spec.ts +52 -0
  5. package/src/lib/actions/action-registry.ts +126 -0
  6. package/src/lib/actions/action-service.spec.ts +215 -0
  7. package/src/lib/actions/action-service.ts +87 -0
  8. package/src/lib/actions/descriptors.spec.ts +155 -0
  9. package/src/lib/actions/descriptors.ts +154 -0
  10. package/src/lib/columns/column-cell.ts +33 -0
  11. package/src/lib/columns/column-defaults.spec.ts +99 -0
  12. package/src/lib/columns/column-defaults.ts +84 -0
  13. package/src/lib/columns/column-format.spec.ts +274 -0
  14. package/src/lib/columns/descriptors.ts +113 -0
  15. package/src/lib/columns/format-badge.ts +51 -0
  16. package/src/lib/columns/format-boolean.ts +58 -0
  17. package/src/lib/columns/format-currency.ts +30 -0
  18. package/src/lib/columns/format-date.ts +35 -0
  19. package/src/lib/columns/format-number.ts +32 -0
  20. package/src/lib/columns/format-text.ts +23 -0
  21. package/src/lib/columns/format-thumbnail.ts +63 -0
  22. package/src/lib/core/adapters/firebase-auth.adapter.ts +6 -0
  23. package/src/lib/core/guards/auth.guard.spec.ts +88 -0
  24. package/src/lib/core/guards/auth.guard.ts +48 -0
  25. package/src/lib/core/guards/unsaved-changes.guard.ts +16 -0
  26. package/src/lib/core/interceptors/api-error.interceptor.spec.ts +182 -0
  27. package/src/lib/core/interceptors/api-error.interceptor.ts +87 -0
  28. package/src/lib/core/models/api.model.ts +104 -0
  29. package/src/lib/core/models/schema-descriptions.ts +118 -0
  30. package/src/lib/core/models/schema.model.ts +1097 -0
  31. package/src/lib/core/registry/composition-validator.spec.ts +358 -0
  32. package/src/lib/core/registry/composition-validator.ts +270 -0
  33. package/src/lib/core/registry/field-validators.spec.ts +199 -0
  34. package/src/lib/core/registry/field-validators.ts +440 -0
  35. package/src/lib/core/registry/render-host.spec.ts +208 -0
  36. package/src/lib/core/registry/render-host.ts +646 -0
  37. package/src/lib/core/registry/render-registry.spec.ts +96 -0
  38. package/src/lib/core/registry/render-registry.ts +312 -0
  39. package/src/lib/core/registry/schema-catalog.ts +156 -0
  40. package/src/lib/core/services/api.service.spec.ts +1142 -0
  41. package/src/lib/core/services/api.service.ts +371 -0
  42. package/src/lib/core/services/auth.service.spec.ts +653 -0
  43. package/src/lib/core/services/auth.service.ts +503 -0
  44. package/src/lib/core/services/config.service.spec.ts +121 -0
  45. package/src/lib/core/services/config.service.ts +119 -0
  46. package/src/lib/core/services/data-source.service.spec.ts +179 -0
  47. package/src/lib/core/services/data-source.service.ts +142 -0
  48. package/src/lib/core/services/error-mapper.service.spec.ts +50 -0
  49. package/src/lib/core/services/error-mapper.service.ts +49 -0
  50. package/src/lib/core/services/notification.service.ts +73 -0
  51. package/src/lib/core/services/relation-drawer.service.ts +41 -0
  52. package/src/lib/core/services/theme.service.spec.ts +106 -0
  53. package/src/lib/core/services/theme.service.ts +86 -0
  54. package/src/lib/core/services/transport.spec.ts +198 -0
  55. package/src/lib/core/services/transport.ts +164 -0
  56. package/src/lib/core/services/upload.service.ts +81 -0
  57. package/src/lib/core/testing/categories.schema.ts +125 -0
  58. package/src/lib/core/testing/orders.schema.ts +372 -0
  59. package/src/lib/core/testing/products.schema.ts +258 -0
  60. package/src/lib/core/testing/site.schema.ts +195 -0
  61. package/src/lib/core/testing/test-catalog.ts +21 -0
  62. package/src/lib/core/tokens.ts +112 -0
  63. package/src/lib/core/utils/clipboard.spec.ts +55 -0
  64. package/src/lib/core/utils/clipboard.ts +90 -0
  65. package/src/lib/core/utils/context-resolver.spec.ts +95 -0
  66. package/src/lib/core/utils/context-resolver.ts +101 -0
  67. package/src/lib/core/utils/response-unwrapper.spec.ts +70 -0
  68. package/src/lib/core/utils/response-unwrapper.ts +103 -0
  69. package/src/lib/core/utils/source-options.spec.ts +96 -0
  70. package/src/lib/core/utils/source-options.ts +54 -0
  71. package/src/lib/core/utils/template-interpolator.spec.ts +59 -0
  72. package/src/lib/core/utils/template-interpolator.ts +56 -0
  73. package/src/lib/core/view-navigation/view-navigation.service.spec.ts +207 -0
  74. package/src/lib/core/view-navigation/view-navigation.service.ts +132 -0
  75. package/src/lib/fields/boolean/boolean.descriptor.ts +11 -0
  76. package/src/lib/fields/boolean/boolean.ts +40 -0
  77. package/src/lib/fields/color/color.descriptor.ts +12 -0
  78. package/src/lib/fields/color/color.ts +34 -0
  79. package/src/lib/fields/currency/currency.descriptor.ts +12 -0
  80. package/src/lib/fields/currency/currency.ts +67 -0
  81. package/src/lib/fields/date/date.descriptor.ts +12 -0
  82. package/src/lib/fields/date/date.ts +36 -0
  83. package/src/lib/fields/descriptors.ts +122 -0
  84. package/src/lib/fields/display/display-badge.ts +65 -0
  85. package/src/lib/fields/display/display-date.ts +39 -0
  86. package/src/lib/fields/display/display-defaults.ts +84 -0
  87. package/src/lib/fields/display/display-file.ts +41 -0
  88. package/src/lib/fields/display/display-image.ts +54 -0
  89. package/src/lib/fields/display/display-list.ts +107 -0
  90. package/src/lib/fields/display/display-number.ts +41 -0
  91. package/src/lib/fields/display/display-text.ts +42 -0
  92. package/src/lib/fields/display/display.spec.ts +242 -0
  93. package/src/lib/fields/dropdown/dropdown.descriptor.ts +12 -0
  94. package/src/lib/fields/dropdown/dropdown.ts +45 -0
  95. package/src/lib/fields/email/email.descriptor.ts +12 -0
  96. package/src/lib/fields/email/email.ts +28 -0
  97. package/src/lib/fields/field-aria.ts +60 -0
  98. package/src/lib/fields/field-host/field-host.spec.ts +118 -0
  99. package/src/lib/fields/field-host/field-host.ts +47 -0
  100. package/src/lib/fields/field-node.ts +88 -0
  101. package/src/lib/fields/file/file.descriptor.ts +14 -0
  102. package/src/lib/fields/file/file.spec.ts +134 -0
  103. package/src/lib/fields/file/file.ts +281 -0
  104. package/src/lib/fields/group/group.descriptor.ts +21 -0
  105. package/src/lib/fields/group/group.ts +110 -0
  106. package/src/lib/fields/image/image.descriptor.ts +12 -0
  107. package/src/lib/fields/image/image.spec.ts +117 -0
  108. package/src/lib/fields/image/image.ts +281 -0
  109. package/src/lib/fields/key-value/key-value.descriptor.ts +11 -0
  110. package/src/lib/fields/key-value/key-value.ts +283 -0
  111. package/src/lib/fields/list/list.descriptor.ts +21 -0
  112. package/src/lib/fields/list/list.ts +376 -0
  113. package/src/lib/fields/multiselect/multiselect.descriptor.ts +11 -0
  114. package/src/lib/fields/multiselect/multiselect.spec.ts +142 -0
  115. package/src/lib/fields/multiselect/multiselect.ts +152 -0
  116. package/src/lib/fields/number/number.descriptor.ts +12 -0
  117. package/src/lib/fields/number/number.ts +40 -0
  118. package/src/lib/fields/phone/phone.descriptor.ts +12 -0
  119. package/src/lib/fields/phone/phone.ts +46 -0
  120. package/src/lib/fields/readonly-text/readonly-text.descriptor.ts +12 -0
  121. package/src/lib/fields/readonly-text/readonly-text.ts +58 -0
  122. package/src/lib/fields/relation/relation.descriptor.ts +12 -0
  123. package/src/lib/fields/relation/relation.spec.ts +193 -0
  124. package/src/lib/fields/relation/relation.ts +224 -0
  125. package/src/lib/fields/rich-select/rich-select.descriptor.ts +12 -0
  126. package/src/lib/fields/rich-select/rich-select.ts +175 -0
  127. package/src/lib/fields/select/radio-mode.ts +25 -0
  128. package/src/lib/fields/select/select.descriptor.ts +12 -0
  129. package/src/lib/fields/select/select.spec.ts +162 -0
  130. package/src/lib/fields/select/select.ts +210 -0
  131. package/src/lib/fields/slug/slug.descriptor.ts +19 -0
  132. package/src/lib/fields/slug/slug.ts +67 -0
  133. package/src/lib/fields/string-list/string-list.descriptor.ts +11 -0
  134. package/src/lib/fields/string-list/string-list.ts +222 -0
  135. package/src/lib/fields/text/text.descriptor.ts +12 -0
  136. package/src/lib/fields/text/text.ts +35 -0
  137. package/src/lib/fields/textarea/textarea.descriptor.ts +12 -0
  138. package/src/lib/fields/textarea/textarea.spec.ts +117 -0
  139. package/src/lib/fields/textarea/textarea.ts +126 -0
  140. package/src/lib/fields/time/time.descriptor.ts +12 -0
  141. package/src/lib/fields/time/time.ts +28 -0
  142. package/src/lib/fields/unknown-render/unknown-render.ts +49 -0
  143. package/src/lib/fields/url/url.descriptor.ts +12 -0
  144. package/src/lib/fields/url/url.ts +53 -0
  145. package/src/lib/gas-admin-ui.spec.ts +21 -0
  146. package/src/lib/gas-admin-ui.ts +9 -0
  147. package/src/lib/layouts/descriptors.ts +9 -0
  148. package/src/lib/pages/dashboard/dashboard.ts +167 -0
  149. package/src/lib/pages/detail-view/detail-view.spec.ts +333 -0
  150. package/src/lib/pages/detail-view/detail-view.ts +570 -0
  151. package/src/lib/pages/drawer-form/drawer-form.ts +228 -0
  152. package/src/lib/pages/form-view/form-model.ts +88 -0
  153. package/src/lib/pages/form-view/form-schema.ts +255 -0
  154. package/src/lib/pages/form-view/form-view.ts +1622 -0
  155. package/src/lib/pages/list-view/list-view.formats.spec.ts +214 -0
  156. package/src/lib/pages/list-view/list-view.ts +882 -0
  157. package/src/lib/pages/login/login.spec.ts +109 -0
  158. package/src/lib/pages/login/login.ts +227 -0
  159. package/src/lib/pages/resource-page/resource-page.ts +234 -0
  160. package/src/lib/shared/components/badge/badge.ts +35 -0
  161. package/src/lib/shared/components/button/button.ts +65 -0
  162. package/src/lib/shared/components/confirm-dialog/confirm-dialog.ts +48 -0
  163. package/src/lib/shared/components/copy-button/copy-button.ts +56 -0
  164. package/src/lib/shared/components/copy-share/copy-share.spec.ts +122 -0
  165. package/src/lib/shared/components/copy-share/copy-share.ts +107 -0
  166. package/src/lib/shared/components/drawer/drawer.spec.ts +127 -0
  167. package/src/lib/shared/components/drawer/drawer.ts +172 -0
  168. package/src/lib/shared/components/empty-state/empty-state.ts +30 -0
  169. package/src/lib/shared/components/modal/modal.spec.ts +149 -0
  170. package/src/lib/shared/components/modal/modal.ts +145 -0
  171. package/src/lib/shared/components/post-create-modal/post-create-modal.spec.ts +165 -0
  172. package/src/lib/shared/components/post-create-modal/post-create-modal.ts +159 -0
  173. package/src/lib/shared/components/skeleton/skeleton.ts +31 -0
  174. package/src/lib/shared/components/toast/toast.ts +58 -0
  175. package/src/lib/shared/utils/col-span.ts +25 -0
  176. package/src/lib/shared/utils/condition-evaluator.ts +53 -0
  177. package/src/lib/shared/utils/depends-on.ts +48 -0
  178. package/src/lib/shared/utils/dialog-focus.ts +28 -0
  179. package/src/lib/shared/utils/field-path.ts +49 -0
  180. package/src/lib/shared/utils/gap-sorting.ts +144 -0
  181. package/src/lib/shared/utils/seed.spec.ts +64 -0
  182. package/src/lib/shared/utils/seed.ts +149 -0
  183. package/src/lib/shared/utils/slugify.ts +13 -0
  184. package/src/lib/shared/utils/validators.ts +41 -0
  185. package/src/lib/shell/layout/layout.ts +63 -0
  186. package/src/lib/shell/shell.routes.spec.ts +59 -0
  187. package/src/lib/shell/shell.routes.ts +122 -0
  188. package/src/lib/shell/sidebar/sidebar.ts +139 -0
  189. package/src/lib/shell/theme-toggle/theme-toggle.ts +74 -0
  190. package/src/lib/views/calendar-view/calendar-model.ts +426 -0
  191. package/src/lib/views/calendar-view/calendar-view.spec.ts +557 -0
  192. package/src/lib/views/calendar-view/calendar-view.ts +568 -0
  193. package/src/lib/views/composer/composer-host.spec.ts +301 -0
  194. package/src/lib/views/composer/composer-host.ts +248 -0
  195. package/src/lib/views/composer/composer-imports.spec.ts +54 -0
  196. package/src/lib/views/composer/view-context.spec.ts +117 -0
  197. package/src/lib/views/composer/view-context.ts +109 -0
  198. package/src/lib/views/composer/view-slot.spec.ts +130 -0
  199. package/src/lib/views/composer/view-slot.ts +178 -0
  200. package/src/lib/views/descriptors.ts +46 -0
  201. package/src/lib/views/kanban-view/kanban-model.ts +173 -0
  202. package/src/lib/views/kanban-view/kanban-view.spec.ts +540 -0
  203. package/src/lib/views/kanban-view/kanban-view.ts +327 -0
  204. package/src/lib/widgets/bar-chart/bar-chart.descriptor.ts +17 -0
  205. package/src/lib/widgets/bar-chart/bar-chart.spec.ts +66 -0
  206. package/src/lib/widgets/bar-chart/bar-chart.ts +76 -0
  207. package/src/lib/widgets/chart-line/chart-line.descriptor.ts +17 -0
  208. package/src/lib/widgets/chart-line/chart-line.spec.ts +113 -0
  209. package/src/lib/widgets/chart-line/chart-line.ts +219 -0
  210. package/src/lib/widgets/descriptors.ts +29 -0
  211. package/src/lib/widgets/metric-card/metric-card.descriptor.ts +41 -0
  212. package/src/lib/widgets/metric-card/metric-card.spec.ts +52 -0
  213. package/src/lib/widgets/metric-card/metric-card.ts +48 -0
  214. package/src/lib/widgets/quick-actions/quick-actions.descriptor.ts +13 -0
  215. package/src/lib/widgets/quick-actions/quick-actions.spec.ts +61 -0
  216. package/src/lib/widgets/quick-actions/quick-actions.ts +100 -0
  217. package/src/lib/widgets/record-list/record-list.descriptor.ts +16 -0
  218. package/src/lib/widgets/record-list/record-list.spec.ts +79 -0
  219. package/src/lib/widgets/record-list/record-list.ts +115 -0
  220. package/src/lib/widgets/series.spec.ts +42 -0
  221. package/src/lib/widgets/series.ts +56 -0
  222. package/src/lib/widgets/status-progress/status-progress.descriptor.ts +19 -0
  223. package/src/lib/widgets/status-progress/status-progress.spec.ts +63 -0
  224. package/src/lib/widgets/status-progress/status-progress.ts +104 -0
  225. package/src/lib/widgets/widget-host/widget-host.spec.ts +179 -0
  226. package/src/lib/widgets/widget-host/widget-host.ts +99 -0
  227. package/src/public-api.ts +104 -0
  228. package/tinya-code-gas-admin-ui-0.2.0-alpha.15.tgz +0 -0
  229. package/tsconfig.lib.json +13 -0
  230. package/tsconfig.lib.prod.json +11 -0
  231. package/tsconfig.spec.json +10 -0
  232. package/fesm2022/tinya-code-gas-admin-ui-badge-EeCA_l-o.mjs +0 -84
  233. package/fesm2022/tinya-code-gas-admin-ui-badge-EeCA_l-o.mjs.map +0 -1
  234. package/fesm2022/tinya-code-gas-admin-ui-bar-chart-G7sEMSBY.mjs +0 -140
  235. package/fesm2022/tinya-code-gas-admin-ui-bar-chart-G7sEMSBY.mjs.map +0 -1
  236. package/fesm2022/tinya-code-gas-admin-ui-boolean-B2ASKL0E.mjs +0 -113
  237. package/fesm2022/tinya-code-gas-admin-ui-boolean-B2ASKL0E.mjs.map +0 -1
  238. package/fesm2022/tinya-code-gas-admin-ui-button-BQjNW8KB.mjs +0 -163
  239. package/fesm2022/tinya-code-gas-admin-ui-button-BQjNW8KB.mjs.map +0 -1
  240. package/fesm2022/tinya-code-gas-admin-ui-calendar-view-DCY8bj2V.mjs +0 -1027
  241. package/fesm2022/tinya-code-gas-admin-ui-calendar-view-DCY8bj2V.mjs.map +0 -1
  242. package/fesm2022/tinya-code-gas-admin-ui-chart-line-BjdlKHoH.mjs +0 -278
  243. package/fesm2022/tinya-code-gas-admin-ui-chart-line-BjdlKHoH.mjs.map +0 -1
  244. package/fesm2022/tinya-code-gas-admin-ui-color-Dljh_By3.mjs +0 -107
  245. package/fesm2022/tinya-code-gas-admin-ui-color-Dljh_By3.mjs.map +0 -1
  246. package/fesm2022/tinya-code-gas-admin-ui-condition-evaluator-DY2k1M3C.mjs +0 -101
  247. package/fesm2022/tinya-code-gas-admin-ui-condition-evaluator-DY2k1M3C.mjs.map +0 -1
  248. package/fesm2022/tinya-code-gas-admin-ui-currency-BvuF3Z63.mjs +0 -141
  249. package/fesm2022/tinya-code-gas-admin-ui-currency-BvuF3Z63.mjs.map +0 -1
  250. package/fesm2022/tinya-code-gas-admin-ui-data-source.service-96FSbN72.mjs +0 -88
  251. package/fesm2022/tinya-code-gas-admin-ui-data-source.service-96FSbN72.mjs.map +0 -1
  252. package/fesm2022/tinya-code-gas-admin-ui-date-1SJGXZrd.mjs +0 -107
  253. package/fesm2022/tinya-code-gas-admin-ui-date-1SJGXZrd.mjs.map +0 -1
  254. package/fesm2022/tinya-code-gas-admin-ui-display-badge-fwk6sDU7.mjs +0 -140
  255. package/fesm2022/tinya-code-gas-admin-ui-display-badge-fwk6sDU7.mjs.map +0 -1
  256. package/fesm2022/tinya-code-gas-admin-ui-display-date-D7zLiDiG.mjs +0 -119
  257. package/fesm2022/tinya-code-gas-admin-ui-display-date-D7zLiDiG.mjs.map +0 -1
  258. package/fesm2022/tinya-code-gas-admin-ui-display-file-BiIhqvQC.mjs +0 -125
  259. package/fesm2022/tinya-code-gas-admin-ui-display-file-BiIhqvQC.mjs.map +0 -1
  260. package/fesm2022/tinya-code-gas-admin-ui-display-image-C1dhnZi1.mjs +0 -152
  261. package/fesm2022/tinya-code-gas-admin-ui-display-image-C1dhnZi1.mjs.map +0 -1
  262. package/fesm2022/tinya-code-gas-admin-ui-display-list-DOEuUbGe.mjs +0 -211
  263. package/fesm2022/tinya-code-gas-admin-ui-display-list-DOEuUbGe.mjs.map +0 -1
  264. package/fesm2022/tinya-code-gas-admin-ui-display-number-DDMzr4Hn.mjs +0 -116
  265. package/fesm2022/tinya-code-gas-admin-ui-display-number-DDMzr4Hn.mjs.map +0 -1
  266. package/fesm2022/tinya-code-gas-admin-ui-display-text-B8CdVNpv.mjs +0 -111
  267. package/fesm2022/tinya-code-gas-admin-ui-display-text-B8CdVNpv.mjs.map +0 -1
  268. package/fesm2022/tinya-code-gas-admin-ui-dropdown-C3blvmE1.mjs +0 -122
  269. package/fesm2022/tinya-code-gas-admin-ui-dropdown-C3blvmE1.mjs.map +0 -1
  270. package/fesm2022/tinya-code-gas-admin-ui-email-CT_O5ihA.mjs +0 -107
  271. package/fesm2022/tinya-code-gas-admin-ui-email-CT_O5ihA.mjs.map +0 -1
  272. package/fesm2022/tinya-code-gas-admin-ui-empty-state-BMc0QtoT.mjs +0 -109
  273. package/fesm2022/tinya-code-gas-admin-ui-empty-state-BMc0QtoT.mjs.map +0 -1
  274. package/fesm2022/tinya-code-gas-admin-ui-error-mapper.service-DY5y1FjE.mjs +0 -45
  275. package/fesm2022/tinya-code-gas-admin-ui-error-mapper.service-DY5y1FjE.mjs.map +0 -1
  276. package/fesm2022/tinya-code-gas-admin-ui-field-aria-BD1Wiggc.mjs +0 -50
  277. package/fesm2022/tinya-code-gas-admin-ui-field-aria-BD1Wiggc.mjs.map +0 -1
  278. package/fesm2022/tinya-code-gas-admin-ui-field-node-CIK6KNN_.mjs +0 -24
  279. package/fesm2022/tinya-code-gas-admin-ui-field-node-CIK6KNN_.mjs.map +0 -1
  280. package/fesm2022/tinya-code-gas-admin-ui-field-path-B4-FTBGZ.mjs +0 -27
  281. package/fesm2022/tinya-code-gas-admin-ui-field-path-B4-FTBGZ.mjs.map +0 -1
  282. package/fesm2022/tinya-code-gas-admin-ui-file-Dmxioog7.mjs +0 -408
  283. package/fesm2022/tinya-code-gas-admin-ui-file-Dmxioog7.mjs.map +0 -1
  284. package/fesm2022/tinya-code-gas-admin-ui-form-view-C709rc57.mjs +0 -4516
  285. package/fesm2022/tinya-code-gas-admin-ui-form-view-C709rc57.mjs.map +0 -1
  286. package/fesm2022/tinya-code-gas-admin-ui-format-badge-CK8ct5Wk.mjs +0 -82
  287. package/fesm2022/tinya-code-gas-admin-ui-format-badge-CK8ct5Wk.mjs.map +0 -1
  288. package/fesm2022/tinya-code-gas-admin-ui-format-boolean-ClzezZz-.mjs +0 -105
  289. package/fesm2022/tinya-code-gas-admin-ui-format-boolean-ClzezZz-.mjs.map +0 -1
  290. package/fesm2022/tinya-code-gas-admin-ui-format-currency-CVmniKml.mjs +0 -65
  291. package/fesm2022/tinya-code-gas-admin-ui-format-currency-CVmniKml.mjs.map +0 -1
  292. package/fesm2022/tinya-code-gas-admin-ui-format-date-Dr1YsDd_.mjs +0 -65
  293. package/fesm2022/tinya-code-gas-admin-ui-format-date-Dr1YsDd_.mjs.map +0 -1
  294. package/fesm2022/tinya-code-gas-admin-ui-format-number-CWCjpI4n.mjs +0 -65
  295. package/fesm2022/tinya-code-gas-admin-ui-format-number-CWCjpI4n.mjs.map +0 -1
  296. package/fesm2022/tinya-code-gas-admin-ui-format-text-DBOgJkH0.mjs +0 -59
  297. package/fesm2022/tinya-code-gas-admin-ui-format-text-DBOgJkH0.mjs.map +0 -1
  298. package/fesm2022/tinya-code-gas-admin-ui-format-thumbnail-PCRvxWKt.mjs +0 -94
  299. package/fesm2022/tinya-code-gas-admin-ui-format-thumbnail-PCRvxWKt.mjs.map +0 -1
  300. package/fesm2022/tinya-code-gas-admin-ui-gap-sorting-iTdy_DYG.mjs +0 -72
  301. package/fesm2022/tinya-code-gas-admin-ui-gap-sorting-iTdy_DYG.mjs.map +0 -1
  302. package/fesm2022/tinya-code-gas-admin-ui-group-h7-O8tt4.mjs +0 -238
  303. package/fesm2022/tinya-code-gas-admin-ui-group-h7-O8tt4.mjs.map +0 -1
  304. package/fesm2022/tinya-code-gas-admin-ui-image-7UhJ7nqI.mjs +0 -416
  305. package/fesm2022/tinya-code-gas-admin-ui-image-7UhJ7nqI.mjs.map +0 -1
  306. package/fesm2022/tinya-code-gas-admin-ui-kanban-view-Dl0Ol5Kb.mjs +0 -439
  307. package/fesm2022/tinya-code-gas-admin-ui-kanban-view-Dl0Ol5Kb.mjs.map +0 -1
  308. package/fesm2022/tinya-code-gas-admin-ui-key-value-nVdgg5at.mjs +0 -513
  309. package/fesm2022/tinya-code-gas-admin-ui-key-value-nVdgg5at.mjs.map +0 -1
  310. package/fesm2022/tinya-code-gas-admin-ui-list-C8ijJHAx.mjs +0 -637
  311. package/fesm2022/tinya-code-gas-admin-ui-list-C8ijJHAx.mjs.map +0 -1
  312. package/fesm2022/tinya-code-gas-admin-ui-metric-card-DnGMQL8l.mjs +0 -127
  313. package/fesm2022/tinya-code-gas-admin-ui-metric-card-DnGMQL8l.mjs.map +0 -1
  314. package/fesm2022/tinya-code-gas-admin-ui-multiselect-PB28e-jl.mjs +0 -251
  315. package/fesm2022/tinya-code-gas-admin-ui-multiselect-PB28e-jl.mjs.map +0 -1
  316. package/fesm2022/tinya-code-gas-admin-ui-notification.service-LjSULER6.mjs +0 -66
  317. package/fesm2022/tinya-code-gas-admin-ui-notification.service-LjSULER6.mjs.map +0 -1
  318. package/fesm2022/tinya-code-gas-admin-ui-number-JNr7glc8.mjs +0 -114
  319. package/fesm2022/tinya-code-gas-admin-ui-number-JNr7glc8.mjs.map +0 -1
  320. package/fesm2022/tinya-code-gas-admin-ui-phone-jR417hPf.mjs +0 -116
  321. package/fesm2022/tinya-code-gas-admin-ui-phone-jR417hPf.mjs.map +0 -1
  322. package/fesm2022/tinya-code-gas-admin-ui-quick-actions-DCQ6emmF.mjs +0 -235
  323. package/fesm2022/tinya-code-gas-admin-ui-quick-actions-DCQ6emmF.mjs.map +0 -1
  324. package/fesm2022/tinya-code-gas-admin-ui-radio-mode-DxvN07DY.mjs +0 -8
  325. package/fesm2022/tinya-code-gas-admin-ui-radio-mode-DxvN07DY.mjs.map +0 -1
  326. package/fesm2022/tinya-code-gas-admin-ui-readonly-text-BL7-4qie.mjs +0 -124
  327. package/fesm2022/tinya-code-gas-admin-ui-readonly-text-BL7-4qie.mjs.map +0 -1
  328. package/fesm2022/tinya-code-gas-admin-ui-record-list-BiZfnd_7.mjs +0 -249
  329. package/fesm2022/tinya-code-gas-admin-ui-record-list-BiZfnd_7.mjs.map +0 -1
  330. package/fesm2022/tinya-code-gas-admin-ui-relation-drawer.service-B0kT8EiW.mjs +0 -39
  331. package/fesm2022/tinya-code-gas-admin-ui-relation-drawer.service-B0kT8EiW.mjs.map +0 -1
  332. package/fesm2022/tinya-code-gas-admin-ui-relation-ykpJ3PYl.mjs +0 -309
  333. package/fesm2022/tinya-code-gas-admin-ui-relation-ykpJ3PYl.mjs.map +0 -1
  334. package/fesm2022/tinya-code-gas-admin-ui-render-registry-CULkxrSt.mjs +0 -576
  335. package/fesm2022/tinya-code-gas-admin-ui-render-registry-CULkxrSt.mjs.map +0 -1
  336. package/fesm2022/tinya-code-gas-admin-ui-rich-select-CIHaOrbU.mjs +0 -161
  337. package/fesm2022/tinya-code-gas-admin-ui-rich-select-CIHaOrbU.mjs.map +0 -1
  338. package/fesm2022/tinya-code-gas-admin-ui-rolldown-runtime-DpJrqANO.mjs +0 -11
  339. package/fesm2022/tinya-code-gas-admin-ui-schema-catalog-D0Pk0wAN.mjs +0 -685
  340. package/fesm2022/tinya-code-gas-admin-ui-schema-catalog-D0Pk0wAN.mjs.map +0 -1
  341. package/fesm2022/tinya-code-gas-admin-ui-seed-CcCA3FdQ.mjs +0 -80
  342. package/fesm2022/tinya-code-gas-admin-ui-seed-CcCA3FdQ.mjs.map +0 -1
  343. package/fesm2022/tinya-code-gas-admin-ui-select-CDwX9yNO.mjs +0 -296
  344. package/fesm2022/tinya-code-gas-admin-ui-select-CDwX9yNO.mjs.map +0 -1
  345. package/fesm2022/tinya-code-gas-admin-ui-skeleton-DnWK0tcZ.mjs +0 -119
  346. package/fesm2022/tinya-code-gas-admin-ui-skeleton-DnWK0tcZ.mjs.map +0 -1
  347. package/fesm2022/tinya-code-gas-admin-ui-slug-DU58cbiY.mjs +0 -158
  348. package/fesm2022/tinya-code-gas-admin-ui-slug-DU58cbiY.mjs.map +0 -1
  349. package/fesm2022/tinya-code-gas-admin-ui-source-options-B9R4mw8-.mjs +0 -26
  350. package/fesm2022/tinya-code-gas-admin-ui-source-options-B9R4mw8-.mjs.map +0 -1
  351. package/fesm2022/tinya-code-gas-admin-ui-status-progress-DmeG8htE.mjs +0 -205
  352. package/fesm2022/tinya-code-gas-admin-ui-status-progress-DmeG8htE.mjs.map +0 -1
  353. package/fesm2022/tinya-code-gas-admin-ui-string-list-gleasqzD.mjs +0 -432
  354. package/fesm2022/tinya-code-gas-admin-ui-string-list-gleasqzD.mjs.map +0 -1
  355. package/fesm2022/tinya-code-gas-admin-ui-template-interpolator-ewv7CkNX.mjs +0 -36
  356. package/fesm2022/tinya-code-gas-admin-ui-template-interpolator-ewv7CkNX.mjs.map +0 -1
  357. package/fesm2022/tinya-code-gas-admin-ui-text-CUPmL7W_.mjs +0 -107
  358. package/fesm2022/tinya-code-gas-admin-ui-text-CUPmL7W_.mjs.map +0 -1
  359. package/fesm2022/tinya-code-gas-admin-ui-textarea-CGLSIW13.mjs +0 -223
  360. package/fesm2022/tinya-code-gas-admin-ui-textarea-CGLSIW13.mjs.map +0 -1
  361. package/fesm2022/tinya-code-gas-admin-ui-time-BcHzsTWf.mjs +0 -107
  362. package/fesm2022/tinya-code-gas-admin-ui-time-BcHzsTWf.mjs.map +0 -1
  363. package/fesm2022/tinya-code-gas-admin-ui-upload.service-CZPEPCeN.mjs +0 -60
  364. package/fesm2022/tinya-code-gas-admin-ui-upload.service-CZPEPCeN.mjs.map +0 -1
  365. package/fesm2022/tinya-code-gas-admin-ui-url-GO4LZi__.mjs +0 -141
  366. package/fesm2022/tinya-code-gas-admin-ui-url-GO4LZi__.mjs.map +0 -1
  367. package/fesm2022/tinya-code-gas-admin-ui.mjs +0 -4599
  368. package/fesm2022/tinya-code-gas-admin-ui.mjs.map +0 -1
  369. package/types/tinya-code-gas-admin-ui.d.ts +0 -3632
@@ -0,0 +1,1622 @@
1
+ import {
2
+ ApplicationRef,
3
+ Component,
4
+ computed,
5
+ effect,
6
+ inject,
7
+ Injector,
8
+ signal,
9
+ } from '@angular/core';
10
+ import { toSignal } from '@angular/core/rxjs-interop';
11
+ import { form, submit } from '@angular/forms/signals';
12
+ import { ActivatedRoute, Router } from '@angular/router';
13
+
14
+ import { ViewNavigationService } from '../../core/view-navigation/view-navigation.service';
15
+ import { firstValueFrom, of } from 'rxjs';
16
+
17
+ import type { FieldSchema, LayoutMode, ResourceSchema } from '../../core/models/schema.model';
18
+ import { isSupported } from '../../core/registry/render-registry';
19
+ import { ApiService } from '../../core/services/api.service';
20
+ import { AuthService } from '../../core/services/auth.service';
21
+ import { ErrorMapperService, type MappedError } from '../../core/services/error-mapper.service';
22
+ import { NotificationService } from '../../core/services/notification.service';
23
+ import {
24
+ RelationDrawerService,
25
+ type RelationCreateRequest,
26
+ } from '../../core/services/relation-drawer.service';
27
+ import { FieldHost } from '../../fields/field-host/field-host';
28
+ import { childTree, groupTree, type RootTree } from '../../fields/field-node';
29
+ import { DrawerForm } from '../drawer-form/drawer-form';
30
+ import { getSchema } from '../../core/registry/schema-catalog';
31
+ import { collectDependsOn } from '../../shared/utils/depends-on';
32
+ import { Button } from '../../shared/components/button/button';
33
+ import { EmptyState } from '../../shared/components/empty-state/empty-state';
34
+ import { PostCreateModal } from '../../shared/components/post-create-modal/post-create-modal';
35
+ import { Skeleton } from '../../shared/components/skeleton/skeleton';
36
+ import { colSpanClass } from '../../shared/utils/col-span';
37
+ import { isRecord, mergeRecord } from '../../shared/utils/seed';
38
+ import { buildFormSchema } from './form-schema';
39
+ import { buildPayload, seedModel } from './form-model';
40
+
41
+ type FormMode = 'create' | 'edit' | 'single';
42
+ type FormStatus = 'loading' | 'ready' | 'error';
43
+
44
+ interface SectionView {
45
+ id: string;
46
+ label: string;
47
+ fields: FieldSchema[];
48
+ }
49
+
50
+ /**
51
+ * Un ítem del resumen de errores (§4.11). `id` es el que ya genera
52
+ * `state().name()`: es a la vez el valor de `data-field` del envoltorio y el
53
+ * `id` real del control, así que no se inventa ningún ancla nueva.
54
+ */
55
+ interface ErrorEntry {
56
+ id: string;
57
+ label: string;
58
+ message: string;
59
+ /** Sección/pestaña del campo raíz; `undefined` si no está en ninguna. */
60
+ section?: string;
61
+ }
62
+
63
+ const TAB_ACTIVE =
64
+ 'border-b-2 border-primary px-3 py-2 text-sm font-medium text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary';
65
+ const TAB_INACTIVE =
66
+ 'border-b-2 border-transparent px-3 py-2 text-sm text-neutral hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary';
67
+
68
+ /** Espera antes de volcar el borrador a localStorage (§4.6). */
69
+ const DRAFT_DEBOUNCE_MS = 800;
70
+
71
+ /**
72
+ * Fecha del registro tal como llega del backend. No está en el schema
73
+ * (`updated_at` es un `readonly-text`, no un campo editable), así que se
74
+ * prueba la forma habitual; si no hay, devuelve `undefined` y el borrador se
75
+ * ofrece igual.
76
+ */
77
+ function readTimestamp(record: Record<string, unknown> | null): number | undefined {
78
+ if (!record) {
79
+ return undefined;
80
+ }
81
+ for (const key of ['updated_at', 'updatedAt', 'updated']) {
82
+ const raw = record[key];
83
+ if (typeof raw === 'number') {
84
+ return raw < 1e12 ? raw * 1000 : raw;
85
+ }
86
+ if (typeof raw === 'string') {
87
+ const parsed = Date.parse(raw);
88
+ if (!Number.isNaN(parsed)) {
89
+ return parsed;
90
+ }
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /**
97
+ * Formulario genérico: lo construye el schema del recurso (base.md §4–§6,
98
+ * §10) con `form()` de Signal Forms sobre un modelo `Record<string, unknown>`.
99
+ *
100
+ * - **Acceso dinámico**: `form()[field.key]` no compila con claves
101
+ * arbitrarias; el modelo se tipa como `Record<string, unknown>` y el acceso
102
+ * vive en `fields/field-node.ts` (`childTree`) y en `form-schema.ts`
103
+ * (`pathAt`), que centralizan los dos casts.
104
+ * - **Sin `debounce('blur')`**: difiere `controlValue → value` (el modelo), no
105
+ * la visibilidad de errores, y con controles custom (`multiselect`, que
106
+ * nunca emite `touch`) se perderían datos en el payload. La validación al
107
+ * perder foco la resuelve FieldHost (`invalid() && touched()`).
108
+ * - `<form novalidate>`: Signal Forms expone `required` como atributo nativo,
109
+ * y la validación nativa del navegador bloquearía el submit sin mostrar
110
+ * nada (campos en `<details>` cerrados).
111
+ */
112
+ @Component({
113
+ selector: 'app-form-view',
114
+ imports: [Button, DrawerForm, EmptyState, FieldHost, PostCreateModal, Skeleton],
115
+ template: `
116
+ @if (schema()) {
117
+ <header class="mb-6 flex flex-wrap items-start justify-between gap-4">
118
+ <div>
119
+ <div class="flex flex-wrap items-center gap-3">
120
+ <h1 class="font-display text-2xl font-semibold">{{ heading() }}</h1>
121
+ @if (dirty()) {
122
+ <span
123
+ class="rounded-full bg-accent/15 px-2.5 py-0.5 text-xs font-medium text-accent-text"
124
+ >
125
+ Sin guardar
126
+ </span>
127
+ }
128
+ </div>
129
+ @if (mode() === 'single') {
130
+ <p class="text-sm text-neutral">Configuración única</p>
131
+ }
132
+ </div>
133
+ @if (mode() !== 'single') {
134
+ <button app-button variant="outline" type="button" (click)="back()">
135
+ Volver al listado
136
+ </button>
137
+ }
138
+ </header>
139
+
140
+ @switch (status()) {
141
+ @case ('loading') {
142
+ <app-skeleton [lines]="8" [height]="48" />
143
+ }
144
+ @case ('error') {
145
+ <app-empty-state
146
+ title="No se pudo cargar el registro"
147
+ message="Revisa tu conexión e intenta de nuevo."
148
+ >
149
+ <button app-button variant="outline" type="button" (click)="retry()">Reintentar</button>
150
+ <button app-button variant="outline" type="button" (click)="volverDeError()">
151
+ Volver
152
+ </button>
153
+ </app-empty-state>
154
+ }
155
+ @case ('ready') {
156
+ @if (formTree(); as tree) {
157
+ @if (serverErrors().general; as general) {
158
+ <p
159
+ class="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger"
160
+ role="alert"
161
+ >
162
+ {{ general }}
163
+ </p>
164
+ }
165
+
166
+ <form novalidate [attr.aria-busy]="saving()" (submit)="onSubmit($event)">
167
+ @if (draftOffer(); as offer) {
168
+ <!-- §4.6: se OFRECE, nunca se restaura solo. role=status para
169
+ anunciarlo sin arrebatarle el foco a quien está escribiendo. -->
170
+ <div
171
+ class="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-neutral/20 bg-surface px-4 py-3 text-sm"
172
+ role="status"
173
+ >
174
+ <span class="text-neutral">
175
+ Hay un borrador guardado {{ draftAge(offer.savedAt) }}.
176
+ </span>
177
+ <button app-button variant="outline" type="button" (click)="restoreDraft()">
178
+ Restaurar
179
+ </button>
180
+ <button app-button variant="ghost" type="button" (click)="discardDraft()">
181
+ Descartar
182
+ </button>
183
+ </div>
184
+ }
185
+
186
+ @if (summaryErrors().length >= 2) {
187
+ <!-- Con uno solo no se pinta: el foco ya está en el campo y
188
+ el resumen sólo restaría. Con 2+ es la única forma de
189
+ ver que hay varios (§4.11). tabindex -1 para poder
190
+ recibir el foco y que role=alert lo anuncie. -->
191
+ <div
192
+ data-error-summary
193
+ tabindex="-1"
194
+ role="alert"
195
+ aria-labelledby="error-summary-title"
196
+ class="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 focus:outline-2 focus:outline-danger"
197
+ >
198
+ <p id="error-summary-title" class="text-sm font-semibold text-danger">
199
+ Hay {{ summaryErrors().length }} errores que corregir
200
+ </p>
201
+ <ul class="mt-2 list-disc space-y-1 pl-5 text-sm">
202
+ @for (entry of summaryErrors(); track entry.id) {
203
+ <li>
204
+ <a
205
+ href="#"
206
+ class="text-danger underline underline-offset-2 hover:text-danger/80"
207
+ (click)="goToError(entry, $event)"
208
+ >
209
+ {{ entry.label }} — {{ entry.message }}
210
+ </a>
211
+ </li>
212
+ }
213
+ </ul>
214
+ </div>
215
+ }
216
+
217
+ @if (layoutMode() === 'grid') {
218
+ <div class="grid grid-cols-1 gap-x-4 sm:grid-cols-12">
219
+ @for (field of renderedFields(); track field.key) {
220
+ <div [class]="colSpan(field)">
221
+ <app-field-host
222
+ [field]="field"
223
+ [tree]="tree"
224
+ [exists]="exists()"
225
+ [serverErrors]="serverErrors().fields"
226
+ [resource]="resourceId()"
227
+ />
228
+ </div>
229
+ }
230
+ </div>
231
+ } @else if (unsectionedFields().length > 0) {
232
+ <div class="grid grid-cols-1 gap-x-4 sm:grid-cols-12">
233
+ @for (field of unsectionedFields(); track field.key) {
234
+ <div [class]="colSpan(field)">
235
+ <app-field-host
236
+ [field]="field"
237
+ [tree]="tree"
238
+ [exists]="exists()"
239
+ [serverErrors]="serverErrors().fields"
240
+ [resource]="resourceId()"
241
+ />
242
+ </div>
243
+ }
244
+ </div>
245
+ }
246
+
247
+ @if (layoutMode() === 'tabs') {
248
+ <div
249
+ role="tablist"
250
+ class="mb-4 flex flex-wrap gap-1 border-b border-neutral/20"
251
+ (keydown)="onTablistKeydown($event)"
252
+ >
253
+ @for (section of sectionViews(); track section.id) {
254
+ <button
255
+ type="button"
256
+ role="tab"
257
+ [id]="tabId(section.id)"
258
+ [class]="tabClass(section)"
259
+ [attr.aria-selected]="activeSection() === section.id"
260
+ [attr.tabindex]="activeSection() === section.id ? 0 : -1"
261
+ (click)="selectSection(section.id)"
262
+ >
263
+ {{ section.label }}
264
+ <!-- Progreso (§4.3, decisión A): el ✓ siempre que esté
265
+ completa; el ⚠ recién después del primer Guardar.
266
+ El glifo va oculto para lectores y el estado se
267
+ cuenta aparte en texto. -->
268
+ @switch (sectionProgress(section)) {
269
+ @case ('problem') {
270
+ <span class="ml-1 text-danger" aria-hidden="true">⚠</span>
271
+ <span class="sr-only"> — con errores</span>
272
+ }
273
+ @case ('complete') {
274
+ <span class="ml-1 text-success" aria-hidden="true">✓</span>
275
+ <span class="sr-only"> — completa</span>
276
+ }
277
+ }
278
+ </button>
279
+ }
280
+ </div>
281
+ }
282
+
283
+ @if (layoutMode() === 'steps') {
284
+ <ol aria-label="Progreso del formulario" class="mb-4 flex flex-wrap gap-2">
285
+ @for (section of sectionViews(); track section.id; let index = $index) {
286
+ <li
287
+ class="rounded-full border px-3 py-1 text-sm"
288
+ [class.border-primary]="activeSection() === section.id"
289
+ [class.font-semibold]="activeSection() === section.id"
290
+ [attr.aria-current]="activeSection() === section.id ? 'step' : null"
291
+ >
292
+ {{ index + 1 }}. {{ section.label }}
293
+ </li>
294
+ }
295
+ </ol>
296
+ }
297
+
298
+ @if (layoutMode() === 'tabs' || layoutMode() === 'steps') {
299
+ @for (section of visibleSections(); track section.id) {
300
+ <div
301
+ role="tabpanel"
302
+ [attr.id]="layoutMode() === 'steps' ? 'step-panel' : null"
303
+ class="grid grid-cols-1 gap-x-4 sm:grid-cols-12"
304
+ >
305
+ @for (field of section.fields; track field.key) {
306
+ <div [class]="colSpan(field)">
307
+ <app-field-host
308
+ [field]="field"
309
+ [tree]="tree"
310
+ [exists]="exists()"
311
+ [serverErrors]="serverErrors().fields"
312
+ [resource]="resourceId()"
313
+ />
314
+ </div>
315
+ }
316
+ </div>
317
+ }
318
+ @if (layoutMode() === 'steps' && sectionViews().length > 1) {
319
+ <div class="mt-4 flex justify-between gap-3">
320
+ <button
321
+ app-button
322
+ variant="outline"
323
+ type="button"
324
+ [disabled]="isFirstStep()"
325
+ (click)="previousStep()"
326
+ >
327
+ Anterior
328
+ </button>
329
+ @if (!isLastStep()) {
330
+ <button app-button type="button" (click)="nextStep()">Continuar</button>
331
+ }
332
+ </div>
333
+ }
334
+ } @else if (layoutMode() === 'columns') {
335
+ <div
336
+ [class]="
337
+ layoutColumns() === 3
338
+ ? 'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'
339
+ : 'grid grid-cols-1 gap-4 md:grid-cols-2'
340
+ "
341
+ >
342
+ @for (section of sectionViews(); track section.id) {
343
+ <section class="rounded-xl border border-neutral/20 bg-surface p-4">
344
+ <h2 class="mb-3 text-section font-semibold">{{ section.label }}</h2>
345
+ <div class="grid grid-cols-1 gap-x-4 sm:grid-cols-12">
346
+ @for (field of section.fields; track field.key) {
347
+ <div [class]="colSpan(field)">
348
+ <app-field-host
349
+ [field]="field"
350
+ [tree]="tree"
351
+ [exists]="exists()"
352
+ [serverErrors]="serverErrors().fields"
353
+ [resource]="resourceId()"
354
+ />
355
+ </div>
356
+ }
357
+ </div>
358
+ </section>
359
+ }
360
+ </div>
361
+ } @else if (layoutMode() === 'sections') {
362
+ <!-- Índice interno (§5.3): en escritorio, índice sticky a la
363
+ izquierda; en móvil, chips horizontales. En tabs las
364
+ pestañas ya hacen de índice, así que esto no se dibuja. -->
365
+ <div class="flex flex-col gap-4 lg:flex-row lg:gap-6">
366
+ <nav
367
+ aria-label="Secciones"
368
+ class="sticky top-4 hidden shrink-0 self-start lg:block lg:w-44"
369
+ >
370
+ <p
371
+ class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral"
372
+ aria-hidden="true"
373
+ >
374
+ Secciones
375
+ </p>
376
+ <ul class="space-y-1 border-l border-neutral/20 pl-3">
377
+ @for (section of visibleSections(); track section.id) {
378
+ <li>
379
+ <button
380
+ type="button"
381
+ class="w-full rounded px-2 py-1 text-left text-sm hover:bg-neutral/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
382
+ [class.font-medium]="sectionProgress(section) === 'problem'"
383
+ (click)="goToSection(section.id)"
384
+ >
385
+ {{ section.label }}
386
+ @switch (sectionProgress(section)) {
387
+ @case ('problem') {
388
+ <span class="ml-1 text-danger" aria-hidden="true">⚠</span>
389
+ <span class="sr-only"> — con errores</span>
390
+ }
391
+ @case ('complete') {
392
+ <span class="ml-1 text-success" aria-hidden="true">✓</span>
393
+ <span class="sr-only"> — completa</span>
394
+ }
395
+ }
396
+ </button>
397
+ </li>
398
+ }
399
+ </ul>
400
+ </nav>
401
+
402
+ <div class="min-w-0 flex-1" [class.lg:grid]="layoutMode() === 'columns'">
403
+ <nav
404
+ aria-label="Secciones"
405
+ class="-mx-1 mb-4 flex gap-2 overflow-x-auto px-1 pb-1 lg:hidden"
406
+ >
407
+ @for (section of visibleSections(); track section.id) {
408
+ <button
409
+ type="button"
410
+ class="shrink-0 rounded-full border border-neutral/30 px-3 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
411
+ [class.border-danger]="sectionProgress(section) === 'problem'"
412
+ [class.text-danger]="sectionProgress(section) === 'problem'"
413
+ [class.font-medium]="sectionProgress(section) === 'problem'"
414
+ (click)="goToSection(section.id)"
415
+ >
416
+ {{ section.label }}
417
+ </button>
418
+ }
419
+ </nav>
420
+
421
+ @for (section of visibleSections(); track section.id) {
422
+ <details
423
+ id="section-{{ section.id }}"
424
+ class="mb-4 rounded-xl border border-neutral/20 bg-surface scroll-mt-4"
425
+ [open]="isSectionOpen(section.id)"
426
+ (toggle)="setSectionOpen(section.id, $event)"
427
+ >
428
+ <summary
429
+ tabindex="-1"
430
+ class="flex cursor-pointer items-center justify-between gap-2 rounded-xl px-4 py-3 text-section font-semibold hover:bg-neutral/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
431
+ >
432
+ <span>{{ section.label }}</span>
433
+ @if (sectionProgress(section) === 'problem') {
434
+ <span class="text-xs font-medium text-danger">Revisar</span>
435
+ }
436
+ </summary>
437
+ <div class="grid grid-cols-1 gap-x-4 px-4 pb-4 sm:grid-cols-12">
438
+ @for (field of section.fields; track field.key) {
439
+ <div [class]="colSpan(field)">
440
+ <app-field-host
441
+ [field]="field"
442
+ [tree]="tree"
443
+ [exists]="exists()"
444
+ [serverErrors]="serverErrors().fields"
445
+ [resource]="resourceId()"
446
+ />
447
+ </div>
448
+ }
449
+ </div>
450
+ </details>
451
+ }
452
+ </div>
453
+ </div>
454
+ }
455
+
456
+ <!-- Sticky (§5.1): en products son 19 campos y Cancelar/Guardar
457
+ desaparecían al final del scroll. bg-surface es opaco: sin
458
+ él el contenido vería pasar por detrás. -->
459
+ <div
460
+ class="sticky bottom-0 z-10 mt-6 flex flex-wrap items-center justify-end gap-3 border-t border-neutral/20 bg-surface pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))]"
461
+ >
462
+ @if (canDelete()) {
463
+ <div class="mr-auto">
464
+ <button app-button variant="danger" type="button" (click)="remove()">
465
+ Eliminar
466
+ </button>
467
+ </div>
468
+ }
469
+ @if (mode() !== 'create') {
470
+ <!-- §5: descartar lo escrito y volver a lo del servidor. -->
471
+ <button app-button variant="ghost" type="button" (click)="restablecer()">
472
+ Restablecer
473
+ </button>
474
+ }
475
+ <button app-button variant="outline" type="button" (click)="back()">
476
+ Cancelar
477
+ </button>
478
+ <button app-button type="submit" [loading]="saving()" [disabled]="!canSave()">
479
+ {{ saving() ? 'Guardando…' : 'Guardar' }}
480
+ </button>
481
+ </div>
482
+ </form>
483
+ }
484
+ }
485
+ }
486
+ } @else {
487
+ <app-empty-state
488
+ title="Recurso no encontrado"
489
+ message="No existe un schema con ese id en el registry."
490
+ />
491
+ }
492
+
493
+ <!-- «Crear nuevo» desde el dropdown de una relación (plan 6.4). Se monta
494
+ y desmonta con el pedido: el Drawer devuelve el foco al botón que lo
495
+ abrió al destruirse. -->
496
+ @if (relationDrawer.request(); as createRequest) {
497
+ @if (drawerSchema(createRequest.resource); as target) {
498
+ <app-drawer-form
499
+ [open]="true"
500
+ [schema]="target"
501
+ mode="create"
502
+ [title]="'Crear ' + target.label"
503
+ (close)="relationDrawer.clear()"
504
+ (saved)="relationCreated($event, createRequest)"
505
+ />
506
+ }
507
+ }
508
+
509
+ @if (createdWithSummary(); as createdRec) {
510
+ @if (schema()?.postCreate; as postCreateConfig) {
511
+ <app-post-create-modal
512
+ [schema]="schema()!"
513
+ [config]="postCreateConfig"
514
+ [record]="createdRec"
515
+ (closed)="onPostCreateClosed()"
516
+ />
517
+ }
518
+ }
519
+ `,
520
+ })
521
+ export class FormView {
522
+ /** Puente con el control `relation` (ver `relation-drawer.service`). */
523
+ readonly relationDrawer = inject(RelationDrawerService);
524
+
525
+ private readonly route = inject(ActivatedRoute);
526
+ private readonly router = inject(Router);
527
+ private readonly nav = inject(ViewNavigationService);
528
+ private readonly api = inject(ApiService);
529
+ private readonly auth = inject(AuthService);
530
+ private readonly notifications = inject(NotificationService);
531
+ private readonly errorMapper = inject(ErrorMapperService);
532
+ /** `form()` puede reconstruirse fuera de contexto (cambio de ruta). */
533
+ private readonly injector = inject(Injector);
534
+ /** Estabilidad de la app: cambio de sección + carga de chunks (ver `goToError`). */
535
+ private readonly appRef = inject(ApplicationRef);
536
+ private readonly params = toSignal(this.route.paramMap, { requireSync: true });
537
+ private readonly routeData = toSignal(this.route.data ?? of({}), { requireSync: true });
538
+
539
+ readonly schema = computed(() => {
540
+ const resourceId = this.routeData()['resourceId'] ?? this.params().get('id');
541
+ return getSchema(typeof resourceId === 'string' ? resourceId : '');
542
+ });
543
+ /** Recurso actual (lo consumen `image` para pedir la firma, §11.2). */
544
+ readonly resourceId = computed(() => this.schema()?.id ?? '');
545
+
546
+ /** Registro recién creado para alimentar el modal de resumen post-creación. */
547
+ readonly createdWithSummary = signal<Record<string, unknown> | null>(null);
548
+
549
+ readonly mode = computed<FormMode>(() => {
550
+ const schema = this.schema();
551
+ if (!schema) {
552
+ return 'single';
553
+ }
554
+ if (this.params().get('key') !== null) {
555
+ return 'edit';
556
+ }
557
+ return schema.kind === 'singleton' ? 'single' : 'create';
558
+ });
559
+
560
+ readonly heading = computed(() => {
561
+ const schema = this.schema();
562
+ if (!schema) {
563
+ return '';
564
+ }
565
+ switch (this.mode()) {
566
+ case 'create':
567
+ return `Nueva ${schema.label}`;
568
+ case 'edit':
569
+ return `Editar ${schema.label}`;
570
+ case 'single':
571
+ return schema.labelPlural;
572
+ }
573
+ });
574
+
575
+ readonly status = signal<FormStatus>('loading');
576
+ /**
577
+ * D4 — el error ORIGINAL de `load()` con identidad intacta (el mensaje
578
+ * mostrado es display-only). Se limpia cuando un reintento termina bien.
579
+ */
580
+ private errorCarga: unknown = undefined;
581
+
582
+ errorDeCarga(): unknown {
583
+ return this.status() === 'error' ? this.errorCarga : undefined;
584
+ }
585
+ /** Estado «guardando» del botón (además del bloqueo interno de `submit`). */
586
+ readonly saving = signal(false);
587
+ /** Errores del backend: rutas de campo + mensaje general (base.md §10). */
588
+ readonly serverErrors = signal<MappedError>({ fields: {} });
589
+ /** Pestaña activa (solo `layout.mode === 'tabs'`). */
590
+ readonly activeSection = signal<string | null>(null);
591
+ private readonly attemptedSteps = signal<ReadonlySet<string>>(new Set());
592
+
593
+ private readonly openSections = signal<Record<string, boolean>>({});
594
+ private readonly formTree = signal<RootTree | null>(null);
595
+ /** Evita que una respuesta lenta pise el formulario ya reconstruido. */
596
+ private loadEpoch = 0;
597
+ /**
598
+ * Borrador (§4.6 / plan 5.4). `draftOffer` queda en `null` cuando no hay
599
+ * nada que ofrecer: NUNCA se restaura solo, sólo con un clic del usuario.
600
+ */
601
+ private readonly draftOffer = signal<{ savedAt: number; value: Record<string, unknown> } | null>(
602
+ null,
603
+ );
604
+ /** Snapshot de lo que vino del servidor; sólo se guarda si el usuario cambió algo. */
605
+ private loadedSnapshot = '';
606
+ /** Registro original, para «Restablecer» (§5). */
607
+ private loadedRecord: Record<string, unknown> | null = null;
608
+ /** Snapshot de los padres de `dependsOn` y el árbol al que pertenece. */
609
+ private readonly dependsOnSnapshot = new Map<string, string>();
610
+ private dependsOnTree: RootTree | null = null;
611
+ /**
612
+ * Hubo un intento de guardado. Sin este guardia el resumen aparecería de
613
+ * entrada en cualquier formulario con `required` vacíos: `invalid()` es
614
+ * cierto desde el primer render, no sólo después de enviar.
615
+ *
616
+ * También gobierna el `⚠` de las pestañas y el bloqueo al cambiar de
617
+ * pestaña (decisión A): antes del primer Guardar un campo `required` vacío
618
+ * YA es inválido, y andar gritando `⚠` —o trancando la navegación— al
619
+ * usuario que todavía no le erró a nada es ruido, no ayuda.
620
+ */
621
+ readonly submitted = signal(false);
622
+
623
+ constructor() {
624
+ // `paramMap` emite síncrono al suscribirse: el primer `rebuild()` corre
625
+ // dentro del constructor (contexto de inyección) y los siguientes, al
626
+ // cambiar de ruta (`new` → `edit`), por eso `form()` lleva `injector`.
627
+ this.route.paramMap.subscribe(() => this.rebuild());
628
+
629
+ // Autoguardado del borrador (§4.6): se dispara con cada cambio del árbol
630
+ // y debouncea 800 ms. `tree().value()` es la lectura que registra la
631
+ // dependencia — sin ella el effect no vería las ediciones del usuario.
632
+ effect((onCleanup) => {
633
+ const tree = this.formTree();
634
+ if (!tree) {
635
+ return;
636
+ }
637
+ const value = tree().value();
638
+ if (this.status() !== 'ready' || JSON.stringify(value) === this.loadedSnapshot) {
639
+ return;
640
+ }
641
+ const timer = setTimeout(() => this.saveDraft(value), DRAFT_DEBOUNCE_MS);
642
+ onCleanup(() => clearTimeout(timer));
643
+ });
644
+
645
+ // Selección dependiente (guía §1, plan 6.6): cuando el padre cambia, el
646
+ // hijo vuelve a su default. `disabled()` en `form-schema.ts` ya lo dejó
647
+ // ineditable mientras el padre esté vacío; esto es la otra mitad.
648
+ // La lectura de `value()` del padre es la que registra la dependencia.
649
+ effect(() => {
650
+ const tree = this.formTree();
651
+ const schema = this.schema();
652
+ if (tree === null || !schema) {
653
+ this.dependsOnSnapshot.clear();
654
+ this.dependsOnTree = null;
655
+ return;
656
+ }
657
+ if (this.dependsOnTree !== tree) {
658
+ // Árbol reconstruido (cambio de ruta): los valores anteriores ya no
659
+ // significan nada, se empieza de cero para no limpiar nada de más.
660
+ this.dependsOnTree = tree;
661
+ this.dependsOnSnapshot.clear();
662
+ }
663
+ for (const link of collectDependsOn(schema.fields)) {
664
+ let container: RootTree = tree;
665
+ for (const key of link.container) {
666
+ container = groupTree(container, key);
667
+ }
668
+ const parentValue = JSON.stringify(
669
+ childTree<unknown>(container, link.parentKey)().value() ?? null,
670
+ );
671
+ const previous = this.dependsOnSnapshot.get(link.id);
672
+ if (previous !== undefined && previous !== parentValue) {
673
+ childTree<unknown>(container, link.childKey)().value.set(link.resetValue);
674
+ }
675
+ this.dependsOnSnapshot.set(link.id, parentValue);
676
+ }
677
+ });
678
+ }
679
+
680
+ // ── Layout (base.md §4) ──
681
+
682
+ readonly layoutMode = computed<LayoutMode>(() => {
683
+ const mode = this.schema()?.layout?.mode ?? 'sections';
684
+ if (!isSupported('layout', mode)) {
685
+ throw new Error(`No existe un descriptor para el layout "${mode}".`);
686
+ }
687
+ return mode;
688
+ });
689
+
690
+ readonly layoutColumns = computed(() => this.schema()?.layout?.columns ?? 2);
691
+
692
+ /** Campos que la UI sabe dibujar (los de Fase 11 se omiten, no se borran del payload). */
693
+ readonly renderedFields = computed(() =>
694
+ (this.schema()?.fields ?? []).filter((field) => isSupported('field', field.type)),
695
+ );
696
+
697
+ /**
698
+ * Ítems del resumen de errores (§4.11), vacío hasta que se intente guardar.
699
+ *
700
+ * Recorre el ÁRBOL, no `serverErrors.fields`: el backend sólo puede
701
+ * devolver un `path` por respuesta (`api.md §7`), así que ahí nunca hay más
702
+ * de un ítem. Los errores múltiples los produce la validación del cliente,
703
+ * que `submit()` deja marcados como tocados de una.
704
+ */
705
+ readonly summaryErrors = computed<ErrorEntry[]>(() => {
706
+ if (!this.submitted()) {
707
+ return [];
708
+ }
709
+ const errors = this.collectErrors();
710
+ if (this.layoutMode() !== 'steps') {
711
+ return errors;
712
+ }
713
+ const attempted = this.attemptedSteps();
714
+ return errors.filter((entry) => !entry.section || attempted.has(entry.section));
715
+ });
716
+
717
+ /** Secciones con al menos un campo dibujable. */
718
+ readonly sectionViews = computed<SectionView[]>(() => {
719
+ const layout = this.schema()?.layout;
720
+ if (!layout) {
721
+ return [];
722
+ }
723
+ const rendered = this.renderedFields();
724
+ return (layout.sections ?? [])
725
+ .map((section) => ({
726
+ id: section.id,
727
+ label: section.label,
728
+ fields: rendered.filter((field) => field.section === section.id),
729
+ }))
730
+ .filter((view) => view.fields.length > 0);
731
+ });
732
+
733
+ /** Campos sin sección (o con una sección inexistente): siempre visibles. */
734
+ readonly unsectionedFields = computed(() => {
735
+ const ids = new Set(this.sectionViews().map((view) => view.id));
736
+ return this.renderedFields().filter(
737
+ (field) => field.section === undefined || !ids.has(field.section),
738
+ );
739
+ });
740
+
741
+ /** En pestañas solo se dibuja la activa; en secciones, todas (colapsables). */
742
+ readonly visibleSections = computed<SectionView[]>(() => {
743
+ const sections = this.sectionViews();
744
+ if (this.layoutMode() !== 'tabs' && this.layoutMode() !== 'steps') {
745
+ return sections;
746
+ }
747
+ const active = sections.find((section) => section.id === this.activeSection()) ?? sections[0];
748
+ return active ? [active] : [];
749
+ });
750
+
751
+ // ── Estado del formulario ──
752
+
753
+ /** Indicador _dirty_ + aviso al salir (base.md §10). Agrega descendientes. */
754
+ readonly dirty = computed(() => this.formTree()?.().dirty() ?? false);
755
+
756
+ /** `slug` con `lockAfterCreate` se bloquea solo si el registro ya existe. */
757
+ readonly exists = computed(() => this.mode() !== 'create');
758
+
759
+ readonly canSave = computed(() => {
760
+ const schema = this.schema();
761
+ if (!schema) return false;
762
+ if (this.layoutMode() === 'steps' && !this.isLastStep()) return false;
763
+ const operation = this.mode() === 'create' ? 'create' : 'update';
764
+ return this.auth.canDoOperation(schema, operation);
765
+ });
766
+
767
+ readonly canDelete = computed(() => {
768
+ const schema = this.schema();
769
+ return this.mode() === 'edit' && schema && this.auth.canDoOperation(schema, 'remove');
770
+ });
771
+
772
+ // ── Ciclo de vida ──
773
+
774
+ /** Reconstruye el formulario al cambiar de ruta o de recurso. */
775
+ private rebuild(): void {
776
+ const schema = this.schema();
777
+ this.serverErrors.set({ fields: {} });
778
+ this.openSections.set({});
779
+ this.activeSection.set(null);
780
+ this.attemptedSteps.set(new Set());
781
+ this.submitted.set(false);
782
+
783
+ if (!schema) {
784
+ this.formTree.set(null);
785
+ this.status.set('error');
786
+ return;
787
+ }
788
+
789
+ const model = signal(seedModel(schema));
790
+ const schemaFn = buildFormSchema(schema, this.api, this.recordKey() ?? undefined);
791
+ this.formTree.set(form(model, schemaFn, { injector: this.injector }));
792
+ this.activeSection.set(this.sectionViews()[0]?.id ?? null);
793
+
794
+ // El snapshot de «sin cambios» arranca en los defaults sembrados; si no,
795
+ // el effect guardaría un borrador con el formulario recién abierto.
796
+ const tree = this.formTree();
797
+ this.loadedSnapshot = JSON.stringify(tree ? tree().value() : {});
798
+ this.loadedRecord = null;
799
+ this.draftOffer.set(null);
800
+
801
+ if (this.mode() === 'create') {
802
+ // Crear no lee nada: el formulario está listo de entrada.
803
+ this.status.set('ready');
804
+ // En crear solo hay clave de recurso sin key: un borrador previo sí
805
+ // puede aplicar (mismo «nuevo»), así que se ofrece.
806
+ this.offerDraft(null);
807
+ return;
808
+ }
809
+ this.status.set('loading');
810
+ void this.load();
811
+ }
812
+
813
+ private async load(): Promise<void> {
814
+ const schema = this.schema();
815
+ const tree = this.formTree();
816
+ if (!schema || !tree) {
817
+ return;
818
+ }
819
+ const epoch = ++this.loadEpoch;
820
+ try {
821
+ const record = await firstValueFrom(
822
+ this.api.get<Record<string, unknown>>(schema.endpoint, this.recordKey() ?? undefined),
823
+ );
824
+ if (epoch !== this.loadEpoch) {
825
+ return;
826
+ }
827
+ this.applyRecord(tree, schema, record ?? {});
828
+ this.loadedRecord = record ?? {};
829
+ this.errorCarga = undefined;
830
+ this.status.set('ready');
831
+ // Recién termina la carga: es el único momento en que tiene sentido
832
+ // comparar el borrador con el servidor.
833
+ this.offerDraft(record ?? null);
834
+ } catch (error) {
835
+ if (epoch !== this.loadEpoch) {
836
+ return;
837
+ }
838
+ this.errorCarga = error;
839
+ this.status.set('error');
840
+ }
841
+ }
842
+
843
+ /**
844
+ * Siembra defaults + registro: los campos ausentes en la respuesta conservan
845
+ * su valor por defecto y los grupos/ítems parciales se fusionan en profundidad
846
+ * (si no, `childTree` no encontraría nodos y revienta al renderizar).
847
+ * `value.set` es programático: no marca `dirty`.
848
+ */
849
+ private applyRecord(
850
+ tree: RootTree,
851
+ schema: ResourceSchema,
852
+ record: Record<string, unknown>,
853
+ ): void {
854
+ tree().value.set(mergeRecord(schema.fields, record));
855
+ // Base de comparación del autoguardado: con esto el effect no escribe un
856
+ // borrador idéntico al servidor apenas termina la carga.
857
+ this.loadedSnapshot = JSON.stringify(tree().value());
858
+ }
859
+
860
+ // ── Borrador (§4.6, plan 5.4) ──
861
+
862
+ /** `draft:<recurso>:<key|nuevo>` — aislado por recurso Y registro. */
863
+ private draftStorageKey(): string {
864
+ return `draft:${this.resourceId()}:${this.recordKey() ?? 'nuevo'}`;
865
+ }
866
+
867
+ /** Best-effort: localStorage puede fallar (modo privado, cuota). */
868
+ private saveDraft(value: Record<string, unknown>): void {
869
+ try {
870
+ localStorage.setItem(this.draftStorageKey(), JSON.stringify({ savedAt: Date.now(), value }));
871
+ } catch {
872
+ /* el borrador nunca debe romper el formulario */
873
+ }
874
+ }
875
+
876
+ /**
877
+ * Ofrece el borrador si es más reciente que el servidor. Si el registro no
878
+ * trae fecha, cualquier borrador sirve — sólo existe si el usuario cambió
879
+ * algo, porque `loadedSnapshot` filtra los idénticos.
880
+ */
881
+ private offerDraft(serverRecord: Record<string, unknown> | null): void {
882
+ let raw: string | null = null;
883
+ try {
884
+ raw = localStorage.getItem(this.draftStorageKey());
885
+ } catch {
886
+ return;
887
+ }
888
+ if (!raw) {
889
+ return;
890
+ }
891
+ let draft: { savedAt?: number; value?: unknown } | null = null;
892
+ try {
893
+ draft = JSON.parse(raw) as { savedAt?: number; value?: unknown };
894
+ } catch {
895
+ return;
896
+ }
897
+ if (!draft || typeof draft.savedAt !== 'number' || !isRecord(draft.value)) {
898
+ this.clearDraft();
899
+ return;
900
+ }
901
+
902
+ const serverTs = readTimestamp(serverRecord);
903
+ if (serverTs !== undefined && serverTs >= draft.savedAt) {
904
+ // El servidor ya tiene algo más nuevo: el borrador está vencido.
905
+ this.clearDraft();
906
+ return;
907
+ }
908
+ this.draftOffer.set({ savedAt: draft.savedAt, value: draft.value });
909
+ }
910
+
911
+ /** Restaurar es una acción explícita del usuario (§4.6: nunca en silencio). */
912
+ restoreDraft(): void {
913
+ const offer = this.draftOffer();
914
+ const tree = this.formTree();
915
+ const schema = this.schema();
916
+ if (!offer || !tree || !schema) {
917
+ return;
918
+ }
919
+ this.applyRecord(tree, schema, offer.value);
920
+ this.draftOffer.set(null);
921
+ this.notifications.info('Borrador restaurado.');
922
+ }
923
+
924
+ /** Descarta el borrador sin tocar lo cargado. */
925
+ discardDraft(): void {
926
+ this.clearDraft();
927
+ this.draftOffer.set(null);
928
+ }
929
+
930
+ private clearDraft(): void {
931
+ try {
932
+ localStorage.removeItem(this.draftStorageKey());
933
+ } catch {
934
+ /* idem */
935
+ }
936
+ }
937
+
938
+ /** «hace 1 min» — relativo y discreto (§4.6). */
939
+ draftAge(savedAt: number): string {
940
+ const minutes = Math.max(1, Math.round((Date.now() - savedAt) / 60_000));
941
+ if (minutes < 60) {
942
+ return `hace ${minutes} min`;
943
+ }
944
+ const hours = Math.round(minutes / 60);
945
+ return `hace ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
946
+ }
947
+
948
+ /**
949
+ * Botón «Restablecer» (§5): vuelve a lo que vino del servidor y limpia el
950
+ * borrador. Es la única vía de descartar cambios en modo edición.
951
+ */
952
+ restablecer(): void {
953
+ const tree = this.formTree();
954
+ const schema = this.schema();
955
+ if (!tree || !schema) {
956
+ return;
957
+ }
958
+ this.applyRecord(tree, schema, this.loadedRecord ?? seedModel(schema));
959
+ this.serverErrors.set({ fields: {} });
960
+ this.submitted.set(false);
961
+ this.attemptedSteps.set(new Set());
962
+ this.draftOffer.set(null);
963
+ this.clearDraft();
964
+ this.notifications.info('Se restauraron los valores originales.');
965
+ }
966
+
967
+ retry(): void {
968
+ void this.load();
969
+ }
970
+
971
+ /**
972
+ * D4 — «way back» desde el estado de error: en la ruta directa
973
+ * (`/:id/new`, `/:id/:key/edit`) no hay output que escape, así que el
974
+ * servicio declarado navega al origen registrado o a la raíz del recurso.
975
+ */
976
+ protected volverDeError(): void {
977
+ void this.nav.back();
978
+ }
979
+
980
+ // ── Guardado ──
981
+
982
+ async onSubmit(event: Event): Promise<void> {
983
+ event.preventDefault();
984
+ const tree = this.formTree();
985
+ if (!tree || this.saving()) {
986
+ return;
987
+ }
988
+ this.serverErrors.set({ fields: {} });
989
+ this.saving.set(true);
990
+ try {
991
+ const ok = await submit(tree, {
992
+ // `null` = sin errores: los del backend salen por `applyServerErrors`
993
+ // (también si `submit` dio `true`), para no dejar el form invalidado.
994
+ action: async () => {
995
+ await this.persist(tree);
996
+ return null;
997
+ },
998
+ onInvalid: () => this.revealErrors(),
999
+ });
1000
+ if (!ok || this.hasServerErrors()) {
1001
+ this.revealErrors();
1002
+ }
1003
+ } finally {
1004
+ this.saving.set(false);
1005
+ }
1006
+ }
1007
+
1008
+ /**
1009
+ * create/update según el modo; nunca lanza (los errores van a `serverErrors`).
1010
+ *
1011
+ * **Regla de éxito por flujo** (guía §6: «toast y volver a la lista o
1012
+ * quedarse según el flujo») — es la misma para los cuatro recursos:
1013
+ *
1014
+ * - **Crear** desde la pantalla completa → **vuelve a la lista** del
1015
+ * recurso: el registro quedó creado y no hay nada más que editar acá.
1016
+ * - **Editar** → **se queda** y recarga lo guardado: el usuario sigue
1017
+ * trabajando sobre el mismo registro.
1018
+ * - **Eliminar** → **vuelve a la lista** (`back()`).
1019
+ * - **Desde un drawer** (crear una relación desde el dropdown, edición
1020
+ * rápida desde la lista) → **se queda** donde estaba: salir destruiría
1021
+ * el contexto que el drawer existía para conservar. Ver
1022
+ * `drawer-form.ts` y `relation-drawer.service.ts`.
1023
+ *
1024
+ * El toast se emite **antes** de navegar y el contenedor vive en el
1025
+ * layout, así que sobrevive al cambio de ruta y el mensaje se lee igual.
1026
+ */
1027
+ private async persist(tree: RootTree): Promise<void> {
1028
+ const schema = this.schema();
1029
+ if (!schema) {
1030
+ return;
1031
+ }
1032
+ const payload = buildPayload(schema, tree);
1033
+
1034
+ try {
1035
+ if (this.mode() === 'create') {
1036
+ const created = await firstValueFrom(
1037
+ this.api.create<Record<string, unknown>>(schema.endpoint, payload),
1038
+ );
1039
+ // Limpia `dirty` antes de navegar o abrir modal: si no, el guard pediría confirmar.
1040
+ tree().reset();
1041
+ // El borrador ya no aporta nada (§5.5).
1042
+ this.clearDraft();
1043
+ this.draftOffer.set(null);
1044
+
1045
+ if (schema.postCreate?.mode === 'modal') {
1046
+ this.createdWithSummary.set(created ?? (payload as Record<string, unknown>));
1047
+ return;
1048
+ }
1049
+
1050
+ this.notifications.success('Registro creado.');
1051
+ // Vuelve al listado del recurso (el «inicio» de la colección); al
1052
+ // reentrar en `:id/new` el form se reconstruye limpio.
1053
+ void this.router.navigate(['/', schema.id]);
1054
+ return;
1055
+ }
1056
+
1057
+ await firstValueFrom(
1058
+ this.api.update<Record<string, unknown>>(
1059
+ schema.endpoint,
1060
+ this.mode() === 'edit' ? (this.recordKey() ?? undefined) : undefined,
1061
+ payload,
1062
+ ),
1063
+ );
1064
+ this.notifications.success('Registro guardado.');
1065
+ tree().reset();
1066
+ // Guardado exitoso = fin del borrador (§5.5). El snapshot se actualiza
1067
+ // para que el effect no lo reescriba con los valores ya persistidos.
1068
+ this.clearDraft();
1069
+ this.draftOffer.set(null);
1070
+ this.loadedSnapshot = JSON.stringify(tree().value());
1071
+ } catch (err) {
1072
+ this.applyServerErrors(this.errorMapper.map(err));
1073
+ return;
1074
+ }
1075
+
1076
+ // Recarga del valor guardado (base.md §10); su fallo no invalida el guardado.
1077
+ try {
1078
+ const record = await firstValueFrom(
1079
+ this.api.get<Record<string, unknown>>(schema.endpoint, this.recordKey() ?? undefined),
1080
+ );
1081
+ this.applyRecord(tree, schema, record ?? {});
1082
+ tree().reset();
1083
+ } catch {
1084
+ this.notifications.error('No se pudo recargar el registro.');
1085
+ }
1086
+ }
1087
+
1088
+ /**
1089
+ * Errores del backend → mapa por ruta; el mensaje se queda en `general`
1090
+ * sólo si su raíz (`address`, `faq[2]` → `faq`) no es un campo dibujado
1091
+ * (base.md §10). Los hijos anidados se resuelven en el host con
1092
+ * `serverErrors[prefix + key]`.
1093
+ */
1094
+ private applyServerErrors(mapped: MappedError): void {
1095
+ const rendered = new Set(this.renderedFields().map((field) => field.key));
1096
+ const fields: Record<string, string> = {};
1097
+ const loose: string[] = [];
1098
+ if (mapped.general) {
1099
+ loose.push(mapped.general);
1100
+ }
1101
+ for (const [path, message] of Object.entries(mapped.fields)) {
1102
+ const root = path.split(/[.[]/)[0];
1103
+ if (rendered.has(root)) {
1104
+ fields[path] = message;
1105
+ } else {
1106
+ // Campo no dibujado u oculto: el usuario tiene que verlo.
1107
+ loose.push(message);
1108
+ }
1109
+ }
1110
+ this.serverErrors.set({ fields, general: loose.length > 0 ? loose.join(' ') : undefined });
1111
+ }
1112
+
1113
+ private hasServerErrors(): boolean {
1114
+ const current = this.serverErrors();
1115
+ return current.general !== undefined || Object.keys(current.fields).length > 0;
1116
+ }
1117
+
1118
+ /** Muestra todos los errores: abre secciones, activa la pestaña con problemas. */
1119
+ private revealErrors(): void {
1120
+ // Marca el intento: habilita `summaryErrors` (vacío hasta acá).
1121
+ this.submitted.set(true);
1122
+ const sections = this.sectionViews();
1123
+ if (this.layoutMode() === 'sections') {
1124
+ this.openSections.update((open) => {
1125
+ const next = { ...open };
1126
+ for (const section of sections) {
1127
+ next[section.id] = true;
1128
+ }
1129
+ return next;
1130
+ });
1131
+ }
1132
+ const withProblem = sections.find((section) => this.sectionHasProblem(section));
1133
+ if (withProblem && this.layoutMode() === 'tabs') {
1134
+ this.activeSection.set(withProblem.id);
1135
+ }
1136
+ if (this.layoutMode() === 'steps') {
1137
+ const invalidSteps = sections
1138
+ .filter((section) => this.sectionHasProblem(section))
1139
+ .map((section) => section.id);
1140
+ this.attemptedSteps.update((attempted) => new Set([...attempted, ...invalidSteps]));
1141
+ if (withProblem) {
1142
+ this.activeSection.set(withProblem.id);
1143
+ }
1144
+ }
1145
+ // Una vez abiertas/activadas las secciones con errores, el navegador
1146
+ // debe IR al primer campo con problema (no quedarse arriba del form).
1147
+ // `setTimeout`: los mensajes se pintan en la pasada de change detection
1148
+ // posterior al submit.
1149
+ setTimeout(() => {
1150
+ if (this.summaryErrors().length >= 2) {
1151
+ this.focusSummary();
1152
+ } else {
1153
+ this.scrollToFirstError();
1154
+ }
1155
+ });
1156
+ }
1157
+
1158
+ /** Scroll + focus al primer campo visible con mensaje de error. */
1159
+ private scrollToFirstError(): void {
1160
+ for (const wrap of document.querySelectorAll<HTMLElement>('[data-field]')) {
1161
+ if (!wrap.querySelector('p[role="alert"]')) {
1162
+ continue;
1163
+ }
1164
+ wrap.scrollIntoView({ behavior: 'smooth', block: 'center' });
1165
+ const focusable = wrap.querySelector<HTMLElement>(
1166
+ 'input:not([type="hidden"]), textarea, select, [contenteditable="true"]',
1167
+ );
1168
+ focusable?.focus({ preventScroll: true });
1169
+ return;
1170
+ }
1171
+ }
1172
+
1173
+ // ── Borrado ──
1174
+
1175
+ async remove(): Promise<void> {
1176
+ const schema = this.schema();
1177
+ const key = this.recordKey();
1178
+ if (!schema || this.mode() !== 'edit' || !key) {
1179
+ return;
1180
+ }
1181
+ const title = this.title();
1182
+ const accepted = await this.notifications.confirm({
1183
+ title: title ? `Eliminar «${title}»` : `Eliminar ${schema.label.toLowerCase()}`,
1184
+ message: 'Esta acción no se puede deshacer.',
1185
+ confirmLabel: 'Eliminar',
1186
+ cancelLabel: 'Cancelar',
1187
+ });
1188
+ if (!accepted) {
1189
+ return;
1190
+ }
1191
+ try {
1192
+ await firstValueFrom(this.api.remove(schema.endpoint, key));
1193
+ this.notifications.success('Registro eliminado.');
1194
+ this.back();
1195
+ } catch (err) {
1196
+ // El motivo (p. ej. «categoría con productos») lo decide el backend.
1197
+ this.notifications.error(this.errorMapper.messageOf(err));
1198
+ }
1199
+ }
1200
+
1201
+ /** Consultado por `unsavedChangesGuard` al salir de la ruta. */
1202
+ async canDeactivate(): Promise<boolean> {
1203
+ if (!this.dirty()) {
1204
+ return true;
1205
+ }
1206
+ return this.notifications.confirm({
1207
+ title: 'Cambios sin guardar',
1208
+ message: 'Si sales ahora, se perderán los cambios realizados.',
1209
+ confirmLabel: 'Salir sin guardar',
1210
+ cancelLabel: 'Seguir editando',
1211
+ });
1212
+ }
1213
+
1214
+ back(): void {
1215
+ void this.router.navigate(['/', this.schema()?.id]);
1216
+ }
1217
+
1218
+ onPostCreateClosed(): void {
1219
+ const schema = this.schema();
1220
+ this.createdWithSummary.set(null);
1221
+ if (schema) {
1222
+ void this.router.navigate(['/', schema.id]);
1223
+ }
1224
+ }
1225
+
1226
+ // ── Helpers de template ──
1227
+
1228
+ colSpan(field: FieldSchema): string {
1229
+ return colSpanClass(field.width);
1230
+ }
1231
+
1232
+ /** Id estable de una pestaña: ancla del foco programático. */
1233
+ tabId(sectionId: string): string {
1234
+ return `section-tab-${sectionId}`;
1235
+ }
1236
+
1237
+ /**
1238
+ * `role="tab"` exige moverse con `ArrowLeft`/`ArrowRight`/`Home`/`End`
1239
+ * (WAI-ARIA APG). El foco se reengancha al que quedó ACTIVO, no al que se
1240
+ * pidió: si `selectSection()` bloquea el cambio por validación (§4.9,
1241
+ * decisión A) la selección no se mueve y el foco se queda donde estaba.
1242
+ *
1243
+ * Este handler es también lo que hace correcto el `Tab`: con el roving
1244
+ * `tabindex` sólo la pestaña activa entra en el orden de tabulación, así
1245
+ * que `Tab` va del tablist al primer campo de la sección en lugar de
1246
+ * recorrer las pestañas apagadas.
1247
+ */
1248
+ onTablistKeydown(event: KeyboardEvent): void {
1249
+ const tabs = this.sectionViews();
1250
+ if (tabs.length < 2) {
1251
+ return;
1252
+ }
1253
+ const current = Math.max(
1254
+ tabs.findIndex((section) => section.id === this.activeSection()),
1255
+ 0,
1256
+ );
1257
+
1258
+ let next: number;
1259
+ switch (event.key) {
1260
+ case 'ArrowRight':
1261
+ next = (current + 1) % tabs.length;
1262
+ break;
1263
+ case 'ArrowLeft':
1264
+ next = (current - 1 + tabs.length) % tabs.length;
1265
+ break;
1266
+ case 'Home':
1267
+ next = 0;
1268
+ break;
1269
+ case 'End':
1270
+ next = tabs.length - 1;
1271
+ break;
1272
+ default:
1273
+ return;
1274
+ }
1275
+
1276
+ event.preventDefault();
1277
+ this.selectSection(tabs[next].id);
1278
+ const active = this.activeSection() ?? tabs[current].id;
1279
+ document.getElementById(this.tabId(active))?.focus();
1280
+ }
1281
+
1282
+ tabClass(section: SectionView): string {
1283
+ return section.id === this.activeSection() ? TAB_ACTIVE : TAB_INACTIVE;
1284
+ }
1285
+
1286
+ isFirstStep(): boolean {
1287
+ const sections = this.sectionViews();
1288
+ return sections.findIndex((section) => section.id === this.activeSection()) <= 0;
1289
+ }
1290
+
1291
+ isLastStep(): boolean {
1292
+ const sections = this.sectionViews();
1293
+ const index = sections.findIndex((section) => section.id === this.activeSection());
1294
+ return sections.length === 0 || index >= sections.length - 1;
1295
+ }
1296
+
1297
+ previousStep(): void {
1298
+ const sections = this.sectionViews();
1299
+ const current = sections.findIndex((section) => section.id === this.activeSection());
1300
+ if (current > 0) {
1301
+ this.activeSection.set(sections[current - 1].id);
1302
+ this.scrollToStep();
1303
+ }
1304
+ }
1305
+
1306
+ nextStep(): void {
1307
+ const sections = this.sectionViews();
1308
+ const current = sections.findIndex((section) => section.id === this.activeSection());
1309
+ if (current < 0 || current >= sections.length - 1) {
1310
+ return;
1311
+ }
1312
+ if (current === 0 && this.unsectionedHasLocalErrors()) {
1313
+ this.revealUnsectionedErrors();
1314
+ return;
1315
+ }
1316
+ const section = sections[current];
1317
+ if (this.sectionHasLocalErrors(section.id)) {
1318
+ this.attemptedSteps.update((attempted) => new Set([...attempted, section.id]));
1319
+ this.revealSectionErrors(section.id);
1320
+ return;
1321
+ }
1322
+ this.activeSection.set(sections[current + 1].id);
1323
+ this.scrollToStep();
1324
+ }
1325
+
1326
+ private unsectionedHasLocalErrors(): boolean {
1327
+ const tree = this.formTree();
1328
+ return (
1329
+ tree !== null &&
1330
+ this.unsectionedFields().some((field) => childTree<unknown>(tree, field.key)().invalid())
1331
+ );
1332
+ }
1333
+
1334
+ private revealUnsectionedErrors(): void {
1335
+ const tree = this.formTree();
1336
+ if (!tree) {
1337
+ return;
1338
+ }
1339
+ this.submitted.set(true);
1340
+ for (const field of this.unsectionedFields()) {
1341
+ childTree<unknown>(tree, field.key)().markAsTouched();
1342
+ }
1343
+ setTimeout(() => this.scrollToFirstError());
1344
+ }
1345
+
1346
+ private scrollToStep(): void {
1347
+ setTimeout(() =>
1348
+ document.getElementById('step-panel')?.scrollIntoView({
1349
+ behavior: 'smooth',
1350
+ block: 'start',
1351
+ }),
1352
+ );
1353
+ }
1354
+
1355
+ selectSection(id: string): void {
1356
+ const current = this.activeSection();
1357
+ // §4.9 / plan 5.7, decisión A: sólo se tranc después del primer envío.
1358
+ // Antes, `invalid()` es cierto por los `required` vacíos y no se le puede
1359
+ // prohibir al usuario recorrer las pestañas para ver qué hay en cada una.
1360
+ if (
1361
+ this.submitted() &&
1362
+ current !== null &&
1363
+ current !== id &&
1364
+ this.sectionHasLocalErrors(current)
1365
+ ) {
1366
+ this.revealSectionErrors(current);
1367
+ return;
1368
+ }
1369
+ this.activeSection.set(id);
1370
+ }
1371
+
1372
+ /** Sólo validación local: los errores del backend no bloquean navegar. */
1373
+ private sectionHasLocalErrors(sectionId: string): boolean {
1374
+ const tree = this.formTree();
1375
+ const section = this.sectionViews().find((view) => view.id === sectionId);
1376
+ if (!tree || !section) {
1377
+ return false;
1378
+ }
1379
+ return section.fields.some((field) => childTree<unknown>(tree, field.key)().invalid());
1380
+ }
1381
+
1382
+ /**
1383
+ * Marca la sección como tocada (los mensajes sólo aparecen una vez tocado)
1384
+ * y devuelve el foco al primer campo con problema, sin cambiar de pestaña.
1385
+ */
1386
+ private revealSectionErrors(sectionId: string): void {
1387
+ const tree = this.formTree();
1388
+ const section = this.sectionViews().find((view) => view.id === sectionId);
1389
+ if (!tree || !section) {
1390
+ return;
1391
+ }
1392
+ this.submitted.set(true);
1393
+ this.attemptedSteps.update((attempted) => new Set([...attempted, sectionId]));
1394
+ for (const field of section.fields) {
1395
+ childTree<unknown>(tree, field.key)().markAsTouched();
1396
+ }
1397
+ // Los mensajes se pintan en la pasada de change detection posterior.
1398
+ setTimeout(() => this.scrollToFirstError());
1399
+ }
1400
+
1401
+ isSectionOpen(id: string): boolean {
1402
+ // Las secciones arrancan abiertas (base.md §4).
1403
+ return this.openSections()[id] ?? true;
1404
+ }
1405
+
1406
+ setSectionOpen(id: string, event: Event): void {
1407
+ const open = (event.target as HTMLDetailsElement).open;
1408
+ this.openSections.update((sections) => ({ ...sections, [id]: open }));
1409
+ }
1410
+
1411
+ /**
1412
+ * Índice lateral (§5.3) → sección. Abre el `<details>` primero: si estaba
1413
+ * plegado, saltar antes daría con un elemento sin altura. El foco va al
1414
+ * `<summary>` para que el teclado siga teniendo dónde continuar.
1415
+ */
1416
+ goToSection(id: string): void {
1417
+ this.openSections.update((open) => ({ ...open, [id]: true }));
1418
+ setTimeout(() => {
1419
+ const section = document.getElementById(`section-${id}`);
1420
+ if (!section) {
1421
+ return;
1422
+ }
1423
+ section.scrollIntoView({ behavior: 'smooth', block: 'start' });
1424
+ section.querySelector<HTMLElement>('summary')?.focus({ preventScroll: true });
1425
+ });
1426
+ }
1427
+
1428
+ /** Invalido (propio, anidado o del backend): marca la pestaña/sección. */
1429
+ fieldHasProblem(field: FieldSchema): boolean {
1430
+ const root = field.key;
1431
+ const hasServerError = Object.keys(this.serverErrors().fields).some(
1432
+ (path) => path === root || path.startsWith(`${root}.`) || path.startsWith(`${root}[`),
1433
+ );
1434
+ if (hasServerError) {
1435
+ return true;
1436
+ }
1437
+ const tree = this.formTree();
1438
+ return tree !== null && childTree<unknown>(tree, root)().invalid();
1439
+ }
1440
+
1441
+ sectionHasProblem(section: SectionView): boolean {
1442
+ return section.fields.some((field) => this.fieldHasProblem(field));
1443
+ }
1444
+
1445
+ /**
1446
+ * Estado de progreso de una sección (§4.3 / decisión A).
1447
+ *
1448
+ * - `complete`: sin problemas → `✓` siempre, es progreso positivo.
1449
+ * - `problem`: con problemas **después** del primer envío → `⚠`.
1450
+ * - `pending`: con problemas pero todavía no se envió → sin marca, para
1451
+ * no amedrentar a quien recién abrió el formulario.
1452
+ */
1453
+ sectionProgress(section: SectionView): 'problem' | 'complete' | 'pending' {
1454
+ if (!this.sectionHasProblem(section)) {
1455
+ return 'complete';
1456
+ }
1457
+ if (this.layoutMode() === 'steps' && !this.attemptedSteps().has(section.id)) {
1458
+ return 'pending';
1459
+ }
1460
+ return this.submitted() ? 'problem' : 'pending';
1461
+ }
1462
+
1463
+ // ── Resumen de errores (§4.11) ──
1464
+
1465
+ /**
1466
+ * Un ítem por campo con problema, recorriendo el árbol. Los `group` se
1467
+ * descienden para quedar en la hoja (ahí está el mensaje real); los
1468
+ * compuestos (`list`, `key-value`, `string-list`) se listan como uno,
1469
+ * porque el envoltorio es el único ancla a la que se puede saltar.
1470
+ */
1471
+ private collectErrors(): ErrorEntry[] {
1472
+ const tree = this.formTree();
1473
+ if (!tree) {
1474
+ return [];
1475
+ }
1476
+ const server = this.serverErrors().fields;
1477
+ const entries: ErrorEntry[] = [];
1478
+ const seen = new Set<string>();
1479
+
1480
+ const walk = (
1481
+ fields: readonly FieldSchema[],
1482
+ subtree: RootTree,
1483
+ inheritedSection?: string,
1484
+ ): void => {
1485
+ for (const field of fields) {
1486
+ const section = field.section ?? inheritedSection;
1487
+ const state = childTree<unknown>(subtree, field.key)();
1488
+ const serverMessage = this.serverMessageFor(field.key, server);
1489
+
1490
+ if (field.type === 'group') {
1491
+ const before = entries.length;
1492
+ walk(field.fields, groupTree(subtree, field.key), section);
1493
+ // El grupo sigue marcado pero ningún hijo aportó el motivo.
1494
+ if (entries.length === before && (serverMessage || state.invalid())) {
1495
+ seen.add(state.name());
1496
+ entries.push({
1497
+ id: state.name(),
1498
+ label: field.label,
1499
+ message: serverMessage ?? 'Revisa este campo',
1500
+ section,
1501
+ });
1502
+ }
1503
+ continue;
1504
+ }
1505
+
1506
+ if (!state.invalid() && !serverMessage) {
1507
+ continue;
1508
+ }
1509
+ const composite =
1510
+ field.type === 'list' || field.type === 'key-value' || field.type === 'string-list';
1511
+ const id = state.name();
1512
+ if (seen.has(id)) {
1513
+ continue;
1514
+ }
1515
+ seen.add(id);
1516
+ entries.push({
1517
+ id,
1518
+ label: field.label,
1519
+ message:
1520
+ serverMessage ??
1521
+ state.errors()[0]?.message ??
1522
+ (composite ? 'Hay elementos con errores' : 'Revisa este campo'),
1523
+ section,
1524
+ });
1525
+ }
1526
+ };
1527
+
1528
+ walk(this.renderedFields(), tree);
1529
+ return entries;
1530
+ }
1531
+
1532
+ /** Mensaje del backend para una ruta raíz (`faq` cubre `faq[2].answer`). */
1533
+ private serverMessageFor(root: string, server: Record<string, string>): string | undefined {
1534
+ for (const [path, message] of Object.entries(server)) {
1535
+ if (path === root || path.startsWith(`${root}.`) || path.startsWith(`${root}[`)) {
1536
+ return message;
1537
+ }
1538
+ }
1539
+ return undefined;
1540
+ }
1541
+
1542
+ /**
1543
+ * Enlace del resumen → campo. Cambia de pestaña/sección si hace falta y
1544
+ * sólo después salta: en modo tabs el campo de otra pestaña todavía no
1545
+ * existe en el DOM en este punto.
1546
+ *
1547
+ * Esperar la estabilidad sustituye al `setTimeout` anterior: cubre de una
1548
+ * vez la pasada de change detection del cambio de sección **y** la carga
1549
+ * del chunk perezoso del descriptor (`load()`, R3.4). Sin esa espera el
1550
+ * foco se pierde sobre el envoltorio, que sí existe pero todavía no tiene
1551
+ * control dentro.
1552
+ */
1553
+ async goToError(entry: ErrorEntry, event: Event): Promise<void> {
1554
+ event.preventDefault();
1555
+ if (entry.section) {
1556
+ if (this.layoutMode() === 'tabs' || this.layoutMode() === 'steps') {
1557
+ this.activeSection.set(entry.section);
1558
+ } else {
1559
+ this.openSections.update((open) => ({ ...open, [entry.section as string]: true }));
1560
+ }
1561
+ }
1562
+ await this.appRef.whenStable();
1563
+ this.focusField(entry.id);
1564
+ }
1565
+
1566
+ /** Scroll + focus al campo con `data-field="id"` (mismo selector que `scrollToFirstError`). */
1567
+ private focusField(id: string): void {
1568
+ const wrap = document.querySelector<HTMLElement>(`[data-field="${id}"]`);
1569
+ if (!wrap) {
1570
+ return;
1571
+ }
1572
+ wrap.scrollIntoView({ behavior: 'smooth', block: 'center' });
1573
+ const focusable = wrap.querySelector<HTMLElement>(
1574
+ 'input:not([type="hidden"]), textarea, select, [contenteditable="true"]',
1575
+ );
1576
+ focusable?.focus({ preventScroll: true });
1577
+ }
1578
+
1579
+ /**
1580
+ * Con 2+ errores el foco va al resumen: es la única forma de que el usuario
1581
+ * sepa que hay varios. Con uno solo se lo roba el campo, que ya está a la
1582
+ * vista — por eso el resumen ni se pinta entonces.
1583
+ */
1584
+ private focusSummary(): void {
1585
+ const summary = document.querySelector<HTMLElement>('[data-error-summary]');
1586
+ summary?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1587
+ summary?.focus({ preventScroll: true });
1588
+ }
1589
+
1590
+ private title(): string {
1591
+ const tree = this.formTree();
1592
+ const schema = this.schema();
1593
+ if (!tree || !schema) {
1594
+ return '';
1595
+ }
1596
+ const value = tree().value()[schema.titleField];
1597
+ return typeof value === 'string' ? value : '';
1598
+ }
1599
+
1600
+ private recordKey(): string | null {
1601
+ return this.params().get('key');
1602
+ }
1603
+
1604
+ /** Schema destino del panel «Crear nuevo»; `undefined` si no existe. */
1605
+ drawerSchema(resource: string): ResourceSchema | undefined {
1606
+ return getSchema(resource);
1607
+ }
1608
+
1609
+ /**
1610
+ * Cerró el panel con un registro nuevo: se cierra, y si vino con clave se
1611
+ * le pasa a la relación que lo pidió (recarga sus opciones y lo selecciona).
1612
+ */
1613
+ relationCreated(record: Record<string, unknown>, request: RelationCreateRequest): void {
1614
+ const target = getSchema(request.resource);
1615
+ const key = target === undefined ? undefined : record[target.keyField];
1616
+ this.relationDrawer.clear();
1617
+ if (key === undefined || key === null || key === '') {
1618
+ return;
1619
+ }
1620
+ request.onCreated(String(key));
1621
+ }
1622
+ }