@tinya-code/gas-admin-ui 0.2.0-alpha.15 → 0.2.0-alpha.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/tinya-code-gas-admin-ui-badge-EeCA_l-o.mjs +84 -0
- package/fesm2022/tinya-code-gas-admin-ui-badge-EeCA_l-o.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-bar-chart-G7sEMSBY.mjs +140 -0
- package/fesm2022/tinya-code-gas-admin-ui-bar-chart-G7sEMSBY.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-boolean-B2ASKL0E.mjs +113 -0
- package/fesm2022/tinya-code-gas-admin-ui-boolean-B2ASKL0E.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-button-BQjNW8KB.mjs +163 -0
- package/fesm2022/tinya-code-gas-admin-ui-button-BQjNW8KB.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-calendar-view-DCY8bj2V.mjs +1027 -0
- package/fesm2022/tinya-code-gas-admin-ui-calendar-view-DCY8bj2V.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-chart-line-BjdlKHoH.mjs +278 -0
- package/fesm2022/tinya-code-gas-admin-ui-chart-line-BjdlKHoH.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-color-Dljh_By3.mjs +107 -0
- package/fesm2022/tinya-code-gas-admin-ui-color-Dljh_By3.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-condition-evaluator-DY2k1M3C.mjs +101 -0
- package/fesm2022/tinya-code-gas-admin-ui-condition-evaluator-DY2k1M3C.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-currency-BvuF3Z63.mjs +141 -0
- package/fesm2022/tinya-code-gas-admin-ui-currency-BvuF3Z63.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-data-source.service-96FSbN72.mjs +88 -0
- package/fesm2022/tinya-code-gas-admin-ui-data-source.service-96FSbN72.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-date-1SJGXZrd.mjs +107 -0
- package/fesm2022/tinya-code-gas-admin-ui-date-1SJGXZrd.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-badge-fwk6sDU7.mjs +140 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-badge-fwk6sDU7.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-date-D7zLiDiG.mjs +119 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-date-D7zLiDiG.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-file-BiIhqvQC.mjs +125 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-file-BiIhqvQC.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-image-C1dhnZi1.mjs +152 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-image-C1dhnZi1.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-list-DOEuUbGe.mjs +211 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-list-DOEuUbGe.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-number-DDMzr4Hn.mjs +116 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-number-DDMzr4Hn.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-text-B8CdVNpv.mjs +111 -0
- package/fesm2022/tinya-code-gas-admin-ui-display-text-B8CdVNpv.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-dropdown-C3blvmE1.mjs +122 -0
- package/fesm2022/tinya-code-gas-admin-ui-dropdown-C3blvmE1.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-email-CT_O5ihA.mjs +107 -0
- package/fesm2022/tinya-code-gas-admin-ui-email-CT_O5ihA.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-empty-state-BMc0QtoT.mjs +109 -0
- package/fesm2022/tinya-code-gas-admin-ui-empty-state-BMc0QtoT.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-error-mapper.service-DY5y1FjE.mjs +45 -0
- package/fesm2022/tinya-code-gas-admin-ui-error-mapper.service-DY5y1FjE.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-field-aria-BD1Wiggc.mjs +50 -0
- package/fesm2022/tinya-code-gas-admin-ui-field-aria-BD1Wiggc.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-field-node-CIK6KNN_.mjs +24 -0
- package/fesm2022/tinya-code-gas-admin-ui-field-node-CIK6KNN_.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-field-path-B4-FTBGZ.mjs +27 -0
- package/fesm2022/tinya-code-gas-admin-ui-field-path-B4-FTBGZ.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-file-Dmxioog7.mjs +408 -0
- package/fesm2022/tinya-code-gas-admin-ui-file-Dmxioog7.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-form-view-CEe7aZ4m.mjs +4516 -0
- package/fesm2022/tinya-code-gas-admin-ui-form-view-CEe7aZ4m.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-badge-CK8ct5Wk.mjs +82 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-badge-CK8ct5Wk.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-boolean-ClzezZz-.mjs +105 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-boolean-ClzezZz-.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-currency-CVmniKml.mjs +65 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-currency-CVmniKml.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-date-Dr1YsDd_.mjs +65 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-date-Dr1YsDd_.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-number-CWCjpI4n.mjs +65 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-number-CWCjpI4n.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-text-DBOgJkH0.mjs +59 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-text-DBOgJkH0.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-thumbnail-PCRvxWKt.mjs +94 -0
- package/fesm2022/tinya-code-gas-admin-ui-format-thumbnail-PCRvxWKt.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-gap-sorting-iTdy_DYG.mjs +72 -0
- package/fesm2022/tinya-code-gas-admin-ui-gap-sorting-iTdy_DYG.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-group-h7-O8tt4.mjs +238 -0
- package/fesm2022/tinya-code-gas-admin-ui-group-h7-O8tt4.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-image-7UhJ7nqI.mjs +416 -0
- package/fesm2022/tinya-code-gas-admin-ui-image-7UhJ7nqI.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-kanban-view-Dl0Ol5Kb.mjs +439 -0
- package/fesm2022/tinya-code-gas-admin-ui-kanban-view-Dl0Ol5Kb.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-key-value-nVdgg5at.mjs +513 -0
- package/fesm2022/tinya-code-gas-admin-ui-key-value-nVdgg5at.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-list-DTtCYS4d.mjs +637 -0
- package/fesm2022/tinya-code-gas-admin-ui-list-DTtCYS4d.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-metric-card-DnGMQL8l.mjs +127 -0
- package/fesm2022/tinya-code-gas-admin-ui-metric-card-DnGMQL8l.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-multiselect-PB28e-jl.mjs +251 -0
- package/fesm2022/tinya-code-gas-admin-ui-multiselect-PB28e-jl.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-notification.service-LjSULER6.mjs +66 -0
- package/fesm2022/tinya-code-gas-admin-ui-notification.service-LjSULER6.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-number-JNr7glc8.mjs +114 -0
- package/fesm2022/tinya-code-gas-admin-ui-number-JNr7glc8.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-phone-jR417hPf.mjs +116 -0
- package/fesm2022/tinya-code-gas-admin-ui-phone-jR417hPf.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-quick-actions-DCQ6emmF.mjs +235 -0
- package/fesm2022/tinya-code-gas-admin-ui-quick-actions-DCQ6emmF.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-radio-mode-DxvN07DY.mjs +8 -0
- package/fesm2022/tinya-code-gas-admin-ui-radio-mode-DxvN07DY.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-readonly-text-BL7-4qie.mjs +124 -0
- package/fesm2022/tinya-code-gas-admin-ui-readonly-text-BL7-4qie.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-record-list-BiZfnd_7.mjs +249 -0
- package/fesm2022/tinya-code-gas-admin-ui-record-list-BiZfnd_7.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-relation-drawer.service-B0kT8EiW.mjs +39 -0
- package/fesm2022/tinya-code-gas-admin-ui-relation-drawer.service-B0kT8EiW.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-relation-ykpJ3PYl.mjs +309 -0
- package/fesm2022/tinya-code-gas-admin-ui-relation-ykpJ3PYl.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-render-registry-B88Pme-y.mjs +576 -0
- package/fesm2022/tinya-code-gas-admin-ui-render-registry-B88Pme-y.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-rich-select-CIHaOrbU.mjs +161 -0
- package/fesm2022/tinya-code-gas-admin-ui-rich-select-CIHaOrbU.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-rolldown-runtime-DpJrqANO.mjs +11 -0
- package/fesm2022/tinya-code-gas-admin-ui-schema-catalog-D0Pk0wAN.mjs +685 -0
- package/fesm2022/tinya-code-gas-admin-ui-schema-catalog-D0Pk0wAN.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-seed-DkVZbxPP.mjs +80 -0
- package/fesm2022/tinya-code-gas-admin-ui-seed-DkVZbxPP.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-select-CDwX9yNO.mjs +296 -0
- package/fesm2022/tinya-code-gas-admin-ui-select-CDwX9yNO.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-skeleton-DnWK0tcZ.mjs +119 -0
- package/fesm2022/tinya-code-gas-admin-ui-skeleton-DnWK0tcZ.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-slug-DU58cbiY.mjs +158 -0
- package/fesm2022/tinya-code-gas-admin-ui-slug-DU58cbiY.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-source-options-B9R4mw8-.mjs +26 -0
- package/fesm2022/tinya-code-gas-admin-ui-source-options-B9R4mw8-.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-status-progress-DmeG8htE.mjs +205 -0
- package/fesm2022/tinya-code-gas-admin-ui-status-progress-DmeG8htE.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-string-list-gleasqzD.mjs +432 -0
- package/fesm2022/tinya-code-gas-admin-ui-string-list-gleasqzD.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-template-interpolator-ewv7CkNX.mjs +36 -0
- package/fesm2022/tinya-code-gas-admin-ui-template-interpolator-ewv7CkNX.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-text-CUPmL7W_.mjs +107 -0
- package/fesm2022/tinya-code-gas-admin-ui-text-CUPmL7W_.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-textarea-CGLSIW13.mjs +223 -0
- package/fesm2022/tinya-code-gas-admin-ui-textarea-CGLSIW13.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-time-BcHzsTWf.mjs +107 -0
- package/fesm2022/tinya-code-gas-admin-ui-time-BcHzsTWf.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-upload.service-CZPEPCeN.mjs +60 -0
- package/fesm2022/tinya-code-gas-admin-ui-upload.service-CZPEPCeN.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui-url-GO4LZi__.mjs +141 -0
- package/fesm2022/tinya-code-gas-admin-ui-url-GO4LZi__.mjs.map +1 -0
- package/fesm2022/tinya-code-gas-admin-ui.mjs +4738 -0
- package/fesm2022/tinya-code-gas-admin-ui.mjs.map +1 -0
- package/package.json +15 -3
- package/types/tinya-code-gas-admin-ui.d.ts +3634 -0
- package/ng-package.json +0 -7
- package/src/lib/actions/action-registry.spec.ts +0 -52
- package/src/lib/actions/action-registry.ts +0 -126
- package/src/lib/actions/action-service.spec.ts +0 -215
- package/src/lib/actions/action-service.ts +0 -87
- package/src/lib/actions/descriptors.spec.ts +0 -155
- package/src/lib/actions/descriptors.ts +0 -154
- package/src/lib/columns/column-cell.ts +0 -33
- package/src/lib/columns/column-defaults.spec.ts +0 -99
- package/src/lib/columns/column-defaults.ts +0 -84
- package/src/lib/columns/column-format.spec.ts +0 -274
- package/src/lib/columns/descriptors.ts +0 -113
- package/src/lib/columns/format-badge.ts +0 -51
- package/src/lib/columns/format-boolean.ts +0 -58
- package/src/lib/columns/format-currency.ts +0 -30
- package/src/lib/columns/format-date.ts +0 -35
- package/src/lib/columns/format-number.ts +0 -32
- package/src/lib/columns/format-text.ts +0 -23
- package/src/lib/columns/format-thumbnail.ts +0 -63
- package/src/lib/core/adapters/firebase-auth.adapter.ts +0 -6
- package/src/lib/core/guards/auth.guard.spec.ts +0 -88
- package/src/lib/core/guards/auth.guard.ts +0 -48
- package/src/lib/core/guards/unsaved-changes.guard.ts +0 -16
- package/src/lib/core/interceptors/api-error.interceptor.spec.ts +0 -182
- package/src/lib/core/interceptors/api-error.interceptor.ts +0 -87
- package/src/lib/core/models/api.model.ts +0 -104
- package/src/lib/core/models/schema-descriptions.ts +0 -118
- package/src/lib/core/models/schema.model.ts +0 -1097
- package/src/lib/core/registry/composition-validator.spec.ts +0 -358
- package/src/lib/core/registry/composition-validator.ts +0 -270
- package/src/lib/core/registry/field-validators.spec.ts +0 -199
- package/src/lib/core/registry/field-validators.ts +0 -440
- package/src/lib/core/registry/render-host.spec.ts +0 -208
- package/src/lib/core/registry/render-host.ts +0 -646
- package/src/lib/core/registry/render-registry.spec.ts +0 -96
- package/src/lib/core/registry/render-registry.ts +0 -312
- package/src/lib/core/registry/schema-catalog.ts +0 -156
- package/src/lib/core/services/api.service.spec.ts +0 -1142
- package/src/lib/core/services/api.service.ts +0 -371
- package/src/lib/core/services/auth.service.spec.ts +0 -653
- package/src/lib/core/services/auth.service.ts +0 -503
- package/src/lib/core/services/config.service.spec.ts +0 -121
- package/src/lib/core/services/config.service.ts +0 -119
- package/src/lib/core/services/data-source.service.spec.ts +0 -179
- package/src/lib/core/services/data-source.service.ts +0 -142
- package/src/lib/core/services/error-mapper.service.spec.ts +0 -50
- package/src/lib/core/services/error-mapper.service.ts +0 -49
- package/src/lib/core/services/notification.service.ts +0 -73
- package/src/lib/core/services/relation-drawer.service.ts +0 -41
- package/src/lib/core/services/theme.service.spec.ts +0 -106
- package/src/lib/core/services/theme.service.ts +0 -86
- package/src/lib/core/services/transport.spec.ts +0 -198
- package/src/lib/core/services/transport.ts +0 -164
- package/src/lib/core/services/upload.service.ts +0 -81
- package/src/lib/core/testing/categories.schema.ts +0 -125
- package/src/lib/core/testing/orders.schema.ts +0 -372
- package/src/lib/core/testing/products.schema.ts +0 -258
- package/src/lib/core/testing/site.schema.ts +0 -195
- package/src/lib/core/testing/test-catalog.ts +0 -21
- package/src/lib/core/tokens.ts +0 -112
- package/src/lib/core/utils/clipboard.spec.ts +0 -55
- package/src/lib/core/utils/clipboard.ts +0 -90
- package/src/lib/core/utils/context-resolver.spec.ts +0 -95
- package/src/lib/core/utils/context-resolver.ts +0 -101
- package/src/lib/core/utils/response-unwrapper.spec.ts +0 -70
- package/src/lib/core/utils/response-unwrapper.ts +0 -103
- package/src/lib/core/utils/source-options.spec.ts +0 -96
- package/src/lib/core/utils/source-options.ts +0 -54
- package/src/lib/core/utils/template-interpolator.spec.ts +0 -59
- package/src/lib/core/utils/template-interpolator.ts +0 -56
- package/src/lib/core/view-navigation/view-navigation.service.spec.ts +0 -207
- package/src/lib/core/view-navigation/view-navigation.service.ts +0 -132
- package/src/lib/fields/boolean/boolean.descriptor.ts +0 -11
- package/src/lib/fields/boolean/boolean.ts +0 -40
- package/src/lib/fields/color/color.descriptor.ts +0 -12
- package/src/lib/fields/color/color.ts +0 -34
- package/src/lib/fields/currency/currency.descriptor.ts +0 -12
- package/src/lib/fields/currency/currency.ts +0 -67
- package/src/lib/fields/date/date.descriptor.ts +0 -12
- package/src/lib/fields/date/date.ts +0 -36
- package/src/lib/fields/descriptors.ts +0 -122
- package/src/lib/fields/display/display-badge.ts +0 -65
- package/src/lib/fields/display/display-date.ts +0 -39
- package/src/lib/fields/display/display-defaults.ts +0 -84
- package/src/lib/fields/display/display-file.ts +0 -41
- package/src/lib/fields/display/display-image.ts +0 -54
- package/src/lib/fields/display/display-list.ts +0 -107
- package/src/lib/fields/display/display-number.ts +0 -41
- package/src/lib/fields/display/display-text.ts +0 -42
- package/src/lib/fields/display/display.spec.ts +0 -242
- package/src/lib/fields/dropdown/dropdown.descriptor.ts +0 -12
- package/src/lib/fields/dropdown/dropdown.ts +0 -45
- package/src/lib/fields/email/email.descriptor.ts +0 -12
- package/src/lib/fields/email/email.ts +0 -28
- package/src/lib/fields/field-aria.ts +0 -60
- package/src/lib/fields/field-host/field-host.spec.ts +0 -118
- package/src/lib/fields/field-host/field-host.ts +0 -47
- package/src/lib/fields/field-node.ts +0 -88
- package/src/lib/fields/file/file.descriptor.ts +0 -14
- package/src/lib/fields/file/file.spec.ts +0 -134
- package/src/lib/fields/file/file.ts +0 -281
- package/src/lib/fields/group/group.descriptor.ts +0 -21
- package/src/lib/fields/group/group.ts +0 -110
- package/src/lib/fields/image/image.descriptor.ts +0 -12
- package/src/lib/fields/image/image.spec.ts +0 -117
- package/src/lib/fields/image/image.ts +0 -281
- package/src/lib/fields/key-value/key-value.descriptor.ts +0 -11
- package/src/lib/fields/key-value/key-value.ts +0 -283
- package/src/lib/fields/list/list.descriptor.ts +0 -21
- package/src/lib/fields/list/list.ts +0 -376
- package/src/lib/fields/multiselect/multiselect.descriptor.ts +0 -11
- package/src/lib/fields/multiselect/multiselect.spec.ts +0 -142
- package/src/lib/fields/multiselect/multiselect.ts +0 -152
- package/src/lib/fields/number/number.descriptor.ts +0 -12
- package/src/lib/fields/number/number.ts +0 -40
- package/src/lib/fields/phone/phone.descriptor.ts +0 -12
- package/src/lib/fields/phone/phone.ts +0 -46
- package/src/lib/fields/readonly-text/readonly-text.descriptor.ts +0 -12
- package/src/lib/fields/readonly-text/readonly-text.ts +0 -58
- package/src/lib/fields/relation/relation.descriptor.ts +0 -12
- package/src/lib/fields/relation/relation.spec.ts +0 -193
- package/src/lib/fields/relation/relation.ts +0 -224
- package/src/lib/fields/rich-select/rich-select.descriptor.ts +0 -12
- package/src/lib/fields/rich-select/rich-select.ts +0 -175
- package/src/lib/fields/select/radio-mode.ts +0 -25
- package/src/lib/fields/select/select.descriptor.ts +0 -12
- package/src/lib/fields/select/select.spec.ts +0 -162
- package/src/lib/fields/select/select.ts +0 -210
- package/src/lib/fields/slug/slug.descriptor.ts +0 -19
- package/src/lib/fields/slug/slug.ts +0 -67
- package/src/lib/fields/string-list/string-list.descriptor.ts +0 -11
- package/src/lib/fields/string-list/string-list.ts +0 -222
- package/src/lib/fields/text/text.descriptor.ts +0 -12
- package/src/lib/fields/text/text.ts +0 -35
- package/src/lib/fields/textarea/textarea.descriptor.ts +0 -12
- package/src/lib/fields/textarea/textarea.spec.ts +0 -117
- package/src/lib/fields/textarea/textarea.ts +0 -126
- package/src/lib/fields/time/time.descriptor.ts +0 -12
- package/src/lib/fields/time/time.ts +0 -28
- package/src/lib/fields/unknown-render/unknown-render.ts +0 -49
- package/src/lib/fields/url/url.descriptor.ts +0 -12
- package/src/lib/fields/url/url.ts +0 -53
- package/src/lib/gas-admin-ui.spec.ts +0 -21
- package/src/lib/gas-admin-ui.ts +0 -9
- package/src/lib/layouts/descriptors.ts +0 -9
- package/src/lib/pages/dashboard/dashboard.ts +0 -167
- package/src/lib/pages/detail-view/detail-view.spec.ts +0 -333
- package/src/lib/pages/detail-view/detail-view.ts +0 -570
- package/src/lib/pages/drawer-form/drawer-form.ts +0 -228
- package/src/lib/pages/form-view/form-model.ts +0 -88
- package/src/lib/pages/form-view/form-schema.ts +0 -255
- package/src/lib/pages/form-view/form-view.ts +0 -1622
- package/src/lib/pages/list-view/list-view.formats.spec.ts +0 -214
- package/src/lib/pages/list-view/list-view.ts +0 -882
- package/src/lib/pages/login/login.spec.ts +0 -109
- package/src/lib/pages/login/login.ts +0 -227
- package/src/lib/pages/resource-page/resource-page.ts +0 -234
- package/src/lib/shared/components/badge/badge.ts +0 -35
- package/src/lib/shared/components/button/button.ts +0 -65
- package/src/lib/shared/components/confirm-dialog/confirm-dialog.ts +0 -48
- package/src/lib/shared/components/copy-button/copy-button.ts +0 -56
- package/src/lib/shared/components/copy-share/copy-share.spec.ts +0 -122
- package/src/lib/shared/components/copy-share/copy-share.ts +0 -107
- package/src/lib/shared/components/drawer/drawer.spec.ts +0 -127
- package/src/lib/shared/components/drawer/drawer.ts +0 -172
- package/src/lib/shared/components/empty-state/empty-state.ts +0 -30
- package/src/lib/shared/components/modal/modal.spec.ts +0 -149
- package/src/lib/shared/components/modal/modal.ts +0 -145
- package/src/lib/shared/components/post-create-modal/post-create-modal.spec.ts +0 -165
- package/src/lib/shared/components/post-create-modal/post-create-modal.ts +0 -159
- package/src/lib/shared/components/skeleton/skeleton.ts +0 -31
- package/src/lib/shared/components/toast/toast.ts +0 -58
- package/src/lib/shared/utils/col-span.ts +0 -25
- package/src/lib/shared/utils/condition-evaluator.ts +0 -53
- package/src/lib/shared/utils/depends-on.ts +0 -48
- package/src/lib/shared/utils/dialog-focus.ts +0 -28
- package/src/lib/shared/utils/field-path.ts +0 -49
- package/src/lib/shared/utils/gap-sorting.ts +0 -144
- package/src/lib/shared/utils/seed.spec.ts +0 -64
- package/src/lib/shared/utils/seed.ts +0 -149
- package/src/lib/shared/utils/slugify.ts +0 -13
- package/src/lib/shared/utils/validators.ts +0 -41
- package/src/lib/shell/layout/layout.ts +0 -63
- package/src/lib/shell/shell.routes.spec.ts +0 -59
- package/src/lib/shell/shell.routes.ts +0 -122
- package/src/lib/shell/sidebar/sidebar.ts +0 -139
- package/src/lib/shell/theme-toggle/theme-toggle.ts +0 -74
- package/src/lib/views/calendar-view/calendar-model.ts +0 -426
- package/src/lib/views/calendar-view/calendar-view.spec.ts +0 -557
- package/src/lib/views/calendar-view/calendar-view.ts +0 -568
- package/src/lib/views/composer/composer-host.spec.ts +0 -301
- package/src/lib/views/composer/composer-host.ts +0 -248
- package/src/lib/views/composer/composer-imports.spec.ts +0 -54
- package/src/lib/views/composer/view-context.spec.ts +0 -117
- package/src/lib/views/composer/view-context.ts +0 -109
- package/src/lib/views/composer/view-slot.spec.ts +0 -130
- package/src/lib/views/composer/view-slot.ts +0 -178
- package/src/lib/views/descriptors.ts +0 -46
- package/src/lib/views/kanban-view/kanban-model.ts +0 -173
- package/src/lib/views/kanban-view/kanban-view.spec.ts +0 -540
- package/src/lib/views/kanban-view/kanban-view.ts +0 -327
- package/src/lib/widgets/bar-chart/bar-chart.descriptor.ts +0 -17
- package/src/lib/widgets/bar-chart/bar-chart.spec.ts +0 -66
- package/src/lib/widgets/bar-chart/bar-chart.ts +0 -76
- package/src/lib/widgets/chart-line/chart-line.descriptor.ts +0 -17
- package/src/lib/widgets/chart-line/chart-line.spec.ts +0 -113
- package/src/lib/widgets/chart-line/chart-line.ts +0 -219
- package/src/lib/widgets/descriptors.ts +0 -29
- package/src/lib/widgets/metric-card/metric-card.descriptor.ts +0 -41
- package/src/lib/widgets/metric-card/metric-card.spec.ts +0 -52
- package/src/lib/widgets/metric-card/metric-card.ts +0 -48
- package/src/lib/widgets/quick-actions/quick-actions.descriptor.ts +0 -13
- package/src/lib/widgets/quick-actions/quick-actions.spec.ts +0 -61
- package/src/lib/widgets/quick-actions/quick-actions.ts +0 -100
- package/src/lib/widgets/record-list/record-list.descriptor.ts +0 -16
- package/src/lib/widgets/record-list/record-list.spec.ts +0 -79
- package/src/lib/widgets/record-list/record-list.ts +0 -115
- package/src/lib/widgets/series.spec.ts +0 -42
- package/src/lib/widgets/series.ts +0 -56
- package/src/lib/widgets/status-progress/status-progress.descriptor.ts +0 -19
- package/src/lib/widgets/status-progress/status-progress.spec.ts +0 -63
- package/src/lib/widgets/status-progress/status-progress.ts +0 -104
- package/src/lib/widgets/widget-host/widget-host.spec.ts +0 -179
- package/src/lib/widgets/widget-host/widget-host.ts +0 -99
- package/src/public-api.ts +0 -104
- package/tinya-code-gas-admin-ui-0.2.0-alpha.15.tgz +0 -0
- package/tsconfig.lib.json +0 -13
- package/tsconfig.lib.prod.json +0 -11
- package/tsconfig.spec.json +0 -10
|
@@ -1,1622 +0,0 @@
|
|
|
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
|
-
}
|