@tinya-code/gas-admin-ui 0.2.0-alpha.12 → 0.2.0-alpha.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +222 -0
- package/ng-package.json +7 -0
- package/package.json +3 -15
- package/src/lib/actions/action-registry.spec.ts +52 -0
- package/src/lib/actions/action-registry.ts +126 -0
- package/src/lib/actions/action-service.spec.ts +215 -0
- package/src/lib/actions/action-service.ts +87 -0
- package/src/lib/actions/descriptors.spec.ts +155 -0
- package/src/lib/actions/descriptors.ts +154 -0
- package/src/lib/columns/column-cell.ts +33 -0
- package/src/lib/columns/column-defaults.spec.ts +99 -0
- package/src/lib/columns/column-defaults.ts +84 -0
- package/src/lib/columns/column-format.spec.ts +274 -0
- package/src/lib/columns/descriptors.ts +113 -0
- package/src/lib/columns/format-badge.ts +51 -0
- package/src/lib/columns/format-boolean.ts +58 -0
- package/src/lib/columns/format-currency.ts +30 -0
- package/src/lib/columns/format-date.ts +35 -0
- package/src/lib/columns/format-number.ts +32 -0
- package/src/lib/columns/format-text.ts +23 -0
- package/src/lib/columns/format-thumbnail.ts +63 -0
- package/src/lib/core/adapters/firebase-auth.adapter.ts +6 -0
- package/src/lib/core/guards/auth.guard.spec.ts +88 -0
- package/src/lib/core/guards/auth.guard.ts +48 -0
- package/src/lib/core/guards/unsaved-changes.guard.ts +16 -0
- package/src/lib/core/interceptors/api-error.interceptor.spec.ts +182 -0
- package/src/lib/core/interceptors/api-error.interceptor.ts +87 -0
- package/src/lib/core/models/api.model.ts +104 -0
- package/src/lib/core/models/schema-descriptions.ts +118 -0
- package/src/lib/core/models/schema.model.ts +1097 -0
- package/src/lib/core/registry/composition-validator.spec.ts +358 -0
- package/src/lib/core/registry/composition-validator.ts +270 -0
- package/src/lib/core/registry/field-validators.spec.ts +199 -0
- package/src/lib/core/registry/field-validators.ts +440 -0
- package/src/lib/core/registry/render-host.spec.ts +208 -0
- package/src/lib/core/registry/render-host.ts +646 -0
- package/src/lib/core/registry/render-registry.spec.ts +96 -0
- package/src/lib/core/registry/render-registry.ts +312 -0
- package/src/lib/core/registry/schema-catalog.ts +156 -0
- package/src/lib/core/services/api.service.spec.ts +1142 -0
- package/src/lib/core/services/api.service.ts +371 -0
- package/src/lib/core/services/auth.service.spec.ts +653 -0
- package/src/lib/core/services/auth.service.ts +503 -0
- package/src/lib/core/services/config.service.spec.ts +121 -0
- package/src/lib/core/services/config.service.ts +119 -0
- package/src/lib/core/services/data-source.service.spec.ts +179 -0
- package/src/lib/core/services/data-source.service.ts +142 -0
- package/src/lib/core/services/error-mapper.service.spec.ts +50 -0
- package/src/lib/core/services/error-mapper.service.ts +49 -0
- package/src/lib/core/services/notification.service.ts +73 -0
- package/src/lib/core/services/relation-drawer.service.ts +41 -0
- package/src/lib/core/services/theme.service.spec.ts +106 -0
- package/src/lib/core/services/theme.service.ts +86 -0
- package/src/lib/core/services/transport.spec.ts +198 -0
- package/src/lib/core/services/transport.ts +164 -0
- package/src/lib/core/services/upload.service.ts +81 -0
- package/src/lib/core/testing/categories.schema.ts +125 -0
- package/src/lib/core/testing/orders.schema.ts +372 -0
- package/src/lib/core/testing/products.schema.ts +258 -0
- package/src/lib/core/testing/site.schema.ts +195 -0
- package/src/lib/core/testing/test-catalog.ts +21 -0
- package/src/lib/core/tokens.ts +112 -0
- package/src/lib/core/utils/clipboard.spec.ts +55 -0
- package/src/lib/core/utils/clipboard.ts +90 -0
- package/src/lib/core/utils/context-resolver.spec.ts +95 -0
- package/src/lib/core/utils/context-resolver.ts +101 -0
- package/src/lib/core/utils/response-unwrapper.spec.ts +70 -0
- package/src/lib/core/utils/response-unwrapper.ts +103 -0
- package/src/lib/core/utils/source-options.spec.ts +96 -0
- package/src/lib/core/utils/source-options.ts +54 -0
- package/src/lib/core/utils/template-interpolator.spec.ts +59 -0
- package/src/lib/core/utils/template-interpolator.ts +56 -0
- package/src/lib/core/view-navigation/view-navigation.service.spec.ts +207 -0
- package/src/lib/core/view-navigation/view-navigation.service.ts +132 -0
- package/src/lib/fields/boolean/boolean.descriptor.ts +11 -0
- package/src/lib/fields/boolean/boolean.ts +40 -0
- package/src/lib/fields/color/color.descriptor.ts +12 -0
- package/src/lib/fields/color/color.ts +34 -0
- package/src/lib/fields/currency/currency.descriptor.ts +12 -0
- package/src/lib/fields/currency/currency.ts +67 -0
- package/src/lib/fields/date/date.descriptor.ts +12 -0
- package/src/lib/fields/date/date.ts +36 -0
- package/src/lib/fields/descriptors.ts +122 -0
- package/src/lib/fields/display/display-badge.ts +65 -0
- package/src/lib/fields/display/display-date.ts +39 -0
- package/src/lib/fields/display/display-defaults.ts +84 -0
- package/src/lib/fields/display/display-file.ts +41 -0
- package/src/lib/fields/display/display-image.ts +54 -0
- package/src/lib/fields/display/display-list.ts +107 -0
- package/src/lib/fields/display/display-number.ts +41 -0
- package/src/lib/fields/display/display-text.ts +42 -0
- package/src/lib/fields/display/display.spec.ts +242 -0
- package/src/lib/fields/dropdown/dropdown.descriptor.ts +12 -0
- package/src/lib/fields/dropdown/dropdown.ts +45 -0
- package/src/lib/fields/email/email.descriptor.ts +12 -0
- package/src/lib/fields/email/email.ts +28 -0
- package/src/lib/fields/field-aria.ts +60 -0
- package/src/lib/fields/field-host/field-host.spec.ts +118 -0
- package/src/lib/fields/field-host/field-host.ts +47 -0
- package/src/lib/fields/field-node.ts +88 -0
- package/src/lib/fields/file/file.descriptor.ts +14 -0
- package/src/lib/fields/file/file.spec.ts +134 -0
- package/src/lib/fields/file/file.ts +281 -0
- package/src/lib/fields/group/group.descriptor.ts +21 -0
- package/src/lib/fields/group/group.ts +110 -0
- package/src/lib/fields/image/image.descriptor.ts +12 -0
- package/src/lib/fields/image/image.spec.ts +117 -0
- package/src/lib/fields/image/image.ts +281 -0
- package/src/lib/fields/key-value/key-value.descriptor.ts +11 -0
- package/src/lib/fields/key-value/key-value.ts +283 -0
- package/src/lib/fields/list/list.descriptor.ts +21 -0
- package/src/lib/fields/list/list.ts +376 -0
- package/src/lib/fields/multiselect/multiselect.descriptor.ts +11 -0
- package/src/lib/fields/multiselect/multiselect.spec.ts +142 -0
- package/src/lib/fields/multiselect/multiselect.ts +152 -0
- package/src/lib/fields/number/number.descriptor.ts +12 -0
- package/src/lib/fields/number/number.ts +40 -0
- package/src/lib/fields/phone/phone.descriptor.ts +12 -0
- package/src/lib/fields/phone/phone.ts +46 -0
- package/src/lib/fields/readonly-text/readonly-text.descriptor.ts +12 -0
- package/src/lib/fields/readonly-text/readonly-text.ts +58 -0
- package/src/lib/fields/relation/relation.descriptor.ts +12 -0
- package/src/lib/fields/relation/relation.spec.ts +193 -0
- package/src/lib/fields/relation/relation.ts +224 -0
- package/src/lib/fields/rich-select/rich-select.descriptor.ts +12 -0
- package/src/lib/fields/rich-select/rich-select.ts +175 -0
- package/src/lib/fields/select/radio-mode.ts +25 -0
- package/src/lib/fields/select/select.descriptor.ts +12 -0
- package/src/lib/fields/select/select.spec.ts +162 -0
- package/src/lib/fields/select/select.ts +210 -0
- package/src/lib/fields/slug/slug.descriptor.ts +19 -0
- package/src/lib/fields/slug/slug.ts +67 -0
- package/src/lib/fields/string-list/string-list.descriptor.ts +11 -0
- package/src/lib/fields/string-list/string-list.ts +222 -0
- package/src/lib/fields/text/text.descriptor.ts +12 -0
- package/src/lib/fields/text/text.ts +35 -0
- package/src/lib/fields/textarea/textarea.descriptor.ts +12 -0
- package/src/lib/fields/textarea/textarea.spec.ts +117 -0
- package/src/lib/fields/textarea/textarea.ts +126 -0
- package/src/lib/fields/time/time.descriptor.ts +12 -0
- package/src/lib/fields/time/time.ts +28 -0
- package/src/lib/fields/unknown-render/unknown-render.ts +49 -0
- package/src/lib/fields/url/url.descriptor.ts +12 -0
- package/src/lib/fields/url/url.ts +53 -0
- package/src/lib/gas-admin-ui.spec.ts +21 -0
- package/src/lib/gas-admin-ui.ts +9 -0
- package/src/lib/layouts/descriptors.ts +9 -0
- package/src/lib/pages/dashboard/dashboard.ts +167 -0
- package/src/lib/pages/detail-view/detail-view.spec.ts +333 -0
- package/src/lib/pages/detail-view/detail-view.ts +570 -0
- package/src/lib/pages/drawer-form/drawer-form.ts +228 -0
- package/src/lib/pages/form-view/form-model.ts +88 -0
- package/src/lib/pages/form-view/form-schema.ts +255 -0
- package/src/lib/pages/form-view/form-view.ts +1622 -0
- package/src/lib/pages/list-view/list-view.formats.spec.ts +214 -0
- package/src/lib/pages/list-view/list-view.ts +882 -0
- package/src/lib/pages/login/login.spec.ts +109 -0
- package/src/lib/pages/login/login.ts +227 -0
- package/src/lib/pages/resource-page/resource-page.ts +234 -0
- package/src/lib/shared/components/badge/badge.ts +35 -0
- package/src/lib/shared/components/button/button.ts +65 -0
- package/src/lib/shared/components/confirm-dialog/confirm-dialog.ts +48 -0
- package/src/lib/shared/components/copy-button/copy-button.ts +56 -0
- package/src/lib/shared/components/copy-share/copy-share.spec.ts +122 -0
- package/src/lib/shared/components/copy-share/copy-share.ts +107 -0
- package/src/lib/shared/components/drawer/drawer.spec.ts +127 -0
- package/src/lib/shared/components/drawer/drawer.ts +172 -0
- package/src/lib/shared/components/empty-state/empty-state.ts +30 -0
- package/src/lib/shared/components/modal/modal.spec.ts +149 -0
- package/src/lib/shared/components/modal/modal.ts +145 -0
- package/src/lib/shared/components/post-create-modal/post-create-modal.spec.ts +165 -0
- package/src/lib/shared/components/post-create-modal/post-create-modal.ts +159 -0
- package/src/lib/shared/components/skeleton/skeleton.ts +31 -0
- package/src/lib/shared/components/toast/toast.ts +58 -0
- package/src/lib/shared/utils/col-span.ts +25 -0
- package/src/lib/shared/utils/condition-evaluator.ts +53 -0
- package/src/lib/shared/utils/depends-on.ts +48 -0
- package/src/lib/shared/utils/dialog-focus.ts +28 -0
- package/src/lib/shared/utils/field-path.ts +49 -0
- package/src/lib/shared/utils/gap-sorting.ts +144 -0
- package/src/lib/shared/utils/seed.spec.ts +64 -0
- package/src/lib/shared/utils/seed.ts +149 -0
- package/src/lib/shared/utils/slugify.ts +13 -0
- package/src/lib/shared/utils/validators.ts +41 -0
- package/src/lib/shell/layout/layout.ts +63 -0
- package/src/lib/shell/shell.routes.spec.ts +59 -0
- package/src/lib/shell/shell.routes.ts +122 -0
- package/src/lib/shell/sidebar/sidebar.ts +139 -0
- package/src/lib/shell/theme-toggle/theme-toggle.ts +74 -0
- package/src/lib/views/calendar-view/calendar-model.ts +426 -0
- package/src/lib/views/calendar-view/calendar-view.spec.ts +557 -0
- package/src/lib/views/calendar-view/calendar-view.ts +568 -0
- package/src/lib/views/composer/composer-host.spec.ts +301 -0
- package/src/lib/views/composer/composer-host.ts +248 -0
- package/src/lib/views/composer/composer-imports.spec.ts +54 -0
- package/src/lib/views/composer/view-context.spec.ts +117 -0
- package/src/lib/views/composer/view-context.ts +109 -0
- package/src/lib/views/composer/view-slot.spec.ts +130 -0
- package/src/lib/views/composer/view-slot.ts +178 -0
- package/src/lib/views/descriptors.ts +46 -0
- package/src/lib/views/kanban-view/kanban-model.ts +173 -0
- package/src/lib/views/kanban-view/kanban-view.spec.ts +540 -0
- package/src/lib/views/kanban-view/kanban-view.ts +327 -0
- package/src/lib/widgets/bar-chart/bar-chart.descriptor.ts +17 -0
- package/src/lib/widgets/bar-chart/bar-chart.spec.ts +66 -0
- package/src/lib/widgets/bar-chart/bar-chart.ts +76 -0
- package/src/lib/widgets/chart-line/chart-line.descriptor.ts +17 -0
- package/src/lib/widgets/chart-line/chart-line.spec.ts +113 -0
- package/src/lib/widgets/chart-line/chart-line.ts +219 -0
- package/src/lib/widgets/descriptors.ts +29 -0
- package/src/lib/widgets/metric-card/metric-card.descriptor.ts +41 -0
- package/src/lib/widgets/metric-card/metric-card.spec.ts +52 -0
- package/src/lib/widgets/metric-card/metric-card.ts +48 -0
- package/src/lib/widgets/quick-actions/quick-actions.descriptor.ts +13 -0
- package/src/lib/widgets/quick-actions/quick-actions.spec.ts +61 -0
- package/src/lib/widgets/quick-actions/quick-actions.ts +100 -0
- package/src/lib/widgets/record-list/record-list.descriptor.ts +16 -0
- package/src/lib/widgets/record-list/record-list.spec.ts +79 -0
- package/src/lib/widgets/record-list/record-list.ts +115 -0
- package/src/lib/widgets/series.spec.ts +42 -0
- package/src/lib/widgets/series.ts +56 -0
- package/src/lib/widgets/status-progress/status-progress.descriptor.ts +19 -0
- package/src/lib/widgets/status-progress/status-progress.spec.ts +63 -0
- package/src/lib/widgets/status-progress/status-progress.ts +104 -0
- package/src/lib/widgets/widget-host/widget-host.spec.ts +179 -0
- package/src/lib/widgets/widget-host/widget-host.ts +99 -0
- package/src/public-api.ts +104 -0
- package/tinya-code-gas-admin-ui-0.2.0-alpha.15.tgz +0 -0
- package/tsconfig.lib.json +13 -0
- package/tsconfig.lib.prod.json +11 -0
- package/tsconfig.spec.json +10 -0
- package/fesm2022/tinya-code-gas-admin-ui-badge-EeCA_l-o.mjs +0 -84
- package/fesm2022/tinya-code-gas-admin-ui-badge-EeCA_l-o.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-bar-chart-G7sEMSBY.mjs +0 -140
- package/fesm2022/tinya-code-gas-admin-ui-bar-chart-G7sEMSBY.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-boolean-B2ASKL0E.mjs +0 -113
- package/fesm2022/tinya-code-gas-admin-ui-boolean-B2ASKL0E.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-button-BQjNW8KB.mjs +0 -163
- package/fesm2022/tinya-code-gas-admin-ui-button-BQjNW8KB.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-calendar-view-DCY8bj2V.mjs +0 -1027
- package/fesm2022/tinya-code-gas-admin-ui-calendar-view-DCY8bj2V.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-chart-line-BjdlKHoH.mjs +0 -278
- package/fesm2022/tinya-code-gas-admin-ui-chart-line-BjdlKHoH.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-color-Dljh_By3.mjs +0 -107
- package/fesm2022/tinya-code-gas-admin-ui-color-Dljh_By3.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-condition-evaluator-DY2k1M3C.mjs +0 -101
- package/fesm2022/tinya-code-gas-admin-ui-condition-evaluator-DY2k1M3C.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-currency-BvuF3Z63.mjs +0 -141
- package/fesm2022/tinya-code-gas-admin-ui-currency-BvuF3Z63.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-data-source.service-96FSbN72.mjs +0 -88
- package/fesm2022/tinya-code-gas-admin-ui-data-source.service-96FSbN72.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-date-1SJGXZrd.mjs +0 -107
- package/fesm2022/tinya-code-gas-admin-ui-date-1SJGXZrd.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-display-badge-fwk6sDU7.mjs +0 -140
- package/fesm2022/tinya-code-gas-admin-ui-display-badge-fwk6sDU7.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-display-date-D7zLiDiG.mjs +0 -119
- package/fesm2022/tinya-code-gas-admin-ui-display-date-D7zLiDiG.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-display-file-BiIhqvQC.mjs +0 -125
- package/fesm2022/tinya-code-gas-admin-ui-display-file-BiIhqvQC.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-display-image-C1dhnZi1.mjs +0 -152
- package/fesm2022/tinya-code-gas-admin-ui-display-image-C1dhnZi1.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-display-list-DOEuUbGe.mjs +0 -211
- package/fesm2022/tinya-code-gas-admin-ui-display-list-DOEuUbGe.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-display-number-DDMzr4Hn.mjs +0 -116
- package/fesm2022/tinya-code-gas-admin-ui-display-number-DDMzr4Hn.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-display-text-B8CdVNpv.mjs +0 -111
- package/fesm2022/tinya-code-gas-admin-ui-display-text-B8CdVNpv.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-dropdown-C3blvmE1.mjs +0 -122
- package/fesm2022/tinya-code-gas-admin-ui-dropdown-C3blvmE1.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-email-CT_O5ihA.mjs +0 -107
- package/fesm2022/tinya-code-gas-admin-ui-email-CT_O5ihA.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-empty-state-BMc0QtoT.mjs +0 -109
- package/fesm2022/tinya-code-gas-admin-ui-empty-state-BMc0QtoT.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-error-mapper.service-DY5y1FjE.mjs +0 -45
- package/fesm2022/tinya-code-gas-admin-ui-error-mapper.service-DY5y1FjE.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-field-aria-BD1Wiggc.mjs +0 -50
- package/fesm2022/tinya-code-gas-admin-ui-field-aria-BD1Wiggc.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-field-node-CIK6KNN_.mjs +0 -24
- package/fesm2022/tinya-code-gas-admin-ui-field-node-CIK6KNN_.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-field-path-B4-FTBGZ.mjs +0 -27
- package/fesm2022/tinya-code-gas-admin-ui-field-path-B4-FTBGZ.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-file-Dmxioog7.mjs +0 -408
- package/fesm2022/tinya-code-gas-admin-ui-file-Dmxioog7.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-form-view-C709rc57.mjs +0 -4516
- package/fesm2022/tinya-code-gas-admin-ui-form-view-C709rc57.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-format-badge-CK8ct5Wk.mjs +0 -82
- package/fesm2022/tinya-code-gas-admin-ui-format-badge-CK8ct5Wk.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-format-boolean-ClzezZz-.mjs +0 -105
- package/fesm2022/tinya-code-gas-admin-ui-format-boolean-ClzezZz-.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-format-currency-CVmniKml.mjs +0 -65
- package/fesm2022/tinya-code-gas-admin-ui-format-currency-CVmniKml.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-format-date-Dr1YsDd_.mjs +0 -65
- package/fesm2022/tinya-code-gas-admin-ui-format-date-Dr1YsDd_.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-format-number-CWCjpI4n.mjs +0 -65
- package/fesm2022/tinya-code-gas-admin-ui-format-number-CWCjpI4n.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-format-text-DBOgJkH0.mjs +0 -59
- package/fesm2022/tinya-code-gas-admin-ui-format-text-DBOgJkH0.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-format-thumbnail-PCRvxWKt.mjs +0 -94
- package/fesm2022/tinya-code-gas-admin-ui-format-thumbnail-PCRvxWKt.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-gap-sorting-iTdy_DYG.mjs +0 -72
- package/fesm2022/tinya-code-gas-admin-ui-gap-sorting-iTdy_DYG.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-group-h7-O8tt4.mjs +0 -238
- package/fesm2022/tinya-code-gas-admin-ui-group-h7-O8tt4.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-image-7UhJ7nqI.mjs +0 -416
- package/fesm2022/tinya-code-gas-admin-ui-image-7UhJ7nqI.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-kanban-view-Dl0Ol5Kb.mjs +0 -439
- package/fesm2022/tinya-code-gas-admin-ui-kanban-view-Dl0Ol5Kb.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-key-value-nVdgg5at.mjs +0 -513
- package/fesm2022/tinya-code-gas-admin-ui-key-value-nVdgg5at.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-list-C8ijJHAx.mjs +0 -637
- package/fesm2022/tinya-code-gas-admin-ui-list-C8ijJHAx.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-metric-card-DnGMQL8l.mjs +0 -127
- package/fesm2022/tinya-code-gas-admin-ui-metric-card-DnGMQL8l.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-multiselect-PB28e-jl.mjs +0 -251
- package/fesm2022/tinya-code-gas-admin-ui-multiselect-PB28e-jl.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-notification.service-LjSULER6.mjs +0 -66
- package/fesm2022/tinya-code-gas-admin-ui-notification.service-LjSULER6.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-number-JNr7glc8.mjs +0 -114
- package/fesm2022/tinya-code-gas-admin-ui-number-JNr7glc8.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-phone-jR417hPf.mjs +0 -116
- package/fesm2022/tinya-code-gas-admin-ui-phone-jR417hPf.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-quick-actions-DCQ6emmF.mjs +0 -235
- package/fesm2022/tinya-code-gas-admin-ui-quick-actions-DCQ6emmF.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-radio-mode-DxvN07DY.mjs +0 -8
- package/fesm2022/tinya-code-gas-admin-ui-radio-mode-DxvN07DY.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-readonly-text-BL7-4qie.mjs +0 -124
- package/fesm2022/tinya-code-gas-admin-ui-readonly-text-BL7-4qie.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-record-list-BiZfnd_7.mjs +0 -249
- package/fesm2022/tinya-code-gas-admin-ui-record-list-BiZfnd_7.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-relation-drawer.service-B0kT8EiW.mjs +0 -39
- package/fesm2022/tinya-code-gas-admin-ui-relation-drawer.service-B0kT8EiW.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-relation-ykpJ3PYl.mjs +0 -309
- package/fesm2022/tinya-code-gas-admin-ui-relation-ykpJ3PYl.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-render-registry-CULkxrSt.mjs +0 -576
- package/fesm2022/tinya-code-gas-admin-ui-render-registry-CULkxrSt.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-rich-select-CIHaOrbU.mjs +0 -161
- package/fesm2022/tinya-code-gas-admin-ui-rich-select-CIHaOrbU.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-rolldown-runtime-DpJrqANO.mjs +0 -11
- package/fesm2022/tinya-code-gas-admin-ui-schema-catalog-D0Pk0wAN.mjs +0 -685
- package/fesm2022/tinya-code-gas-admin-ui-schema-catalog-D0Pk0wAN.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-seed-CcCA3FdQ.mjs +0 -80
- package/fesm2022/tinya-code-gas-admin-ui-seed-CcCA3FdQ.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-select-CDwX9yNO.mjs +0 -296
- package/fesm2022/tinya-code-gas-admin-ui-select-CDwX9yNO.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-skeleton-DnWK0tcZ.mjs +0 -119
- package/fesm2022/tinya-code-gas-admin-ui-skeleton-DnWK0tcZ.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-slug-DU58cbiY.mjs +0 -158
- package/fesm2022/tinya-code-gas-admin-ui-slug-DU58cbiY.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-source-options-B9R4mw8-.mjs +0 -26
- package/fesm2022/tinya-code-gas-admin-ui-source-options-B9R4mw8-.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-status-progress-DmeG8htE.mjs +0 -205
- package/fesm2022/tinya-code-gas-admin-ui-status-progress-DmeG8htE.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-string-list-gleasqzD.mjs +0 -432
- package/fesm2022/tinya-code-gas-admin-ui-string-list-gleasqzD.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-template-interpolator-ewv7CkNX.mjs +0 -36
- package/fesm2022/tinya-code-gas-admin-ui-template-interpolator-ewv7CkNX.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-text-CUPmL7W_.mjs +0 -107
- package/fesm2022/tinya-code-gas-admin-ui-text-CUPmL7W_.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-textarea-CGLSIW13.mjs +0 -223
- package/fesm2022/tinya-code-gas-admin-ui-textarea-CGLSIW13.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-time-BcHzsTWf.mjs +0 -107
- package/fesm2022/tinya-code-gas-admin-ui-time-BcHzsTWf.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-upload.service-CZPEPCeN.mjs +0 -60
- package/fesm2022/tinya-code-gas-admin-ui-upload.service-CZPEPCeN.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui-url-GO4LZi__.mjs +0 -141
- package/fesm2022/tinya-code-gas-admin-ui-url-GO4LZi__.mjs.map +0 -1
- package/fesm2022/tinya-code-gas-admin-ui.mjs +0 -4599
- package/fesm2022/tinya-code-gas-admin-ui.mjs.map +0 -1
- package/types/tinya-code-gas-admin-ui.d.ts +0 -3632
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { HttpEvent, HttpEventType } from '@angular/common/http';
|
|
2
|
+
import { TestBed } from '@angular/core/testing';
|
|
3
|
+
import { Observable, of } from 'rxjs';
|
|
4
|
+
import { vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { FileField } from '../../core/models/schema.model';
|
|
7
|
+
import { NotificationService } from '../../core/services/notification.service';
|
|
8
|
+
import { UploadService } from '../../core/services/upload.service';
|
|
9
|
+
import { FieldFile } from './file';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* R9.5.3 — campo `file`: espejo de `FieldImage` para cualquier archivo.
|
|
13
|
+
* Cubre el selector (template ref), el drop en los dos estados, la
|
|
14
|
+
* validación de `accept`/`maxSizeMB` y el nombre derivado de la URL.
|
|
15
|
+
*/
|
|
16
|
+
describe('Campo archivo', () => {
|
|
17
|
+
const NEW_URL = 'https://cdn.example.com/v1/produccion/adjuntos/informe_2024.pdf';
|
|
18
|
+
const uploadSpy = vi.fn((): Observable<HttpEvent<string>> =>
|
|
19
|
+
of({ type: HttpEventType.Response, body: NEW_URL } as HttpEvent<string>),
|
|
20
|
+
);
|
|
21
|
+
const notifyStub = { success: vi.fn(), error: vi.fn(), info: vi.fn() };
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
uploadSpy.mockClear();
|
|
25
|
+
notifyStub.success.mockClear();
|
|
26
|
+
await TestBed.configureTestingModule({
|
|
27
|
+
imports: [FieldFile],
|
|
28
|
+
providers: [
|
|
29
|
+
{ provide: UploadService, useValue: { uploadWithProgress: uploadSpy } },
|
|
30
|
+
{ provide: NotificationService, useValue: notifyStub },
|
|
31
|
+
],
|
|
32
|
+
}).compileComponents();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function create(value: string, overrides: Partial<FileField> = {}) {
|
|
36
|
+
const fixture = TestBed.createComponent(FieldFile);
|
|
37
|
+
const field: FileField = { key: 'adjunto', label: 'Adjunto', type: 'file', ...overrides };
|
|
38
|
+
fixture.componentRef.setInput('field', field);
|
|
39
|
+
fixture.componentRef.setInput('value', value);
|
|
40
|
+
fixture.componentRef.setInput('resource', 'orders');
|
|
41
|
+
await fixture.whenStable();
|
|
42
|
+
fixture.detectChanges();
|
|
43
|
+
return fixture;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findButton(fixture: { nativeElement: HTMLElement }, text: string): HTMLButtonElement {
|
|
47
|
+
const btn = Array.from(fixture.nativeElement.querySelectorAll('button')).find((b) =>
|
|
48
|
+
b.textContent?.includes(text),
|
|
49
|
+
);
|
|
50
|
+
if (!btn) throw new Error(`No se encontró el botón «${text}»`);
|
|
51
|
+
return btn;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dispatchDrop(zone: Element, file: File): void {
|
|
55
|
+
const event = new Event('drop', { bubbles: true, cancelable: true });
|
|
56
|
+
Object.defineProperty(event, 'dataTransfer', { value: { files: [file] } });
|
|
57
|
+
zone.dispatchEvent(event);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pdfFile = (): File => new File(['x'], 'informe.pdf', { type: 'application/pdf' });
|
|
61
|
+
|
|
62
|
+
it('el botón «Subir archivo» abre el selector de archivos', async () => {
|
|
63
|
+
const fixture = await create('');
|
|
64
|
+
const input = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement;
|
|
65
|
+
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {});
|
|
66
|
+
|
|
67
|
+
findButton(fixture, 'Subir archivo').click();
|
|
68
|
+
|
|
69
|
+
expect(clickSpy).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('soltar un archivo lo sube pasando el nombre del campo (formats por campo)', async () => {
|
|
73
|
+
const fixture = await create('');
|
|
74
|
+
const zone = fixture.nativeElement.querySelector('[data-testid="file-dropzone"]');
|
|
75
|
+
expect(zone).not.toBeNull();
|
|
76
|
+
|
|
77
|
+
dispatchDrop(zone!, pdfFile());
|
|
78
|
+
|
|
79
|
+
expect(uploadSpy).toHaveBeenCalledWith(pdfFile(), 'orders', 'adjunto');
|
|
80
|
+
expect(fixture.componentInstance.value()).toBe(NEW_URL);
|
|
81
|
+
expect(notifyStub.success).toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('el preview muestra el nombre derivado de la URL y el enlace «Abrir»', async () => {
|
|
85
|
+
const fixture = await create(NEW_URL);
|
|
86
|
+
|
|
87
|
+
expect(fixture.nativeElement.textContent).toContain('informe_2024.pdf');
|
|
88
|
+
const link = fixture.nativeElement.querySelector('a[target="_blank"]') as HTMLAnchorElement;
|
|
89
|
+
expect(link.getAttribute('href')).toBe(NEW_URL);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('un archivo fuera de `accept` se rechaza en cliente sin subir', async () => {
|
|
93
|
+
const fixture = await create('', { accept: '.pdf' });
|
|
94
|
+
const zone = fixture.nativeElement.querySelector('[data-testid="file-dropzone"]')!;
|
|
95
|
+
|
|
96
|
+
dispatchDrop(zone, new File(['x'], 'foto.png', { type: 'image/png' }));
|
|
97
|
+
fixture.detectChanges();
|
|
98
|
+
|
|
99
|
+
expect(uploadSpy).not.toHaveBeenCalled();
|
|
100
|
+
expect(fixture.nativeElement.textContent).toContain('Formato no permitido');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('un archivo que supera `maxSizeMB` se rechaza en cliente sin subir', async () => {
|
|
104
|
+
// ~0,104 bytes: el archivo de 1 byte ya la supera.
|
|
105
|
+
const fixture = await create('', { maxSizeMB: 0.0000001 });
|
|
106
|
+
const zone = fixture.nativeElement.querySelector('[data-testid="file-dropzone"]')!;
|
|
107
|
+
|
|
108
|
+
dispatchDrop(zone, pdfFile());
|
|
109
|
+
fixture.detectChanges();
|
|
110
|
+
|
|
111
|
+
expect(uploadSpy).not.toHaveBeenCalled();
|
|
112
|
+
expect(fixture.nativeElement.textContent).toContain('supera');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('«Quitar» vacía el campo (campo no requerido)', async () => {
|
|
116
|
+
const fixture = await create(NEW_URL);
|
|
117
|
+
|
|
118
|
+
findButton(fixture, 'Quitar').click();
|
|
119
|
+
fixture.detectChanges();
|
|
120
|
+
|
|
121
|
+
expect(fixture.componentInstance.value()).toBe('');
|
|
122
|
+
expect(fixture.nativeElement.querySelector('[data-testid="file-dropzone"]')).not.toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('dragover se cancela por defecto (habilita el drop del navegador)', async () => {
|
|
126
|
+
const fixture = await create('');
|
|
127
|
+
const zone = fixture.nativeElement.querySelector('[data-testid="file-dropzone"]')!;
|
|
128
|
+
const event = new Event('dragover', { bubbles: true, cancelable: true });
|
|
129
|
+
|
|
130
|
+
zone.dispatchEvent(event);
|
|
131
|
+
|
|
132
|
+
expect(event.defaultPrevented).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { HttpEventType } from '@angular/common/http';
|
|
2
|
+
import {
|
|
3
|
+
Component,
|
|
4
|
+
computed,
|
|
5
|
+
DestroyRef,
|
|
6
|
+
ElementRef,
|
|
7
|
+
inject,
|
|
8
|
+
input,
|
|
9
|
+
model,
|
|
10
|
+
output,
|
|
11
|
+
signal,
|
|
12
|
+
viewChild,
|
|
13
|
+
} from '@angular/core';
|
|
14
|
+
import type { FormValueControl } from '@angular/forms/signals';
|
|
15
|
+
|
|
16
|
+
import type { FileField } from '../../core/models/schema.model';
|
|
17
|
+
import { ApiError } from '../../core/models/api.model';
|
|
18
|
+
import { NotificationService } from '../../core/services/notification.service';
|
|
19
|
+
import { Button } from '../../shared/components/button/button';
|
|
20
|
+
import { UploadService } from '../../core/services/upload.service';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Carga de archivo (base.md §7 · R9.5.3): el valor es SOLO la URL pública,
|
|
24
|
+
* igual que `image`, pero para cualquier tipo de archivo (pdf, xlsx, zip…).
|
|
25
|
+
*
|
|
26
|
+
* Espejo de `FieldImage` (D27: es un control propio que no pasa por
|
|
27
|
+
* `NgComponentOutlet`, porque su `value` es un `model()` que el host
|
|
28
|
+
* sincroniza por `[formField]`). La ÚNICA diferencia de comportamiento: no
|
|
29
|
+
* fuerza el prefijo `image/` — el catálogo de formatos lo define el backend
|
|
30
|
+
* por campo (`formats` declarado) o la lista global, y el cliente sólo
|
|
31
|
+
* respeta `accept`/`maxSizeMB` como validación de comodidad.
|
|
32
|
+
*
|
|
33
|
+
* Estados: vacío (zona punteada + arrastrar-soltar), subiendo (progreso; el
|
|
34
|
+
* valor no cambia hasta la respuesta), con archivo (nombre + Quitar / Ver) y
|
|
35
|
+
* error (conserva el archivo anterior).
|
|
36
|
+
*/
|
|
37
|
+
@Component({
|
|
38
|
+
selector: 'app-field-file',
|
|
39
|
+
imports: [Button],
|
|
40
|
+
template: ` @if (error(); as message) {
|
|
41
|
+
<p
|
|
42
|
+
class="mb-2 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger"
|
|
43
|
+
role="alert"
|
|
44
|
+
>
|
|
45
|
+
{{ message }}
|
|
46
|
+
</p>
|
|
47
|
+
}
|
|
48
|
+
@if (uploading()) {
|
|
49
|
+
<div class="rounded-xl border border-neutral/20 bg-surface p-3" data-testid="file-uploading">
|
|
50
|
+
<div class="h-1.5 w-full overflow-hidden rounded-full bg-neutral/20">
|
|
51
|
+
<div
|
|
52
|
+
class="h-full rounded-full bg-primary transition-all"
|
|
53
|
+
[style.width.%]="progress()"
|
|
54
|
+
></div>
|
|
55
|
+
</div>
|
|
56
|
+
<p class="mt-1 text-xs text-neutral">Subiendo… {{ progress() }}%</p>
|
|
57
|
+
</div>
|
|
58
|
+
} @else if (value()) {
|
|
59
|
+
<div
|
|
60
|
+
class="rounded-xl border border-neutral/20 bg-surface p-3"
|
|
61
|
+
data-testid="file-preview"
|
|
62
|
+
(dragover)="$event.preventDefault()"
|
|
63
|
+
(drop)="onDrop($event)"
|
|
64
|
+
>
|
|
65
|
+
<p class="truncate text-sm font-medium" [title]="value()">{{ fileName() }}</p>
|
|
66
|
+
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
app-button
|
|
70
|
+
variant="outline"
|
|
71
|
+
[disabled]="disabled()"
|
|
72
|
+
(click)="pickFile()"
|
|
73
|
+
>
|
|
74
|
+
Reemplazar
|
|
75
|
+
</button>
|
|
76
|
+
@if (!field().required) {
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
app-button
|
|
80
|
+
variant="outline"
|
|
81
|
+
[disabled]="disabled()"
|
|
82
|
+
(click)="clear()"
|
|
83
|
+
>
|
|
84
|
+
Quitar
|
|
85
|
+
</button>
|
|
86
|
+
}
|
|
87
|
+
<a
|
|
88
|
+
class="text-sm font-medium text-primary hover:underline"
|
|
89
|
+
[href]="value()"
|
|
90
|
+
target="_blank"
|
|
91
|
+
rel="noopener noreferrer"
|
|
92
|
+
>
|
|
93
|
+
Abrir
|
|
94
|
+
</a>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
} @else {
|
|
98
|
+
<div
|
|
99
|
+
class="rounded-xl border-2 border-dashed border-neutral/30 p-6 text-center"
|
|
100
|
+
data-testid="file-dropzone"
|
|
101
|
+
(dragover)="$event.preventDefault()"
|
|
102
|
+
(drop)="onDrop($event)"
|
|
103
|
+
>
|
|
104
|
+
<p class="text-sm text-neutral">Arrastrá un archivo acá</p>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
app-button
|
|
108
|
+
variant="outline"
|
|
109
|
+
class="mt-2"
|
|
110
|
+
[disabled]="disabled()"
|
|
111
|
+
(click)="pickFile()"
|
|
112
|
+
>
|
|
113
|
+
Subir archivo
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
<input
|
|
119
|
+
#fileInput
|
|
120
|
+
type="file"
|
|
121
|
+
class="sr-only"
|
|
122
|
+
[accept]="acceptAttr()"
|
|
123
|
+
[disabled]="disabled()"
|
|
124
|
+
(change)="onFileSelected($event)"
|
|
125
|
+
/>`,
|
|
126
|
+
})
|
|
127
|
+
export class FieldFile implements FormValueControl<string> {
|
|
128
|
+
readonly field = input.required<FileField>();
|
|
129
|
+
/** Recurso dueño del archivo (firma de subida: carpeta de Cloudinary, §11.2). */
|
|
130
|
+
readonly resource = input('');
|
|
131
|
+
|
|
132
|
+
/** Sincronizado por el directive `FormField` del host. */
|
|
133
|
+
readonly value = model.required<string>();
|
|
134
|
+
readonly disabled = input(false);
|
|
135
|
+
/** Marca el campo como tocado (upload/clear/error), como el `blur` nativo. */
|
|
136
|
+
readonly touch = output<void>();
|
|
137
|
+
|
|
138
|
+
private readonly upload = inject(UploadService);
|
|
139
|
+
private readonly notifications = inject(NotificationService);
|
|
140
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
141
|
+
|
|
142
|
+
protected readonly uploading = signal(false);
|
|
143
|
+
protected readonly progress = signal(0);
|
|
144
|
+
protected readonly error = signal<string | null>(null);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Nombre derivado de la URL para el preview. Cloudinary guarda el
|
|
148
|
+
* `public_id` al final del path (…/v123/produccion/adjuntos/archivo_xyz):
|
|
149
|
+
* lo presentable es el sufijo tras el último `/`, sin extensión ruidosa.
|
|
150
|
+
*/
|
|
151
|
+
protected readonly fileName = computed(() => {
|
|
152
|
+
const url = this.value();
|
|
153
|
+
const segment = url.split('/').pop() ?? url;
|
|
154
|
+
return decodeURIComponent(segment);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/** `accept` de `<input type=file>`: el declarado o `''` (sin forzar `image/*`). */
|
|
158
|
+
protected readonly acceptAttr = computed(() => this.field().accept ?? '');
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Template ref `#fileInput`: Angular NO auto-asigna las template refs a
|
|
162
|
+
* propiedades de clase (mismo motivo que en `FieldImage`).
|
|
163
|
+
*/
|
|
164
|
+
private readonly fileInput = viewChild.required<ElementRef<HTMLInputElement>>('fileInput');
|
|
165
|
+
private uploadSub?: { unsubscribe(): void };
|
|
166
|
+
|
|
167
|
+
protected pickFile(): void {
|
|
168
|
+
this.fileInput().nativeElement.click();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
protected clear(): void {
|
|
172
|
+
this.value.set('');
|
|
173
|
+
this.error.set(null);
|
|
174
|
+
this.touch.emit();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
protected onFileSelected(event: Event): void {
|
|
178
|
+
const input = event.target as HTMLInputElement;
|
|
179
|
+
const file = input.files?.[0];
|
|
180
|
+
// Permite volver a elegir el mismo archivo tras un error.
|
|
181
|
+
input.value = '';
|
|
182
|
+
if (file) {
|
|
183
|
+
this.startUpload(file);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
protected onDrop(event: Event): void {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
const file = (event as DragEvent).dataTransfer?.files?.[0];
|
|
190
|
+
if (file) {
|
|
191
|
+
this.startUpload(file);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Valida en cliente (accept + maxSizeMB) y sube con progreso. */
|
|
196
|
+
private startUpload(file: File): void {
|
|
197
|
+
if (this.disabled()) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const problem = this.validate(file);
|
|
201
|
+
if (problem !== null) {
|
|
202
|
+
this.error.set(problem);
|
|
203
|
+
this.touch.emit();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.error.set(null);
|
|
207
|
+
this.uploading.set(true);
|
|
208
|
+
this.progress.set(0);
|
|
209
|
+
this.touch.emit();
|
|
210
|
+
|
|
211
|
+
this.uploadSub?.unsubscribe();
|
|
212
|
+
// El nombre del campo viaja siempre: si declara `formats`, el backend
|
|
213
|
+
// firma ESA lista; sin formatos firma la lista global (R9.5.3).
|
|
214
|
+
this.uploadSub = this.upload
|
|
215
|
+
.uploadWithProgress(file, this.resource(), this.field().key)
|
|
216
|
+
.subscribe({
|
|
217
|
+
next: (event) => {
|
|
218
|
+
if (event.type === HttpEventType.UploadProgress) {
|
|
219
|
+
if (event.total) {
|
|
220
|
+
this.progress.set(Math.round((event.loaded / event.total) * 100));
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (event.type === HttpEventType.Response) {
|
|
225
|
+
const url = event.body ?? '';
|
|
226
|
+
this.uploading.set(false);
|
|
227
|
+
if (url === '') {
|
|
228
|
+
this.error.set('No se pudo subir el archivo.');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.value.set(url);
|
|
232
|
+
this.notifications.success('Archivo subido.');
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
error: (err: unknown) => {
|
|
236
|
+
this.uploading.set(false);
|
|
237
|
+
// El backend explica el motivo (firma inválida, 429 rate-limit…).
|
|
238
|
+
this.error.set(
|
|
239
|
+
err instanceof ApiError
|
|
240
|
+
? err.message
|
|
241
|
+
: 'No se pudo subir el archivo. Revisa la conexión e inténtalo de nuevo.',
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
this.destroyRef.onDestroy(() => this.uploadSub?.unsubscribe());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private validate(file: File): string | null {
|
|
249
|
+
const accept = this.field().accept;
|
|
250
|
+
if (accept !== undefined && accept !== '' && !acceptMatches(accept, file)) {
|
|
251
|
+
return 'Formato no permitido para este campo.';
|
|
252
|
+
}
|
|
253
|
+
const maxSizeMB = this.field().maxSizeMB;
|
|
254
|
+
if (maxSizeMB !== undefined && file.size > maxSizeMB * 1024 * 1024) {
|
|
255
|
+
return `El archivo supera ${maxSizeMB} MB.`;
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** `accept` de `<input type=file>`: lista, `tipo/*` o extensión `.png`. */
|
|
262
|
+
function acceptMatches(accept: string, file: File): boolean {
|
|
263
|
+
for (const raw of accept.split(',')) {
|
|
264
|
+
const entry = raw.trim().toLowerCase();
|
|
265
|
+
if (entry === '') {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (entry.startsWith('.')) {
|
|
269
|
+
if (file.name.toLowerCase().endsWith(entry)) {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
} else if (entry.endsWith('/*')) {
|
|
273
|
+
if (file.type.startsWith(entry.slice(0, -1))) {
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
} else if (file.type === entry) {
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RenderDescriptor } from '../../core/registry/render-registry';
|
|
2
|
+
|
|
3
|
+
/** Descriptor del campo `group` (R3.3). */
|
|
4
|
+
export const descriptor: RenderDescriptor = {
|
|
5
|
+
kind: 'field',
|
|
6
|
+
type: 'group',
|
|
7
|
+
since: '1.0',
|
|
8
|
+
load: () => import('./group').then((module) => module.FieldGroup),
|
|
9
|
+
// Contenedor: siembra un nodo por cada hijo declarado.
|
|
10
|
+
seed: 'recursive',
|
|
11
|
+
surfaces: ['form', 'display'],
|
|
12
|
+
// Compuesto: además de su árbol dibuja el template de sus hijos, que sólo
|
|
13
|
+
// existe en la plantilla del host. Él es quien decide recibirlo (R3.6).
|
|
14
|
+
inputs: (config, extras) => ({
|
|
15
|
+
field: config,
|
|
16
|
+
tree: extras.ctx.tree,
|
|
17
|
+
serverErrors: extras.ctx.serverErrors,
|
|
18
|
+
pathPrefix: extras.ctx.pathPrefix,
|
|
19
|
+
content: extras.groupContent,
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
2
|
+
import { Component, computed, input, signal, type TemplateRef } from '@angular/core';
|
|
3
|
+
|
|
4
|
+
import type { GroupField } from '../../core/models/schema.model';
|
|
5
|
+
import { childTree, type RootTree } from '../field-node';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Grupo de campos (base.md §5.4).
|
|
9
|
+
*
|
|
10
|
+
* **Sin importar FieldHost**: los hijos los renderiza el despachador pasando
|
|
11
|
+
* un `ng-template` (`content`) que este componente instancia con
|
|
12
|
+
* `ngTemplateOutlet` en cada variante de chrome. Un import mutuo
|
|
13
|
+
* `field-host ↔ group` rompe en runtime con `Cannot read properties of
|
|
14
|
+
* undefined (reading 'ɵcmp')` y `ng build` NO lo detecta.
|
|
15
|
+
*
|
|
16
|
+
* **Por qué no `<ng-content>`**: con un `<ng-content>` por rama de display,
|
|
17
|
+
* el compilador asigna slot secuencial a cada uno (`generateProjectionDefs`)
|
|
18
|
+
* y el contenido del host cae sólo en el último slot — las ramas
|
|
19
|
+
* `collapsible`/`inline` quedaban vacías. Un solo destino (`TemplateRef`)
|
|
20
|
+
* evita los slots de proyección por completo.
|
|
21
|
+
*
|
|
22
|
+
* Presentaciones:
|
|
23
|
+
* - `card` (por defecto): tarjeta con título.
|
|
24
|
+
* - `collapsible`: `<details>` que se auto-abre si el grupo tiene problemas.
|
|
25
|
+
* - `inline`: sólo la grilla, sin chrome.
|
|
26
|
+
*/
|
|
27
|
+
@Component({
|
|
28
|
+
selector: 'app-field-group',
|
|
29
|
+
imports: [NgTemplateOutlet],
|
|
30
|
+
template: `
|
|
31
|
+
@if (display() === 'collapsible') {
|
|
32
|
+
<details
|
|
33
|
+
class="rounded-xl border border-neutral/20 bg-surface"
|
|
34
|
+
[open]="open() || hasProblem()"
|
|
35
|
+
(toggle)="onToggle($event)"
|
|
36
|
+
>
|
|
37
|
+
<summary
|
|
38
|
+
class="flex cursor-pointer items-center justify-between gap-2 rounded-xl px-4 py-3 text-section font-semibold hover:bg-neutral/5"
|
|
39
|
+
>
|
|
40
|
+
<span>
|
|
41
|
+
{{ field().label }}
|
|
42
|
+
@if (field().required) {
|
|
43
|
+
<span class="text-danger" aria-hidden="true">*</span>
|
|
44
|
+
}
|
|
45
|
+
</span>
|
|
46
|
+
@if (hasProblem()) {
|
|
47
|
+
<span class="text-xs font-medium text-danger">Revisar</span>
|
|
48
|
+
}
|
|
49
|
+
</summary>
|
|
50
|
+
<div class="px-4 pb-4">
|
|
51
|
+
<div class="grid grid-cols-1 gap-x-4 sm:grid-cols-12">
|
|
52
|
+
<ng-container [ngTemplateOutlet]="content()" />
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</details>
|
|
56
|
+
} @else if (display() === 'inline') {
|
|
57
|
+
<div class="grid grid-cols-1 gap-x-4 sm:grid-cols-12">
|
|
58
|
+
<ng-container [ngTemplateOutlet]="content()" />
|
|
59
|
+
</div>
|
|
60
|
+
} @else {
|
|
61
|
+
<section class="rounded-xl border border-neutral/20 bg-surface p-4">
|
|
62
|
+
<h2 class="mb-3 text-section font-semibold">
|
|
63
|
+
{{ field().label }}
|
|
64
|
+
@if (field().required) {
|
|
65
|
+
<span class="text-danger" aria-hidden="true">*</span>
|
|
66
|
+
}
|
|
67
|
+
@if (hasProblem()) {
|
|
68
|
+
<span class="ml-2 text-xs font-medium text-danger">Revisar</span>
|
|
69
|
+
}
|
|
70
|
+
</h2>
|
|
71
|
+
<div class="grid grid-cols-1 gap-x-4 sm:grid-cols-12">
|
|
72
|
+
<ng-container [ngTemplateOutlet]="content()" />
|
|
73
|
+
</div>
|
|
74
|
+
</section>
|
|
75
|
+
}
|
|
76
|
+
`,
|
|
77
|
+
})
|
|
78
|
+
export class FieldGroup {
|
|
79
|
+
readonly field = input.required<GroupField>();
|
|
80
|
+
readonly tree = input.required<RootTree>();
|
|
81
|
+
/** Errores del backend: detecta rutas anidadas (`address.street`). */
|
|
82
|
+
readonly serverErrors = input<Record<string, string>>({});
|
|
83
|
+
/** Prefijo de ruta del grupo en el registro raíz (`address.`). */
|
|
84
|
+
readonly pathPrefix = input('');
|
|
85
|
+
/** Hijos: `ng-template` declarado en el case de grupo del FieldHost. */
|
|
86
|
+
readonly content = input.required<TemplateRef<unknown>>();
|
|
87
|
+
|
|
88
|
+
protected readonly display = computed(() => this.field().display ?? 'card');
|
|
89
|
+
|
|
90
|
+
/** Estado del subárbol: `invalid()`/`touched()` agregan a los hijos. */
|
|
91
|
+
private readonly state = computed(() =>
|
|
92
|
+
childTree<Record<string, unknown>>(this.tree(), this.field().key)(),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
protected readonly hasProblem = computed(
|
|
96
|
+
() => (this.state().invalid() && this.state().touched()) || this.hasServerError(),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/** El usuario abrió/cerró el `<details>` a mano (se persiste en el binding). */
|
|
100
|
+
protected readonly open = signal(false);
|
|
101
|
+
|
|
102
|
+
protected onToggle(event: Event): void {
|
|
103
|
+
this.open.set((event.currentTarget as HTMLDetailsElement).open);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private hasServerError(): boolean {
|
|
107
|
+
const prefix = `${this.pathPrefix()}${this.field().key}.`;
|
|
108
|
+
return Object.keys(this.serverErrors()).some((path) => path.startsWith(prefix));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RenderDescriptor } from '../../core/registry/render-registry';
|
|
2
|
+
|
|
3
|
+
/** Descriptor del campo `image` (R3.3). */
|
|
4
|
+
export const descriptor: RenderDescriptor = {
|
|
5
|
+
kind: 'field',
|
|
6
|
+
type: 'image',
|
|
7
|
+
since: '1.0',
|
|
8
|
+
load: () => import('./image').then((module) => module.FieldImage),
|
|
9
|
+
// Nace como cadena: los textos, fechas, urls, selects y relacionales.
|
|
10
|
+
seed: 'string',
|
|
11
|
+
surfaces: ['form', 'display'],
|
|
12
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { HttpEvent, HttpEventType } from '@angular/common/http';
|
|
2
|
+
import { TestBed } from '@angular/core/testing';
|
|
3
|
+
import { Observable, of } from 'rxjs';
|
|
4
|
+
import { vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { ImageField } from '../../core/models/schema.model';
|
|
7
|
+
import { NotificationService } from '../../core/services/notification.service';
|
|
8
|
+
import { UploadService } from '../../core/services/upload.service';
|
|
9
|
+
import { FieldImage } from './image';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Regresión del selector de imagen: `#fileInput` es una template ref y
|
|
13
|
+
* Angular NO la auto-asigna a la propiedad de clase — con el campo
|
|
14
|
+
* `fileInput` en `undefined`, `pickFile()` no abría nada. Cubre también el
|
|
15
|
+
* arrastre en los dos estados (zona vacía y preview).
|
|
16
|
+
*/
|
|
17
|
+
describe('Campo imagen', () => {
|
|
18
|
+
const NEW_URL = 'https://cdn.example.com/nueva.png';
|
|
19
|
+
const uploadSpy = vi.fn((): Observable<HttpEvent<string>> =>
|
|
20
|
+
of({ type: HttpEventType.Response, body: NEW_URL } as HttpEvent<string>),
|
|
21
|
+
);
|
|
22
|
+
const notifyStub = { success: vi.fn(), error: vi.fn(), info: vi.fn() };
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
uploadSpy.mockClear();
|
|
26
|
+
notifyStub.success.mockClear();
|
|
27
|
+
await TestBed.configureTestingModule({
|
|
28
|
+
imports: [FieldImage],
|
|
29
|
+
providers: [
|
|
30
|
+
{ provide: UploadService, useValue: { uploadWithProgress: uploadSpy } },
|
|
31
|
+
{ provide: NotificationService, useValue: notifyStub },
|
|
32
|
+
],
|
|
33
|
+
}).compileComponents();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function create(value: string) {
|
|
37
|
+
const fixture = TestBed.createComponent(FieldImage);
|
|
38
|
+
const field: ImageField = { key: 'image_url', label: 'Imagen', type: 'image' };
|
|
39
|
+
fixture.componentRef.setInput('field', field);
|
|
40
|
+
fixture.componentRef.setInput('value', value);
|
|
41
|
+
fixture.componentRef.setInput('resource', 'products');
|
|
42
|
+
await fixture.whenStable();
|
|
43
|
+
fixture.detectChanges();
|
|
44
|
+
return fixture;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buttons(fixture: { nativeElement: HTMLElement }): HTMLButtonElement[] {
|
|
48
|
+
return Array.from(fixture.nativeElement.querySelectorAll('button'));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function findButton(fixture: { nativeElement: HTMLElement }, text: string): HTMLButtonElement {
|
|
52
|
+
const btn = buttons(fixture).find((b) => b.textContent?.includes(text));
|
|
53
|
+
if (!btn) throw new Error(`No se encontró el botón «${text}»`);
|
|
54
|
+
return btn;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function dispatchDrop(zone: Element, file: File): void {
|
|
58
|
+
const event = new Event('drop', { bubbles: true, cancelable: true });
|
|
59
|
+
Object.defineProperty(event, 'dataTransfer', { value: { files: [file] } });
|
|
60
|
+
zone.dispatchEvent(event);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const imageFile = (): File => new File(['x'], 'foto.png', { type: 'image/png' });
|
|
64
|
+
|
|
65
|
+
it('el botón «Subir imagen» abre el selector de archivos', async () => {
|
|
66
|
+
const fixture = await create('');
|
|
67
|
+
const input = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement;
|
|
68
|
+
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {});
|
|
69
|
+
|
|
70
|
+
findButton(fixture, 'Subir imagen').click();
|
|
71
|
+
|
|
72
|
+
expect(clickSpy).toHaveBeenCalledTimes(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('el botón «Reemplazar» abre el selector de archivos', async () => {
|
|
76
|
+
const fixture = await create('https://cdn.example.com/vieja.png');
|
|
77
|
+
const input = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement;
|
|
78
|
+
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {});
|
|
79
|
+
|
|
80
|
+
findButton(fixture, 'Reemplazar').click();
|
|
81
|
+
|
|
82
|
+
expect(clickSpy).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('soltar una imagen en la zona vacía la sube y setea el valor', async () => {
|
|
86
|
+
const fixture = await create('');
|
|
87
|
+
const zone = fixture.nativeElement.querySelector('[data-testid="image-dropzone"]');
|
|
88
|
+
expect(zone).not.toBeNull();
|
|
89
|
+
|
|
90
|
+
dispatchDrop(zone!, imageFile());
|
|
91
|
+
|
|
92
|
+
expect(uploadSpy).toHaveBeenCalledWith(imageFile(), 'products', 'image_url');
|
|
93
|
+
expect(fixture.componentInstance.value()).toBe(NEW_URL);
|
|
94
|
+
expect(notifyStub.success).toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('soltar una imagen sobre el preview también la sube (reemplazo)', async () => {
|
|
98
|
+
const fixture = await create('https://cdn.example.com/vieja.png');
|
|
99
|
+
const preview = fixture.nativeElement.querySelector('[data-testid="image-preview"]');
|
|
100
|
+
expect(preview).not.toBeNull();
|
|
101
|
+
|
|
102
|
+
dispatchDrop(preview!, imageFile());
|
|
103
|
+
|
|
104
|
+
expect(uploadSpy).toHaveBeenCalledWith(imageFile(), 'products', 'image_url');
|
|
105
|
+
expect(fixture.componentInstance.value()).toBe(NEW_URL);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('dragover se cancela por defecto (habilita el drop del navegador)', async () => {
|
|
109
|
+
const fixture = await create('');
|
|
110
|
+
const zone = fixture.nativeElement.querySelector('[data-testid="image-dropzone"]')!;
|
|
111
|
+
const event = new Event('dragover', { bubbles: true, cancelable: true });
|
|
112
|
+
|
|
113
|
+
zone.dispatchEvent(event);
|
|
114
|
+
|
|
115
|
+
expect(event.defaultPrevented).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|