From d6d2ffa881e4496b9a0abf7a5e1973565fb0e2a0 Mon Sep 17 00:00:00 2001
From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com>
Date: Fri, 17 Apr 2026 15:56:44 +0100
Subject: [PATCH 1/2] add datumctl passthrough to could-portal
---
.env.example | 255 +-
.github/workflows/ci.yml | 740 +-
.github/workflows/set-project-month.yaml | 20 +-
.gitignore | 62 +-
.tool-versions | 4 +-
Dockerfile | 142 +-
app/components/avatar-stack/avatar-stack.tsx | 148 +-
app/components/back-button/back-button.tsx | 212 +-
app/components/badge/badge-copy.tsx | 190 +-
app/components/badge/badge-status.tsx | 364 +-
.../chips-overflow/chips-overflow.tsx | 306 +-
.../code-editor/code-editor-tabs.tsx | 386 +-
app/components/code-editor/code-editor.tsx | 302 +-
.../code-editor/code-editor.types.ts | 162 +-
.../coming-soon/coming-soon-feature-card.tsx | 66 +-
.../confirmation-dialog.tsx | 368 +-
.../content-wrapper/content-wrapper.tsx | 44 +-
app/components/danger-card/danger-card.tsx | 108 +-
app/components/date-time/date-time.tsx | 436 +-
app/components/demo/alert.tsx | 1034 +-
app/components/demo/badge.tsx | 566 +-
app/components/demo/button.tsx | 796 +-
app/components/demo/form-fields.tsx | 1356 +-
app/components/demo/grid.tsx | 382 +-
app/components/demo/status-badge.tsx | 620 +-
app/components/demo/toast.tsx | 372 +-
app/components/demo/tooltip.tsx | 752 +-
app/components/error/auth.tsx | 58 +-
app/components/error/generic.tsx | 130 +-
app/components/field/field-label.tsx | 118 +-
app/components/field/field.tsx | 106 +-
app/components/header/breadcrumb.tsx | 266 +-
app/components/header/header.tsx | 226 +-
app/components/header/index.ts | 14 +-
.../header/mobile-switcher-sheet.tsx | 631 +-
.../header/org-project-switcher.tsx | 172 +-
app/components/header/org-switcher.tsx | 80 +-
app/components/header/project-switcher.tsx | 293 +-
app/components/header/search-bar.tsx | 170 +-
app/components/header/user-dropdown.tsx | 286 +-
app/components/input-name/input-name.tsx | 226 +-
app/components/key-value-form-dialog.tsx | 152 +-
app/components/list/list.tsx | 108 +-
app/components/logo/logo-icon.tsx | 84 +-
app/components/logo/logo-text.tsx | 100 +-
app/components/metadata/metadata-form.tsx | 94 +-
app/components/metadata/metadata-preview.tsx | 88 +-
app/components/mobile-menu/mobile-menu.tsx | 202 +-
app/components/multi-select/multi-select.tsx | 1170 +-
app/components/note-card/note-card.tsx | 156 +-
.../notification-checkbox-item.tsx | 42 +-
.../notification-settings-card-skeleton.tsx | 118 +-
.../notification-settings-card.tsx | 120 +-
.../items/invitation-notification-item.tsx | 278 +-
.../notification/notification-bell.tsx | 72 +-
.../notification/notification-dropdown.tsx | 150 +-
.../notification-item-wrapper.tsx | 58 +-
.../personal-badge/personal-badge.tsx | 32 +-
.../profile-identity/profile-identity.tsx | 194 +-
.../select-annotations/select-annotations.tsx | 246 +-
.../select-autocomplete.tsx | 246 +-
.../select-autocomplete.types.ts | 56 +-
.../select-autocomplete/virtualized-list.tsx | 344 +-
app/components/select-box/select-box.tsx | 726 +-
app/components/select-group/select-group.tsx | 110 +-
.../select-labels/select-labels.tsx | 244 +-
.../select-member/select-member.tsx | 120 +-
.../select-organization/organization-item.tsx | 42 +-
.../select-organization.tsx | 302 +-
.../select-project/select-project.tsx | 114 +-
app/components/select-role/select-role.tsx | 208 +-
.../select-secret/select-secret.tsx | 114 +-
.../select-timezone/select-timezone.tsx | 192 +-
app/components/select-timezone/timezones.json | 4996 ++--
.../status-pulse-dot/status-pulse-dot.tsx | 64 +-
.../sub-navigation/sub-navigation-tabs.tsx | 144 +-
app/components/text-copy/text-copy-box.tsx | 152 +-
app/components/text-copy/text-copy.tsx | 106 +-
app/components/waiting-page/waiting-page.tsx | 58 +-
app/entry.client.tsx | 208 +-
app/entry.server.tsx | 148 +-
app/features/account/cards/2fa-card.tsx | 146 +-
app/features/account/cards/danger-card.tsx | 104 +-
app/features/account/cards/identity-card.tsx | 236 +-
.../account/cards/newsletter-card.tsx | 88 +-
.../account/cards/notification-card.tsx | 400 +-
app/features/account/cards/profile-card.tsx | 206 +-
.../account/cards/sign-in-method-card.tsx | 174 +-
app/features/account/cards/team-auth-card.tsx | 40 +-
.../account/identity-item-skeleton.tsx | 128 +-
app/features/account/identity-item.tsx | 134 +-
.../activity-log/activity-log-columns.tsx | 228 +-
.../activity-log/activity-log-filters.ts | 18 +-
.../activity-log/activity-log-table.tsx | 586 +-
app/features/activity-log/index.ts | 30 +-
.../activity-log/use-activity-log-table.ts | 706 +-
.../connectors/connector-download-card.tsx | 186 +-
.../dns-records/dns-record-ai-edge-cell.tsx | 238 +-
.../edge/dns-records/dns-record-card.tsx | 72 +-
.../dns-records/dns-record-modal-form.tsx | 176 +-
.../edge/dns-records/dns-record-table.tsx | 484 +-
.../components/dropzone-state-content.tsx | 104 +-
.../components/import-result-table.tsx | 354 +-
.../dns-record-import-action.tsx | 624 +-
.../hooks/use-dns-record-export.ts | 96 +-
app/features/edge/dns-records/index.ts | 22 +-
app/features/edge/dns-records/types.ts | 212 +-
.../components/refresh-nameservers-button.tsx | 274 +-
.../edge/dns-zone/discovery-preview.tsx | 554 +-
.../edge/dns-zone/dns-zone-form-dialog.tsx | 206 +-
.../edge/dns-zone/form/dns-record-form.tsx | 522 +-
.../dns-zone/form/types/a-record-field.tsx | 14 +-
.../dns-zone/form/types/aaaa-record-field.tsx | 14 +-
.../form/types/alias-record-field.tsx | 14 +-
.../dns-zone/form/types/caa-record-field.tsx | 80 +-
.../form/types/cname-record-field.tsx | 14 +-
.../form/types/https-record-field.tsx | 34 +-
.../dns-zone/form/types/mx-record-field.tsx | 26 +-
.../dns-zone/form/types/ns-record-field.tsx | 14 +-
.../dns-zone/form/types/ptr-record-field.tsx | 14 +-
.../dns-zone/form/types/soa-record-field.tsx | 66 +-
.../dns-zone/form/types/srv-record-field.tsx | 42 +-
.../dns-zone/form/types/svcb-record-field.tsx | 34 +-
.../dns-zone/form/types/tlsa-record-field.tsx | 58 +-
.../dns-zone/form/types/txt-record-field.tsx | 14 +-
.../dns-zone/overview/coming-soon-card.tsx | 62 +-
.../overview/description-form-card.tsx | 186 +-
.../overview/task-nameserver-card.tsx | 164 +-
.../dns-zone/overview/task-record-card.tsx | 130 +-
.../bulk-add/bulk-add-domains-action.tsx | 448 +-
.../edge/domain/domain-form-dialog.tsx | 166 +-
app/features/edge/domain/expiration.tsx | 58 +-
.../edge/domain/overview/general-card.tsx | 254 +-
.../edge/domain/overview/quick-setup-card.tsx | 230 +-
.../domain/overview/verification-card.tsx | 232 +-
app/features/edge/domain/select-domain.tsx | 578 +-
app/features/edge/domain/status.tsx | 204 +-
.../edge/nameservers/nameserver-card.tsx | 88 +-
.../edge/nameservers/nameserver-table.tsx | 194 +-
.../edge/proxy/form/hostnames-field.tsx | 126 +-
.../proxy/form/protocol-endpoint-input.tsx | 156 +-
.../proxy/form/subdomain-hostname-field.tsx | 718 +-
app/features/edge/proxy/form/tls-field.tsx | 42 +-
.../proxy/metrics/connector-sparkline.tsx | 272 +-
.../edge/proxy/metrics/edge-requests.tsx | 300 +-
.../edge/proxy/metrics/proxy-sparkline.tsx | 256 +-
.../edge/proxy/metrics/upstream-table.tsx | 148 +-
.../edge/proxy/metrics/waf-events.tsx | 222 +-
.../edge/proxy/overview/active-pops-card.tsx | 258 +-
.../edge/proxy/overview/config-card.tsx | 560 +-
.../edge/proxy/overview/general-card.tsx | 418 +-
.../proxy/overview/grafana-setup-card.tsx | 72 +-
.../edge/proxy/overview/hostnames-card.tsx | 400 +-
.../edge/proxy/overview/origins-card.tsx | 168 +-
.../edge/proxy/proxy-basic-auth-dialog.tsx | 502 +-
.../edge/proxy/proxy-display-name-dialog.tsx | 180 +-
app/features/edge/proxy/proxy-form-dialog.tsx | 304 +-
.../edge/proxy/proxy-hostnames-dialog.tsx | 222 +-
.../edge/proxy/proxy-origins-dialog.tsx | 228 +-
app/features/edge/proxy/proxy-waf-dialog.tsx | 346 +-
.../export-policies/card/activity-card.tsx | 496 +-
.../export-policies/card/coming-soon-card.tsx | 50 +-
.../export-policies/card/general-card.tsx | 184 +-
.../export-policies/card/grafana-card.tsx | 96 +-
.../form/sink/prometheus/auth-field.tsx | 178 +-
.../form/sink/prometheus/batch-field.tsx | 74 +-
.../form/sink/prometheus/prometheus-field.tsx | 68 +-
.../form/sink/prometheus/retry-field.tsx | 74 +-
.../export-policies/form/sink/sink-field.tsx | 208 +-
.../export-policies/form/sink/sinks-form.tsx | 144 +-
.../form/sink/sinks-preview.tsx | 156 +-
.../form/source/source-field.tsx | 110 +-
.../form/source/sources-form.tsx | 110 +-
.../form/source/sources-preview.tsx | 84 +-
.../export-policies/form/stepper-form.tsx | 442 +-
.../export-policies/form/update-form.tsx | 562 +-
.../providers/grafana/grafana-dialog.tsx | 140 +-
.../providers/grafana/grafana-form.tsx | 706 +-
.../metric/export-policies/sinks-table.tsx | 328 +-
.../metric/export-policies/sources-table.tsx | 228 +-
app/features/notes/note-card.tsx | 96 +-
app/features/notes/note-form-dialog.tsx | 268 +-
app/features/notes/note-meta.tsx | 44 +-
app/features/notes/notes-section.tsx | 300 +-
.../cards/create-organization-card.tsx | 144 +-
.../organization/cards/organization-card.tsx | 552 +-
app/features/organization/list-card.tsx | 94 +-
.../organization/settings/danger-card.tsx | 106 +-
.../organization/settings/general-card.tsx | 212 +-
.../organization/team/group-header.tsx | 182 +-
.../organization/team/groups/group-form.tsx | 106 +-
.../organization/team/groups/index.ts | 2 +-
.../organization/team/invitation-form.tsx | 200 +-
.../team/manage-members-dialog.tsx | 446 +-
.../organization/team/manage-role.tsx | 268 +-
.../organization/team/roles/action-bar.tsx | 124 +-
.../team/roles/add-role-screen.tsx | 710 +-
.../team/roles/permissions-panel.tsx | 438 +-
.../organization/team/roles/role-row.tsx | 148 +-
.../organization/team/roles/roles-panel.tsx | 308 +-
.../form/policy-binding-form-dialog.tsx | 736 +-
.../policy-binding/form/select-resource.tsx | 76 +-
.../policy-binding/policy-binding-table.tsx | 68 +-
.../policy-binding/policy-binding.helpers.tsx | 334 +-
.../chat/assistant-message.tsx | 300 +-
.../project-bottom-bar/chat/chat-input.tsx | 214 +-
.../project-bottom-bar/chat/chat-panel.tsx | 716 +-
.../project-bottom-bar/chat/chat-sidebar.tsx | 254 +-
.../chat/thinking-block.tsx | 130 +-
.../project-bottom-bar/chat/use-chat-logic.ts | 606 +-
.../project-bottom-bar/project-bottom-bar.tsx | 489 +-
.../project-bottom-bar/terminal-panel.tsx | 1004 +-
app/features/project/dashboard.tsx | 214 +-
app/features/project/settings/danger-card.tsx | 118 +-
.../project/settings/general-card.tsx | 192 +-
app/features/quotas/quotas-table.tsx | 294 +-
.../form/edit/edit-key-value-dialog.tsx | 202 +-
app/features/secret/form/edit/edit-keys.tsx | 250 +-
.../secret/form/key-value-field-array.tsx | 164 +-
.../secret/form/keys/keys-form-dialog.tsx | 204 +-
.../secret/form/overview/general-card.tsx | 94 +-
.../secret/form/secret-form-dialog.tsx | 218 +-
app/hooks/useCopyToClipboard.ts | 106 +-
app/hooks/useOs.ts | 104 +-
app/layouts/blank.layout.tsx | 58 +-
app/layouts/dashboard.layout.tsx | 280 +-
app/layouts/private.layout.tsx | 257 +-
app/layouts/sub/sub.layout.tsx | 188 +-
app/layouts/sub/sub.types.ts | 22 +-
app/modules/auth/strategies/zitadel.server.ts | 94 +-
app/modules/axios/axios.server.ts | 438 +-
app/modules/axios/request-context.ts | 94 +-
app/modules/control-plane/iam/index.ts | 1670 +-
app/modules/control-plane/iam/schemas.gen.ts | 7336 ++---
app/modules/control-plane/iam/sdk.gen.ts | 6570 ++---
app/modules/control-plane/iam/types.gen.ts | 22992 ++++++++--------
app/modules/control-plane/identity/index.ts | 114 +-
.../control-plane/identity/schemas.gen.ts | 2268 +-
app/modules/control-plane/identity/sdk.gen.ts | 238 +-
.../control-plane/identity/types.gen.ts | 1662 +-
app/modules/datum-ui/README.md | 434 +-
.../datum-ui/components/alert/README.md | 886 +-
.../datum-ui/components/alert/alert.tsx | 282 +-
.../datum-ui/components/alert/index.ts | 2 +-
.../components/avatar-stack/avatar-stack.tsx | 174 +-
.../datum-ui/components/avatar-stack/index.ts | 2 +-
.../datum-ui/components/badge/README.md | 634 +-
.../datum-ui/components/badge/badge.tsx | 456 +-
.../datum-ui/components/badge/index.ts | 2 +-
.../datum-ui/components/button/README.md | 698 +-
.../datum-ui/components/button/button.tsx | 684 +-
.../datum-ui/components/button/index.tsx | 8 +-
.../components/button/link-button.tsx | 142 +-
.../calendar-date-picker.tsx | 1746 +-
.../components/calendar-date-picker/index.ts | 2 +-
.../datum-ui/components/calendar/calendar.tsx | 350 +-
.../datum-ui/components/calendar/index.ts | 2 +-
app/modules/datum-ui/components/card/card.tsx | 76 +-
app/modules/datum-ui/components/card/index.ts | 2 +-
.../components/data-table/MIGRATION.md | 1330 +-
.../datum-ui/components/data-table/README.md | 3004 +-
.../components/data-table/TOOLBAR_GUIDE.md | 858 +-
.../data-table/core/data-table-card-view.tsx | 174 +-
.../data-table/core/data-table-loading.tsx | 292 +-
.../data-table/core/data-table-view.tsx | 232 +-
.../data-table/core/data-table.context.tsx | 1544 +-
.../components/data-table/core/data-table.tsx | 1474 +-
.../data-table/core/data-table.types.ts | 862 +-
.../actions/data-table-inline-actions.tsx | 172 +-
.../actions/data-table-row-actions.tsx | 216 +-
.../columns/data-table-column-header.tsx | 244 +-
.../columns/data-table-column-meta.d.ts | 80 +-
.../columns/data-table-column.types.ts | 112 +-
.../data-table/features/filter/README.md | 1552 +-
.../filter/components/checkbox-popover.tsx | 278 +-
.../features/filter/components/checkbox.tsx | 284 +-
.../features/filter/components/datepicker.tsx | 396 +-
.../filter/components/global-search.tsx | 314 +-
.../filter/components/radio-popover.tsx | 244 +-
.../features/filter/components/radio.tsx | 164 +-
.../features/filter/components/search.tsx | 118 +-
.../features/filter/components/select.tsx | 534 +-
.../filter/components/shared/search-input.tsx | 156 +-
.../components/shared/use-search-state.ts | 110 +-
.../features/filter/components/tag.tsx | 184 +-
.../features/filter/components/time-range.tsx | 168 +-
.../features/filter/data-table-filter.tsx | 408 +-
.../data-table/features/filter/index.ts | 84 +-
.../data-table-inline-content.tsx | 210 +-
.../pagination/data-table-pagination.tsx | 254 +-
.../data-table-toolbar-filter-dropdown.tsx | 128 +-
.../data-table-toolbar-multi-actions.tsx | 244 +-
.../toolbar/data-table-toolbar-row-count.tsx | 110 +-
.../toolbar/data-table-toolbar-search.tsx | 132 +-
.../toolbar/data-table-toolbar-skeleton.tsx | 250 +-
.../features/toolbar/data-table-toolbar.tsx | 794 +-
.../data-table/hooks/useFilterQueryState.ts | 348 +-
.../data-table/hooks/useInlineContent.ts | 134 +-
.../datum-ui/components/data-table/index.ts | 368 +-
.../data-table/utils/date-serialization.ts | 200 +-
.../data-table/utils/global-search.helpers.ts | 566 +-
.../data-table/utils/sort-labels.ts | 98 +-
.../data-table/utils/sorting.helpers.ts | 444 +-
.../utils/time-range-serialization.ts | 136 +-
.../datum-ui/components/dialog/README.md | 360 +-
.../datum-ui/components/dialog/dialog.tsx | 358 +-
.../datum-ui/components/dialog/index.ts | 18 +-
.../datum-ui/components/dropdown/dropdown.tsx | 444 +-
.../datum-ui/components/dropdown/index.ts | 2 +-
.../datum-ui/components/dropzone/dropzone.tsx | 418 +-
.../empty-content/empty-content.tsx | 476 +-
.../components/empty-content/index.ts | 4 +-
.../file-input-button/file-input-button.tsx | 270 +-
.../datum-ui/components/form/README.md | 1148 +-
.../form/components/form-autocomplete.tsx | 148 +-
.../form/components/form-button.tsx | 102 +-
.../form/components/form-checkbox.tsx | 124 +-
.../form/components/form-copy-box.tsx | 234 +-
.../form/components/form-custom.tsx | 80 +-
.../form/components/form-description.tsx | 60 +-
.../form/components/form-dialog.tsx | 398 +-
.../components/form/components/form-error.tsx | 122 +-
.../form/components/form-field-array.tsx | 260 +-
.../components/form/components/form-field.tsx | 602 +-
.../form/components/form-input-group.tsx | 112 +-
.../components/form/components/form-input.tsx | 86 +-
.../form/components/form-radio-group.tsx | 176 +-
.../components/form/components/form-root.tsx | 428 +-
.../form/components/form-select.tsx | 152 +-
.../form/components/form-submit.tsx | 54 +-
.../form/components/form-switch.tsx | 124 +-
.../form/components/form-textarea.tsx | 86 +-
.../components/form/components/form-when.tsx | 132 +-
.../components/form/components/index.ts | 56 +-
.../form/components/stepper/form-step.tsx | 64 +-
.../form/components/stepper/form-stepper.tsx | 994 +-
.../form/components/stepper/index.ts | 8 +-
.../components/stepper/stepper-controls.tsx | 218 +-
.../components/stepper/stepper-navigation.tsx | 348 +-
.../components/form/context/field-context.tsx | 72 +-
.../components/form/context/form-context.tsx | 88 +-
.../datum-ui/components/form/context/index.ts | 34 +-
.../datum-ui/components/form/hooks/index.ts | 10 +-
.../form/hooks/use-field-context.ts | 54 +-
.../components/form/hooks/use-field.ts | 216 +-
.../components/form/hooks/use-form-context.ts | 48 +-
.../components/form/hooks/use-stepper.ts | 146 +-
.../components/form/hooks/use-watch.ts | 238 +-
app/modules/datum-ui/components/form/index.ts | 564 +-
.../form/primitives/autocomplete.tsx | 886 +-
.../form/primitives/autocomplete.types.ts | 204 +-
.../components/form/primitives/checkbox.tsx | 28 +-
.../components/form/primitives/index.ts | 50 +-
.../components/form/primitives/input.tsx | 60 +-
.../components/form/primitives/label.tsx | 28 +-
.../form/primitives/radio-group.tsx | 52 +-
.../components/form/primitives/select.tsx | 240 +-
.../components/form/primitives/switch.tsx | 28 +-
.../components/form/primitives/textarea.tsx | 60 +-
.../datum-ui/components/form/types/index.ts | 1440 +-
.../datum-ui/components/grid/README.md | 460 +-
.../components/grid/components/col.tsx | 168 +-
.../components/grid/components/index.tsx | 18 +-
.../components/grid/components/row.tsx | 198 +-
.../grid/constants/grid.constants.ts | 52 +-
app/modules/datum-ui/components/grid/index.ts | 48 +-
.../datum-ui/components/grid/style.css | 2176 +-
.../components/grid/types/grid.types.ts | 96 +-
.../components/grid/utils/responsive.ts | 144 +-
.../datum-ui/components/icons/close-icon.tsx | 50 +-
.../components/icons/icon-wrapper.tsx | 44 +-
.../datum-ui/components/icons/index.ts | 6 +-
.../components/icons/spinner-icon.tsx | 138 +-
app/modules/datum-ui/components/index.ts | 74 +-
.../datum-ui/components/input-number/index.ts | 2 +-
.../components/input-number/input-number.tsx | 294 +-
.../components/input-with-addons/index.ts | 2 +-
.../input-with-addons/input-with-addons.tsx | 108 +-
.../datum-ui/components/label/index.ts | 34 +-
.../datum-ui/components/label/label.tsx | 80 +-
.../components/loader-overlay/index.ts | 2 +-
.../loader-overlay/loader-overlay.tsx | 50 +-
.../datum-ui/components/mobile-sheet/index.ts | 2 +-
.../components/mobile-sheet/mobile-sheet.tsx | 108 +-
.../datum-ui/components/more-actions/index.ts | 4 +-
.../components/more-actions/more-actions.tsx | 208 +-
.../datum-ui/components/nprogress/index.ts | 62 +-
.../components/nprogress/nprogress.css | 48 +-
.../datum-ui/components/page-title/index.ts | 4 +-
.../components/page-title/page-title.tsx | 112 +-
.../components/responsive-dropdown/index.ts | 2 +-
.../responsive-dropdown.tsx | 162 +-
.../components/rich-text-editor/index.ts | 6 +-
.../rich-text-editor/rich-text-content.tsx | 100 +-
.../rich-text-editor/rich-text-editor.tsx | 276 +-
.../rich-text-editor/toolbar/bold-toolbar.tsx | 34 +-
.../toolbar/italic-toolbar.tsx | 34 +-
.../rich-text-editor/toolbar/link-toolbar.tsx | 154 +-
.../toolbar/strike-toolbar.tsx | 34 +-
.../toolbar/toolbar-button.tsx | 92 +-
.../rich-text-editor/toolbar/toolbar.tsx | 40 +-
.../toolbar/underline-toolbar.tsx | 34 +-
.../components/rich-text-editor/types.ts | 44 +-
.../datum-ui/components/sheet/index.ts | 22 +-
.../datum-ui/components/sheet/sheet.tsx | 216 +-
.../components/sidebar/app-sidebar.tsx | 234 +-
.../datum-ui/components/sidebar/index.ts | 6 +-
.../datum-ui/components/sidebar/nav-main.tsx | 1212 +-
.../datum-ui/components/sidebar/sidebar.tsx | 1844 +-
.../datum-ui/components/skeleton/index.ts | 2 +-
.../datum-ui/components/skeleton/skeleton.tsx | 62 +-
.../datum-ui/components/stepper/index.ts | 2 +-
.../datum-ui/components/stepper/stepper.tsx | 996 +-
app/modules/datum-ui/components/tabs/index.ts | 2 +-
app/modules/datum-ui/components/tabs/tabs.tsx | 152 +-
.../datum-ui/components/tag-input/index.ts | 2 +-
.../components/tag-input/tag-input.tsx | 1028 +-
.../task-queue/FUTURE_ENHANCEMENTS.md | 468 +-
.../datum-ui/components/task-queue/README.md | 1036 +-
.../components/task-queue/constants.ts | 16 +-
.../components/task-queue/core/index.ts | 18 +-
.../task-queue/core/task-panel-actions.tsx | 168 +-
.../task-queue/core/task-panel-counter.tsx | 96 +-
.../task-queue/core/task-panel-header.tsx | 14 +-
.../task-queue/core/task-panel-item.tsx | 334 +-
.../components/task-queue/core/task-panel.tsx | 72 +-
.../task-queue/core/task-queue-dropdown.tsx | 198 +-
.../task-queue/core/task-queue-trigger.tsx | 174 +-
.../task-queue/core/task-summary-dialog.tsx | 334 +-
.../components/task-queue/engine/executor.ts | 302 +-
.../components/task-queue/engine/index.ts | 8 +-
.../components/task-queue/engine/queue.ts | 1074 +-
.../engine/storage/detect-storage.ts | 82 +-
.../task-queue/engine/storage/index.ts | 10 +-
.../engine/storage/local-storage.ts | 144 +-
.../engine/storage/memory-storage.ts | 66 +-
.../engine/storage/redis-storage.ts | 200 +-
.../task-queue/engine/storage/storage.ts | 2 +-
.../components/task-queue/hooks/index.ts | 18 +-
.../task-queue/hooks/use-task-queue.ts | 88 +-
.../task-queue/hooks/use-task-scope.ts | 296 +-
.../datum-ui/components/task-queue/index.ts | 106 +-
.../components/task-queue/provider/index.ts | 2 +-
.../provider/task-queue-provider.tsx | 126 +-
.../datum-ui/components/task-queue/types.ts | 536 +-
.../components/task-queue/utils/index.ts | 168 +-
.../components/absolute-range-panel.tsx | 642 +-
.../time-range-picker/components/index.ts | 10 +-
.../components/quick-ranges-panel.tsx | 104 +-
.../components/timezone-selector.tsx | 80 +-
.../components/time-range-picker/index.ts | 98 +-
.../components/time-range-picker/presets.ts | 320 +-
.../time-range-picker/time-range-picker.tsx | 702 +-
.../components/time-range-picker/types.ts | 134 +-
.../time-range-picker/utils/format-display.ts | 174 +-
.../time-range-picker/utils/index.ts | 46 +-
.../time-range-picker/utils/timezone.ts | 330 +-
.../time-range-picker/utils/to-api-format.ts | 102 +-
.../components/toast/headless-toast.tsx | 104 +-
.../datum-ui/components/toast/index.ts | 6 +-
.../datum-ui/components/toast/toast.ts | 102 +-
.../datum-ui/components/toast/toaster.tsx | 26 +-
.../datum-ui/components/toast/types.ts | 12 +-
.../datum-ui/components/toast/use-toast.ts | 40 +-
.../datum-ui/components/tooltip/README.md | 740 +-
.../datum-ui/components/tooltip/index.ts | 2 +-
.../datum-ui/components/tooltip/tooltip.tsx | 334 +-
app/modules/datum-ui/datum-ui.css | 4 +-
app/modules/datum-ui/hooks/index.ts | 4 +-
.../datum-ui/hooks/use-copy-to-clipboard.ts | 96 +-
app/modules/datum-ui/hooks/use-debounce.ts | 46 +-
app/modules/datum-ui/index.ts | 28 +-
app/modules/datum-ui/utils/index.ts | 2 +-
app/modules/datum-ui/utils/timezone.ts | 70 +-
app/modules/graphql/generated/guards.esm.js | 622 +-
app/modules/graphql/generated/schema.graphql | 2380 +-
app/modules/graphql/generated/schema.ts | 3564 +--
app/modules/graphql/generated/types.esm.js | 714 +-
.../metrics/components/base-metric.tsx | 222 +-
.../components/controls/refresh-control.tsx | 270 +-
.../components/controls/step-control.tsx | 142 +-
.../controls/time-range-control.tsx | 262 +-
.../filters/base/metrics-filter-radio.tsx | 176 +-
.../filters/base/metrics-filter-search.tsx | 180 +-
.../filters/base/metrics-filter-select.tsx | 336 +-
app/modules/metrics/components/index.ts | 8 +-
.../metrics/components/metric-card.tsx | 450 +-
.../metrics/components/metric-chart.tsx | 618 +-
.../metrics/components/metrics-toolbar.tsx | 168 +-
app/modules/metrics/utils/date-parsers.ts | 350 +-
app/modules/prometheus/service.ts | 466 +-
app/modules/rbac/README.md | 2250 +-
app/modules/rbac/client/index.ts | 10 +-
app/modules/rbac/client/rbac-api.ts | 206 +-
.../rbac/components/PermissionCheck.tsx | 164 +-
.../rbac/components/PermissionGate.tsx | 114 +-
app/modules/rbac/components/index.ts | 16 +-
.../rbac/components/withPermission.tsx | 132 +-
app/modules/rbac/context/index.ts | 12 +-
app/modules/rbac/context/rbac.context.tsx | 26 +-
app/modules/rbac/context/rbac.provider.tsx | 210 +-
app/modules/rbac/hooks/index.ts | 30 +-
app/modules/rbac/hooks/useHasPermission.ts | 260 +-
app/modules/rbac/hooks/usePermissionCheck.ts | 298 +-
app/modules/rbac/hooks/usePermissions.ts | 78 +-
app/modules/rbac/index.ts | 190 +-
app/modules/rbac/permission-checker.ts | 244 +-
app/modules/rbac/rbac.middleware.ts | 538 +-
app/modules/rbac/service/index.ts | 10 +-
app/modules/rbac/service/rbac.service.ts | 462 +-
app/modules/rbac/types.ts | 738 +-
app/modules/redis/connection.ts | 258 +-
app/modules/sentry/filters.ts | 64 +-
app/modules/shadcn/README.md | 664 +-
app/modules/shadcn/hooks/use-mobile.ts | 38 +-
app/modules/shadcn/lib/utils.ts | 32 +-
app/modules/shadcn/style.css | 4 +-
app/modules/shadcn/styles/animations.css | 202 +-
app/modules/shadcn/styles/shadcn.css | 456 +-
app/modules/shadcn/ui/alert.tsx | 96 +-
app/modules/shadcn/ui/avatar.tsx | 82 +-
app/modules/shadcn/ui/badge.tsx | 74 +-
app/modules/shadcn/ui/breadcrumb.tsx | 198 +-
app/modules/shadcn/ui/button-group.tsx | 154 +-
app/modules/shadcn/ui/button.tsx | 106 +-
app/modules/shadcn/ui/card.tsx | 114 +-
app/modules/shadcn/ui/chart.tsx | 666 +-
app/modules/shadcn/ui/checkbox.tsx | 48 +-
app/modules/shadcn/ui/collapsible.tsx | 18 +-
app/modules/shadcn/ui/command.tsx | 328 +-
app/modules/shadcn/ui/dialog.tsx | 240 +-
app/modules/shadcn/ui/dropdown-menu.tsx | 374 +-
app/modules/shadcn/ui/hover-card.tsx | 70 +-
app/modules/shadcn/ui/input-group.tsx | 314 +-
app/modules/shadcn/ui/input.tsx | 48 +-
app/modules/shadcn/ui/label.tsx | 36 +-
app/modules/shadcn/ui/map.tsx | 2824 +-
app/modules/shadcn/ui/place-autocomplete.tsx | 702 +-
app/modules/shadcn/ui/popover.tsx | 78 +-
app/modules/shadcn/ui/radio-group.tsx | 80 +-
app/modules/shadcn/ui/select.tsx | 324 +-
app/modules/shadcn/ui/separator.tsx | 50 +-
app/modules/shadcn/ui/sheet.tsx | 252 +-
app/modules/shadcn/ui/skeleton.tsx | 14 +-
app/modules/shadcn/ui/sonner.tsx | 48 +-
app/modules/shadcn/ui/spinner.tsx | 30 +-
app/modules/shadcn/ui/switch.tsx | 48 +-
app/modules/shadcn/ui/table.tsx | 178 +-
app/modules/shadcn/ui/tabs.tsx | 116 +-
app/modules/shadcn/ui/textarea.tsx | 44 +-
app/modules/shadcn/ui/tooltip.tsx | 108 +-
app/modules/shadcn/ui/visuallyhidden.tsx | 10 +-
app/modules/tanstack/query.ts | 6 +-
app/providers/terminal-session.provider.tsx | 128 +
.../access-review/access-review.service.ts | 152 +-
.../activity-logs/activity-log.helpers.ts | 576 +-
app/resources/base/types.ts | 6 +-
app/resources/connectors/connector.queries.ts | 132 +-
app/resources/connectors/index.ts | 38 +-
.../dns-records/dns-record.adapter.ts | 380 +-
.../dns-records/dns-record.queries.ts | 484 +-
.../dns-records/dns-record.service.ts | 672 +-
app/resources/dns-records/index.ts | 248 +-
app/resources/dns-zones/dns-zone.queries.ts | 278 +-
app/resources/dns-zones/dns-zone.service.ts | 484 +-
app/resources/dns-zones/index.ts | 68 +-
app/resources/domains/domain.queries.ts | 352 +-
app/resources/domains/domain.service.ts | 616 +-
app/resources/domains/index.ts | 104 +-
.../export-policies/export-policy.service.ts | 518 +-
.../export-policies/export-policy.watch.ts | 126 +-
app/resources/groups/group.service.ts | 486 +-
.../http-proxies/http-proxy.adapter.ts | 1026 +-
.../http-proxies/http-proxy.queries.ts | 384 +-
.../http-proxies/http-proxy.schema.ts | 644 +-
.../http-proxies/http-proxy.service.ts | 1258 +-
app/resources/http-proxies/index.ts | 140 +-
.../invitations/invitation.queries.ts | 534 +-
.../invitations/invitation.schema.ts | 246 +-
app/resources/members/member.queries.ts | 256 +-
app/resources/notes/note.service.ts | 282 +-
...ontact-group-membership-removal.service.ts | 358 +-
.../contact-group-membership.service.ts | 344 +-
.../contact-group/contact-group.service.ts | 448 +-
.../notifications/contact/contact.service.ts | 442 +-
.../organizations/organization.gql-service.ts | 578 +-
.../organizations/organization.service.ts | 432 +-
.../policy-bindings/policy-binding.adapter.ts | 282 +-
.../policy-bindings/policy-binding.queries.ts | 208 +-
.../policy-bindings/policy-binding.schema.ts | 284 +-
.../policy-bindings/policy-binding.service.ts | 466 +-
app/resources/projects/index.ts | 74 +-
app/resources/projects/project.queries.ts | 260 +-
app/resources/projects/project.service.ts | 414 +-
app/resources/secrets/index.ts | 110 +-
app/resources/secrets/secret.queries.ts | 252 +-
app/resources/secrets/secret.service.ts | 442 +-
app/resources/users/index.ts | 8 +-
app/resources/users/user.adapter.ts | 360 +-
app/resources/users/user.queries.ts | 302 +-
app/resources/users/user.schema.ts | 202 +-
app/root.tsx | 588 +-
app/routes.ts | 406 +-
app/routes/account/organizations/index.tsx | 548 +-
app/routes/account/settings/access-tokens.tsx | 208 +-
.../account/settings/active-sessions.tsx | 364 +-
app/routes/account/settings/activity.tsx | 26 +-
app/routes/account/settings/general.tsx | 84 +-
app/routes/account/settings/layout.tsx | 94 +-
app/routes/account/settings/security.tsx | 52 +-
app/routes/auth/callback.tsx | 242 +-
app/routes/fraud/account-suspended.tsx | 126 +-
app/routes/fraud/account-under-review.tsx | 114 +-
app/routes/fraud/verifying.tsx | 210 +-
app/routes/invitation/index.tsx | 426 +-
app/routes/not-found.tsx | 146 +-
app/routes/onboarding/complete-profile.tsx | 264 +-
app/routes/org/detail/layout.tsx | 284 +-
app/routes/org/detail/projects/index.tsx | 732 +-
app/routes/org/detail/settings/activity.tsx | 44 +-
app/routes/org/detail/settings/general.tsx | 68 +-
app/routes/org/detail/settings/layout.tsx | 112 +-
.../org/detail/settings/notifications.tsx | 44 +-
.../org/detail/settings/policy-bindings.tsx | 228 +-
app/routes/org/detail/settings/quotas.tsx | 56 +-
app/routes/org/detail/team/create-group.tsx | 116 +-
app/routes/org/detail/team/group-detail.tsx | 726 +-
app/routes/org/detail/team/groups.tsx | 412 +-
app/routes/org/detail/team/index.tsx | 870 +-
app/routes/org/detail/team/invite.tsx | 330 +-
app/routes/org/detail/team/member-roles.tsx | 818 +-
.../project/detail/connectors/index.tsx | 614 +-
.../detail/dns-zones/detail/dns-records.tsx | 936 +-
.../detail/dns-zones/detail/layout.tsx | 280 +-
.../detail/dns-zones/detail/nameservers.tsx | 224 +-
.../detail/dns-zones/detail/overview.tsx | 230 +-
.../detail/dns-zones/detail/settings.tsx | 166 +-
app/routes/project/detail/dns-zones/index.tsx | 788 +-
.../project/detail/domains/detail/layout.tsx | 202 +-
.../detail/domains/detail/overview.tsx | 496 +-
.../detail/domains/detail/settings.tsx | 160 +-
app/routes/project/detail/domains/index.tsx | 1148 +-
.../project/detail/edge/detail/index.tsx | 214 +-
.../project/detail/edge/detail/layout.tsx | 106 +-
app/routes/project/detail/edge/index.tsx | 562 +-
app/routes/project/detail/home.tsx | 608 +-
app/routes/project/detail/layout.tsx | 630 +-
.../project/detail/metrics/detail/edit.tsx | 54 +-
.../project/detail/metrics/detail/layout.tsx | 72 +-
.../detail/metrics/detail/overview.tsx | 140 +-
app/routes/project/detail/metrics/index.tsx | 416 +-
app/routes/project/detail/metrics/layout.tsx | 104 +-
app/routes/project/detail/metrics/new.tsx | 106 +-
.../project/detail/secrets/detail/layout.tsx | 74 +-
.../detail/secrets/detail/overview.tsx | 82 +-
app/routes/project/detail/secrets/index.tsx | 398 +-
.../project/detail/settings/activity.tsx | 34 +-
.../project/detail/settings/general.tsx | 74 +-
app/routes/project/detail/settings/layout.tsx | 108 +-
.../project/detail/settings/notifications.tsx | 44 +-
app/routes/project/detail/settings/quotas.tsx | 64 +-
app/routes/test/demo.tsx | 390 +-
.../components/dns-record-test-card.tsx | 520 +-
app/routes/test/dns-record/dns-record.tsx | 548 +-
app/routes/test/metrics.tsx | 534 +-
app/routes/test/permissions.tsx | 552 +-
app/server/entry.ts | 415 +-
app/server/middleware/error-handler.ts | 96 +-
app/server/middleware/rate-limit.ts | 501 +-
app/server/routes/api.ts | 140 +-
app/server/routes/assistant.ts | 200 +-
app/server/routes/permissions.ts | 106 +-
app/server/routes/terminal.ts | 893 +
app/styles/custom.css | 132 +-
app/styles/fonts.css | 122 +-
app/styles/root.css | 68 +-
app/styles/themes/alpha.css | 1148 +-
app/styles/tokens/brand-tokens.css | 114 +-
app/styles/tokens/figma-tokens.css | 1252 +-
app/types.d.ts | 102 +-
app/utils/common.ts | 242 +-
app/utils/config/paths.config.ts | 252 +-
app/utils/env/env.server.ts | 439 +-
app/utils/env/index.ts | 196 +-
app/utils/env/types.ts | 178 +-
app/utils/errors/app-error.ts | 362 +-
app/utils/errors/index.ts | 74 +-
app/utils/helpers/resource-labels.ts | 64 +-
.../middlewares/fraud-status.middleware.ts | 182 +-
bun.lock | 8408 +++---
config/base/deployment.yaml | 162 +-
cypress.config.ts | 168 +-
cypress/component/button.cy.tsx | 16 +-
cypress/component/form-field.cy.tsx | 94 +-
cypress/component/resource-labels.cy.ts | 54 +-
cypress/e2e/regression/dns-zones.cy.ts | 194 +-
cypress/e2e/regression/organisations.cy.ts | 188 +-
cypress/e2e/regression/projects.cy.ts | 204 +-
cypress/e2e/smoke/account.cy.ts | 64 +-
cypress/support/e2e.ts | 872 +-
docker-compose.yml | 122 +-
docs/getting-started/02-environment-setup.md | 327 +-
eslint.config.mjs | 162 +-
graphql.config.json | 24 +-
observability/providers/sentry.ts | 476 +-
package.json | 400 +-
renovate.json | 38 +-
scripts/graphql.ts | 1090 +-
scripts/openapi.ts | 678 +-
tsconfig.json | 94 +-
vite.config.ts | 232 +-
711 files changed, 125719 insertions(+), 123693 deletions(-)
create mode 100644 app/providers/terminal-session.provider.tsx
create mode 100644 app/server/routes/terminal.ts
diff --git a/.env.example b/.env.example
index 70a1bc83d..080e3e3fa 100644
--- a/.env.example
+++ b/.env.example
@@ -1,121 +1,134 @@
-# ═══════════════════════════════════════════════════════════
-# Environment Configuration
-# Copy this file to .env and fill in your values
-# ═══════════════════════════════════════════════════════════
-
-# ───────────────────────────────────────────────────────────
-# Required: Core URLs
-# ───────────────────────────────────────────────────────────
-
-# Application URLs (must be valid URLs)
-APP_URL=http://localhost:3000
-API_URL=http://localhost:8080
-GRAPHQL_URL=http://localhost:8080/graphql
-
-# ───────────────────────────────────────────────────────────
-# Required: Authentication
-# ───────────────────────────────────────────────────────────
-
-# Session secret (minimum 32 characters)
-SESSION_SECRET=your-super-secret-session-key-min-32-chars
-
-# OIDC Authentication
-AUTH_OIDC_ISSUER=https://your-oidc-provider.com
-AUTH_OIDC_CLIENT_ID=your-client-id
-
-# ───────────────────────────────────────────────────────────
-# Required: Feature Services
-# ───────────────────────────────────────────────────────────
-
-# Prometheus metrics (for proxy metrics dashboard)
-PROMETHEUS_URL=http://localhost:9090
-
-# CloudValid DNS validation (for domain auto-verification)
-CLOUDVALID_API_URL=https://api.cloudvalid.com/api/v2
-CLOUDVALID_API_KEY=your-cloudvalid-api-key
-CLOUDVALID_TEMPLATE_ID=your-cloudvalid-template-id
-
-# ───────────────────────────────────────────────────────────
-# Optional: Runtime Configuration
-# ───────────────────────────────────────────────────────────
-
-# Environment mode: production | development | test
-NODE_ENV=development
-
-# Application version (e.g., git commit SHA)
-# VERSION=
-
-# Enable debug mode: true | false
-# DEBUG=false
-
-# ───────────────────────────────────────────────────────────
-# Optional: Logging
-# ───────────────────────────────────────────────────────────
-
-# Log level: debug | info | warn | error
-# LOG_LEVEL=debug
-
-# Log format: json | pretty | compact
-# LOG_FORMAT=pretty
-
-# Generate curl commands for API calls: true | false
-# LOG_CURL=true
-
-# Redact sensitive tokens in logs: true | false
-# LOG_REDACT_TOKENS=true
-
-# Log request/response payloads: true | false
-# LOG_PAYLOADS=false
-
-# ───────────────────────────────────────────────────────────
-# Optional: Observability (graceful degradation when missing)
-# ───────────────────────────────────────────────────────────
-
-# Sentry error tracking (must be valid URL if provided)
-# SENTRY_DSN=https://xxx@sentry.io/xxx
-# SENTRY_ENV=development
-
-# OpenTelemetry tracing (must be valid URL if provided)
-# OTEL_ENABLED=false
-# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
-# OTEL_EXPORTER_TIMEOUT=10000
-# OTEL_LOG_LEVEL=info
-
-# ───────────────────────────────────────────────────────────
-# Optional: External Integrations (graceful degradation)
-# ───────────────────────────────────────────────────────────
-
-# Grafana (must be valid URL if provided)
-# GRAFANA_URL=http://localhost:3001
-
-# ───────────────────────────────────────────────────────────
-# Optional: Analytics & Support (graceful degradation)
-# ───────────────────────────────────────────────────────────
-
-# Fathom Analytics
-# FATHOM_ID=
-
-# HelpScout Beacon
-# HELPSCOUT_BEACON_ID=
-# HELPSCOUT_SECRET_KEY=
-
-# ───────────────────────────────────────────────────────────
-# Optional: Redis (falls back to in-memory rate limiting)
-# ───────────────────────────────────────────────────────────
-
-# Redis connection URL (must be valid URL if provided)
-# REDIS_URL=redis://localhost:6379
-
-# Redis connection settings (have defaults)
-# REDIS_MAX_RETRIES=3
-# REDIS_CONNECT_TIMEOUT=5000
-# REDIS_COMMAND_TIMEOUT=3000
-# REDIS_KEY_PREFIX=cloud-portal:
-
-# ───────────────────────────────────────────────────────────
-# Optional: Chatbot
-# ───────────────────────────────────────────────────────────
-
-# Enable chatbot: true | false
-# CHATBOT_ENABLED=false
-# ANTHROPIC_API_KEY=your-anthropic-api-key
\ No newline at end of file
+# ═══════════════════════════════════════════════════════════
+# Environment Configuration
+# Copy this file to .env and fill in your values
+# ═══════════════════════════════════════════════════════════
+
+# ───────────────────────────────────────────────────────────
+# Required: Core URLs
+# ───────────────────────────────────────────────────────────
+
+# Application URLs (must be valid URLs)
+APP_URL=http://localhost:3000
+API_URL=http://localhost:8080
+GRAPHQL_URL=http://localhost:8080/graphql
+
+# ───────────────────────────────────────────────────────────
+# Required: Authentication
+# ───────────────────────────────────────────────────────────
+
+# Session secret (minimum 32 characters)
+SESSION_SECRET=your-super-secret-session-key-min-32-chars
+
+# OIDC Authentication
+AUTH_OIDC_ISSUER=https://your-oidc-provider.com
+AUTH_OIDC_CLIENT_ID=your-client-id
+
+# ───────────────────────────────────────────────────────────
+# Required: Feature Services
+# ───────────────────────────────────────────────────────────
+
+# Prometheus metrics (for proxy metrics dashboard)
+PROMETHEUS_URL=http://localhost:9090
+
+# CloudValid DNS validation (for domain auto-verification)
+CLOUDVALID_API_URL=https://api.cloudvalid.com/api/v2
+CLOUDVALID_API_KEY=your-cloudvalid-api-key
+CLOUDVALID_TEMPLATE_ID=your-cloudvalid-template-id
+
+# ───────────────────────────────────────────────────────────
+# Optional: Runtime Configuration
+# ───────────────────────────────────────────────────────────
+
+# Environment mode: production | development | test
+NODE_ENV=development
+
+# Application version (e.g., git commit SHA)
+# VERSION=
+
+# Enable debug mode: true | false
+# DEBUG=false
+
+# ───────────────────────────────────────────────────────────
+# Optional: Logging
+# ───────────────────────────────────────────────────────────
+
+# Log level: debug | info | warn | error
+# LOG_LEVEL=debug
+
+# Log format: json | pretty | compact
+# LOG_FORMAT=pretty
+
+# Generate curl commands for API calls: true | false
+# LOG_CURL=true
+
+# Redact sensitive tokens in logs: true | false
+# LOG_REDACT_TOKENS=true
+
+# Log request/response payloads: true | false
+# LOG_PAYLOADS=false
+
+# ───────────────────────────────────────────────────────────
+# Optional: Observability (graceful degradation when missing)
+# ───────────────────────────────────────────────────────────
+
+# Sentry error tracking (must be valid URL if provided)
+# SENTRY_DSN=https://xxx@sentry.io/xxx
+# SENTRY_ENV=development
+
+# OpenTelemetry tracing (must be valid URL if provided)
+# OTEL_ENABLED=false
+# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
+# OTEL_EXPORTER_TIMEOUT=10000
+# OTEL_LOG_LEVEL=info
+
+# ───────────────────────────────────────────────────────────
+# Optional: External Integrations (graceful degradation)
+# ───────────────────────────────────────────────────────────
+
+# Grafana (must be valid URL if provided)
+# GRAFANA_URL=http://localhost:3001
+
+# ───────────────────────────────────────────────────────────
+# Optional: Analytics & Support (graceful degradation)
+# ───────────────────────────────────────────────────────────
+
+# Fathom Analytics
+# FATHOM_ID=
+
+# HelpScout Beacon
+# HELPSCOUT_BEACON_ID=
+# HELPSCOUT_SECRET_KEY=
+
+# ───────────────────────────────────────────────────────────
+# Optional: Redis (falls back to in-memory rate limiting)
+# ───────────────────────────────────────────────────────────
+
+# Redis connection URL (must be valid URL if provided)
+# REDIS_URL=redis://localhost:6379
+
+# Redis connection settings (have defaults)
+# REDIS_MAX_RETRIES=3
+# REDIS_CONNECT_TIMEOUT=5000
+# REDIS_COMMAND_TIMEOUT=3000
+# REDIS_KEY_PREFIX=cloud-portal:
+
+# ───────────────────────────────────────────────────────────
+# Optional: Chatbot
+# ───────────────────────────────────────────────────────────
+
+# Enable chatbot: true | false
+# CHATBOT_ENABLED=false
+# ANTHROPIC_API_KEY=your-anthropic-api-key
+
+# ───────────────────────────────────────────────────────────
+# Optional: Embedded datumctl terminal (Developer Tools)
+# ───────────────────────────────────────────────────────────
+
+# Absolute path to a locally-built datumctl binary that supports
+# ambient-token mode (DATUMCTL_TOKEN / DATUM_API_HOSTNAME).
+# For local development, clone datum-cloud/datumctl as a sibling
+# of cloud-portal, check out the cloud-portal/ambient-token branch,
+# and run `go build -o bin/datumctl .`, then set:
+# DATUMCTL_BIN=/Users/matthewjenkinson/Git/datum/datumctl/bin/datumctl
+# When unset, the Developer Tools terminal is disabled.
+# DATUMCTL_BIN=
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0a0f9869b..c027a14ae 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,370 +1,370 @@
-name: CI
-
-on:
- pull_request:
- push:
- branches: [main]
- paths-ignore:
- - 'docs/**'
- - 'README.md'
- release:
- types: ['published']
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-env:
- CYPRESS_CACHE_FOLDER: ~/.cache/Cypress
- BUN_CACHE: ~/.bun/install/cache
-
-jobs:
- # ─── Quality ────────────────────────────────────────────────
- install:
- name: Install Dependencies
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- - uses: oven-sh/setup-bun@v2
- with: { bun-version: latest }
- - uses: actions/cache@v5
- id: cache-deps
- with:
- path: |
- ${{ env.BUN_CACHE }}
- **/node_modules
- key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
- - if: steps.cache-deps.outputs.cache-hit != 'true'
- run: bun install --frozen-lockfile
-
- lint:
- name: Lint & Format
- needs: install
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- - uses: oven-sh/setup-bun@v2
- - uses: actions/cache@v5
- with:
- path: |
- ${{ env.BUN_CACHE }}
- **/node_modules
- key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
- - run: bun run lint && bun run format
-
- typecheck:
- name: Type Check
- needs: install
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- - uses: oven-sh/setup-bun@v2
- with: { bun-version: latest }
- - uses: actions/cache@v5
- with:
- path: |
- ${{ env.BUN_CACHE }}
- **/node_modules
- key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
- - run: bun typecheck
-
- build:
- name: Build
- needs: [lint, typecheck]
- runs-on: ubuntu-latest
- if: ${{ !failure() && !cancelled() }}
- steps:
- - uses: actions/checkout@v6
- - uses: oven-sh/setup-bun@v2
- with: { bun-version: latest }
- - uses: actions/cache@v5
- with:
- path: |
- ${{ env.BUN_CACHE }}
- **/node_modules
- key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
- - run: bun run build
- - uses: actions/cache@v5
- with:
- path: build
- key: build-${{ github.sha }}
-
- # ─── Tests ──────────────────────────────────────────────────
- unit-tests:
- name: Unit Tests
- needs: build
- runs-on: ubuntu-latest
- if: ${{ !failure() && !cancelled() }}
- steps:
- - uses: actions/checkout@v6
- - uses: oven-sh/setup-bun@v2
- with: { bun-version: latest }
- - uses: actions/cache@v5
- with:
- path: |
- ${{ env.BUN_CACHE }}
- **/node_modules
- key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
- - run: bunx cypress install && bunx cypress verify
- - run: bun run test:unit:prod
- env:
- NODE_ENV: test
- CYPRESS: 'true'
-
- e2e-smoke:
- name: E2E Smoke
- needs: build
- runs-on: ubuntu-latest
- timeout-minutes: 10
- if: ${{ !failure() && !cancelled() }}
- steps:
- - uses: actions/checkout@v6
- - uses: oven-sh/setup-bun@v2
- with: { bun-version: latest }
- - uses: actions/cache@v5
- with:
- path: |
- ${{ env.BUN_CACHE }}
- **/node_modules
- key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
- - uses: actions/cache@v5
- with:
- path: build
- key: build-${{ github.sha }}
- - name: Setup Environment
- env:
- SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
- ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- SUB: ${{ secrets.SUB }}
- API_URL: ${{ secrets.API_URL }}
- GRAPHQL_URL: ${{ secrets.GRAPHQL_URL }}
- AUTH_OIDC_ISSUER: ${{ secrets.AUTH_OIDC_ISSUER }}
- AUTH_OIDC_CLIENT_ID: ${{ secrets.AUTH_OIDC_CLIENT_ID }}
- run: |
- cat > .env << EOF
- NODE_ENV=test
- CYPRESS=true
- APP_URL=http://localhost:3000
- API_URL=$API_URL
- GRAPHQL_URL=$GRAPHQL_URL
- AUTH_OIDC_ISSUER=$AUTH_OIDC_ISSUER
- AUTH_OIDC_CLIENT_ID=$AUTH_OIDC_CLIENT_ID
- SESSION_SECRET=$SESSION_SECRET
- LOG_LEVEL=warn
- CYPRESS_BASE_URL=http://localhost:3000
- EOF
- - run: bunx cypress install && bunx cypress verify
- - name: Run Smoke Tests
- env:
- NODE_ENV: test
- CYPRESS: 'true'
- CYPRESS_BASE_URL: http://localhost:3000
- SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
- ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- SUB: ${{ secrets.SUB }}
- run: >-
- bunx start-server-and-test
- 'bun run start' http://localhost:3000/_healthz
- 'bunx cypress run --spec "cypress/e2e/smoke/**" --config-file cypress.config.ts'
- - if: failure()
- uses: actions/upload-artifact@v7
- with:
- name: e2e-smoke-artifacts-${{ github.run_id }}
- path: |
- cypress/videos
- cypress/screenshots
- retention-days: 7
- if-no-files-found: warn
-
- e2e-regression:
- name: E2E Regression
- needs: e2e-smoke
- runs-on: ubuntu-latest
- timeout-minutes: 20
- if: ${{ !failure() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- # if: ${{ !failure() && !cancelled() }}
- strategy:
- fail-fast: false
- matrix:
- shard: [0, 1, 2, 3]
- steps:
- - uses: actions/checkout@v6
- - uses: oven-sh/setup-bun@v2
- with: { bun-version: latest }
- - uses: actions/cache@v5
- with:
- path: |
- ${{ env.BUN_CACHE }}
- **/node_modules
- key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
- - uses: actions/cache@v5
- with:
- path: build
- key: build-${{ github.sha }}
- - name: Setup Environment
- env:
- SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
- ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- SUB: ${{ secrets.SUB }}
- API_URL: ${{ secrets.API_URL }}
- GRAPHQL_URL: ${{ secrets.GRAPHQL_URL }}
- AUTH_OIDC_ISSUER: ${{ secrets.AUTH_OIDC_ISSUER }}
- AUTH_OIDC_CLIENT_ID: ${{ secrets.AUTH_OIDC_CLIENT_ID }}
- run: |
- cat > .env << EOF
- NODE_ENV=test
- CYPRESS=true
- APP_URL=http://localhost:3000
- API_URL=$API_URL
- GRAPHQL_URL=$GRAPHQL_URL
- AUTH_OIDC_ISSUER=$AUTH_OIDC_ISSUER
- AUTH_OIDC_CLIENT_ID=$AUTH_OIDC_CLIENT_ID
- SESSION_SECRET=$SESSION_SECRET
- LOG_LEVEL=warn
- CYPRESS_BASE_URL=http://localhost:3000
- EOF
- - run: bunx cypress install && bunx cypress verify
- - name: Run Regression Tests
- env:
- NODE_ENV: test
- CYPRESS: 'true'
- CYPRESS_BASE_URL: http://localhost:3000
- SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
- ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- SUB: ${{ secrets.SUB }}
- SPLIT: ${{ strategy.job-total }}
- SPLIT_INDEX: ${{ strategy.job-index }}
- CYPRESS_specPattern: 'cypress/e2e/regression/**/*.{cy,spec}.{js,jsx,ts,tsx}'
- run: >-
- bunx start-server-and-test
- 'bun run start' http://localhost:3000/_healthz
- 'bunx cypress run --config-file cypress.config.ts'
- - if: failure()
- uses: actions/upload-artifact@v7
- with:
- name: e2e-regression-artifacts-${{ github.run_id }}-shard-${{ matrix.shard }}
- path: |
- cypress/videos
- cypress/screenshots
- retention-days: 7
- if-no-files-found: warn
-
- # ─── Status Gate ────────────────────────────────────────────
- status-check:
- name: Final Status Check
- needs: [install, lint, typecheck, build, unit-tests, e2e-smoke, e2e-regression]
- if: always()
- runs-on: ubuntu-latest
- steps:
- - name: Check Workflow Status
- run: |
- if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then
- echo "❌ One or more jobs failed"
- exit 1
- elif [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
- echo "⚠️ One or more jobs were cancelled"
- exit 1
- else
- echo "✅ All jobs passed"
- fi
-
- # ─── Publish (only after all checks pass) ──────────────────
- publish-container-image:
- name: Publish Container Image
- needs: [status-check]
- if: ${{ !failure() && !cancelled() }}
- permissions:
- id-token: write
- contents: read
- packages: write
- attestations: write
- uses: datum-cloud/actions/.github/workflows/publish-docker.yaml@v1.13.3
- with:
- image-name: cloud-portal
- secrets: inherit
-
- publish-kustomize-bundles:
- name: Publish Kustomize Bundles
- needs: [publish-container-image]
- if: ${{ !failure() && !cancelled() }}
- permissions:
- id-token: write
- contents: read
- packages: write
- uses: datum-cloud/actions/.github/workflows/publish-kustomize-bundle.yaml@v1.13.3
- with:
- bundle-name: ghcr.io/datum-cloud/cloud-portal-kustomize
- bundle-path: config
- image-overlays: config/base
- image-name: ghcr.io/datum-cloud/cloud-portal
- secrets: inherit
-
- # ─── PR Comment ─────────────────────────────────────────────
- pr-comment:
- name: PR Test Summary
- needs: [unit-tests, e2e-smoke]
- runs-on: ubuntu-latest
- if: github.event_name == 'pull_request'
- permissions:
- contents: read
- pull-requests: write
- actions: read
- steps:
- - uses: actions/checkout@v6
- - name: Build test summary
- uses: actions/github-script@v9
- with:
- script: |
- const fs = require('fs');
- const runId = context.runId;
- const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
-
- const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
- owner: context.repo.owner,
- repo: context.repo.repo,
- run_id: runId,
- });
-
- const testJobs = jobs.jobs.filter(j =>
- j.name === 'Unit Tests' || j.name.startsWith('E2E')
- );
-
- const statusEmoji = (conclusion) => {
- if (!conclusion || conclusion === 'skipped') return '⏭️';
- if (conclusion === 'success') return '✅';
- if (conclusion === 'failure') return '❌';
- if (conclusion === 'cancelled') return '🚫';
- return '❓';
- };
-
- let table = '| Job | Status |\n|-----|--------|\n';
- for (const job of testJobs.sort((a, b) => a.name.localeCompare(b.name))) {
- table += `| ${job.name} | ${statusEmoji(job.conclusion)} ${job.conclusion || 'pending'} |\n`;
- }
-
- const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
- owner: context.repo.owner,
- repo: context.repo.repo,
- run_id: runId,
- });
-
- let body = `## 🧪 Test Summary\n\n${table}\n\n`;
- body += `[View workflow run](${runUrl})`;
- body += `\n\n### 📎 Artifacts\n\n`;
- if (artifacts.artifacts.length > 0) {
- body += `Videos and screenshots from failed E2E tests:\n\n`;
- for (const a of artifacts.artifacts) {
- body += `- **${a.name}** – [Download](${runUrl})\n`;
- }
- } else {
- body += `No artifacts (all tests passed).\n`;
- }
-
- fs.writeFileSync('pr-summary.md', body);
-
- - uses: peter-evans/create-or-update-comment@v5
- with:
- issue-number: ${{ github.event.pull_request.number }}
- body-path: pr-summary.md
- comment-tag: ci-summary
- edit-mode: replace
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+ paths-ignore:
+ - 'docs/**'
+ - 'README.md'
+ release:
+ types: ['published']
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ CYPRESS_CACHE_FOLDER: ~/.cache/Cypress
+ BUN_CACHE: ~/.bun/install/cache
+
+jobs:
+ # ─── Quality ────────────────────────────────────────────────
+ install:
+ name: Install Dependencies
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: oven-sh/setup-bun@v2
+ with: { bun-version: latest }
+ - uses: actions/cache@v5
+ id: cache-deps
+ with:
+ path: |
+ ${{ env.BUN_CACHE }}
+ **/node_modules
+ key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
+ - if: steps.cache-deps.outputs.cache-hit != 'true'
+ run: bun install --frozen-lockfile
+
+ lint:
+ name: Lint & Format
+ needs: install
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: oven-sh/setup-bun@v2
+ - uses: actions/cache@v5
+ with:
+ path: |
+ ${{ env.BUN_CACHE }}
+ **/node_modules
+ key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
+ - run: bun run lint && bun run format
+
+ typecheck:
+ name: Type Check
+ needs: install
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: oven-sh/setup-bun@v2
+ with: { bun-version: latest }
+ - uses: actions/cache@v5
+ with:
+ path: |
+ ${{ env.BUN_CACHE }}
+ **/node_modules
+ key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
+ - run: bun typecheck
+
+ build:
+ name: Build
+ needs: [lint, typecheck]
+ runs-on: ubuntu-latest
+ if: ${{ !failure() && !cancelled() }}
+ steps:
+ - uses: actions/checkout@v6
+ - uses: oven-sh/setup-bun@v2
+ with: { bun-version: latest }
+ - uses: actions/cache@v5
+ with:
+ path: |
+ ${{ env.BUN_CACHE }}
+ **/node_modules
+ key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
+ - run: bun run build
+ - uses: actions/cache@v5
+ with:
+ path: build
+ key: build-${{ github.sha }}
+
+ # ─── Tests ──────────────────────────────────────────────────
+ unit-tests:
+ name: Unit Tests
+ needs: build
+ runs-on: ubuntu-latest
+ if: ${{ !failure() && !cancelled() }}
+ steps:
+ - uses: actions/checkout@v6
+ - uses: oven-sh/setup-bun@v2
+ with: { bun-version: latest }
+ - uses: actions/cache@v5
+ with:
+ path: |
+ ${{ env.BUN_CACHE }}
+ **/node_modules
+ key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
+ - run: bunx cypress install && bunx cypress verify
+ - run: bun run test:unit:prod
+ env:
+ NODE_ENV: test
+ CYPRESS: 'true'
+
+ e2e-smoke:
+ name: E2E Smoke
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ if: ${{ !failure() && !cancelled() }}
+ steps:
+ - uses: actions/checkout@v6
+ - uses: oven-sh/setup-bun@v2
+ with: { bun-version: latest }
+ - uses: actions/cache@v5
+ with:
+ path: |
+ ${{ env.BUN_CACHE }}
+ **/node_modules
+ key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
+ - uses: actions/cache@v5
+ with:
+ path: build
+ key: build-${{ github.sha }}
+ - name: Setup Environment
+ env:
+ SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
+ ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
+ SUB: ${{ secrets.SUB }}
+ API_URL: ${{ secrets.API_URL }}
+ GRAPHQL_URL: ${{ secrets.GRAPHQL_URL }}
+ AUTH_OIDC_ISSUER: ${{ secrets.AUTH_OIDC_ISSUER }}
+ AUTH_OIDC_CLIENT_ID: ${{ secrets.AUTH_OIDC_CLIENT_ID }}
+ run: |
+ cat > .env << EOF
+ NODE_ENV=test
+ CYPRESS=true
+ APP_URL=http://localhost:3000
+ API_URL=$API_URL
+ GRAPHQL_URL=$GRAPHQL_URL
+ AUTH_OIDC_ISSUER=$AUTH_OIDC_ISSUER
+ AUTH_OIDC_CLIENT_ID=$AUTH_OIDC_CLIENT_ID
+ SESSION_SECRET=$SESSION_SECRET
+ LOG_LEVEL=warn
+ CYPRESS_BASE_URL=http://localhost:3000
+ EOF
+ - run: bunx cypress install && bunx cypress verify
+ - name: Run Smoke Tests
+ env:
+ NODE_ENV: test
+ CYPRESS: 'true'
+ CYPRESS_BASE_URL: http://localhost:3000
+ SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
+ ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
+ SUB: ${{ secrets.SUB }}
+ run: >-
+ bunx start-server-and-test
+ 'bun run start' http://localhost:3000/_healthz
+ 'bunx cypress run --spec "cypress/e2e/smoke/**" --config-file cypress.config.ts'
+ - if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: e2e-smoke-artifacts-${{ github.run_id }}
+ path: |
+ cypress/videos
+ cypress/screenshots
+ retention-days: 7
+ if-no-files-found: warn
+
+ e2e-regression:
+ name: E2E Regression
+ needs: e2e-smoke
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ if: ${{ !failure() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }}
+ # if: ${{ !failure() && !cancelled() }}
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [0, 1, 2, 3]
+ steps:
+ - uses: actions/checkout@v6
+ - uses: oven-sh/setup-bun@v2
+ with: { bun-version: latest }
+ - uses: actions/cache@v5
+ with:
+ path: |
+ ${{ env.BUN_CACHE }}
+ **/node_modules
+ key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }}
+ - uses: actions/cache@v5
+ with:
+ path: build
+ key: build-${{ github.sha }}
+ - name: Setup Environment
+ env:
+ SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
+ ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
+ SUB: ${{ secrets.SUB }}
+ API_URL: ${{ secrets.API_URL }}
+ GRAPHQL_URL: ${{ secrets.GRAPHQL_URL }}
+ AUTH_OIDC_ISSUER: ${{ secrets.AUTH_OIDC_ISSUER }}
+ AUTH_OIDC_CLIENT_ID: ${{ secrets.AUTH_OIDC_CLIENT_ID }}
+ run: |
+ cat > .env << EOF
+ NODE_ENV=test
+ CYPRESS=true
+ APP_URL=http://localhost:3000
+ API_URL=$API_URL
+ GRAPHQL_URL=$GRAPHQL_URL
+ AUTH_OIDC_ISSUER=$AUTH_OIDC_ISSUER
+ AUTH_OIDC_CLIENT_ID=$AUTH_OIDC_CLIENT_ID
+ SESSION_SECRET=$SESSION_SECRET
+ LOG_LEVEL=warn
+ CYPRESS_BASE_URL=http://localhost:3000
+ EOF
+ - run: bunx cypress install && bunx cypress verify
+ - name: Run Regression Tests
+ env:
+ NODE_ENV: test
+ CYPRESS: 'true'
+ CYPRESS_BASE_URL: http://localhost:3000
+ SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
+ ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
+ SUB: ${{ secrets.SUB }}
+ SPLIT: ${{ strategy.job-total }}
+ SPLIT_INDEX: ${{ strategy.job-index }}
+ CYPRESS_specPattern: 'cypress/e2e/regression/**/*.{cy,spec}.{js,jsx,ts,tsx}'
+ run: >-
+ bunx start-server-and-test
+ 'bun run start' http://localhost:3000/_healthz
+ 'bunx cypress run --config-file cypress.config.ts'
+ - if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: e2e-regression-artifacts-${{ github.run_id }}-shard-${{ matrix.shard }}
+ path: |
+ cypress/videos
+ cypress/screenshots
+ retention-days: 7
+ if-no-files-found: warn
+
+ # ─── Status Gate ────────────────────────────────────────────
+ status-check:
+ name: Final Status Check
+ needs: [install, lint, typecheck, build, unit-tests, e2e-smoke, e2e-regression]
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check Workflow Status
+ run: |
+ if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then
+ echo "❌ One or more jobs failed"
+ exit 1
+ elif [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
+ echo "⚠️ One or more jobs were cancelled"
+ exit 1
+ else
+ echo "✅ All jobs passed"
+ fi
+
+ # ─── Publish (only after all checks pass) ──────────────────
+ publish-container-image:
+ name: Publish Container Image
+ needs: [status-check]
+ if: ${{ !failure() && !cancelled() }}
+ permissions:
+ id-token: write
+ contents: read
+ packages: write
+ attestations: write
+ uses: datum-cloud/actions/.github/workflows/publish-docker.yaml@v1.13.3
+ with:
+ image-name: cloud-portal
+ secrets: inherit
+
+ publish-kustomize-bundles:
+ name: Publish Kustomize Bundles
+ needs: [publish-container-image]
+ if: ${{ !failure() && !cancelled() }}
+ permissions:
+ id-token: write
+ contents: read
+ packages: write
+ uses: datum-cloud/actions/.github/workflows/publish-kustomize-bundle.yaml@v1.13.3
+ with:
+ bundle-name: ghcr.io/datum-cloud/cloud-portal-kustomize
+ bundle-path: config
+ image-overlays: config/base
+ image-name: ghcr.io/datum-cloud/cloud-portal
+ secrets: inherit
+
+ # ─── PR Comment ─────────────────────────────────────────────
+ pr-comment:
+ name: PR Test Summary
+ needs: [unit-tests, e2e-smoke]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+ permissions:
+ contents: read
+ pull-requests: write
+ actions: read
+ steps:
+ - uses: actions/checkout@v6
+ - name: Build test summary
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const fs = require('fs');
+ const runId = context.runId;
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
+
+ const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: runId,
+ });
+
+ const testJobs = jobs.jobs.filter(j =>
+ j.name === 'Unit Tests' || j.name.startsWith('E2E')
+ );
+
+ const statusEmoji = (conclusion) => {
+ if (!conclusion || conclusion === 'skipped') return '⏭️';
+ if (conclusion === 'success') return '✅';
+ if (conclusion === 'failure') return '❌';
+ if (conclusion === 'cancelled') return '🚫';
+ return '❓';
+ };
+
+ let table = '| Job | Status |\n|-----|--------|\n';
+ for (const job of testJobs.sort((a, b) => a.name.localeCompare(b.name))) {
+ table += `| ${job.name} | ${statusEmoji(job.conclusion)} ${job.conclusion || 'pending'} |\n`;
+ }
+
+ const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: runId,
+ });
+
+ let body = `## 🧪 Test Summary\n\n${table}\n\n`;
+ body += `[View workflow run](${runUrl})`;
+ body += `\n\n### 📎 Artifacts\n\n`;
+ if (artifacts.artifacts.length > 0) {
+ body += `Videos and screenshots from failed E2E tests:\n\n`;
+ for (const a of artifacts.artifacts) {
+ body += `- **${a.name}** – [Download](${runUrl})\n`;
+ }
+ } else {
+ body += `No artifacts (all tests passed).\n`;
+ }
+
+ fs.writeFileSync('pr-summary.md', body);
+
+ - uses: peter-evans/create-or-update-comment@v5
+ with:
+ issue-number: ${{ github.event.pull_request.number }}
+ body-path: pr-summary.md
+ comment-tag: ci-summary
+ edit-mode: replace
diff --git a/.github/workflows/set-project-month.yaml b/.github/workflows/set-project-month.yaml
index e6d62d26b..7b99cd2d8 100644
--- a/.github/workflows/set-project-month.yaml
+++ b/.github/workflows/set-project-month.yaml
@@ -1,10 +1,10 @@
-name: Set Project Month
-
-on:
- pull_request:
- types: [opened, ready_for_review, reopened]
-
-jobs:
- set-month:
- uses: datum-cloud/actions/.github/workflows/set-project-month.yaml@v1.13.2
- secrets: inherit
+name: Set Project Month
+
+on:
+ pull_request:
+ types: [opened, ready_for_review, reopened]
+
+jobs:
+ set-month:
+ uses: datum-cloud/actions/.github/workflows/set-project-month.yaml@v1.13.2
+ secrets: inherit
diff --git a/.gitignore b/.gitignore
index e7a147304..086a041d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,31 +1,31 @@
-node_modules
-
-/.cache
-/build
-.env
-.DS_Store
-.react-router/
-
-# AI assistant files
-.cursor/
-.windsurf/
-.claude/
-!.claude/settings.json
-CLAUDE.MD
-.superpowers/
-docs/plans/
-docs/superpowers/
-
-# OpenAPI generation temp files
-temp-openapi-spec.json
-
-# GraphQL generation temp files
-temp-filtered-schema.graphql
-
-# Cypress files
-cypress/screenshots
-cypress/videos
-cypress/results
-
-# Playwright MCP
-.playwright-mcp/
+node_modules
+
+/.cache
+/build
+.DS_Store
+.react-router/
+
+# AI assistant files
+.cursor/
+.windsurf/
+.claude/
+!.claude/settings.json
+CLAUDE.MD
+.superpowers/
+docs/plans/
+docs/superpowers/
+
+# OpenAPI generation temp files
+temp-openapi-spec.json
+
+# GraphQL generation temp files
+temp-filtered-schema.graphql
+
+# Cypress files
+cypress/screenshots
+cypress/videos
+cypress/results
+
+# Playwright MCP
+.playwright-mcp/
+config.bat
diff --git a/.tool-versions b/.tool-versions
index ea5d138f0..69c0c1a9d 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,2 +1,2 @@
-nodejs 24.14.1
-bun 1.3.12
+nodejs 24.14.1
+bun 1.3.12
diff --git a/Dockerfile b/Dockerfile
index 83a9b5f5b..2ba599cb4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,71 +1,71 @@
-# ==========================================
-# BASE STAGE - Common dependencies and setup
-# ==========================================
-
-# syntax = docker/dockerfile:1
-
-# Use the latest Bun version
-FROM oven/bun:1.3.12 AS base
-
-# Install system dependencies and clean up in the same layer
-RUN apt-get update && \
- apt-get install -y --no-install-recommends unzip ca-certificates && \
- update-ca-certificates && \
- rm -rf /var/lib/apt/lists/* && \
- apt-get clean
-
-# Remix app lives here
-WORKDIR /app
-
-# Set production environment
-ENV NODE_ENV=production \
- PORT=3000
-
-# ==========================================
-# BUILD STAGE - Compile and prepare the app
-# ==========================================
-FROM base AS build
-
-ARG SENTRY_AUTH_TOKEN
-ARG VERSION=dev
-
-# Set environment variables
-ENV VERSION=${VERSION}
-ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
-
-# Install dependencies
-COPY --link package.json bun.lock ./
-RUN bun install --frozen-lockfile
-
-# Copy application code
-COPY --link . .
-
-# Build application
-RUN bun run build && \
- bun install --production --frozen-lockfile && \
- touch .env
-
-# ==========================================
-# PRODUCTION STAGE - Final lightweight image
-# ==========================================
-FROM base
-
-# Accept VERSION as build argument and set as environment variable
-ARG VERSION=dev
-ENV VERSION=${VERSION}
-
-# Copy only necessary files from build stage
-COPY --from=build /app /app
-
-# Start the server by default, this can be overwritten at runtime
-EXPOSE ${PORT}
-
-# Use non-root user for better security
-RUN groupadd --gid 1001 datum && \
- useradd --uid 1001 --gid 1001 --no-create-home datum && \
- chown -R datum:datum /app
-
-USER datum
-
-# Use the start script from package.json
-CMD [ "bun", "run", "start" ]
+# ==========================================
+# BASE STAGE - Common dependencies and setup
+# ==========================================
+
+# syntax = docker/dockerfile:1
+
+# Use the latest Bun version
+FROM oven/bun:1.3.12 AS base
+
+# Install system dependencies and clean up in the same layer
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends unzip ca-certificates && \
+ update-ca-certificates && \
+ rm -rf /var/lib/apt/lists/* && \
+ apt-get clean
+
+# Remix app lives here
+WORKDIR /app
+
+# Set production environment
+ENV NODE_ENV=production \
+ PORT=3000
+
+# ==========================================
+# BUILD STAGE - Compile and prepare the app
+# ==========================================
+FROM base AS build
+
+ARG SENTRY_AUTH_TOKEN
+ARG VERSION=dev
+
+# Set environment variables
+ENV VERSION=${VERSION}
+ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
+
+# Install dependencies
+COPY --link package.json bun.lock ./
+RUN bun install --frozen-lockfile
+
+# Copy application code
+COPY --link . .
+
+# Build application
+RUN bun run build && \
+ bun install --production --frozen-lockfile && \
+ touch .env
+
+# ==========================================
+# PRODUCTION STAGE - Final lightweight image
+# ==========================================
+FROM base
+
+# Accept VERSION as build argument and set as environment variable
+ARG VERSION=dev
+ENV VERSION=${VERSION}
+
+# Copy only necessary files from build stage
+COPY --from=build /app /app
+
+# Start the server by default, this can be overwritten at runtime
+EXPOSE ${PORT}
+
+# Use non-root user for better security
+RUN groupadd --gid 1001 datum && \
+ useradd --uid 1001 --gid 1001 --no-create-home datum && \
+ chown -R datum:datum /app
+
+USER datum
+
+# Use the start script from package.json
+CMD [ "bun", "run", "start" ]
diff --git a/app/components/avatar-stack/avatar-stack.tsx b/app/components/avatar-stack/avatar-stack.tsx
index 0e42f98f4..1b315f8ff 100644
--- a/app/components/avatar-stack/avatar-stack.tsx
+++ b/app/components/avatar-stack/avatar-stack.tsx
@@ -1,74 +1,74 @@
-import { getInitials } from '@/utils/helpers/text.helper';
-import { cn } from '@shadcn/lib/utils';
-import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar';
-
-export interface AvatarStackItem {
- name: string;
- avatarUrl?: string;
-}
-
-export interface AvatarStackProps {
- items: AvatarStackItem[];
- max?: number;
- size?: 'xs' | 'sm';
-}
-
-const COLORS = [
- 'bg-blue-500',
- 'bg-rose-500',
- 'bg-amber-600',
- 'bg-purple-500',
- 'bg-emerald-500',
- 'bg-cyan-500',
- 'bg-pink-500',
- 'bg-indigo-500',
-];
-
-function hashName(name: string): number {
- let hash = 0;
- for (let i = 0; i < name.length; i++) {
- hash = (hash << 5) - hash + name.charCodeAt(i);
- hash |= 0;
- }
- return Math.abs(hash);
-}
-
-export function AvatarStack({ items, max = 4, size = 'xs' }: AvatarStackProps) {
- if (items.length === 0) return null;
-
- const visible = items.slice(0, max);
- const overflow = items.length - max;
-
- const sizeClasses = size === 'xs' ? 'size-6 text-[9px]' : 'size-8 text-[11px]';
-
- return (
-
- {visible.map((item, i) => (
-
0 && '-ml-1.5')}
- style={{ zIndex: visible.length - i }}>
- {item.avatarUrl && }
-
- {getInitials(item.name)}
-
-
- ))}
- {overflow > 0 && (
-
- +{overflow}
-
- )}
-
- );
-}
+import { getInitials } from '@/utils/helpers/text.helper';
+import { cn } from '@shadcn/lib/utils';
+import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar';
+
+export interface AvatarStackItem {
+ name: string;
+ avatarUrl?: string;
+}
+
+export interface AvatarStackProps {
+ items: AvatarStackItem[];
+ max?: number;
+ size?: 'xs' | 'sm';
+}
+
+const COLORS = [
+ 'bg-blue-500',
+ 'bg-rose-500',
+ 'bg-amber-600',
+ 'bg-purple-500',
+ 'bg-emerald-500',
+ 'bg-cyan-500',
+ 'bg-pink-500',
+ 'bg-indigo-500',
+];
+
+function hashName(name: string): number {
+ let hash = 0;
+ for (let i = 0; i < name.length; i++) {
+ hash = (hash << 5) - hash + name.charCodeAt(i);
+ hash |= 0;
+ }
+ return Math.abs(hash);
+}
+
+export function AvatarStack({ items, max = 4, size = 'xs' }: AvatarStackProps) {
+ if (items.length === 0) return null;
+
+ const visible = items.slice(0, max);
+ const overflow = items.length - max;
+
+ const sizeClasses = size === 'xs' ? 'size-6 text-[9px]' : 'size-8 text-[11px]';
+
+ return (
+
+ {visible.map((item, i) => (
+
0 && '-ml-1.5')}
+ style={{ zIndex: visible.length - i }}>
+ {item.avatarUrl && }
+
+ {getInitials(item.name)}
+
+
+ ))}
+ {overflow > 0 && (
+
+ +{overflow}
+
+ )}
+
+ );
+}
diff --git a/app/components/back-button/back-button.tsx b/app/components/back-button/back-button.tsx
index 6864f3e44..3c11d5d5b 100644
--- a/app/components/back-button/back-button.tsx
+++ b/app/components/back-button/back-button.tsx
@@ -1,106 +1,106 @@
-import { paths } from '@/utils/config/paths.config';
-import { Button, ButtonProps } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { ArrowLeft } from 'lucide-react';
-import { useNavigate } from 'react-router';
-
-export interface BackButtonProps extends Omit {
- /**
- * Custom back link to navigate to
- * If not provided, will use navigate(-1) or fallback to account root
- */
- to?: string;
- /**
- * Callback function called before navigation
- * Return false to prevent navigation
- */
- onBeforeNavigate?: () => boolean | void;
- /**
- * Callback function called after navigation
- */
- onAfterNavigate?: () => void;
- /**
- * Custom icon component
- * @default ArrowLeft
- */
- customIcon?: React.ReactNode;
- /**
- * Show icon on the button
- * @default true
- */
- showIcon?: boolean;
- /**
- * Fallback path when navigate(-1) is not available
- * @default paths.account.root
- */
- fallbackPath?: string;
-}
-
-export const BackButton = ({
- to,
- onBeforeNavigate,
- onAfterNavigate,
- customIcon,
- showIcon = true,
- fallbackPath = paths.account.root,
- children = 'Back',
- type = 'quaternary',
- theme = 'outline',
- size = 'xs',
- ...buttonProps
-}: BackButtonProps) => {
- const navigate = useNavigate();
-
- const handleClick = () => {
- // Call onBeforeNavigate callback if provided
- if (onBeforeNavigate) {
- const shouldContinue = onBeforeNavigate();
- if (shouldContinue === false) {
- return;
- }
- }
-
- // Navigate based on priority:
- // 1. Custom 'to' prop
- // 2. Browser history back (navigate(-1))
- // 3. Fallback path
- if (to) {
- navigate(to);
- } else {
- // Check if there's history to go back to
- // Note: window.history.length > 1 indicates there's a previous page
- const hasHistory = window.history.length > 1;
-
- if (hasHistory) {
- navigate(-1);
- } else {
- navigate(fallbackPath);
- }
- }
-
- // Call onAfterNavigate callback if provided
- if (onAfterNavigate) {
- onAfterNavigate();
- }
- };
-
- const icon = showIcon
- ? customIcon ||
- : undefined;
-
- return (
-
- {children}
-
- );
-};
-
-BackButton.displayName = 'BackButton';
+import { paths } from '@/utils/config/paths.config';
+import { Button, ButtonProps } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { ArrowLeft } from 'lucide-react';
+import { useNavigate } from 'react-router';
+
+export interface BackButtonProps extends Omit {
+ /**
+ * Custom back link to navigate to
+ * If not provided, will use navigate(-1) or fallback to account root
+ */
+ to?: string;
+ /**
+ * Callback function called before navigation
+ * Return false to prevent navigation
+ */
+ onBeforeNavigate?: () => boolean | void;
+ /**
+ * Callback function called after navigation
+ */
+ onAfterNavigate?: () => void;
+ /**
+ * Custom icon component
+ * @default ArrowLeft
+ */
+ customIcon?: React.ReactNode;
+ /**
+ * Show icon on the button
+ * @default true
+ */
+ showIcon?: boolean;
+ /**
+ * Fallback path when navigate(-1) is not available
+ * @default paths.account.root
+ */
+ fallbackPath?: string;
+}
+
+export const BackButton = ({
+ to,
+ onBeforeNavigate,
+ onAfterNavigate,
+ customIcon,
+ showIcon = true,
+ fallbackPath = paths.account.root,
+ children = 'Back',
+ type = 'quaternary',
+ theme = 'outline',
+ size = 'xs',
+ ...buttonProps
+}: BackButtonProps) => {
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ // Call onBeforeNavigate callback if provided
+ if (onBeforeNavigate) {
+ const shouldContinue = onBeforeNavigate();
+ if (shouldContinue === false) {
+ return;
+ }
+ }
+
+ // Navigate based on priority:
+ // 1. Custom 'to' prop
+ // 2. Browser history back (navigate(-1))
+ // 3. Fallback path
+ if (to) {
+ navigate(to);
+ } else {
+ // Check if there's history to go back to
+ // Note: window.history.length > 1 indicates there's a previous page
+ const hasHistory = window.history.length > 1;
+
+ if (hasHistory) {
+ navigate(-1);
+ } else {
+ navigate(fallbackPath);
+ }
+ }
+
+ // Call onAfterNavigate callback if provided
+ if (onAfterNavigate) {
+ onAfterNavigate();
+ }
+ };
+
+ const icon = showIcon
+ ? customIcon ||
+ : undefined;
+
+ return (
+
+ {children}
+
+ );
+};
+
+BackButton.displayName = 'BackButton';
diff --git a/app/components/badge/badge-copy.tsx b/app/components/badge/badge-copy.tsx
index 1ac900999..f82a4d988 100644
--- a/app/components/badge/badge-copy.tsx
+++ b/app/components/badge/badge-copy.tsx
@@ -1,95 +1,95 @@
-import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
-import { Badge, type BadgeProps, toast, Tooltip } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { cn } from '@shadcn/lib/utils';
-import { CopyIcon } from 'lucide-react';
-import { useState } from 'react';
-
-export interface BadgeCopyProps {
- value: string;
- text?: string;
- className?: string;
- textClassName?: string;
- containerClassName?: string;
- badgeType?: BadgeProps['type'];
- badgeTheme?: BadgeProps['theme'];
- showTooltip?: boolean;
- copyButtonClassName?: string;
- 'data-e2e'?: string;
- wrapperTooltipMessage?: string | React.ReactNode;
-}
-
-export const BadgeCopy = ({
- value,
- text,
- className,
- textClassName,
- containerClassName,
- badgeType = 'secondary',
- badgeTheme = 'light',
- showTooltip = true,
- copyButtonClassName,
- 'data-e2e': dataE2e,
- wrapperTooltipMessage,
-}: BadgeCopyProps) => {
- const [_, copy] = useCopyToClipboard();
- const [copied, setCopied] = useState(false);
-
- const copyToClipboard = () => {
- if (!value) return;
-
- copy(value).then(() => {
- toast.success('Copied to clipboard');
- setCopied(true);
- setTimeout(() => {
- setCopied(false);
- }, 2000);
- });
- };
-
- const displayText = text ?? value;
-
- const copyButton = (
-
-
-
- );
-
- const badge = (
- {
- event.preventDefault();
- event.stopPropagation();
- copyToClipboard();
- }}>
- {displayText}
- {copyButton}
-
- );
-
- const badgeContent = showTooltip ? (
- {badge}
- ) : (
- badge
- );
-
- const wrappedBadge = (
-
- {badgeContent}
-
- );
-
- if (wrapperTooltipMessage) {
- return {wrappedBadge} ;
- }
-
- return wrappedBadge;
-};
+import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
+import { Badge, type BadgeProps, toast, Tooltip } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { cn } from '@shadcn/lib/utils';
+import { CopyIcon } from 'lucide-react';
+import { useState } from 'react';
+
+export interface BadgeCopyProps {
+ value: string;
+ text?: string;
+ className?: string;
+ textClassName?: string;
+ containerClassName?: string;
+ badgeType?: BadgeProps['type'];
+ badgeTheme?: BadgeProps['theme'];
+ showTooltip?: boolean;
+ copyButtonClassName?: string;
+ 'data-e2e'?: string;
+ wrapperTooltipMessage?: string | React.ReactNode;
+}
+
+export const BadgeCopy = ({
+ value,
+ text,
+ className,
+ textClassName,
+ containerClassName,
+ badgeType = 'secondary',
+ badgeTheme = 'light',
+ showTooltip = true,
+ copyButtonClassName,
+ 'data-e2e': dataE2e,
+ wrapperTooltipMessage,
+}: BadgeCopyProps) => {
+ const [_, copy] = useCopyToClipboard();
+ const [copied, setCopied] = useState(false);
+
+ const copyToClipboard = () => {
+ if (!value) return;
+
+ copy(value).then(() => {
+ toast.success('Copied to clipboard');
+ setCopied(true);
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ });
+ };
+
+ const displayText = text ?? value;
+
+ const copyButton = (
+
+
+
+ );
+
+ const badge = (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ copyToClipboard();
+ }}>
+ {displayText}
+ {copyButton}
+
+ );
+
+ const badgeContent = showTooltip ? (
+ {badge}
+ ) : (
+ badge
+ );
+
+ const wrappedBadge = (
+
+ {badgeContent}
+
+ );
+
+ if (wrapperTooltipMessage) {
+ return {wrappedBadge} ;
+ }
+
+ return wrappedBadge;
+};
diff --git a/app/components/badge/badge-status.tsx b/app/components/badge/badge-status.tsx
index 28b429dc3..73926b84c 100644
--- a/app/components/badge/badge-status.tsx
+++ b/app/components/badge/badge-status.tsx
@@ -1,182 +1,182 @@
-import { ControlPlaneStatus, IControlPlaneStatus } from '@/resources/base';
-import { Badge, type BadgeProps, Tooltip, SpinnerIcon } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-import { ReactNode } from 'react';
-
-interface StatusConfig {
- badgeType?: BadgeProps['type'];
- badgeTheme?: BadgeProps['theme'];
- // Custom colors (overrides badgeType/badgeTheme if provided)
- customColor?: {
- border: string;
- text: string;
- bg: string;
- };
- icon?: ReactNode;
- defaultLabel: string;
-}
-
-// Centralized status configuration
-// Customize colors here - all usages will inherit these settings
-const STATUS_CONFIG: Record = {
- active: {
- badgeType: 'success',
- badgeTheme: 'light',
- defaultLabel: 'Active',
- },
- pending: {
- // Option 1: Use existing Badge type/theme
- badgeType: 'info',
- badgeTheme: 'light',
- // Option 2: Use custom colors
- // customColor: {
- // border: 'border-blue-500',
- // text: 'text-blue-600 dark:text-blue-400',
- // bg: 'bg-blue-500/20 dark:bg-blue-500/20',
- // },
- icon: (
-
- ),
- defaultLabel: 'Pending',
- },
- error: {
- badgeType: 'danger',
- badgeTheme: 'light',
- defaultLabel: 'Failed',
- },
- inactive: {
- badgeType: 'secondary',
- badgeTheme: 'light',
- defaultLabel: 'Inactive',
- },
- success: {
- badgeType: 'success',
- badgeTheme: 'light',
- defaultLabel: 'Ready',
- },
- personal: {
- badgeType: 'primary',
- badgeTheme: 'light',
- defaultLabel: 'Personal',
- },
- standard: {
- badgeType: 'info',
- badgeTheme: 'light',
- defaultLabel: 'Standard',
- },
-};
-
-// Helper to map ControlPlaneStatus to BadgeStatusStatus
-const mapControlPlaneStatus = (status: ControlPlaneStatus): string => {
- switch (status) {
- case ControlPlaneStatus.Success:
- return 'active';
- case ControlPlaneStatus.Pending:
- return 'pending';
- case ControlPlaneStatus.Error:
- return 'error';
- default:
- return 'inactive';
- }
-};
-
-export interface BadgeStatusProps {
- // Accept either new status string or legacy IControlPlaneStatus
- status?: string | IControlPlaneStatus;
- label?: string;
- showIcon?: boolean;
- showTooltip?: boolean;
- tooltipText?: string | ReactNode;
- className?: string;
- // Override centralized config (use sparingly)
- badgeType?: BadgeProps['type'];
- badgeTheme?: BadgeProps['theme'];
- // Custom icon (overrides default config icon)
- customIcon?: ReactNode;
- tooltipContentClassName?: string;
- tooltipArrowClassName?: string;
-}
-
-export const BadgeStatus = ({
- status,
- label,
- showIcon = false,
- showTooltip = true,
- tooltipText,
- className,
- badgeType: overrideBadgeType,
- badgeTheme: overrideBadgeTheme,
- tooltipContentClassName,
- tooltipArrowClassName,
- customIcon,
-}: BadgeStatusProps) => {
- // Handle legacy IControlPlaneStatus format
- let statusValue: string;
- let statusMessage: string | undefined;
-
- if (!status) return null;
-
- if (typeof status === 'object' && 'status' in status) {
- // Legacy format: IControlPlaneStatus
- statusValue = mapControlPlaneStatus(status.status);
- statusMessage = status.message;
- } else {
- // New format: BadgeStatusStatus string
- statusValue = status;
- }
-
- const config = STATUS_CONFIG[statusValue.toLowerCase()];
- if (!config) return null;
-
- // Determine badge props
- const badgeType = overrideBadgeType ?? config.badgeType;
- const badgeTheme = overrideBadgeTheme ?? config.badgeTheme;
- const displayLabel = label ?? config.defaultLabel;
- const finalTooltipText = tooltipText ?? statusMessage;
-
- // Build className for custom colors
- const customColorClasses = config.customColor
- ? cn(
- config.customColor.border,
- config.customColor.text,
- config.customColor.bg,
- 'border' // Ensure border is shown
- )
- : undefined;
-
- const badgeContent = (
-
- {showIcon && (customIcon || config.icon)}
- {displayLabel}
-
- );
-
- // Wrap with tooltip if needed
- if (showTooltip && finalTooltipText && statusValue !== 'active') {
- return (
-
-
- {badgeContent}
-
-
- );
- }
-
- // Wrap in container to prevent stretching in table cells
- return {badgeContent}
;
-};
+import { ControlPlaneStatus, IControlPlaneStatus } from '@/resources/base';
+import { Badge, type BadgeProps, Tooltip, SpinnerIcon } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+import { ReactNode } from 'react';
+
+interface StatusConfig {
+ badgeType?: BadgeProps['type'];
+ badgeTheme?: BadgeProps['theme'];
+ // Custom colors (overrides badgeType/badgeTheme if provided)
+ customColor?: {
+ border: string;
+ text: string;
+ bg: string;
+ };
+ icon?: ReactNode;
+ defaultLabel: string;
+}
+
+// Centralized status configuration
+// Customize colors here - all usages will inherit these settings
+const STATUS_CONFIG: Record = {
+ active: {
+ badgeType: 'success',
+ badgeTheme: 'light',
+ defaultLabel: 'Active',
+ },
+ pending: {
+ // Option 1: Use existing Badge type/theme
+ badgeType: 'info',
+ badgeTheme: 'light',
+ // Option 2: Use custom colors
+ // customColor: {
+ // border: 'border-blue-500',
+ // text: 'text-blue-600 dark:text-blue-400',
+ // bg: 'bg-blue-500/20 dark:bg-blue-500/20',
+ // },
+ icon: (
+
+ ),
+ defaultLabel: 'Pending',
+ },
+ error: {
+ badgeType: 'danger',
+ badgeTheme: 'light',
+ defaultLabel: 'Failed',
+ },
+ inactive: {
+ badgeType: 'secondary',
+ badgeTheme: 'light',
+ defaultLabel: 'Inactive',
+ },
+ success: {
+ badgeType: 'success',
+ badgeTheme: 'light',
+ defaultLabel: 'Ready',
+ },
+ personal: {
+ badgeType: 'primary',
+ badgeTheme: 'light',
+ defaultLabel: 'Personal',
+ },
+ standard: {
+ badgeType: 'info',
+ badgeTheme: 'light',
+ defaultLabel: 'Standard',
+ },
+};
+
+// Helper to map ControlPlaneStatus to BadgeStatusStatus
+const mapControlPlaneStatus = (status: ControlPlaneStatus): string => {
+ switch (status) {
+ case ControlPlaneStatus.Success:
+ return 'active';
+ case ControlPlaneStatus.Pending:
+ return 'pending';
+ case ControlPlaneStatus.Error:
+ return 'error';
+ default:
+ return 'inactive';
+ }
+};
+
+export interface BadgeStatusProps {
+ // Accept either new status string or legacy IControlPlaneStatus
+ status?: string | IControlPlaneStatus;
+ label?: string;
+ showIcon?: boolean;
+ showTooltip?: boolean;
+ tooltipText?: string | ReactNode;
+ className?: string;
+ // Override centralized config (use sparingly)
+ badgeType?: BadgeProps['type'];
+ badgeTheme?: BadgeProps['theme'];
+ // Custom icon (overrides default config icon)
+ customIcon?: ReactNode;
+ tooltipContentClassName?: string;
+ tooltipArrowClassName?: string;
+}
+
+export const BadgeStatus = ({
+ status,
+ label,
+ showIcon = false,
+ showTooltip = true,
+ tooltipText,
+ className,
+ badgeType: overrideBadgeType,
+ badgeTheme: overrideBadgeTheme,
+ tooltipContentClassName,
+ tooltipArrowClassName,
+ customIcon,
+}: BadgeStatusProps) => {
+ // Handle legacy IControlPlaneStatus format
+ let statusValue: string;
+ let statusMessage: string | undefined;
+
+ if (!status) return null;
+
+ if (typeof status === 'object' && 'status' in status) {
+ // Legacy format: IControlPlaneStatus
+ statusValue = mapControlPlaneStatus(status.status);
+ statusMessage = status.message;
+ } else {
+ // New format: BadgeStatusStatus string
+ statusValue = status;
+ }
+
+ const config = STATUS_CONFIG[statusValue.toLowerCase()];
+ if (!config) return null;
+
+ // Determine badge props
+ const badgeType = overrideBadgeType ?? config.badgeType;
+ const badgeTheme = overrideBadgeTheme ?? config.badgeTheme;
+ const displayLabel = label ?? config.defaultLabel;
+ const finalTooltipText = tooltipText ?? statusMessage;
+
+ // Build className for custom colors
+ const customColorClasses = config.customColor
+ ? cn(
+ config.customColor.border,
+ config.customColor.text,
+ config.customColor.bg,
+ 'border' // Ensure border is shown
+ )
+ : undefined;
+
+ const badgeContent = (
+
+ {showIcon && (customIcon || config.icon)}
+ {displayLabel}
+
+ );
+
+ // Wrap with tooltip if needed
+ if (showTooltip && finalTooltipText && statusValue !== 'active') {
+ return (
+
+
+ {badgeContent}
+
+
+ );
+ }
+
+ // Wrap in container to prevent stretching in table cells
+ return {badgeContent}
;
+};
diff --git a/app/components/chips-overflow/chips-overflow.tsx b/app/components/chips-overflow/chips-overflow.tsx
index c43146c7d..5bca07061 100644
--- a/app/components/chips-overflow/chips-overflow.tsx
+++ b/app/components/chips-overflow/chips-overflow.tsx
@@ -1,153 +1,153 @@
-import { Badge, type BadgeProps } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
-import * as React from 'react';
-
-export type ChipsOverflowItem = string | React.ReactNode;
-
-export type ChipsOverflowSize = 'sm' | 'md' | 'lg';
-
-export interface ChipsOverflowProps {
- items: ChipsOverflowItem[];
- maxVisible?: number;
- size?: ChipsOverflowSize;
- type?: BadgeProps['type'];
- theme?: BadgeProps['theme'];
- renderChip?: (item: ChipsOverflowItem, index: number) => React.ReactNode;
- overflowLabel?: (hiddenCount: number) => string;
- className?: string;
- wrap?: boolean;
- disabled?: boolean;
- onChipClick?: (item: ChipsOverflowItem, index: number) => void;
-}
-
-const sizeClassNames: Record = {
- sm: 'text-[10px] px-1.5 py-0.5',
- md: 'text-xs px-2 py-0.5',
- lg: 'text-sm px-3 py-1',
-};
-
-export function ChipsOverflow({
- items,
- maxVisible = 2,
- size = 'md',
- type = 'quaternary',
- theme = 'outline',
- renderChip,
- overflowLabel = (n) => `${n}+`,
- className,
- wrap = false,
- disabled = false,
- onChipClick,
-}: ChipsOverflowProps) {
- const [open, setOpen] = React.useState(false);
-
- const { visibleItems, hiddenItems } = React.useMemo(() => {
- if (wrap) return { visibleItems: items, hiddenItems: [] as ChipsOverflowItem[] };
- if (!items || items.length === 0)
- return { visibleItems: [] as ChipsOverflowItem[], hiddenItems: [] as ChipsOverflowItem[] };
- if (items.length <= maxVisible)
- return { visibleItems: items, hiddenItems: [] as ChipsOverflowItem[] };
- return { visibleItems: items.slice(0, maxVisible), hiddenItems: items.slice(maxVisible) };
- }, [items, maxVisible, wrap]);
-
- const renderDefaultChip = (item: ChipsOverflowItem, index: number) => {
- const isClickable = Boolean(onChipClick) && !disabled;
- const content = typeof item === 'string' ? item : item;
- return (
- {
- event.stopPropagation();
- event.preventDefault();
- if (!isClickable) return;
- onChipClick?.(item, index);
- }}>
- {content}
-
- );
- };
-
- const chips = visibleItems.map((item, index) => (
-
- {renderChip ? renderChip(item, index) : renderDefaultChip(item, index)}
-
- ));
-
- const hiddenCount = hiddenItems.length;
-
- const overflowChip = hiddenCount > 0 && (
-
-
- {
- event.stopPropagation();
- event.preventDefault();
- }}
- onClick={(event) => {
- event.stopPropagation();
- event.preventDefault();
- // Toggle via click as a fallback interaction
- setOpen((prev) => !prev);
- }}
- onFocus={(event) => {
- // Prevent table row focus handlers
- event.stopPropagation();
- event.preventDefault();
- }}
- onKeyDown={(event) => {
- if (event.key === 'Enter' || event.key === ' ') {
- event.stopPropagation();
- event.preventDefault();
- setOpen((prev) => !prev);
- }
- }}>
-
- {overflowLabel(hiddenCount)}
-
-
-
-
-
- {hiddenItems.map((item, index) => (
-
- {renderChip
- ? renderChip(item, index + visibleItems.length)
- : renderDefaultChip(item, index + visibleItems.length)}
-
- ))}
-
-
-
- );
-
- return (
-
- {chips}
- {overflowChip}
-
- );
-}
-
-export default ChipsOverflow;
+import { Badge, type BadgeProps } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
+import * as React from 'react';
+
+export type ChipsOverflowItem = string | React.ReactNode;
+
+export type ChipsOverflowSize = 'sm' | 'md' | 'lg';
+
+export interface ChipsOverflowProps {
+ items: ChipsOverflowItem[];
+ maxVisible?: number;
+ size?: ChipsOverflowSize;
+ type?: BadgeProps['type'];
+ theme?: BadgeProps['theme'];
+ renderChip?: (item: ChipsOverflowItem, index: number) => React.ReactNode;
+ overflowLabel?: (hiddenCount: number) => string;
+ className?: string;
+ wrap?: boolean;
+ disabled?: boolean;
+ onChipClick?: (item: ChipsOverflowItem, index: number) => void;
+}
+
+const sizeClassNames: Record = {
+ sm: 'text-[10px] px-1.5 py-0.5',
+ md: 'text-xs px-2 py-0.5',
+ lg: 'text-sm px-3 py-1',
+};
+
+export function ChipsOverflow({
+ items,
+ maxVisible = 2,
+ size = 'md',
+ type = 'quaternary',
+ theme = 'outline',
+ renderChip,
+ overflowLabel = (n) => `${n}+`,
+ className,
+ wrap = false,
+ disabled = false,
+ onChipClick,
+}: ChipsOverflowProps) {
+ const [open, setOpen] = React.useState(false);
+
+ const { visibleItems, hiddenItems } = React.useMemo(() => {
+ if (wrap) return { visibleItems: items, hiddenItems: [] as ChipsOverflowItem[] };
+ if (!items || items.length === 0)
+ return { visibleItems: [] as ChipsOverflowItem[], hiddenItems: [] as ChipsOverflowItem[] };
+ if (items.length <= maxVisible)
+ return { visibleItems: items, hiddenItems: [] as ChipsOverflowItem[] };
+ return { visibleItems: items.slice(0, maxVisible), hiddenItems: items.slice(maxVisible) };
+ }, [items, maxVisible, wrap]);
+
+ const renderDefaultChip = (item: ChipsOverflowItem, index: number) => {
+ const isClickable = Boolean(onChipClick) && !disabled;
+ const content = typeof item === 'string' ? item : item;
+ return (
+ {
+ event.stopPropagation();
+ event.preventDefault();
+ if (!isClickable) return;
+ onChipClick?.(item, index);
+ }}>
+ {content}
+
+ );
+ };
+
+ const chips = visibleItems.map((item, index) => (
+
+ {renderChip ? renderChip(item, index) : renderDefaultChip(item, index)}
+
+ ));
+
+ const hiddenCount = hiddenItems.length;
+
+ const overflowChip = hiddenCount > 0 && (
+
+
+ {
+ event.stopPropagation();
+ event.preventDefault();
+ }}
+ onClick={(event) => {
+ event.stopPropagation();
+ event.preventDefault();
+ // Toggle via click as a fallback interaction
+ setOpen((prev) => !prev);
+ }}
+ onFocus={(event) => {
+ // Prevent table row focus handlers
+ event.stopPropagation();
+ event.preventDefault();
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.stopPropagation();
+ event.preventDefault();
+ setOpen((prev) => !prev);
+ }
+ }}>
+
+ {overflowLabel(hiddenCount)}
+
+
+
+
+
+ {hiddenItems.map((item, index) => (
+
+ {renderChip
+ ? renderChip(item, index + visibleItems.length)
+ : renderDefaultChip(item, index + visibleItems.length)}
+
+ ))}
+
+
+
+ );
+
+ return (
+
+ {chips}
+ {overflowChip}
+
+ );
+}
+
+export default ChipsOverflow;
diff --git a/app/components/code-editor/code-editor-tabs.tsx b/app/components/code-editor/code-editor-tabs.tsx
index 22381d38c..397b5960d 100644
--- a/app/components/code-editor/code-editor-tabs.tsx
+++ b/app/components/code-editor/code-editor-tabs.tsx
@@ -1,193 +1,193 @@
-import { CodeEditor } from './code-editor';
-import { EditorLanguage, CodeEditorTabsProps } from './code-editor.types';
-import { isValidJson, isValidYaml, jsonToYaml, yamlToJson } from '@/utils/helpers/format.helper';
-import { Tabs, TabsContent, TabsList, TabsTrigger, toast } from '@datum-ui/components';
-import { useEffect, useState } from 'react';
-
-export function CodeEditorTabs({
- value,
- onChange,
- format = 'yaml',
- onFormatChange,
- error,
- id,
- name = 'code-editor',
- minHeight = '300px',
-}: CodeEditorTabsProps) {
- const [activeTab, setActiveTab] = useState(format);
-
- // Initialize JSON value - if the input is in JSON, use it directly, otherwise convert from YAML
- const [jsonValue, setJsonValue] = useState(() => {
- if (format === 'json') return value;
-
- try {
- return value ? yamlToJson(value) : '{}';
- } catch {
- toast.error('Initial YAML to JSON conversion failed');
- return '{}';
- }
- });
-
- // Initialize YAML value - if the input is in YAML, use it directly, otherwise convert from JSON
- const [yamlValue, setYamlValue] = useState(() => {
- if (format === 'yaml') return value;
-
- try {
- return value ? jsonToYaml(value) : '';
- } catch {
- toast.error('Initial JSON to YAML conversion failed');
- return '';
- }
- });
-
- // Sync with external value changes
- useEffect(() => {
- if (format === 'json' && value !== jsonValue) {
- setJsonValue(value || '{}');
- try {
- setYamlValue(jsonToYaml(value || '{}'));
- } catch (e) {
- console.error('Failed to update YAML from external JSON value:', e);
- }
- } else if (format === 'yaml' && value !== yamlValue) {
- setYamlValue(value || '');
- try {
- setJsonValue(yamlToJson(value || ''));
- } catch (e) {
- console.error('Failed to update JSON from external YAML value:', e);
- }
- }
- }, [value, format]);
-
- // Sync active tab with format
- useEffect(() => {
- if (format !== activeTab) {
- setActiveTab(format);
- }
- }, [format]);
-
- // Handle tab changes with format conversion
- const handleTabChange = (newTab: EditorLanguage) => {
- // If the tab hasn't changed, do nothing
- if (newTab === activeTab) return;
-
- try {
- // Convert content based on which tab we're switching from
- if (activeTab === 'json' && newTab === 'yaml') {
- // JSON to YAML conversion
- if (!isValidJson(jsonValue)) {
- throw new Error('Cannot convert: Invalid JSON');
- }
-
- // Convert and update state
- const newYaml = jsonToYaml(jsonValue);
- setYamlValue(newYaml);
- setActiveTab('yaml');
-
- // Notify parent components
- onChange?.(newYaml, 'yaml');
- onFormatChange?.('yaml');
- } else if (activeTab === 'yaml' && newTab === 'json') {
- // YAML to JSON conversion
- if (!isValidYaml(yamlValue)) {
- throw new Error('Cannot convert: Invalid YAML');
- }
-
- // Convert and update state
- const newJson = yamlToJson(yamlValue);
- setJsonValue(newJson);
- setActiveTab('json');
-
- // Notify parent components
- onChange?.(newJson, 'json');
- onFormatChange?.('json');
- }
- } catch (error) {
- // Show error message and stay on current tab
- const errorMessage = error instanceof Error ? error.message : 'Conversion failed';
- toast.error(errorMessage, {
- id: 'conversion-error', // Use an ID to prevent duplicate toasts
- });
- }
- };
-
- // Handle value changes from editor
- const handleJsonChange = (newJson: string) => {
- setJsonValue(newJson);
- try {
- // Update YAML version too
- if (isValidJson(newJson)) {
- const newYaml = jsonToYaml(newJson);
- setYamlValue(newYaml);
- }
- // Only update the external value if this is the active format
- if (activeTab === 'json') {
- onChange?.(newJson, 'json');
- }
- } catch {
- toast.error('Failed to update YAML from JSON value', {
- id: 'json-to-yaml-error',
- });
- // Invalid JSON, don't update YAML
- }
- };
-
- const handleYamlChange = (newYaml: string) => {
- setYamlValue(newYaml);
- try {
- // Update JSON version too
- if (isValidYaml(newYaml)) {
- const newJson = yamlToJson(newYaml);
- setJsonValue(newJson);
- }
- // Only update the external value if this is the active format
- if (activeTab === 'yaml') {
- onChange?.(newYaml, 'yaml');
- }
- } catch {
- toast.error('Failed to update JSON from YAML value', {
- id: 'yaml-to-json-error',
- });
- // Invalid YAML, don't update JSON
- }
- };
-
- return (
- <>
- handleTabChange(val as EditorLanguage)}
- className="w-full">
-
- YAML
- JSON
-
-
-
-
-
-
-
-
-
- {/* Hidden field to capture the current format */}
-
- >
- );
-}
+import { CodeEditor } from './code-editor';
+import { EditorLanguage, CodeEditorTabsProps } from './code-editor.types';
+import { isValidJson, isValidYaml, jsonToYaml, yamlToJson } from '@/utils/helpers/format.helper';
+import { Tabs, TabsContent, TabsList, TabsTrigger, toast } from '@datum-ui/components';
+import { useEffect, useState } from 'react';
+
+export function CodeEditorTabs({
+ value,
+ onChange,
+ format = 'yaml',
+ onFormatChange,
+ error,
+ id,
+ name = 'code-editor',
+ minHeight = '300px',
+}: CodeEditorTabsProps) {
+ const [activeTab, setActiveTab] = useState(format);
+
+ // Initialize JSON value - if the input is in JSON, use it directly, otherwise convert from YAML
+ const [jsonValue, setJsonValue] = useState(() => {
+ if (format === 'json') return value;
+
+ try {
+ return value ? yamlToJson(value) : '{}';
+ } catch {
+ toast.error('Initial YAML to JSON conversion failed');
+ return '{}';
+ }
+ });
+
+ // Initialize YAML value - if the input is in YAML, use it directly, otherwise convert from JSON
+ const [yamlValue, setYamlValue] = useState(() => {
+ if (format === 'yaml') return value;
+
+ try {
+ return value ? jsonToYaml(value) : '';
+ } catch {
+ toast.error('Initial JSON to YAML conversion failed');
+ return '';
+ }
+ });
+
+ // Sync with external value changes
+ useEffect(() => {
+ if (format === 'json' && value !== jsonValue) {
+ setJsonValue(value || '{}');
+ try {
+ setYamlValue(jsonToYaml(value || '{}'));
+ } catch (e) {
+ console.error('Failed to update YAML from external JSON value:', e);
+ }
+ } else if (format === 'yaml' && value !== yamlValue) {
+ setYamlValue(value || '');
+ try {
+ setJsonValue(yamlToJson(value || ''));
+ } catch (e) {
+ console.error('Failed to update JSON from external YAML value:', e);
+ }
+ }
+ }, [value, format]);
+
+ // Sync active tab with format
+ useEffect(() => {
+ if (format !== activeTab) {
+ setActiveTab(format);
+ }
+ }, [format]);
+
+ // Handle tab changes with format conversion
+ const handleTabChange = (newTab: EditorLanguage) => {
+ // If the tab hasn't changed, do nothing
+ if (newTab === activeTab) return;
+
+ try {
+ // Convert content based on which tab we're switching from
+ if (activeTab === 'json' && newTab === 'yaml') {
+ // JSON to YAML conversion
+ if (!isValidJson(jsonValue)) {
+ throw new Error('Cannot convert: Invalid JSON');
+ }
+
+ // Convert and update state
+ const newYaml = jsonToYaml(jsonValue);
+ setYamlValue(newYaml);
+ setActiveTab('yaml');
+
+ // Notify parent components
+ onChange?.(newYaml, 'yaml');
+ onFormatChange?.('yaml');
+ } else if (activeTab === 'yaml' && newTab === 'json') {
+ // YAML to JSON conversion
+ if (!isValidYaml(yamlValue)) {
+ throw new Error('Cannot convert: Invalid YAML');
+ }
+
+ // Convert and update state
+ const newJson = yamlToJson(yamlValue);
+ setJsonValue(newJson);
+ setActiveTab('json');
+
+ // Notify parent components
+ onChange?.(newJson, 'json');
+ onFormatChange?.('json');
+ }
+ } catch (error) {
+ // Show error message and stay on current tab
+ const errorMessage = error instanceof Error ? error.message : 'Conversion failed';
+ toast.error(errorMessage, {
+ id: 'conversion-error', // Use an ID to prevent duplicate toasts
+ });
+ }
+ };
+
+ // Handle value changes from editor
+ const handleJsonChange = (newJson: string) => {
+ setJsonValue(newJson);
+ try {
+ // Update YAML version too
+ if (isValidJson(newJson)) {
+ const newYaml = jsonToYaml(newJson);
+ setYamlValue(newYaml);
+ }
+ // Only update the external value if this is the active format
+ if (activeTab === 'json') {
+ onChange?.(newJson, 'json');
+ }
+ } catch {
+ toast.error('Failed to update YAML from JSON value', {
+ id: 'json-to-yaml-error',
+ });
+ // Invalid JSON, don't update YAML
+ }
+ };
+
+ const handleYamlChange = (newYaml: string) => {
+ setYamlValue(newYaml);
+ try {
+ // Update JSON version too
+ if (isValidYaml(newYaml)) {
+ const newJson = yamlToJson(newYaml);
+ setJsonValue(newJson);
+ }
+ // Only update the external value if this is the active format
+ if (activeTab === 'yaml') {
+ onChange?.(newYaml, 'yaml');
+ }
+ } catch {
+ toast.error('Failed to update JSON from YAML value', {
+ id: 'yaml-to-json-error',
+ });
+ // Invalid YAML, don't update JSON
+ }
+ };
+
+ return (
+ <>
+ handleTabChange(val as EditorLanguage)}
+ className="w-full">
+
+ YAML
+ JSON
+
+
+
+
+
+
+
+
+
+ {/* Hidden field to capture the current format */}
+
+ >
+ );
+}
diff --git a/app/components/code-editor/code-editor.tsx b/app/components/code-editor/code-editor.tsx
index 3c61f2704..483bd1a0d 100644
--- a/app/components/code-editor/code-editor.tsx
+++ b/app/components/code-editor/code-editor.tsx
@@ -1,151 +1,151 @@
-import { CodeEditorProps } from './code-editor.types';
-import { useTheme } from '@datum-cloud/datum-ui/theme';
-import Editor, { Monaco, OnMount } from '@monaco-editor/react';
-import { cn } from '@shadcn/lib/utils';
-import { useRef } from 'react';
-
-// Custom theme names
-const CUSTOM_THEME_LIGHT = 'datum-light';
-const CUSTOM_THEME_DARK = 'datum-dark';
-
-// Define custom themes that match the input styling
-const defineCustomThemes = (monaco: Monaco) => {
- // Light theme - matches bg-input-background/50
- monaco.editor.defineTheme(CUSTOM_THEME_LIGHT, {
- base: 'vs',
- inherit: true,
- rules: [],
- colors: {
- 'editor.background': '#f8f9fa80', // Semi-transparent light background
- 'editor.lineHighlightBackground': '#f1f3f5',
- 'editorLineNumber.foreground': '#adb5bd',
- },
- });
-
- // Dark theme - matches bg-input-background/50
- monaco.editor.defineTheme(CUSTOM_THEME_DARK, {
- base: 'vs-dark',
- inherit: true,
- rules: [],
- colors: {
- 'editor.background': '#1a1b1e80', // Semi-transparent dark background
- 'editor.lineHighlightBackground': '#25262b',
- 'editorLineNumber.foreground': '#5c5f66',
- },
- });
-};
-
-export const CodeEditor = ({
- value = '',
- onChange,
- language = 'yaml',
- id,
- name,
- error,
- className,
- readOnly = false,
- minHeight = '200px',
- placeholder,
-}: CodeEditorProps) => {
- const { resolvedTheme } = useTheme();
- const editorRef = useRef[0] | null>(null);
-
- // Determine if placeholder should be shown
- const showPlaceholder = placeholder && !value;
-
- // Handle editor mounting
- const handleEditorDidMount: OnMount = (editorInstance, monaco) => {
- editorRef.current = editorInstance;
-
- // Define and apply custom themes
- defineCustomThemes(monaco);
- monaco.editor.setTheme(resolvedTheme === 'dark' ? CUSTOM_THEME_DARK : CUSTOM_THEME_LIGHT);
-
- // Set up customizations
- editorInstance.updateOptions({
- tabSize: 2,
- minimap: { enabled: false }, // Disable minimap for cleaner UI
- scrollBeyondLastLine: false,
- folding: true,
- lineNumbers: 'on',
- renderLineHighlight: 'all',
- scrollbar: {
- vertical: 'auto',
- horizontal: 'auto',
- },
- readOnly,
- automaticLayout: true, // Important for responsive resizing
- });
-
- // Configure JSON schemas if needed
- if (language === 'json') {
- monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
- validate: true,
- allowComments: false,
- schemas: [],
- });
- }
-
- // Format the content on first load
- setTimeout(() => {
- editorInstance.getAction('editor.action.formatDocument')?.run();
- }, 300);
- };
-
- return (
- <>
-
- {/* Placeholder overlay */}
- {showPlaceholder && (
-
- {placeholder}
-
- )}
-
-
onChange?.(newValue || '')}
- onMount={handleEditorDidMount}
- height="100%"
- width="100%"
- className="monaco-editor-container"
- loading={Loading editor...
}
- />
-
-
- {/* Hidden field to capture the current value */}
- undefined}
- />
- >
- );
-};
+import { CodeEditorProps } from './code-editor.types';
+import { useTheme } from '@datum-cloud/datum-ui/theme';
+import Editor, { Monaco, OnMount } from '@monaco-editor/react';
+import { cn } from '@shadcn/lib/utils';
+import { useRef } from 'react';
+
+// Custom theme names
+const CUSTOM_THEME_LIGHT = 'datum-light';
+const CUSTOM_THEME_DARK = 'datum-dark';
+
+// Define custom themes that match the input styling
+const defineCustomThemes = (monaco: Monaco) => {
+ // Light theme - matches bg-input-background/50
+ monaco.editor.defineTheme(CUSTOM_THEME_LIGHT, {
+ base: 'vs',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editor.background': '#f8f9fa80', // Semi-transparent light background
+ 'editor.lineHighlightBackground': '#f1f3f5',
+ 'editorLineNumber.foreground': '#adb5bd',
+ },
+ });
+
+ // Dark theme - matches bg-input-background/50
+ monaco.editor.defineTheme(CUSTOM_THEME_DARK, {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editor.background': '#1a1b1e80', // Semi-transparent dark background
+ 'editor.lineHighlightBackground': '#25262b',
+ 'editorLineNumber.foreground': '#5c5f66',
+ },
+ });
+};
+
+export const CodeEditor = ({
+ value = '',
+ onChange,
+ language = 'yaml',
+ id,
+ name,
+ error,
+ className,
+ readOnly = false,
+ minHeight = '200px',
+ placeholder,
+}: CodeEditorProps) => {
+ const { resolvedTheme } = useTheme();
+ const editorRef = useRef[0] | null>(null);
+
+ // Determine if placeholder should be shown
+ const showPlaceholder = placeholder && !value;
+
+ // Handle editor mounting
+ const handleEditorDidMount: OnMount = (editorInstance, monaco) => {
+ editorRef.current = editorInstance;
+
+ // Define and apply custom themes
+ defineCustomThemes(monaco);
+ monaco.editor.setTheme(resolvedTheme === 'dark' ? CUSTOM_THEME_DARK : CUSTOM_THEME_LIGHT);
+
+ // Set up customizations
+ editorInstance.updateOptions({
+ tabSize: 2,
+ minimap: { enabled: false }, // Disable minimap for cleaner UI
+ scrollBeyondLastLine: false,
+ folding: true,
+ lineNumbers: 'on',
+ renderLineHighlight: 'all',
+ scrollbar: {
+ vertical: 'auto',
+ horizontal: 'auto',
+ },
+ readOnly,
+ automaticLayout: true, // Important for responsive resizing
+ });
+
+ // Configure JSON schemas if needed
+ if (language === 'json') {
+ monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
+ validate: true,
+ allowComments: false,
+ schemas: [],
+ });
+ }
+
+ // Format the content on first load
+ setTimeout(() => {
+ editorInstance.getAction('editor.action.formatDocument')?.run();
+ }, 300);
+ };
+
+ return (
+ <>
+
+ {/* Placeholder overlay */}
+ {showPlaceholder && (
+
+ {placeholder}
+
+ )}
+
+
onChange?.(newValue || '')}
+ onMount={handleEditorDidMount}
+ height="100%"
+ width="100%"
+ className="monaco-editor-container"
+ loading={Loading editor...
}
+ />
+
+
+ {/* Hidden field to capture the current value */}
+ undefined}
+ />
+ >
+ );
+};
diff --git a/app/components/code-editor/code-editor.types.ts b/app/components/code-editor/code-editor.types.ts
index 9b188e280..d9db17849 100644
--- a/app/components/code-editor/code-editor.types.ts
+++ b/app/components/code-editor/code-editor.types.ts
@@ -1,81 +1,81 @@
-import { isValidJson, isValidYaml } from '@/utils/helpers/format.helper';
-import { z } from 'zod';
-
-export type EditorLanguage = string;
-
-export interface CodeEditorProps {
- value: string;
- onChange?: (value: string) => void;
- language: EditorLanguage;
- id?: string;
- name?: string;
- error?: string;
- className?: string;
- readOnly?: boolean;
- minHeight?: string;
- /** Placeholder text shown when editor is empty */
- placeholder?: string;
-}
-
-export interface CodeEditorTabsProps {
- /** The current value in the editor's preferred format */
- value: string;
- /** Called when the value changes */
- onChange?: (value: string, format: EditorLanguage) => void;
- /** The format to use (json or yaml) */
- format?: EditorLanguage;
- /** Called when the format changes */
- onFormatChange?: (format: EditorLanguage) => void;
- /** Error message to display */
- error?: string;
- /** ID of the field (for form integration) */
- id?: string;
- /** Name of the field (for form integration) */
- name?: string;
- /** Minimum height of the editor */
- minHeight?: string;
-}
-
-export const jsonSchema = z.object({
- jsonContent: z
- .string({ error: 'JSON content is required' })
- .refine(isValidJson, { message: 'Invalid JSON format' }),
-});
-
-export const yamlSchema = z.object({
- yamlContent: z
- .string({ error: 'YAML content is required' })
- .refine(isValidYaml, { message: 'Invalid YAML format' }),
-});
-
-/**
- * Common schema for validating a field that could be either JSON or YAML
- * based on format specification
- */
-export function createCodeEditorSchema(name: string = 'Content') {
- return z
- .object({
- // The actual content
- content: z.string({ error: `${name} is required` }).min(1, `${name} is required`),
- // The format identifier (json or yaml)
- format: z.enum(['json', 'yaml']),
- })
- .refine(
- (data) => {
- // Validate based on the specified format
- if (data.format === 'json') {
- return isValidJson(data?.content as string);
- } else {
- return isValidYaml(data?.content as string);
- }
- },
- {
- // Error message and path for the refinement rule
- message: `Invalid ${name} format`,
- path: ['content'],
- }
- );
-}
-
-export type JsonFormValues = z.infer;
-export type YamlFormValues = z.infer;
+import { isValidJson, isValidYaml } from '@/utils/helpers/format.helper';
+import { z } from 'zod';
+
+export type EditorLanguage = string;
+
+export interface CodeEditorProps {
+ value: string;
+ onChange?: (value: string) => void;
+ language: EditorLanguage;
+ id?: string;
+ name?: string;
+ error?: string;
+ className?: string;
+ readOnly?: boolean;
+ minHeight?: string;
+ /** Placeholder text shown when editor is empty */
+ placeholder?: string;
+}
+
+export interface CodeEditorTabsProps {
+ /** The current value in the editor's preferred format */
+ value: string;
+ /** Called when the value changes */
+ onChange?: (value: string, format: EditorLanguage) => void;
+ /** The format to use (json or yaml) */
+ format?: EditorLanguage;
+ /** Called when the format changes */
+ onFormatChange?: (format: EditorLanguage) => void;
+ /** Error message to display */
+ error?: string;
+ /** ID of the field (for form integration) */
+ id?: string;
+ /** Name of the field (for form integration) */
+ name?: string;
+ /** Minimum height of the editor */
+ minHeight?: string;
+}
+
+export const jsonSchema = z.object({
+ jsonContent: z
+ .string({ error: 'JSON content is required' })
+ .refine(isValidJson, { message: 'Invalid JSON format' }),
+});
+
+export const yamlSchema = z.object({
+ yamlContent: z
+ .string({ error: 'YAML content is required' })
+ .refine(isValidYaml, { message: 'Invalid YAML format' }),
+});
+
+/**
+ * Common schema for validating a field that could be either JSON or YAML
+ * based on format specification
+ */
+export function createCodeEditorSchema(name: string = 'Content') {
+ return z
+ .object({
+ // The actual content
+ content: z.string({ error: `${name} is required` }).min(1, `${name} is required`),
+ // The format identifier (json or yaml)
+ format: z.enum(['json', 'yaml']),
+ })
+ .refine(
+ (data) => {
+ // Validate based on the specified format
+ if (data.format === 'json') {
+ return isValidJson(data?.content as string);
+ } else {
+ return isValidYaml(data?.content as string);
+ }
+ },
+ {
+ // Error message and path for the refinement rule
+ message: `Invalid ${name} format`,
+ path: ['content'],
+ }
+ );
+}
+
+export type JsonFormValues = z.infer;
+export type YamlFormValues = z.infer;
diff --git a/app/components/coming-soon/coming-soon-feature-card.tsx b/app/components/coming-soon/coming-soon-feature-card.tsx
index e34c79639..3f2add0e3 100644
--- a/app/components/coming-soon/coming-soon-feature-card.tsx
+++ b/app/components/coming-soon/coming-soon-feature-card.tsx
@@ -1,33 +1,33 @@
-import { Button, Card, CardContent } from '@datum-ui/components';
-
-export const ComingSoonFeatureCard = ({
- title,
- description,
-}: {
- title: string;
- description: string;
-}) => {
- return (
-
-
-
- {title}
-
- {description}
-
-
-
-
- Coming Soon
-
-
-
-
- );
-};
+import { Button, Card, CardContent } from '@datum-ui/components';
+
+export const ComingSoonFeatureCard = ({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}) => {
+ return (
+
+
+
+ {title}
+
+ {description}
+
+
+
+
+ Coming Soon
+
+
+
+
+ );
+};
diff --git a/app/components/confirmation-dialog/confirmation-dialog.tsx b/app/components/confirmation-dialog/confirmation-dialog.tsx
index cad35475d..011321725 100644
--- a/app/components/confirmation-dialog/confirmation-dialog.tsx
+++ b/app/components/confirmation-dialog/confirmation-dialog.tsx
@@ -1,184 +1,184 @@
-import { Alert, AlertDescription, AlertTitle } from '@datum-ui/components';
-import { Button } from '@datum-ui/components';
-import { Input, Label } from '@datum-ui/components';
-import { Dialog } from '@datum-ui/components/dialog';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { AlertCircle } from 'lucide-react';
-import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
-
-export interface ConfirmationDialogProps {
- title?: string;
- description?: string | React.ReactNode;
- submitText?: string;
- cancelText?: string;
- variant?: 'default' | 'destructive';
- onSubmit?: () => Promise;
-
- // Alert
- showAlert?: boolean;
- alertVariant?: 'default' | 'destructive';
- alertTitle?: string;
- alertDescription?: string | React.ReactNode;
- alertIcon?: React.ReactNode;
- alertClassName?: string;
-
- // Confirmation
- showConfirmInput?: boolean;
- confirmInputLabel?: string;
- confirmInputPlaceholder?: string;
- confirmValue?: string;
-}
-
-export interface ConfirmationDialogRef {
- show: (options: ConfirmationDialogProps) => Promise;
- close: () => void;
-}
-
-export const ConfirmationDialog = ({
- ref,
-}: ConfirmationDialogProps & {
- ref: React.RefObject;
-}) => {
- const [isOpen, setIsOpen] = useState(false);
- const [isPending, setIsPending] = useState(false);
-
- const [confirmValidationValue, setConfirmValidationValue] = useState('');
-
- const defaultDialogProps: ConfirmationDialogProps = {
- title: '',
- description: '',
- submitText: 'Confirm',
- cancelText: 'Cancel',
- variant: 'destructive',
-
- // Alert
- showAlert: false,
- alertIcon: ,
- alertTitle: '',
- alertDescription: '',
- alertVariant: 'default',
-
- // Confirmation
- showConfirmInput: false,
- confirmInputLabel: 'Type "DELETE" to confirm.',
- confirmInputPlaceholder: 'Type in here...',
- confirmValue: 'DELETE',
- };
-
- const [dialogProps, setDialogProps] = useState(defaultDialogProps);
-
- const resolveRef = useRef<(value: boolean) => void>(null);
-
- useImperativeHandle(ref, () => ({
- show: (options) => {
- setDialogProps({ ...defaultDialogProps, ...options });
- setIsOpen(true);
- return new Promise((resolve) => {
- resolveRef.current = resolve;
- });
- },
- close: () => {
- setIsOpen(false);
- },
- }));
-
- const handleOpenChange = (open: boolean) => {
- if (!open) {
- resolveRef.current?.(false);
- }
- setIsOpen(open);
- };
-
- const handleConfirm = async () => {
- if (isDisabled) return;
-
- setIsPending(true);
- try {
- if (dialogProps.onSubmit) {
- await dialogProps.onSubmit();
- }
- resolveRef.current?.(true);
- } finally {
- setIsPending(false);
- setIsOpen(false);
- }
- };
-
- const handleCancel = () => {
- resolveRef.current?.(false);
- setIsOpen(false);
- };
-
- const isDisabled = useMemo(() => {
- if (dialogProps.showConfirmInput) {
- return confirmValidationValue !== (dialogProps.confirmValue ?? 'DELETE');
- }
-
- return isPending;
- }, [dialogProps, confirmValidationValue, isPending]);
-
- useEffect(() => {
- if (isOpen) {
- setConfirmValidationValue('');
- }
- }, [isOpen]);
-
- return (
-
-
-
- {dialogProps.showAlert || dialogProps.showConfirmInput ? (
-
- {dialogProps.showAlert && (
-
- {dialogProps.alertIcon}
- {dialogProps.alertTitle}
- {dialogProps.alertDescription}
-
- )}
- {dialogProps.showConfirmInput && (
-
- {dialogProps.confirmInputLabel}
- setConfirmValidationValue(e.target.value)}
- />
-
- )}
-
- ) : (
- <>>
- )}
-
-
-
- {dialogProps.cancelText}
-
-
- {dialogProps.submitText}
-
-
-
-
- );
-};
-
-ConfirmationDialog.displayName = 'ConfirmationDialog';
+import { Alert, AlertDescription, AlertTitle } from '@datum-ui/components';
+import { Button } from '@datum-ui/components';
+import { Input, Label } from '@datum-ui/components';
+import { Dialog } from '@datum-ui/components/dialog';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { AlertCircle } from 'lucide-react';
+import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
+
+export interface ConfirmationDialogProps {
+ title?: string;
+ description?: string | React.ReactNode;
+ submitText?: string;
+ cancelText?: string;
+ variant?: 'default' | 'destructive';
+ onSubmit?: () => Promise;
+
+ // Alert
+ showAlert?: boolean;
+ alertVariant?: 'default' | 'destructive';
+ alertTitle?: string;
+ alertDescription?: string | React.ReactNode;
+ alertIcon?: React.ReactNode;
+ alertClassName?: string;
+
+ // Confirmation
+ showConfirmInput?: boolean;
+ confirmInputLabel?: string;
+ confirmInputPlaceholder?: string;
+ confirmValue?: string;
+}
+
+export interface ConfirmationDialogRef {
+ show: (options: ConfirmationDialogProps) => Promise;
+ close: () => void;
+}
+
+export const ConfirmationDialog = ({
+ ref,
+}: ConfirmationDialogProps & {
+ ref: React.RefObject;
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isPending, setIsPending] = useState(false);
+
+ const [confirmValidationValue, setConfirmValidationValue] = useState('');
+
+ const defaultDialogProps: ConfirmationDialogProps = {
+ title: '',
+ description: '',
+ submitText: 'Confirm',
+ cancelText: 'Cancel',
+ variant: 'destructive',
+
+ // Alert
+ showAlert: false,
+ alertIcon: ,
+ alertTitle: '',
+ alertDescription: '',
+ alertVariant: 'default',
+
+ // Confirmation
+ showConfirmInput: false,
+ confirmInputLabel: 'Type "DELETE" to confirm.',
+ confirmInputPlaceholder: 'Type in here...',
+ confirmValue: 'DELETE',
+ };
+
+ const [dialogProps, setDialogProps] = useState(defaultDialogProps);
+
+ const resolveRef = useRef<(value: boolean) => void>(null);
+
+ useImperativeHandle(ref, () => ({
+ show: (options) => {
+ setDialogProps({ ...defaultDialogProps, ...options });
+ setIsOpen(true);
+ return new Promise((resolve) => {
+ resolveRef.current = resolve;
+ });
+ },
+ close: () => {
+ setIsOpen(false);
+ },
+ }));
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) {
+ resolveRef.current?.(false);
+ }
+ setIsOpen(open);
+ };
+
+ const handleConfirm = async () => {
+ if (isDisabled) return;
+
+ setIsPending(true);
+ try {
+ if (dialogProps.onSubmit) {
+ await dialogProps.onSubmit();
+ }
+ resolveRef.current?.(true);
+ } finally {
+ setIsPending(false);
+ setIsOpen(false);
+ }
+ };
+
+ const handleCancel = () => {
+ resolveRef.current?.(false);
+ setIsOpen(false);
+ };
+
+ const isDisabled = useMemo(() => {
+ if (dialogProps.showConfirmInput) {
+ return confirmValidationValue !== (dialogProps.confirmValue ?? 'DELETE');
+ }
+
+ return isPending;
+ }, [dialogProps, confirmValidationValue, isPending]);
+
+ useEffect(() => {
+ if (isOpen) {
+ setConfirmValidationValue('');
+ }
+ }, [isOpen]);
+
+ return (
+
+
+
+ {dialogProps.showAlert || dialogProps.showConfirmInput ? (
+
+ {dialogProps.showAlert && (
+
+ {dialogProps.alertIcon}
+ {dialogProps.alertTitle}
+ {dialogProps.alertDescription}
+
+ )}
+ {dialogProps.showConfirmInput && (
+
+ {dialogProps.confirmInputLabel}
+ setConfirmValidationValue(e.target.value)}
+ />
+
+ )}
+
+ ) : (
+ <>>
+ )}
+
+
+
+ {dialogProps.cancelText}
+
+
+ {dialogProps.submitText}
+
+
+
+
+ );
+};
+
+ConfirmationDialog.displayName = 'ConfirmationDialog';
diff --git a/app/components/content-wrapper/content-wrapper.tsx b/app/components/content-wrapper/content-wrapper.tsx
index e98cbee9e..f4921d076 100644
--- a/app/components/content-wrapper/content-wrapper.tsx
+++ b/app/components/content-wrapper/content-wrapper.tsx
@@ -1,22 +1,22 @@
-import { ContentWrapperProps } from './content-wrapper.types';
-import { Breadcrumb } from '@/components/header';
-import { cn } from '@shadcn/lib/utils';
-
-export function ContentWrapper({
- children,
- containerClassName,
- contentClassName,
-}: ContentWrapperProps) {
- return (
-
- );
-}
+import { ContentWrapperProps } from './content-wrapper.types';
+import { Breadcrumb } from '@/components/header';
+import { cn } from '@shadcn/lib/utils';
+
+export function ContentWrapper({
+ children,
+ containerClassName,
+ contentClassName,
+}: ContentWrapperProps) {
+ return (
+
+ );
+}
diff --git a/app/components/danger-card/danger-card.tsx b/app/components/danger-card/danger-card.tsx
index 7af13da7c..0a4596c32 100644
--- a/app/components/danger-card/danger-card.tsx
+++ b/app/components/danger-card/danger-card.tsx
@@ -1,54 +1,54 @@
-import { Button, Card, CardContent } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { TriangleAlertIcon } from 'lucide-react';
-
-export const DangerCard = ({
- title = 'Warning: This Action is Irreversible',
- description = 'This action cannot be undone. Once deleted, the resource and all associated data will be permanently removed.',
- deleteText = 'Delete',
- loading = false,
- disabled = false,
- onDelete,
- 'data-e2e': dataE2e,
-}: {
- title?: string;
- description?: string | React.ReactNode;
- deleteText?: string;
- loading?: boolean;
- disabled?: boolean;
- onDelete: () => void;
- 'data-e2e'?: string;
-}) => {
- const isDisabled = loading || disabled;
- return (
-
-
-
-
-
- {title}
- {description}
-
-
-
-
- {loading ? 'Deleting...' : deleteText}
-
-
-
-
- );
-};
+import { Button, Card, CardContent } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { TriangleAlertIcon } from 'lucide-react';
+
+export const DangerCard = ({
+ title = 'Warning: This Action is Irreversible',
+ description = 'This action cannot be undone. Once deleted, the resource and all associated data will be permanently removed.',
+ deleteText = 'Delete',
+ loading = false,
+ disabled = false,
+ onDelete,
+ 'data-e2e': dataE2e,
+}: {
+ title?: string;
+ description?: string | React.ReactNode;
+ deleteText?: string;
+ loading?: boolean;
+ disabled?: boolean;
+ onDelete: () => void;
+ 'data-e2e'?: string;
+}) => {
+ const isDisabled = loading || disabled;
+ return (
+
+
+
+
+
+ {title}
+ {description}
+
+
+
+
+ {loading ? 'Deleting...' : deleteText}
+
+
+
+
+ );
+};
diff --git a/app/components/date-time/date-time.tsx b/app/components/date-time/date-time.tsx
index c6cb1f9f9..55cef4c79 100644
--- a/app/components/date-time/date-time.tsx
+++ b/app/components/date-time/date-time.tsx
@@ -1,218 +1,218 @@
-import {
- formatAbsoluteDate,
- formatCombinedDate,
- formatRelativeDate,
- formatTimezoneDate,
- formatUTCDate,
- getTimestamp,
- getTimezoneAbbreviation,
- parseDate,
-} from './formatters';
-import type { DateTimeProps, FormatterOptions } from './types';
-import { useApp } from '@/providers/app.provider';
-import { getBrowserTimezone } from '@/utils/helpers/timezone.helper';
-import { Tooltip } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-import { useEffect, useState } from 'react';
-
-/**
- * Unified component for displaying dates in absolute, relative, or combined formats
- * with intelligent tooltip support and timezone awareness.
- *
- * @example
- * // Absolute date
- *
- *
- * @example
- * // Relative time
- *
- *
- * @example
- * // Combined format
- *
- */
-export const DateTime = ({
- date,
- variant = 'detailed',
- format,
- addSuffix,
- tooltip = 'auto',
- timezone,
- disableTimezone = false,
- className,
- separator = ' ',
- disableHydrationProtection = false,
- showTooltip = true, // Legacy prop from DateFormat
-}: DateTimeProps) => {
- const { userPreferences } = useApp();
- const [mounted, setMounted] = useState(false);
-
- // Hydration protection for all date variants (client-side only)
- // All variants can have mismatches due to timezone differences between server/client
- const needsHydrationProtection = true;
-
- useEffect(() => {
- if (needsHydrationProtection && !disableHydrationProtection) {
- setMounted(true);
- }
- }, [needsHydrationProtection, disableHydrationProtection]);
-
- // Parse and validate date
- const parsedDate = parseDate(date);
-
- if (!parsedDate) {
- return null;
- }
-
- // Show loading state during hydration
- if (needsHydrationProtection && !disableHydrationProtection && !mounted) {
- return ... ;
- }
-
- // Prepare formatter options
- const timeZone = timezone ?? userPreferences?.timezone ?? getBrowserTimezone();
- const formatterOptions: FormatterOptions = {
- timezone: timeZone,
- disableTimezone,
- format,
- addSuffix,
- };
-
- // Format content based on variant
- let content: string;
- switch (variant) {
- case 'detailed':
- content = formatTimezoneDate(parsedDate, timeZone, format);
- break;
- case 'relative':
- content = formatRelativeDate(parsedDate, formatterOptions);
- break;
- case 'both':
- content = formatCombinedDate(parsedDate, formatterOptions, separator);
- break;
- case 'absolute':
- default:
- content = formatAbsoluteDate(parsedDate, formatterOptions);
- break;
- }
-
- // Determine tooltip behavior
- const shouldShowTooltip = determineTooltipVisibility(tooltip, showTooltip);
-
- if (!shouldShowTooltip || disableTimezone) {
- return {content} ;
- }
-
- // Determine tooltip content
- const tooltipContent = getTooltipContent(
- parsedDate,
- variant,
- tooltip,
- formatterOptions,
- timeZone
- );
-
- return (
-
- {content}
-
- );
-};
-
-/**
- * Determines if tooltip should be shown
- */
-function determineTooltipVisibility(
- tooltip: DateTimeProps['tooltip'],
- showTooltip: boolean
-): boolean {
- if (typeof tooltip === 'boolean') {
- return tooltip;
- }
-
- // Legacy support for showTooltip prop
- if (tooltip === 'auto' && !showTooltip) {
- return false;
- }
-
- // Auto mode shows tooltip by default
- return true;
-}
-
-/**
- * Gets the appropriate tooltip content based on variant and mode
- */
-function getTooltipContent(
- date: Date,
- variant: DateTimeProps['variant'],
- tooltip: DateTimeProps['tooltip'],
- options: FormatterOptions,
- timeZone: string
-): React.ReactNode {
- // Detailed variant - show all time formats
- if (variant === 'detailed') {
- const utcTime = formatUTCDate(date);
- const timezoneTime = formatTimezoneDate(date, timeZone);
- const relativeTime = formatRelativeDate(date, options);
- const timestamp = getTimestamp(date);
-
- const rows = [
- { label: 'UTC', value: utcTime },
- { label: timeZone.replace('_', ' '), value: timezoneTime },
- { label: 'Relative', value: relativeTime },
- { label: 'Timestamp', value: timestamp },
- ];
-
- return (
-
- {rows.map((row) => (
-
- {row.label}
-
- {row.value}
-
- ))}
-
- );
- }
-
- // Explicit timezone mode
- if (tooltip === 'timezone') {
- return (
-
- {timeZone.replace('_', ' ')} ({getTimezoneAbbreviation(date, timeZone)})
-
- );
- }
-
- // Alternate mode - show opposite format
- if (tooltip === 'alternate') {
- if (variant === 'relative') {
- return formatAbsoluteDate(date, options);
- }
- if (variant === 'absolute' || variant === 'both') {
- return formatRelativeDate(date, options);
- }
- }
-
- // Auto mode - intelligent defaults
- if (tooltip === 'auto' || tooltip === true) {
- switch (variant) {
- case 'relative':
- // Show absolute date for relative time
- return formatAbsoluteDate(date, options);
-
- case 'both':
- case 'absolute':
- default:
- // Show timezone info for absolute dates
- return (
-
- {timeZone.replace('_', ' ')} ({getTimezoneAbbreviation(date, timeZone)})
-
- );
- }
- }
-
- return null;
-}
+import {
+ formatAbsoluteDate,
+ formatCombinedDate,
+ formatRelativeDate,
+ formatTimezoneDate,
+ formatUTCDate,
+ getTimestamp,
+ getTimezoneAbbreviation,
+ parseDate,
+} from './formatters';
+import type { DateTimeProps, FormatterOptions } from './types';
+import { useApp } from '@/providers/app.provider';
+import { getBrowserTimezone } from '@/utils/helpers/timezone.helper';
+import { Tooltip } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+import { useEffect, useState } from 'react';
+
+/**
+ * Unified component for displaying dates in absolute, relative, or combined formats
+ * with intelligent tooltip support and timezone awareness.
+ *
+ * @example
+ * // Absolute date
+ *
+ *
+ * @example
+ * // Relative time
+ *
+ *
+ * @example
+ * // Combined format
+ *
+ */
+export const DateTime = ({
+ date,
+ variant = 'detailed',
+ format,
+ addSuffix,
+ tooltip = 'auto',
+ timezone,
+ disableTimezone = false,
+ className,
+ separator = ' ',
+ disableHydrationProtection = false,
+ showTooltip = true, // Legacy prop from DateFormat
+}: DateTimeProps) => {
+ const { userPreferences } = useApp();
+ const [mounted, setMounted] = useState(false);
+
+ // Hydration protection for all date variants (client-side only)
+ // All variants can have mismatches due to timezone differences between server/client
+ const needsHydrationProtection = true;
+
+ useEffect(() => {
+ if (needsHydrationProtection && !disableHydrationProtection) {
+ setMounted(true);
+ }
+ }, [needsHydrationProtection, disableHydrationProtection]);
+
+ // Parse and validate date
+ const parsedDate = parseDate(date);
+
+ if (!parsedDate) {
+ return null;
+ }
+
+ // Show loading state during hydration
+ if (needsHydrationProtection && !disableHydrationProtection && !mounted) {
+ return ... ;
+ }
+
+ // Prepare formatter options
+ const timeZone = timezone ?? userPreferences?.timezone ?? getBrowserTimezone();
+ const formatterOptions: FormatterOptions = {
+ timezone: timeZone,
+ disableTimezone,
+ format,
+ addSuffix,
+ };
+
+ // Format content based on variant
+ let content: string;
+ switch (variant) {
+ case 'detailed':
+ content = formatTimezoneDate(parsedDate, timeZone, format);
+ break;
+ case 'relative':
+ content = formatRelativeDate(parsedDate, formatterOptions);
+ break;
+ case 'both':
+ content = formatCombinedDate(parsedDate, formatterOptions, separator);
+ break;
+ case 'absolute':
+ default:
+ content = formatAbsoluteDate(parsedDate, formatterOptions);
+ break;
+ }
+
+ // Determine tooltip behavior
+ const shouldShowTooltip = determineTooltipVisibility(tooltip, showTooltip);
+
+ if (!shouldShowTooltip || disableTimezone) {
+ return {content} ;
+ }
+
+ // Determine tooltip content
+ const tooltipContent = getTooltipContent(
+ parsedDate,
+ variant,
+ tooltip,
+ formatterOptions,
+ timeZone
+ );
+
+ return (
+
+ {content}
+
+ );
+};
+
+/**
+ * Determines if tooltip should be shown
+ */
+function determineTooltipVisibility(
+ tooltip: DateTimeProps['tooltip'],
+ showTooltip: boolean
+): boolean {
+ if (typeof tooltip === 'boolean') {
+ return tooltip;
+ }
+
+ // Legacy support for showTooltip prop
+ if (tooltip === 'auto' && !showTooltip) {
+ return false;
+ }
+
+ // Auto mode shows tooltip by default
+ return true;
+}
+
+/**
+ * Gets the appropriate tooltip content based on variant and mode
+ */
+function getTooltipContent(
+ date: Date,
+ variant: DateTimeProps['variant'],
+ tooltip: DateTimeProps['tooltip'],
+ options: FormatterOptions,
+ timeZone: string
+): React.ReactNode {
+ // Detailed variant - show all time formats
+ if (variant === 'detailed') {
+ const utcTime = formatUTCDate(date);
+ const timezoneTime = formatTimezoneDate(date, timeZone);
+ const relativeTime = formatRelativeDate(date, options);
+ const timestamp = getTimestamp(date);
+
+ const rows = [
+ { label: 'UTC', value: utcTime },
+ { label: timeZone.replace('_', ' '), value: timezoneTime },
+ { label: 'Relative', value: relativeTime },
+ { label: 'Timestamp', value: timestamp },
+ ];
+
+ return (
+
+ {rows.map((row) => (
+
+ {row.label}
+
+ {row.value}
+
+ ))}
+
+ );
+ }
+
+ // Explicit timezone mode
+ if (tooltip === 'timezone') {
+ return (
+
+ {timeZone.replace('_', ' ')} ({getTimezoneAbbreviation(date, timeZone)})
+
+ );
+ }
+
+ // Alternate mode - show opposite format
+ if (tooltip === 'alternate') {
+ if (variant === 'relative') {
+ return formatAbsoluteDate(date, options);
+ }
+ if (variant === 'absolute' || variant === 'both') {
+ return formatRelativeDate(date, options);
+ }
+ }
+
+ // Auto mode - intelligent defaults
+ if (tooltip === 'auto' || tooltip === true) {
+ switch (variant) {
+ case 'relative':
+ // Show absolute date for relative time
+ return formatAbsoluteDate(date, options);
+
+ case 'both':
+ case 'absolute':
+ default:
+ // Show timezone info for absolute dates
+ return (
+
+ {timeZone.replace('_', ' ')} ({getTimezoneAbbreviation(date, timeZone)})
+
+ );
+ }
+ }
+
+ return null;
+}
diff --git a/app/components/demo/alert.tsx b/app/components/demo/alert.tsx
index 6000e9c7e..dcbc615f3 100644
--- a/app/components/demo/alert.tsx
+++ b/app/components/demo/alert.tsx
@@ -1,517 +1,517 @@
-import { Alert, AlertDescription, AlertTitle } from '@datum-ui/components';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
-import {
- AlertCircle,
- CheckCircle,
- Info,
- TriangleAlert,
- XCircle,
- Bell,
- ShieldAlert,
-} from 'lucide-react';
-import { useState } from 'react';
-
-export const alertDemoSections = [
- { id: 'alert-variants', label: 'Alert Variants' },
- { id: 'alert-with-icons', label: 'Alerts with Icons' },
- { id: 'alert-status', label: 'Status Alerts' },
- { id: 'alert-rich-content', label: 'Rich Content' },
- { id: 'alert-simple', label: 'Simple Alerts' },
- { id: 'alert-closable', label: 'Closable Alerts' },
- { id: 'alert-use-cases', label: 'Use Cases' },
-];
-
-export default function AlertDemo() {
- const [closableAlerts, setClosableAlerts] = useState([
- {
- id: 1,
- variant: 'info' as const,
- title: 'Information',
- message: 'This alert can be dismissed.',
- },
- {
- id: 2,
- variant: 'success' as const,
- title: 'Success',
- message: 'Operation completed successfully.',
- },
- {
- id: 3,
- variant: 'warning' as const,
- title: 'Warning',
- message: 'Please review your settings.',
- },
- ]);
-
- const removeClosableAlert = (id: number) => {
- setClosableAlerts((prev) => prev.filter((alert) => alert.id !== id));
- };
-
- return (
-
- {/* Alert Variants */}
-
-
- Alert Variants
-
- All available alert variants with their default styling and colors
-
-
-
-
-
- Default Alert
-
- This is a default alert with neutral styling. Use for general information.
-
-
-
-
- Secondary Alert
-
- This is a secondary alert with muted styling. Use for less prominent messages.
-
-
-
-
- Outline Alert
-
- This is an outline alert with a border and transparent background. Use for subtle
- notifications.
-
-
-
-
- Destructive Alert
-
- This is a destructive alert for errors and critical issues. Use for error messages
- and warnings.
-
-
-
-
- Success Alert
-
- This is a success alert for positive feedback. Use when operations complete
- successfully.
-
-
-
-
- Info Alert
-
- This is an info alert for informational messages. Use to provide helpful context or
- guidance.
-
-
-
-
- Warning Alert
-
- This is a warning alert for cautionary messages. Use when action is required or
- attention is needed.
-
-
-
-
-
-
- {/* Alerts with Icons */}
-
-
- Alerts with Icons
-
- Icons are automatically positioned when placed as the first child of the Alert component
-
-
-
-
-
-
- Error Occurred
-
- An error occurred while processing your request. Please try again or contact
- support.
-
-
-
-
-
- Operation Successful
-
- Your changes have been saved successfully. All data has been updated.
-
-
-
-
-
- Information
-
- Here's some helpful information that might be useful for you to know.
-
-
-
-
-
- Warning
-
- Please review this information before proceeding. This action cannot be undone.
-
-
-
-
-
- Notification
-
- You have a new notification. Check your inbox for more details.
-
-
-
-
-
- Security Notice
-
- Your account security settings have been updated. Review your security preferences.
-
-
-
-
-
- Icons are automatically positioned in the top-left corner and content is padded
- accordingly. Icon colors inherit from the alert variant.
-
-
-
-
-
- {/* Status Alerts */}
-
-
- Status Alerts
- Common status alert patterns for different scenarios
-
-
-
-
-
- Connection Established
-
- Your connection has been established successfully. You can now proceed with your
- operations.
-
-
-
-
-
- Connection Failed
-
- Unable to establish connection. Please check your network settings and try again.
-
-
-
-
-
- Action Required
-
- Your account requires verification. Please complete the verification process to
- continue.
-
-
-
-
-
- Processing
-
- Your request is being processed. This may take a few moments. Please wait.
-
-
-
-
-
-
- {/* Rich Content */}
-
-
- Rich Content Alerts
-
- Alerts can contain rich content including lists, formatted text, and custom components
-
-
-
-
-
-
- Domain Validation Errors
-
-
-
The following issues must be resolved before your domain can be verified:
-
- DNS records are not configured correctly
- HTTP verification token is missing
- Domain ownership could not be verified
-
-
-
-
-
-
-
- Before You Continue
-
-
-
Please review the following before proceeding:
-
- Verify all required fields are completed
- Check that your configuration is correct
- Ensure you have the necessary permissions
-
-
-
-
-
-
-
- System Update
-
-
-
- A new system update is available. The update includes the following
- improvements:
-
-
- Enhanced security features
- Performance optimizations
- New user interface improvements
-
-
- Note: The update will be applied automatically during the next
- maintenance window.
-
-
-
-
-
-
-
-
- {/* Simple Alerts */}
-
-
- Simple Alerts
-
- Alerts can be used without titles or with minimal content for simple notifications
-
-
-
-
-
- This is a simple alert without a title.
-
-
-
- Operation completed successfully.
-
-
-
- Please review your settings before continuing.
-
-
-
- An error occurred. Please try again.
-
-
-
-
- Your changes have been saved.
-
-
-
-
-
- New features are available. Check the documentation for more information.
-
-
-
-
-
-
- {/* Closable Alerts */}
-
-
- Closable Alerts
-
- Alerts can be made dismissible by setting the closable prop. Click the X button to close
- them.
-
-
-
-
-
alert('Close button clicked!')}>
-
- Dismissible Info Alert
-
- This alert has a close button. Click the X in the top-right corner to dismiss it. In
- a real application, you would handle the onClose callback to remove the alert from
- state.
-
-
-
-
alert('Close button clicked!')}>
-
- Dismissible Success Alert
-
- Success alerts can also be closable. The close button inherits the alert's
- color scheme.
-
-
-
-
alert('Close button clicked!')}>
-
- Dismissible Warning Alert
-
- Warning alerts with close buttons are useful for temporary notifications that users
- can dismiss.
-
-
-
-
alert('Close button clicked!')}>
-
- Dismissible Error Alert
-
- Even error alerts can be dismissible, allowing users to acknowledge and dismiss
- error messages.
-
-
-
-
-
-
Multiple Closable Alerts (Interactive)
-
- Click the X button on any alert below to remove it from the list:
-
-
- {closableAlerts.length > 0 ? (
- closableAlerts.map((alert) => (
-
removeClosableAlert(alert.id)}>
- {alert.variant === 'success' ? (
-
- ) : alert.variant === 'warning' ? (
-
- ) : (
-
- )}
- {alert.title}
- {alert.message}
-
- ))
- ) : (
-
- All alerts have been dismissed. Refresh the page to see them again.
-
- )}
-
-
-
-
-
- Note: By default, alerts are not closable. Set the{' '}
- closable prop to true and provide an onClose{' '}
- callback to enable the close button.
-
-
-
-
-
- {/* Use Cases */}
-
-
- Common Use Cases
- Real-world examples of Alert component usage
-
-
-
-
Form Validation Errors
-
-
- Validation Failed
-
- Please correct the following errors before submitting:
-
- Email address is required
- Password must be at least 8 characters
-
-
-
-
-
-
-
Success Messages
-
-
- Profile Updated
-
- Your profile information has been successfully updated and saved.
-
-
-
-
-
-
System Notifications
-
-
- Maintenance Scheduled
-
- System maintenance is scheduled for tonight at 2:00 AM. Some services may be
- temporarily unavailable.
-
-
-
-
-
-
Warning Messages
-
-
- Action Required
-
- Your subscription will expire in 7 days. Please renew to continue using all
- features.
-
-
-
-
-
-
Error Recovery
-
-
- Connection Lost
-
- Your connection was interrupted. Attempting to reconnect automatically. If this
- problem persists, please refresh the page.
-
-
-
-
-
-
In Dialog/Modal
-
-
-
- Confirm Action
-
- This action cannot be undone. Are you sure you want to proceed?
-
-
-
- Alerts work well in dialogs and modals to provide context for user actions.
-
-
-
-
-
-
- );
-}
+import { Alert, AlertDescription, AlertTitle } from '@datum-ui/components';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
+import {
+ AlertCircle,
+ CheckCircle,
+ Info,
+ TriangleAlert,
+ XCircle,
+ Bell,
+ ShieldAlert,
+} from 'lucide-react';
+import { useState } from 'react';
+
+export const alertDemoSections = [
+ { id: 'alert-variants', label: 'Alert Variants' },
+ { id: 'alert-with-icons', label: 'Alerts with Icons' },
+ { id: 'alert-status', label: 'Status Alerts' },
+ { id: 'alert-rich-content', label: 'Rich Content' },
+ { id: 'alert-simple', label: 'Simple Alerts' },
+ { id: 'alert-closable', label: 'Closable Alerts' },
+ { id: 'alert-use-cases', label: 'Use Cases' },
+];
+
+export default function AlertDemo() {
+ const [closableAlerts, setClosableAlerts] = useState([
+ {
+ id: 1,
+ variant: 'info' as const,
+ title: 'Information',
+ message: 'This alert can be dismissed.',
+ },
+ {
+ id: 2,
+ variant: 'success' as const,
+ title: 'Success',
+ message: 'Operation completed successfully.',
+ },
+ {
+ id: 3,
+ variant: 'warning' as const,
+ title: 'Warning',
+ message: 'Please review your settings.',
+ },
+ ]);
+
+ const removeClosableAlert = (id: number) => {
+ setClosableAlerts((prev) => prev.filter((alert) => alert.id !== id));
+ };
+
+ return (
+
+ {/* Alert Variants */}
+
+
+ Alert Variants
+
+ All available alert variants with their default styling and colors
+
+
+
+
+
+ Default Alert
+
+ This is a default alert with neutral styling. Use for general information.
+
+
+
+
+ Secondary Alert
+
+ This is a secondary alert with muted styling. Use for less prominent messages.
+
+
+
+
+ Outline Alert
+
+ This is an outline alert with a border and transparent background. Use for subtle
+ notifications.
+
+
+
+
+ Destructive Alert
+
+ This is a destructive alert for errors and critical issues. Use for error messages
+ and warnings.
+
+
+
+
+ Success Alert
+
+ This is a success alert for positive feedback. Use when operations complete
+ successfully.
+
+
+
+
+ Info Alert
+
+ This is an info alert for informational messages. Use to provide helpful context or
+ guidance.
+
+
+
+
+ Warning Alert
+
+ This is a warning alert for cautionary messages. Use when action is required or
+ attention is needed.
+
+
+
+
+
+
+ {/* Alerts with Icons */}
+
+
+ Alerts with Icons
+
+ Icons are automatically positioned when placed as the first child of the Alert component
+
+
+
+
+
+
+ Error Occurred
+
+ An error occurred while processing your request. Please try again or contact
+ support.
+
+
+
+
+
+ Operation Successful
+
+ Your changes have been saved successfully. All data has been updated.
+
+
+
+
+
+ Information
+
+ Here's some helpful information that might be useful for you to know.
+
+
+
+
+
+ Warning
+
+ Please review this information before proceeding. This action cannot be undone.
+
+
+
+
+
+ Notification
+
+ You have a new notification. Check your inbox for more details.
+
+
+
+
+
+ Security Notice
+
+ Your account security settings have been updated. Review your security preferences.
+
+
+
+
+
+ Icons are automatically positioned in the top-left corner and content is padded
+ accordingly. Icon colors inherit from the alert variant.
+
+
+
+
+
+ {/* Status Alerts */}
+
+
+ Status Alerts
+ Common status alert patterns for different scenarios
+
+
+
+
+
+ Connection Established
+
+ Your connection has been established successfully. You can now proceed with your
+ operations.
+
+
+
+
+
+ Connection Failed
+
+ Unable to establish connection. Please check your network settings and try again.
+
+
+
+
+
+ Action Required
+
+ Your account requires verification. Please complete the verification process to
+ continue.
+
+
+
+
+
+ Processing
+
+ Your request is being processed. This may take a few moments. Please wait.
+
+
+
+
+
+
+ {/* Rich Content */}
+
+
+ Rich Content Alerts
+
+ Alerts can contain rich content including lists, formatted text, and custom components
+
+
+
+
+
+
+ Domain Validation Errors
+
+
+
The following issues must be resolved before your domain can be verified:
+
+ DNS records are not configured correctly
+ HTTP verification token is missing
+ Domain ownership could not be verified
+
+
+
+
+
+
+
+ Before You Continue
+
+
+
Please review the following before proceeding:
+
+ Verify all required fields are completed
+ Check that your configuration is correct
+ Ensure you have the necessary permissions
+
+
+
+
+
+
+
+ System Update
+
+
+
+ A new system update is available. The update includes the following
+ improvements:
+
+
+ Enhanced security features
+ Performance optimizations
+ New user interface improvements
+
+
+ Note: The update will be applied automatically during the next
+ maintenance window.
+
+
+
+
+
+
+
+
+ {/* Simple Alerts */}
+
+
+ Simple Alerts
+
+ Alerts can be used without titles or with minimal content for simple notifications
+
+
+
+
+
+ This is a simple alert without a title.
+
+
+
+ Operation completed successfully.
+
+
+
+ Please review your settings before continuing.
+
+
+
+ An error occurred. Please try again.
+
+
+
+
+ Your changes have been saved.
+
+
+
+
+
+ New features are available. Check the documentation for more information.
+
+
+
+
+
+
+ {/* Closable Alerts */}
+
+
+ Closable Alerts
+
+ Alerts can be made dismissible by setting the closable prop. Click the X button to close
+ them.
+
+
+
+
+
alert('Close button clicked!')}>
+
+ Dismissible Info Alert
+
+ This alert has a close button. Click the X in the top-right corner to dismiss it. In
+ a real application, you would handle the onClose callback to remove the alert from
+ state.
+
+
+
+
alert('Close button clicked!')}>
+
+ Dismissible Success Alert
+
+ Success alerts can also be closable. The close button inherits the alert's
+ color scheme.
+
+
+
+
alert('Close button clicked!')}>
+
+ Dismissible Warning Alert
+
+ Warning alerts with close buttons are useful for temporary notifications that users
+ can dismiss.
+
+
+
+
alert('Close button clicked!')}>
+
+ Dismissible Error Alert
+
+ Even error alerts can be dismissible, allowing users to acknowledge and dismiss
+ error messages.
+
+
+
+
+
+
Multiple Closable Alerts (Interactive)
+
+ Click the X button on any alert below to remove it from the list:
+
+
+ {closableAlerts.length > 0 ? (
+ closableAlerts.map((alert) => (
+
removeClosableAlert(alert.id)}>
+ {alert.variant === 'success' ? (
+
+ ) : alert.variant === 'warning' ? (
+
+ ) : (
+
+ )}
+ {alert.title}
+ {alert.message}
+
+ ))
+ ) : (
+
+ All alerts have been dismissed. Refresh the page to see them again.
+
+ )}
+
+
+
+
+
+ Note: By default, alerts are not closable. Set the{' '}
+ closable prop to true and provide an onClose{' '}
+ callback to enable the close button.
+
+
+
+
+
+ {/* Use Cases */}
+
+
+ Common Use Cases
+ Real-world examples of Alert component usage
+
+
+
+
Form Validation Errors
+
+
+ Validation Failed
+
+ Please correct the following errors before submitting:
+
+ Email address is required
+ Password must be at least 8 characters
+
+
+
+
+
+
+
Success Messages
+
+
+ Profile Updated
+
+ Your profile information has been successfully updated and saved.
+
+
+
+
+
+
System Notifications
+
+
+ Maintenance Scheduled
+
+ System maintenance is scheduled for tonight at 2:00 AM. Some services may be
+ temporarily unavailable.
+
+
+
+
+
+
Warning Messages
+
+
+ Action Required
+
+ Your subscription will expire in 7 days. Please renew to continue using all
+ features.
+
+
+
+
+
+
Error Recovery
+
+
+ Connection Lost
+
+ Your connection was interrupted. Attempting to reconnect automatically. If this
+ problem persists, please refresh the page.
+
+
+
+
+
+
In Dialog/Modal
+
+
+
+ Confirm Action
+
+ This action cannot be undone. Are you sure you want to proceed?
+
+
+
+ Alerts work well in dialogs and modals to provide context for user actions.
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/demo/badge.tsx b/app/components/demo/badge.tsx
index d98844255..057be4d64 100644
--- a/app/components/demo/badge.tsx
+++ b/app/components/demo/badge.tsx
@@ -1,283 +1,283 @@
-import { BadgeCopy } from '@/components/badge/badge-copy';
-import { Badge } from '@datum-ui/components';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
-import { AlertCircle, CheckCircle, XCircle, Star, Info } from 'lucide-react';
-
-export const badgeDemoSections = [
- { id: 'badge-types', label: 'Badge Types' },
- { id: 'badge-themes', label: 'Badge Themes' },
- { id: 'badges-with-icons', label: 'Badges with Icons' },
- { id: 'status-indicators', label: 'Status Indicators' },
- { id: 'custom-styling', label: 'Custom Styling' },
- { id: 'interactive-badges', label: 'Interactive Badges' },
- { id: 'badge-copy', label: 'Copyable Badges' },
- { id: 'use-cases', label: 'Common Use Cases' },
-];
-
-export default function BadgeDemo() {
- return (
-
- {/* Badge Types */}
-
-
- Badge Types
- Different badge types for various use cases
-
-
-
- Primary
- Secondary
- Tertiary
- Quaternary
- Info
- Warning
- Danger
- Success
-
-
-
-
- {/* Badge Themes */}
-
-
- Badge Themes
- Different visual themes for each badge type
-
-
- {[
- 'primary',
- 'secondary',
- 'tertiary',
- 'quaternary',
- 'info',
- 'warning',
- 'danger',
- 'success',
- ].map((type) => (
-
-
{type}
-
-
- Solid
-
-
- Outline
-
-
- Light
-
-
-
- ))}
-
-
-
- {/* Status Indicators */}
-
-
- Status Indicators
- Using badges to show different statuses
-
-
-
- Active
- Pending
- Failed
- Completed
- Info
- Warning
- Inactive
-
-
-
-
- {/* Badges with Icons */}
-
-
- Badges with Icons
-
- Badges can include icons for better visual communication
-
-
-
-
-
-
- Success
-
-
-
- Error
-
-
-
- Warning
-
-
-
- Info
-
-
-
- Featured
-
-
-
- Note
-
-
-
-
-
- {/* Custom Styling */}
-
-
- Custom Styling
- Badges with custom padding and styling
-
-
-
-
- Custom Padding
-
-
- Custom Border
-
-
- With Shadow
-
-
- Larger Text
-
-
-
-
-
- {/* Interactive Badges */}
-
-
- Interactive Badges
- Badges that can be clicked or removed
-
-
-
-
- Clickable Badge
-
-
-
- Removable
-
-
- Hover Effect
-
-
-
-
-
- {/* Copyable Badges */}
-
-
- Copyable Badges
-
- Use `BadgeCopy` for quickly copying values like IDs, hostnames, or tokens.
-
-
-
- {['solid', 'outline', 'light'].map((theme) => (
-
-
{theme} theme
-
- {[
- 'primary',
- 'secondary',
- 'tertiary',
- 'quaternary',
- 'info',
- 'warning',
- 'danger',
- 'success',
- ].map((type) => (
-
- ))}
-
-
- ))}
-
-
-
- {/* Use Cases */}
-
-
- Common Use Cases
- Real-world examples of badge usage
-
-
-
-
Tags
-
-
- React
-
-
- TypeScript
-
-
- Tailwind
-
-
- Next.js
-
-
-
-
-
Notifications
-
- 3
- 12
- New
-
-
-
-
User Roles
-
- Admin
- Member
-
- Guest
-
-
-
-
-
Content Status
-
-
-
- Published
-
- Draft
- Archived
-
- Featured
-
-
-
-
-
-
- );
-}
+import { BadgeCopy } from '@/components/badge/badge-copy';
+import { Badge } from '@datum-ui/components';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
+import { AlertCircle, CheckCircle, XCircle, Star, Info } from 'lucide-react';
+
+export const badgeDemoSections = [
+ { id: 'badge-types', label: 'Badge Types' },
+ { id: 'badge-themes', label: 'Badge Themes' },
+ { id: 'badges-with-icons', label: 'Badges with Icons' },
+ { id: 'status-indicators', label: 'Status Indicators' },
+ { id: 'custom-styling', label: 'Custom Styling' },
+ { id: 'interactive-badges', label: 'Interactive Badges' },
+ { id: 'badge-copy', label: 'Copyable Badges' },
+ { id: 'use-cases', label: 'Common Use Cases' },
+];
+
+export default function BadgeDemo() {
+ return (
+
+ {/* Badge Types */}
+
+
+ Badge Types
+ Different badge types for various use cases
+
+
+
+ Primary
+ Secondary
+ Tertiary
+ Quaternary
+ Info
+ Warning
+ Danger
+ Success
+
+
+
+
+ {/* Badge Themes */}
+
+
+ Badge Themes
+ Different visual themes for each badge type
+
+
+ {[
+ 'primary',
+ 'secondary',
+ 'tertiary',
+ 'quaternary',
+ 'info',
+ 'warning',
+ 'danger',
+ 'success',
+ ].map((type) => (
+
+
{type}
+
+
+ Solid
+
+
+ Outline
+
+
+ Light
+
+
+
+ ))}
+
+
+
+ {/* Status Indicators */}
+
+
+ Status Indicators
+ Using badges to show different statuses
+
+
+
+ Active
+ Pending
+ Failed
+ Completed
+ Info
+ Warning
+ Inactive
+
+
+
+
+ {/* Badges with Icons */}
+
+
+ Badges with Icons
+
+ Badges can include icons for better visual communication
+
+
+
+
+
+
+ Success
+
+
+
+ Error
+
+
+
+ Warning
+
+
+
+ Info
+
+
+
+ Featured
+
+
+
+ Note
+
+
+
+
+
+ {/* Custom Styling */}
+
+
+ Custom Styling
+ Badges with custom padding and styling
+
+
+
+
+ Custom Padding
+
+
+ Custom Border
+
+
+ With Shadow
+
+
+ Larger Text
+
+
+
+
+
+ {/* Interactive Badges */}
+
+
+ Interactive Badges
+ Badges that can be clicked or removed
+
+
+
+
+ Clickable Badge
+
+
+
+ Removable
+
+
+ Hover Effect
+
+
+
+
+
+ {/* Copyable Badges */}
+
+
+ Copyable Badges
+
+ Use `BadgeCopy` for quickly copying values like IDs, hostnames, or tokens.
+
+
+
+ {['solid', 'outline', 'light'].map((theme) => (
+
+
{theme} theme
+
+ {[
+ 'primary',
+ 'secondary',
+ 'tertiary',
+ 'quaternary',
+ 'info',
+ 'warning',
+ 'danger',
+ 'success',
+ ].map((type) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ {/* Use Cases */}
+
+
+ Common Use Cases
+ Real-world examples of badge usage
+
+
+
+
Tags
+
+
+ React
+
+
+ TypeScript
+
+
+ Tailwind
+
+
+ Next.js
+
+
+
+
+
Notifications
+
+ 3
+ 12
+ New
+
+
+
+
User Roles
+
+ Admin
+ Member
+
+ Guest
+
+
+
+
+
Content Status
+
+
+
+ Published
+
+ Draft
+ Archived
+
+ Featured
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/demo/button.tsx b/app/components/demo/button.tsx
index 4833e137d..e0f11d607 100644
--- a/app/components/demo/button.tsx
+++ b/app/components/demo/button.tsx
@@ -1,398 +1,398 @@
-import { Button } from '@datum-ui/components';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
-import { Download, Heart, Plus, Settings, Trash2 } from 'lucide-react';
-import { useState } from 'react';
-
-export const buttonDemoSections = [
- { id: 'button-types', label: 'Button Types' },
- { id: 'button-themes', label: 'Button Themes' },
- { id: 'button-sizes', label: 'Button Sizes' },
- { id: 'icon-only-buttons', label: 'Icon-Only Buttons' },
- { id: 'buttons-with-icons', label: 'Buttons with Icons' },
- { id: 'loading-states', label: 'Loading States' },
- { id: 'block-buttons', label: 'Block Buttons' },
- { id: 'disabled-states', label: 'Disabled States' },
- { id: 'state-transitions', label: 'State Transitions' },
-];
-
-export default function ButtonDemo() {
- const [loading, setLoading] = useState>({});
-
- const handleLoadingDemo = (key: string) => {
- setLoading((prev) => ({ ...prev, [key]: true }));
- setTimeout(() => {
- setLoading((prev) => ({ ...prev, [key]: false }));
- }, 2000);
- };
-
- return (
-
- {/* Button Types */}
-
-
- Button Types
- Different button types for various use cases
-
-
-
- Primary
- Secondary
- Tertiary
- Quaternary
- Warning
- Danger
- Success
-
-
-
-
- {/* Button Themes */}
-
-
- Button Themes
- Different visual themes for each button type
-
-
- {['primary', 'secondary', 'tertiary', 'quaternary', 'warning', 'danger', 'success'].map(
- (type) => (
-
-
{type}
-
-
- Solid
-
-
- Light
-
-
- Outline
-
-
- Borderless
-
-
-
- )
- )}
-
-
-
- {/* Button Sizes */}
-
-
- Button Sizes
- Different sizes for different contexts
-
-
-
- Small
- Default
- Large
-
-
-
-
-
-
-
- {/* Icon-Only Buttons */}
-
-
- Icon-Only Buttons
- Icon-only buttons in different sizes and themes
-
-
-
-
Small Icon-Only Buttons
-
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
-
-
-
-
-
Default Icon-Only Buttons
-
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
-
-
-
-
-
Large Icon-Only Buttons
-
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
-
-
-
-
-
Icon-Only Loading States
-
- }
- loading={loading.iconSmall}
- onClick={() => handleLoadingDemo('iconSmall')}
- />
- }
- loading={loading.iconDefault}
- onClick={() => handleLoadingDemo('iconDefault')}
- />
- }
- loading={loading.iconLarge}
- onClick={() => handleLoadingDemo('iconLarge')}
- />
-
-
- Click icon buttons to see loading states - icons are replaced with spinners
-
-
-
-
-
- {/* Button with Icons */}
-
-
- Buttons with Icons
- Buttons can include icons on left or right side
-
-
-
- }>Add Item
- } iconPosition="right">
- Download
-
- } theme="outline">
- Delete
-
- } theme="light">
- Like
-
- } theme="solid">
- Save
-
-
-
-
-
- {/* Loading States */}
-
-
- Loading States
- Buttons can show loading states with spinners
-
-
-
- handleLoadingDemo('basic')}>
- {loading.basic ? 'Loading...' : 'Click to Load'}
-
- handleLoadingDemo('secondary')}>
- {loading.secondary ? 'Processing...' : 'Process'}
-
- handleLoadingDemo('danger')}>
- {loading.danger ? 'Deleting...' : 'Delete'}
-
- handleLoadingDemo('success')}>
- {loading.success ? 'Saving...' : 'Save'}
-
-
-
-
-
- {/* Block Buttons */}
-
-
- Block Buttons
- Full-width buttons for forms and layouts
-
-
-
- Full Width Primary
-
- Full Width Secondary
-
-
- Full Width Danger Light
-
-
-
-
-
- {/* Disabled State */}
-
-
- Disabled States
- Buttons can be disabled to prevent interaction
-
-
-
- Disabled Primary
-
- Disabled Secondary
-
-
- Disabled Danger
-
- } disabled>
- Disabled with Icon
-
-
-
-
-
- {/* State Transitions */}
-
-
- State Transitions
- Compare default and disabled styles side by side.
-
-
- {['primary', 'secondary', 'tertiary', 'quaternary', 'warning', 'danger', 'success'].map(
- (type) => (
-
-
{type}
- {['solid', 'light', 'outline', 'borderless'].map((theme) => (
-
-
- {theme}
-
-
- Default (hover me)
-
-
- Disabled
-
-
- ))}
-
- )
- )}
-
-
-
- );
-}
+import { Button } from '@datum-ui/components';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
+import { Download, Heart, Plus, Settings, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+
+export const buttonDemoSections = [
+ { id: 'button-types', label: 'Button Types' },
+ { id: 'button-themes', label: 'Button Themes' },
+ { id: 'button-sizes', label: 'Button Sizes' },
+ { id: 'icon-only-buttons', label: 'Icon-Only Buttons' },
+ { id: 'buttons-with-icons', label: 'Buttons with Icons' },
+ { id: 'loading-states', label: 'Loading States' },
+ { id: 'block-buttons', label: 'Block Buttons' },
+ { id: 'disabled-states', label: 'Disabled States' },
+ { id: 'state-transitions', label: 'State Transitions' },
+];
+
+export default function ButtonDemo() {
+ const [loading, setLoading] = useState>({});
+
+ const handleLoadingDemo = (key: string) => {
+ setLoading((prev) => ({ ...prev, [key]: true }));
+ setTimeout(() => {
+ setLoading((prev) => ({ ...prev, [key]: false }));
+ }, 2000);
+ };
+
+ return (
+
+ {/* Button Types */}
+
+
+ Button Types
+ Different button types for various use cases
+
+
+
+ Primary
+ Secondary
+ Tertiary
+ Quaternary
+ Warning
+ Danger
+ Success
+
+
+
+
+ {/* Button Themes */}
+
+
+ Button Themes
+ Different visual themes for each button type
+
+
+ {['primary', 'secondary', 'tertiary', 'quaternary', 'warning', 'danger', 'success'].map(
+ (type) => (
+
+
{type}
+
+
+ Solid
+
+
+ Light
+
+
+ Outline
+
+
+ Borderless
+
+
+
+ )
+ )}
+
+
+
+ {/* Button Sizes */}
+
+
+ Button Sizes
+ Different sizes for different contexts
+
+
+
+ Small
+ Default
+ Large
+
+
+
+
+
+
+
+ {/* Icon-Only Buttons */}
+
+
+ Icon-Only Buttons
+ Icon-only buttons in different sizes and themes
+
+
+
+
Small Icon-Only Buttons
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
Default Icon-Only Buttons
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
Large Icon-Only Buttons
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
Icon-Only Loading States
+
+ }
+ loading={loading.iconSmall}
+ onClick={() => handleLoadingDemo('iconSmall')}
+ />
+ }
+ loading={loading.iconDefault}
+ onClick={() => handleLoadingDemo('iconDefault')}
+ />
+ }
+ loading={loading.iconLarge}
+ onClick={() => handleLoadingDemo('iconLarge')}
+ />
+
+
+ Click icon buttons to see loading states - icons are replaced with spinners
+
+
+
+
+
+ {/* Button with Icons */}
+
+
+ Buttons with Icons
+ Buttons can include icons on left or right side
+
+
+
+ }>Add Item
+ } iconPosition="right">
+ Download
+
+ } theme="outline">
+ Delete
+
+ } theme="light">
+ Like
+
+ } theme="solid">
+ Save
+
+
+
+
+
+ {/* Loading States */}
+
+
+ Loading States
+ Buttons can show loading states with spinners
+
+
+
+ handleLoadingDemo('basic')}>
+ {loading.basic ? 'Loading...' : 'Click to Load'}
+
+ handleLoadingDemo('secondary')}>
+ {loading.secondary ? 'Processing...' : 'Process'}
+
+ handleLoadingDemo('danger')}>
+ {loading.danger ? 'Deleting...' : 'Delete'}
+
+ handleLoadingDemo('success')}>
+ {loading.success ? 'Saving...' : 'Save'}
+
+
+
+
+
+ {/* Block Buttons */}
+
+
+ Block Buttons
+ Full-width buttons for forms and layouts
+
+
+
+ Full Width Primary
+
+ Full Width Secondary
+
+
+ Full Width Danger Light
+
+
+
+
+
+ {/* Disabled State */}
+
+
+ Disabled States
+ Buttons can be disabled to prevent interaction
+
+
+
+ Disabled Primary
+
+ Disabled Secondary
+
+
+ Disabled Danger
+
+ } disabled>
+ Disabled with Icon
+
+
+
+
+
+ {/* State Transitions */}
+
+
+ State Transitions
+ Compare default and disabled styles side by side.
+
+
+ {['primary', 'secondary', 'tertiary', 'quaternary', 'warning', 'danger', 'success'].map(
+ (type) => (
+
+
{type}
+ {['solid', 'light', 'outline', 'borderless'].map((theme) => (
+
+
+ {theme}
+
+
+ Default (hover me)
+
+
+ Disabled
+
+
+ ))}
+
+ )
+ )}
+
+
+
+ );
+}
diff --git a/app/components/demo/form-fields.tsx b/app/components/demo/form-fields.tsx
index 515b58c49..90195c345 100644
--- a/app/components/demo/form-fields.tsx
+++ b/app/components/demo/form-fields.tsx
@@ -1,678 +1,678 @@
-import { Field } from '@/components/field/field';
-import { MultiSelect } from '@/components/multi-select/multi-select';
-import { SelectBox } from '@/components/select-box/select-box';
-import {
- Button,
- Checkbox,
- Input,
- InputWithAddons,
- Label,
- RadioGroup,
- RadioGroupItem,
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- Switch,
- TagsInput,
- Textarea,
-} from '@datum-ui/components';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
-import { Globe, Mail, Search } from 'lucide-react';
-import { useState } from 'react';
-
-export const formFieldsDemoSections = [
- { id: 'input-fields', label: 'Input Fields' },
- { id: 'input-with-addons-field', label: 'Input with Addons' },
- { id: 'select-box-field', label: 'Select Box' },
- { id: 'multi-select-field', label: 'Multi Select' },
- { id: 'tag-input-field', label: 'Tag Input' },
- { id: 'textarea-field', label: 'Textarea' },
- { id: 'checkbox-field', label: 'Checkbox' },
- { id: 'switch-field', label: 'Switch' },
- { id: 'radio-group-field', label: 'Radio Group' },
- { id: 'select-field', label: 'Select' },
- { id: 'form-with-fields', label: 'Complete Form Example' },
- { id: 'disabled-states', label: 'Disabled States' },
- { id: 'error-states', label: 'Error States' },
-];
-
-export default function FormFieldsDemo() {
- const [inputValue, setInputValue] = useState('');
- const [textareaValue, setTextareaValue] = useState('');
- const [checkboxChecked, setCheckboxChecked] = useState(false);
- const [switchChecked, setSwitchChecked] = useState(false);
- const [radioValue, setRadioValue] = useState('option1');
- const [selectValue, setSelectValue] = useState('');
- const [selectBoxValue, setSelectBoxValue] = useState('owner');
- const [multiSelectValues, setMultiSelectValues] = useState(['grafana']);
- const [tagValues, setTagValues] = useState(['alpha', 'beta']);
- const [limitedTagValues, setLimitedTagValues] = useState(['grafana']);
- const [limitedMultiSelectValues, setLimitedMultiSelectValues] = useState([
- 'prod',
- 'staging',
- ]);
-
- return (
-
- {/* Input Fields */}
-
-
- Input Fields
- Text input fields with various types and states
-
-
-
-
- setInputValue(e.target.value)}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Multi Select */}
-
-
- Multi Select
- Select multiple items with badges and quick actions
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Select Box */}
-
-
- Select Box
- Custom select built on Command and Popover
-
-
-
- setSelectBoxValue(option?.value)}
- placeholder="Select a role"
- options={[
- { value: 'owner', label: 'Owner', description: 'Full access to workspace' },
- { value: 'editor', label: 'Editor', description: 'Can modify most resources' },
- { value: 'viewer', label: 'Viewer', description: 'Read-only access' },
- ]}
- />
-
-
-
-
-
-
-
-
- {/* Tag Input */}
-
-
- Tag Input
- Create tokenized inputs for lists and metadata
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Input With Addons */}
-
-
- Input with Addons
- Use leading and trailing content for structured inputs
-
-
-
- https://}
- placeholder="my-service.datum.cloud"
- />
-
-
-
- }
- trailing={⌘ K }
- placeholder="Search clusters, policies, projects..."
- />
-
-
-
- }
- trailing={
-
- Subscribe
-
- }
- />
-
-
-
- $}
- trailing={
-
-
- USD
-
- }
- />
-
-
-
-
- {/* Textarea */}
-
-
- Textarea
- Multi-line text input for longer content
-
-
- 0 && textareaValue.length < 10
- ? ['Minimum 10 characters required']
- : undefined
- }>
-
-
-
-
-
-
-
-
- {/* Checkbox */}
-
-
- Checkbox
- Checkbox input for binary choices
-
-
-
-
- setCheckboxChecked(checked === true)}
- />
-
- Accept terms and conditions
-
-
-
-
-
-
- Subscribe to newsletter (default checked)
-
-
-
-
-
-
- Disabled checkbox
-
-
-
-
-
-
- Disabled checked checkbox
-
-
-
-
-
-
- {/* Switch */}
-
-
- Switch
- Toggle switch for on/off states
-
-
-
-
-
-
Enable notifications
-
- {switchChecked ? 'Notifications are enabled' : 'Notifications are disabled'}
-
-
-
setSwitchChecked(checked)}
- />
-
-
-
-
-
Dark mode
-
Toggle dark mode theme
-
-
-
-
-
-
-
- Disabled switch
-
-
This switch cannot be toggled
-
-
-
-
-
-
-
- Disabled checked
-
-
This switch is checked but disabled
-
-
-
-
-
-
-
- {/* Radio Group */}
-
-
- Radio Group
-
- Radio buttons for single selection from multiple options
-
-
-
-
-
-
-
-
- Option 1
-
-
-
-
-
- Option 2
-
-
-
-
-
- Option 3
-
-
-
- Selected: {radioValue}
-
-
-
-
-
-
-
- Disabled Option 1
-
-
-
-
-
- Disabled Option 2
-
-
-
-
-
-
-
- {/* Select */}
-
-
- Select
- Dropdown select for choosing from a list of options
-
-
-
-
-
-
-
-
- Option 1
- Option 2
- Option 3
- Option 4
-
-
- {selectValue && (
- Selected: {selectValue}
- )}
-
-
-
-
-
-
-
-
- Apple
- Banana
- Orange
- Carrot
- Broccoli
-
-
-
-
-
-
-
-
-
-
- Option 1
- Option 2
-
-
-
-
-
-
- {/* Complete Form Example */}
-
-
- Complete Form Example
- Example form using all field types together
-
-
-
-
-
-
- {/* Disabled States */}
-
-
- Disabled States
- All form fields support disabled state
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Option 1
-
-
-
-
-
-
-
- Disabled checkbox
-
-
-
-
-
- Disabled switch
-
-
-
-
-
-
- {/* Error States */}
-
-
- Error States
- Form fields with error messages and validation
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Option 1
- Option 2
-
-
-
-
-
-
- );
-}
+import { Field } from '@/components/field/field';
+import { MultiSelect } from '@/components/multi-select/multi-select';
+import { SelectBox } from '@/components/select-box/select-box';
+import {
+ Button,
+ Checkbox,
+ Input,
+ InputWithAddons,
+ Label,
+ RadioGroup,
+ RadioGroupItem,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Switch,
+ TagsInput,
+ Textarea,
+} from '@datum-ui/components';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
+import { Globe, Mail, Search } from 'lucide-react';
+import { useState } from 'react';
+
+export const formFieldsDemoSections = [
+ { id: 'input-fields', label: 'Input Fields' },
+ { id: 'input-with-addons-field', label: 'Input with Addons' },
+ { id: 'select-box-field', label: 'Select Box' },
+ { id: 'multi-select-field', label: 'Multi Select' },
+ { id: 'tag-input-field', label: 'Tag Input' },
+ { id: 'textarea-field', label: 'Textarea' },
+ { id: 'checkbox-field', label: 'Checkbox' },
+ { id: 'switch-field', label: 'Switch' },
+ { id: 'radio-group-field', label: 'Radio Group' },
+ { id: 'select-field', label: 'Select' },
+ { id: 'form-with-fields', label: 'Complete Form Example' },
+ { id: 'disabled-states', label: 'Disabled States' },
+ { id: 'error-states', label: 'Error States' },
+];
+
+export default function FormFieldsDemo() {
+ const [inputValue, setInputValue] = useState('');
+ const [textareaValue, setTextareaValue] = useState('');
+ const [checkboxChecked, setCheckboxChecked] = useState(false);
+ const [switchChecked, setSwitchChecked] = useState(false);
+ const [radioValue, setRadioValue] = useState('option1');
+ const [selectValue, setSelectValue] = useState('');
+ const [selectBoxValue, setSelectBoxValue] = useState('owner');
+ const [multiSelectValues, setMultiSelectValues] = useState(['grafana']);
+ const [tagValues, setTagValues] = useState(['alpha', 'beta']);
+ const [limitedTagValues, setLimitedTagValues] = useState(['grafana']);
+ const [limitedMultiSelectValues, setLimitedMultiSelectValues] = useState([
+ 'prod',
+ 'staging',
+ ]);
+
+ return (
+
+ {/* Input Fields */}
+
+
+ Input Fields
+ Text input fields with various types and states
+
+
+
+
+ setInputValue(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Multi Select */}
+
+
+ Multi Select
+ Select multiple items with badges and quick actions
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Select Box */}
+
+
+ Select Box
+ Custom select built on Command and Popover
+
+
+
+ setSelectBoxValue(option?.value)}
+ placeholder="Select a role"
+ options={[
+ { value: 'owner', label: 'Owner', description: 'Full access to workspace' },
+ { value: 'editor', label: 'Editor', description: 'Can modify most resources' },
+ { value: 'viewer', label: 'Viewer', description: 'Read-only access' },
+ ]}
+ />
+
+
+
+
+
+
+
+
+ {/* Tag Input */}
+
+
+ Tag Input
+ Create tokenized inputs for lists and metadata
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Input With Addons */}
+
+
+ Input with Addons
+ Use leading and trailing content for structured inputs
+
+
+
+ https://}
+ placeholder="my-service.datum.cloud"
+ />
+
+
+
+ }
+ trailing={⌘ K }
+ placeholder="Search clusters, policies, projects..."
+ />
+
+
+
+ }
+ trailing={
+
+ Subscribe
+
+ }
+ />
+
+
+
+ $}
+ trailing={
+
+
+ USD
+
+ }
+ />
+
+
+
+
+ {/* Textarea */}
+
+
+ Textarea
+ Multi-line text input for longer content
+
+
+ 0 && textareaValue.length < 10
+ ? ['Minimum 10 characters required']
+ : undefined
+ }>
+ setTextareaValue(e.target.value)}
+ rows={4}
+ />
+
+
+
+
+
+
+
+
+ {/* Checkbox */}
+
+
+ Checkbox
+ Checkbox input for binary choices
+
+
+
+
+ setCheckboxChecked(checked === true)}
+ />
+
+ Accept terms and conditions
+
+
+
+
+
+
+ Subscribe to newsletter (default checked)
+
+
+
+
+
+
+ Disabled checkbox
+
+
+
+
+
+
+ Disabled checked checkbox
+
+
+
+
+
+
+ {/* Switch */}
+
+
+ Switch
+ Toggle switch for on/off states
+
+
+
+
+
+
Enable notifications
+
+ {switchChecked ? 'Notifications are enabled' : 'Notifications are disabled'}
+
+
+
setSwitchChecked(checked)}
+ />
+
+
+
+
+
Dark mode
+
Toggle dark mode theme
+
+
+
+
+
+
+
+ Disabled switch
+
+
This switch cannot be toggled
+
+
+
+
+
+
+
+ Disabled checked
+
+
This switch is checked but disabled
+
+
+
+
+
+
+
+ {/* Radio Group */}
+
+
+ Radio Group
+
+ Radio buttons for single selection from multiple options
+
+
+
+
+
+
+
+
+ Option 1
+
+
+
+
+
+ Option 2
+
+
+
+
+
+ Option 3
+
+
+
+ Selected: {radioValue}
+
+
+
+
+
+
+
+ Disabled Option 1
+
+
+
+
+
+ Disabled Option 2
+
+
+
+
+
+
+
+ {/* Select */}
+
+
+ Select
+ Dropdown select for choosing from a list of options
+
+
+
+
+
+
+
+
+ Option 1
+ Option 2
+ Option 3
+ Option 4
+
+
+ {selectValue && (
+ Selected: {selectValue}
+ )}
+
+
+
+
+
+
+
+
+ Apple
+ Banana
+ Orange
+ Carrot
+ Broccoli
+
+
+
+
+
+
+
+
+
+
+ Option 1
+ Option 2
+
+
+
+
+
+
+ {/* Complete Form Example */}
+
+
+ Complete Form Example
+ Example form using all field types together
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ United States
+ United Kingdom
+ Canada
+ Australia
+
+
+
+
+
+
+
+
+
+ Email
+
+
+
+
+
+ SMS
+
+
+
+
+
+ None
+
+
+
+
+
+
+
+
+
+ I agree to the terms and conditions
+
+
+
+
+
+
Enable push notifications
+
+ Receive notifications on your device
+
+
+
+
+
+
+
+
+
+ {/* Disabled States */}
+
+
+ Disabled States
+ All form fields support disabled state
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Option 1
+
+
+
+
+
+
+
+ Disabled checkbox
+
+
+
+
+
+ Disabled switch
+
+
+
+
+
+
+ {/* Error States */}
+
+
+ Error States
+ Form fields with error messages and validation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Option 1
+ Option 2
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/demo/grid.tsx b/app/components/demo/grid.tsx
index 1a23705da..4961bce2d 100644
--- a/app/components/demo/grid.tsx
+++ b/app/components/demo/grid.tsx
@@ -1,191 +1,191 @@
-import { Row, Col } from '@datum-ui/components';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
-
-export const gridDemoSections = [
- { id: 'basic-usage', label: 'Basic Usage' },
- { id: 'responsive', label: 'Responsive' },
- { id: 'flex-layout', label: 'Flex Layout' },
- { id: 'offset', label: 'Offset' },
- { id: 'order', label: 'Order' },
- { id: 'push-pull', label: 'Push/Pull' },
- { id: 'vertical-gutter', label: 'Vertical Gutter' },
-];
-
-export default function GridDemo() {
- return (
-
- {/* Basic Usage */}
-
-
- Basic Usage
- Basic grid layout with different column spans
-
-
-
-
- col-24
-
-
-
-
- col-12
-
-
- col-12
-
-
-
-
- col-8
-
-
- col-8
-
-
- col-8
-
-
-
-
-
- {/* Responsive */}
-
-
- Responsive
- Responsive grid that adapts to different screen sizes
-
-
-
-
- Responsive Col
-
-
- Responsive Col
-
-
- Responsive Col
-
-
- Responsive Col
-
-
-
-
-
- {/* Flex Layout */}
-
-
- Flex Layout
- Flex-based layouts with justify and align options
-
-
-
-
- Centered
-
-
- Centered
-
-
-
-
- Space Between
-
-
- Space Between
-
-
-
-
-
- {/* Offset */}
-
-
- Offset
- Offset columns to create spacing before them
-
-
-
-
- col-8 offset-8
-
-
-
-
- col-6 offset-6
-
-
- col-6 offset-6
-
-
-
-
-
- {/* Order */}
-
-
- Order
- Change the visual order of columns using flex order
-
-
-
-
- Order 4
-
-
- Order 3
-
-
- Order 2
-
-
- Order 1
-
-
-
-
-
- {/* Push/Pull */}
-
-
- Push/Pull
- Push and pull columns to reorder them in the layout
-
-
-
-
- col-8 push-16
-
-
- col-16 pull-8
-
-
-
-
-
- {/* Vertical Gutter */}
-
-
- Vertical Gutter
- Grid with both horizontal and vertical gutters
-
-
-
-
- Vertical Gutter
-
-
- Vertical Gutter
-
-
- Vertical Gutter
-
-
- Vertical Gutter
-
-
-
-
-
- );
-}
+import { Row, Col } from '@datum-ui/components';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
+
+export const gridDemoSections = [
+ { id: 'basic-usage', label: 'Basic Usage' },
+ { id: 'responsive', label: 'Responsive' },
+ { id: 'flex-layout', label: 'Flex Layout' },
+ { id: 'offset', label: 'Offset' },
+ { id: 'order', label: 'Order' },
+ { id: 'push-pull', label: 'Push/Pull' },
+ { id: 'vertical-gutter', label: 'Vertical Gutter' },
+];
+
+export default function GridDemo() {
+ return (
+
+ {/* Basic Usage */}
+
+
+ Basic Usage
+ Basic grid layout with different column spans
+
+
+
+
+ col-24
+
+
+
+
+ col-12
+
+
+ col-12
+
+
+
+
+ col-8
+
+
+ col-8
+
+
+ col-8
+
+
+
+
+
+ {/* Responsive */}
+
+
+ Responsive
+ Responsive grid that adapts to different screen sizes
+
+
+
+
+ Responsive Col
+
+
+ Responsive Col
+
+
+ Responsive Col
+
+
+ Responsive Col
+
+
+
+
+
+ {/* Flex Layout */}
+
+
+ Flex Layout
+ Flex-based layouts with justify and align options
+
+
+
+
+ Centered
+
+
+ Centered
+
+
+
+
+ Space Between
+
+
+ Space Between
+
+
+
+
+
+ {/* Offset */}
+
+
+ Offset
+ Offset columns to create spacing before them
+
+
+
+
+ col-8 offset-8
+
+
+
+
+ col-6 offset-6
+
+
+ col-6 offset-6
+
+
+
+
+
+ {/* Order */}
+
+
+ Order
+ Change the visual order of columns using flex order
+
+
+
+
+ Order 4
+
+
+ Order 3
+
+
+ Order 2
+
+
+ Order 1
+
+
+
+
+
+ {/* Push/Pull */}
+
+
+ Push/Pull
+ Push and pull columns to reorder them in the layout
+
+
+
+
+ col-8 push-16
+
+
+ col-16 pull-8
+
+
+
+
+
+ {/* Vertical Gutter */}
+
+
+ Vertical Gutter
+ Grid with both horizontal and vertical gutters
+
+
+
+
+ Vertical Gutter
+
+
+ Vertical Gutter
+
+
+ Vertical Gutter
+
+
+ Vertical Gutter
+
+
+
+
+
+ );
+}
diff --git a/app/components/demo/status-badge.tsx b/app/components/demo/status-badge.tsx
index 27556b164..59ec3eba4 100644
--- a/app/components/demo/status-badge.tsx
+++ b/app/components/demo/status-badge.tsx
@@ -1,310 +1,310 @@
-import { BadgeStatus } from '@/components/badge/badge-status';
-import { ControlPlaneStatus } from '@/resources/base';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
-
-export const statusBadgeDemoSections = [
- { id: 'status-badge-statuses', label: 'Status Badge Statuses' },
- { id: 'status-badge-labels', label: 'Custom Labels' },
- { id: 'status-badge-tooltips', label: 'With Tooltips' },
- { id: 'status-badge-icons', label: 'Icon Display' },
- { id: 'status-badge-control-plane', label: 'ControlPlaneStatus Format' },
- { id: 'status-badge-use-cases', label: 'Use Cases' },
-];
-
-export default function BadgeStatusDemo() {
- return (
-
- {/* Status Badge Statuses */}
-
-
- Status Badge Statuses
-
- All available status types with their default labels and colors
-
-
-
-
-
-
-
-
-
-
-
-
Each status has centralized color configuration in STATUS_CONFIG:
-
-
- active - Green (success theme, light)
-
-
- pending - Blue (custom color)
-
-
- error - Red (danger theme, light)
-
-
- inactive - Gray (secondary theme, light)
-
-
- success - Green (success theme, light)
-
-
-
-
-
-
- {/* Custom Labels */}
-
-
- Custom Labels
- Override default labels with custom text
-
-
-
-
Active Status with Custom Labels
-
-
-
-
-
-
-
-
Success Status with Custom Labels
-
-
-
-
-
-
-
-
Pending Status with Custom Labels
-
-
-
-
-
-
-
-
Error Status with Custom Labels
-
-
-
-
-
-
-
-
-
- {/* With Tooltips */}
-
-
- With Tooltips
-
- Status badges can display tooltips with additional information
-
-
-
-
-
-
-
-
-
-
-
Tooltips are automatically disabled for active status by default.
-
-
-
-
- {/* Icon Display */}
-
-
- Icon Display
- Control icon visibility in status badges
-
-
-
-
With Icons
-
-
-
-
-
-
-
- Note: Only pending status has an icon defined (spinning loader). Other statuses will
- show no icon even when showIcon is true.
-
-
-
-
Without Icons (Default)
-
-
-
-
-
-
-
- By default, showIcon is false. Icons are only shown when explicitly enabled and
- defined in STATUS_CONFIG.
-
-
-
-
-
- {/* ControlPlaneStatus Format */}
-
-
- ControlPlaneStatus Format
-
- BadgeStatus accepts legacy IControlPlaneStatus format for backward compatibility
-
-
-
-
-
Success Status
-
-
-
-
-
-
-
Pending Status
-
-
-
-
-
-
-
-
- The status message from IControlPlaneStatus is automatically used as tooltip text when
- no custom tooltipText is provided.
-
-
-
-
-
- {/* Use Cases */}
-
-
- Common Use Cases
- Real-world examples of BadgeStatus usage
-
-
-
-
Project Status
-
-
-
-
-
-
-
Workload Status
-
-
-
-
-
-
-
-
Export Policy Status
-
-
-
-
-
-
-
-
Domain Verification
-
-
-
-
-
-
-
In Table Cells
-
-
-
-
- Resource
- Status
-
-
-
-
- Project Alpha
-
-
-
-
-
- Workload Beta
-
-
-
-
-
- Export Policy Gamma
-
-
-
-
-
-
-
-
-
-
-
- );
-}
+import { BadgeStatus } from '@/components/badge/badge-status';
+import { ControlPlaneStatus } from '@/resources/base';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
+
+export const statusBadgeDemoSections = [
+ { id: 'status-badge-statuses', label: 'Status Badge Statuses' },
+ { id: 'status-badge-labels', label: 'Custom Labels' },
+ { id: 'status-badge-tooltips', label: 'With Tooltips' },
+ { id: 'status-badge-icons', label: 'Icon Display' },
+ { id: 'status-badge-control-plane', label: 'ControlPlaneStatus Format' },
+ { id: 'status-badge-use-cases', label: 'Use Cases' },
+];
+
+export default function BadgeStatusDemo() {
+ return (
+
+ {/* Status Badge Statuses */}
+
+
+ Status Badge Statuses
+
+ All available status types with their default labels and colors
+
+
+
+
+
+
+
+
+
+
+
+
Each status has centralized color configuration in STATUS_CONFIG:
+
+
+ active - Green (success theme, light)
+
+
+ pending - Blue (custom color)
+
+
+ error - Red (danger theme, light)
+
+
+ inactive - Gray (secondary theme, light)
+
+
+ success - Green (success theme, light)
+
+
+
+
+
+
+ {/* Custom Labels */}
+
+
+ Custom Labels
+ Override default labels with custom text
+
+
+
+
Active Status with Custom Labels
+
+
+
+
+
+
+
+
Success Status with Custom Labels
+
+
+
+
+
+
+
+
Pending Status with Custom Labels
+
+
+
+
+
+
+
+
Error Status with Custom Labels
+
+
+
+
+
+
+
+
+
+ {/* With Tooltips */}
+
+
+ With Tooltips
+
+ Status badges can display tooltips with additional information
+
+
+
+
+
+
+
+
+
+
+
Tooltips are automatically disabled for active status by default.
+
+
+
+
+ {/* Icon Display */}
+
+
+ Icon Display
+ Control icon visibility in status badges
+
+
+
+
With Icons
+
+
+
+
+
+
+
+ Note: Only pending status has an icon defined (spinning loader). Other statuses will
+ show no icon even when showIcon is true.
+
+
+
+
Without Icons (Default)
+
+
+
+
+
+
+
+ By default, showIcon is false. Icons are only shown when explicitly enabled and
+ defined in STATUS_CONFIG.
+
+
+
+
+
+ {/* ControlPlaneStatus Format */}
+
+
+ ControlPlaneStatus Format
+
+ BadgeStatus accepts legacy IControlPlaneStatus format for backward compatibility
+
+
+
+
+
Success Status
+
+
+
+
+
+
+
Pending Status
+
+
+
+
+
+
+
+
+ The status message from IControlPlaneStatus is automatically used as tooltip text when
+ no custom tooltipText is provided.
+
+
+
+
+
+ {/* Use Cases */}
+
+
+ Common Use Cases
+ Real-world examples of BadgeStatus usage
+
+
+
+
Project Status
+
+
+
+
+
+
+
Workload Status
+
+
+
+
+
+
+
+
Export Policy Status
+
+
+
+
+
+
+
+
Domain Verification
+
+
+
+
+
+
+
In Table Cells
+
+
+
+
+ Resource
+ Status
+
+
+
+
+ Project Alpha
+
+
+
+
+
+ Workload Beta
+
+
+
+
+
+ Export Policy Gamma
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/demo/toast.tsx b/app/components/demo/toast.tsx
index 32829d06b..4fcab2a91 100644
--- a/app/components/demo/toast.tsx
+++ b/app/components/demo/toast.tsx
@@ -1,186 +1,186 @@
-import {
- Button,
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
- toast,
-} from '@datum-ui/components';
-
-export const toastDemoSections = [
- { id: 'toast-variants', label: 'Toast Variants' },
- { id: 'toast-descriptions', label: 'Descriptions' },
- { id: 'toast-persistent', label: 'Persistent (Infinity)' },
- { id: 'toast-update', label: 'Update by ID' },
-];
-
-export default function ToastDemo() {
- const showManyToasts = () => {
- toast.message('Toast 1');
- toast.info('Toast 2');
- toast.warning('Toast 3');
- toast.success('Toast 4');
- toast.error('Toast 5');
- };
-
- const demoId = 'toast-demo-id';
-
- return (
-
-
-
- Toast Variants
-
- Quickly test the different toast types and stacking behavior
-
-
-
-
- toast.message('Message toast')}>
- Message
-
- toast.info('Info toast')}>
- Info
-
- toast.warning('Warning toast')}>
- Warning
-
- toast.success('Success toast')}>
- Success
-
- toast.error('Error toast')}>
- Error
-
-
- Fire 5 toasts
-
-
-
-
-
-
-
- Descriptions
- Test title vs description layout, wrapping, and spacing
-
-
-
-
- toast.message('Toast with description', {
- description: 'This is a longer description to test wrapping and spacing.',
- })
- }>
- Message + description
-
-
- toast.success('Saved export policy', {
- description: 'Your changes have been applied and will take effect shortly.',
- })
- }>
- Success + description
-
-
- toast.error('Could not save changes', {
- description:
- 'Something went wrong while saving. Try again, or check the browser console for details.',
- })
- }>
- Error + long description
-
-
-
-
-
-
-
- Persistent (Infinity)
-
- Useful for testing close button, focus, and long-lived errors
-
-
-
-
-
- toast.error('This toast stays until dismissed', {
- description: 'It uses duration: Infinity.',
- duration: Infinity,
- })
- }>
- Persistent error
-
-
-
-
-
-
-
- Update by ID
-
- Re-use the same toast ID to update contents without stacking
-
-
-
-
-
- toast.message('Starting…', {
- id: demoId,
- description: 'This toast will be updated by subsequent actions.',
- })
- }>
- Create (id)
-
-
- toast.info('Still working…', {
- id: demoId,
- description: 'Updated in-place using the same id.',
- })
- }>
- Update → info
-
-
- toast.success('Done!', {
- id: demoId,
- description: 'Final update to the same toast.',
- })
- }>
- Update → success
-
-
- toast.error('Failed', {
- id: demoId,
- description: 'Try updating again to verify replacement behavior.',
- })
- }>
- Update → error
-
-
-
-
-
- );
-}
+import {
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ toast,
+} from '@datum-ui/components';
+
+export const toastDemoSections = [
+ { id: 'toast-variants', label: 'Toast Variants' },
+ { id: 'toast-descriptions', label: 'Descriptions' },
+ { id: 'toast-persistent', label: 'Persistent (Infinity)' },
+ { id: 'toast-update', label: 'Update by ID' },
+];
+
+export default function ToastDemo() {
+ const showManyToasts = () => {
+ toast.message('Toast 1');
+ toast.info('Toast 2');
+ toast.warning('Toast 3');
+ toast.success('Toast 4');
+ toast.error('Toast 5');
+ };
+
+ const demoId = 'toast-demo-id';
+
+ return (
+
+
+
+ Toast Variants
+
+ Quickly test the different toast types and stacking behavior
+
+
+
+
+ toast.message('Message toast')}>
+ Message
+
+ toast.info('Info toast')}>
+ Info
+
+ toast.warning('Warning toast')}>
+ Warning
+
+ toast.success('Success toast')}>
+ Success
+
+ toast.error('Error toast')}>
+ Error
+
+
+ Fire 5 toasts
+
+
+
+
+
+
+
+ Descriptions
+ Test title vs description layout, wrapping, and spacing
+
+
+
+
+ toast.message('Toast with description', {
+ description: 'This is a longer description to test wrapping and spacing.',
+ })
+ }>
+ Message + description
+
+
+ toast.success('Saved export policy', {
+ description: 'Your changes have been applied and will take effect shortly.',
+ })
+ }>
+ Success + description
+
+
+ toast.error('Could not save changes', {
+ description:
+ 'Something went wrong while saving. Try again, or check the browser console for details.',
+ })
+ }>
+ Error + long description
+
+
+
+
+
+
+
+ Persistent (Infinity)
+
+ Useful for testing close button, focus, and long-lived errors
+
+
+
+
+
+ toast.error('This toast stays until dismissed', {
+ description: 'It uses duration: Infinity.',
+ duration: Infinity,
+ })
+ }>
+ Persistent error
+
+
+
+
+
+
+
+ Update by ID
+
+ Re-use the same toast ID to update contents without stacking
+
+
+
+
+
+ toast.message('Starting…', {
+ id: demoId,
+ description: 'This toast will be updated by subsequent actions.',
+ })
+ }>
+ Create (id)
+
+
+ toast.info('Still working…', {
+ id: demoId,
+ description: 'Updated in-place using the same id.',
+ })
+ }>
+ Update → info
+
+
+ toast.success('Done!', {
+ id: demoId,
+ description: 'Final update to the same toast.',
+ })
+ }>
+ Update → success
+
+
+ toast.error('Failed', {
+ id: demoId,
+ description: 'Try updating again to verify replacement behavior.',
+ })
+ }>
+ Update → error
+
+
+
+
+
+ );
+}
diff --git a/app/components/demo/tooltip.tsx b/app/components/demo/tooltip.tsx
index d2c3bc3ae..e786ed166 100644
--- a/app/components/demo/tooltip.tsx
+++ b/app/components/demo/tooltip.tsx
@@ -1,376 +1,376 @@
-import { Tooltip } from '@datum-ui/components';
-import { Button } from '@datum-ui/components';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
-import { Input } from '@datum-ui/components';
-import { Info, Settings, Trash2, Download, HelpCircle, AlertCircle } from 'lucide-react';
-
-export const tooltipDemoSections = [
- { id: 'basic-tooltips', label: 'Basic Tooltips' },
- { id: 'tooltip-delays', label: 'Tooltip Delays' },
- { id: 'tooltips-with-buttons', label: 'Tooltips with Buttons' },
- { id: 'tooltips-with-icons', label: 'Tooltips with Icons' },
- { id: 'rich-content', label: 'Rich Content' },
- { id: 'form-tooltips', label: 'Form Tooltips' },
- { id: 'disabled-elements', label: 'Disabled Elements' },
- { id: 'text-tooltips', label: 'Text Tooltips' },
-];
-
-export default function TooltipDemo() {
- return (
-
- {/* Basic Tooltips */}
-
-
- Basic Tooltips
- Simple tooltips with string messages
-
-
-
-
- Hover me
-
-
- Save
-
-
- Delete
-
-
- Settings
-
-
-
-
-
- {/* Tooltip Delays */}
-
-
- Tooltip Delays
- Different delay durations before showing tooltips
-
-
-
-
- No Delay
-
-
- Default
-
-
- Slow
-
-
- Very Slow
-
-
-
- Hover over each button to experience different delay timings
-
-
-
-
- {/* Tooltips with Buttons */}
-
-
- Tooltips with Buttons
- Tooltips on various button types and sizes
-
-
-
-
Button Sizes
-
-
- Small
-
-
- Default
-
-
- Large
-
-
-
-
-
Button Types
-
-
- Primary
-
-
- Secondary
-
-
- Warning
-
-
- Danger
-
-
- Success
-
-
-
-
-
Button Themes
-
-
- Solid
-
-
-
- Outline
-
-
-
-
- Light
-
-
-
-
- Borderless
-
-
-
-
-
-
-
- {/* Tooltips with Icons */}
-
-
- Tooltips with Icons
- Tooltips on icon buttons and icon elements
-
-
-
-
Icon Buttons
-
-
- } />
-
-
- } type="danger" />
-
-
- } type="secondary" />
-
-
- } type="tertiary" />
-
-
-
-
-
-
Buttons with Icons
-
-
- }>Download
-
-
- } iconPosition="right">
- Settings
-
-
-
-
-
-
-
- {/* Rich Content */}
-
-
- Rich Content
- Tooltips with formatted ReactNode content
-
-
-
-
- Additional Information
-
- This tooltip contains formatted content with multiple lines
-
-
- }>
- Rich Content
-
-
- Feature Details
-
- Feature 1
- Feature 2
- Feature 3
-
-
- }>
- With List
-
-
- Status: Active
- All systems operational
-
- }>
-
-
-
-
-
-
- {/* Form Tooltips */}
-
-
- Form Tooltips
- Tooltips in form contexts for helpful hints
-
-
-
-
- Password
-
-
-
-
-
-
-
-
- Email Address
-
-
-
-
-
-
-
-
- API Key
-
- API Key Requirements
- Must be 32 characters long
- Starts with 'sk-' prefix
-
- }>
-
-
-
-
-
-
-
-
- {/* Disabled Elements */}
-
-
- Disabled Elements
- Tooltips on disabled buttons and elements
-
-
-
-
-
- Disabled Button
-
-
-
-
- }>
- Delete
-
-
-
-
-
- }>
- Settings
-
-
-
-
-
- Note: Wrap disabled buttons in a span to enable tooltip functionality
-
-
-
-
- {/* Text Tooltips */}
-
-
- Text Tooltips
- Tooltips on text elements and truncated content
-
-
-
-
Truncated Text
-
-
-
- This is a very long text that gets truncated in the UI
-
-
-
-
-
-
Date/Time Tooltips
-
-
- 2024-01-15
-
-
- 2h ago
-
-
-
-
-
Abbreviations
-
-
- API
-
-
- HTML
-
-
- CSS
-
-
-
-
-
Status Text
-
-
- Online
-
-
- Maintenance
-
-
- Offline
-
-
-
-
-
-
- );
-}
+import { Tooltip } from '@datum-ui/components';
+import { Button } from '@datum-ui/components';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
+import { Input } from '@datum-ui/components';
+import { Info, Settings, Trash2, Download, HelpCircle, AlertCircle } from 'lucide-react';
+
+export const tooltipDemoSections = [
+ { id: 'basic-tooltips', label: 'Basic Tooltips' },
+ { id: 'tooltip-delays', label: 'Tooltip Delays' },
+ { id: 'tooltips-with-buttons', label: 'Tooltips with Buttons' },
+ { id: 'tooltips-with-icons', label: 'Tooltips with Icons' },
+ { id: 'rich-content', label: 'Rich Content' },
+ { id: 'form-tooltips', label: 'Form Tooltips' },
+ { id: 'disabled-elements', label: 'Disabled Elements' },
+ { id: 'text-tooltips', label: 'Text Tooltips' },
+];
+
+export default function TooltipDemo() {
+ return (
+
+ {/* Basic Tooltips */}
+
+
+ Basic Tooltips
+ Simple tooltips with string messages
+
+
+
+
+ Hover me
+
+
+ Save
+
+
+ Delete
+
+
+ Settings
+
+
+
+
+
+ {/* Tooltip Delays */}
+
+
+ Tooltip Delays
+ Different delay durations before showing tooltips
+
+
+
+
+ No Delay
+
+
+ Default
+
+
+ Slow
+
+
+ Very Slow
+
+
+
+ Hover over each button to experience different delay timings
+
+
+
+
+ {/* Tooltips with Buttons */}
+
+
+ Tooltips with Buttons
+ Tooltips on various button types and sizes
+
+
+
+
Button Sizes
+
+
+ Small
+
+
+ Default
+
+
+ Large
+
+
+
+
+
Button Types
+
+
+ Primary
+
+
+ Secondary
+
+
+ Warning
+
+
+ Danger
+
+
+ Success
+
+
+
+
+
Button Themes
+
+
+ Solid
+
+
+
+ Outline
+
+
+
+
+ Light
+
+
+
+
+ Borderless
+
+
+
+
+
+
+
+ {/* Tooltips with Icons */}
+
+
+ Tooltips with Icons
+ Tooltips on icon buttons and icon elements
+
+
+
+
Icon Buttons
+
+
+ } />
+
+
+ } type="danger" />
+
+
+ } type="secondary" />
+
+
+ } type="tertiary" />
+
+
+
+
+
+
Buttons with Icons
+
+
+ }>Download
+
+
+ } iconPosition="right">
+ Settings
+
+
+
+
+
+
+
+ {/* Rich Content */}
+
+
+ Rich Content
+ Tooltips with formatted ReactNode content
+
+
+
+
+ Additional Information
+
+ This tooltip contains formatted content with multiple lines
+
+
+ }>
+ Rich Content
+
+
+ Feature Details
+
+ Feature 1
+ Feature 2
+ Feature 3
+
+
+ }>
+ With List
+
+
+ Status: Active
+ All systems operational
+
+ }>
+
+
+
+
+
+
+ {/* Form Tooltips */}
+
+
+ Form Tooltips
+ Tooltips in form contexts for helpful hints
+
+
+
+
+ Password
+
+
+
+
+
+
+
+
+ Email Address
+
+
+
+
+
+
+
+
+ API Key
+
+ API Key Requirements
+ Must be 32 characters long
+ Starts with 'sk-' prefix
+
+ }>
+
+
+
+
+
+
+
+
+ {/* Disabled Elements */}
+
+
+ Disabled Elements
+ Tooltips on disabled buttons and elements
+
+
+
+
+
+ Disabled Button
+
+
+
+
+ }>
+ Delete
+
+
+
+
+
+ }>
+ Settings
+
+
+
+
+
+ Note: Wrap disabled buttons in a span to enable tooltip functionality
+
+
+
+
+ {/* Text Tooltips */}
+
+
+ Text Tooltips
+ Tooltips on text elements and truncated content
+
+
+
+
Truncated Text
+
+
+
+ This is a very long text that gets truncated in the UI
+
+
+
+
+
+
Date/Time Tooltips
+
+
+ 2024-01-15
+
+
+ 2h ago
+
+
+
+
+
Abbreviations
+
+
+ API
+
+
+ HTML
+
+
+ CSS
+
+
+
+
+
Status Text
+
+
+ Online
+
+
+ Maintenance
+
+
+ Offline
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/error/auth.tsx b/app/components/error/auth.tsx
index 81bfc70df..fa7bd490e 100644
--- a/app/components/error/auth.tsx
+++ b/app/components/error/auth.tsx
@@ -1,29 +1,29 @@
-import { LogoIcon } from '@/components/logo/logo-icon';
-import { paths } from '@/utils/config/paths.config';
-import { Card, CardContent, SpinnerIcon } from '@datum-ui/components';
-import { useEffect } from 'react';
-import { useNavigate } from 'react-router';
-
-export const AuthError = () => {
- const navigate = useNavigate();
-
- useEffect(() => {
- navigate(paths.auth.logOut, { replace: true });
- }, [navigate]);
-
- return (
-
-
-
-
-
Your session has expired
-
-
-
- Logging out...
-
-
-
-
- );
-};
+import { LogoIcon } from '@/components/logo/logo-icon';
+import { paths } from '@/utils/config/paths.config';
+import { Card, CardContent, SpinnerIcon } from '@datum-ui/components';
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router';
+
+export const AuthError = () => {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ navigate(paths.auth.logOut, { replace: true });
+ }, [navigate]);
+
+ return (
+
+
+
+
+
Your session has expired
+
+
+
+ Logging out...
+
+
+
+
+ );
+};
diff --git a/app/components/error/generic.tsx b/app/components/error/generic.tsx
index 36c2ae179..92bff8fdd 100644
--- a/app/components/error/generic.tsx
+++ b/app/components/error/generic.tsx
@@ -1,65 +1,65 @@
-import { LogoIcon } from '@/components/logo/logo-icon';
-import { paths } from '@/utils/config/paths.config';
-import { Button } from '@datum-ui/components';
-import { Card, CardContent } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { BuildingIcon, RefreshCcwIcon } from 'lucide-react';
-// import { useEffect, useState } from 'react';
-import { Link, useNavigate } from 'react-router';
-
-export const GenericError = ({ message }: { message: string }) => {
- const navigate = useNavigate();
- // const [isDebug, setIsDebug] = useState(false);
-
- // useEffect(() => {
- // setIsDebug(window.ENV?.DEBUG || ['localhost', '127.0.0.1'].includes(window.location.hostname));
- // }, []);
-
- return (
-
-
-
-
-
Whoops! Something went wrong.
-
-
- Something went wrong on our end. Our team has been notified, and we're working to
- fix it. Please try again later. If the issue persists, reach out to{' '}
-
- support@datum.net
-
- .
-
- {/* {isDebug && ( */}
-
- {message}
-
- {/* )} */}
-
-
- }
- className="bg-primary hover:bg-primary/90 active:bg-primary/80"
- onClick={() => {
- navigate(paths.home);
- }}>
- Organization
-
- {
- navigate(0);
- }}>
-
- Refresh Page
-
-
-
-
- );
-};
+import { LogoIcon } from '@/components/logo/logo-icon';
+import { paths } from '@/utils/config/paths.config';
+import { Button } from '@datum-ui/components';
+import { Card, CardContent } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { BuildingIcon, RefreshCcwIcon } from 'lucide-react';
+// import { useEffect, useState } from 'react';
+import { Link, useNavigate } from 'react-router';
+
+export const GenericError = ({ message }: { message: string }) => {
+ const navigate = useNavigate();
+ // const [isDebug, setIsDebug] = useState(false);
+
+ // useEffect(() => {
+ // setIsDebug(window.ENV?.DEBUG || ['localhost', '127.0.0.1'].includes(window.location.hostname));
+ // }, []);
+
+ return (
+
+
+
+
+
Whoops! Something went wrong.
+
+
+ Something went wrong on our end. Our team has been notified, and we're working to
+ fix it. Please try again later. If the issue persists, reach out to{' '}
+
+ support@datum.net
+
+ .
+
+ {/* {isDebug && ( */}
+
+ {message}
+
+ {/* )} */}
+
+
+ }
+ className="bg-primary hover:bg-primary/90 active:bg-primary/80"
+ onClick={() => {
+ navigate(paths.home);
+ }}>
+ Organization
+
+ {
+ navigate(0);
+ }}>
+
+ Refresh Page
+
+
+
+
+ );
+};
diff --git a/app/components/field/field-label.tsx b/app/components/field/field-label.tsx
index ffbfb8178..272c1dddc 100644
--- a/app/components/field/field-label.tsx
+++ b/app/components/field/field-label.tsx
@@ -1,59 +1,59 @@
-import { Tooltip } from '@datum-ui/components';
-import { Label } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-import { CircleHelp } from 'lucide-react';
-import { useState } from 'react';
-
-export const FieldLabel = ({
- className,
- label,
- isError,
- isRequired,
- tooltipInfo,
-}: {
- className?: string;
- label: string | React.ReactNode;
- isError?: boolean;
- isRequired?: boolean;
- tooltipInfo?: string | React.ReactNode;
-}) => {
- const [isTooltipVisible, setIsTooltipVisible] = useState(false);
- const [isHovering, setIsHovering] = useState(false);
-
- const handleMouseEnter = (): void => setIsHovering(true);
- const handleMouseLeave = (): void => setIsHovering(false);
-
- const handleTooltipOpenChange = (open: boolean): void => {
- setIsTooltipVisible(open);
- };
- return (
-
-
- {label}
- {isRequired && * }
-
- {tooltipInfo && (
-
-
-
- )}
-
- );
-};
+import { Tooltip } from '@datum-ui/components';
+import { Label } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+import { CircleHelp } from 'lucide-react';
+import { useState } from 'react';
+
+export const FieldLabel = ({
+ className,
+ label,
+ isError,
+ isRequired,
+ tooltipInfo,
+}: {
+ className?: string;
+ label: string | React.ReactNode;
+ isError?: boolean;
+ isRequired?: boolean;
+ tooltipInfo?: string | React.ReactNode;
+}) => {
+ const [isTooltipVisible, setIsTooltipVisible] = useState(false);
+ const [isHovering, setIsHovering] = useState(false);
+
+ const handleMouseEnter = (): void => setIsHovering(true);
+ const handleMouseLeave = (): void => setIsHovering(false);
+
+ const handleTooltipOpenChange = (open: boolean): void => {
+ setIsTooltipVisible(open);
+ };
+ return (
+
+
+ {label}
+ {isRequired && * }
+
+ {tooltipInfo && (
+
+
+
+ )}
+
+ );
+};
diff --git a/app/components/field/field.tsx b/app/components/field/field.tsx
index 63e6f30fb..7a3e687ed 100644
--- a/app/components/field/field.tsx
+++ b/app/components/field/field.tsx
@@ -1,53 +1,53 @@
-import { FieldLabel } from './field-label';
-import { cn } from '@shadcn/lib/utils';
-
-interface FieldProps {
- label?: string | React.ReactNode;
- children: React.ReactNode;
- description?: string | React.ReactNode;
- errors?: string[];
- className?: string;
- labelClassName?: string;
- tooltipInfo?: string | React.ReactNode;
- isRequired?: boolean;
-}
-
-export const Field = ({
- label,
- children,
- description,
- errors,
- className,
- labelClassName,
- tooltipInfo,
- isRequired = false,
-}: FieldProps) => {
- return (
-
- {label && (
-
- )}
- {children}
- {description &&
{description}
}
- {errors && (
-
1 && 'list-disc pl-4'
- )}>
- {errors.map((error) => (
-
- {error}
-
- ))}
-
- )}
-
- );
-};
+import { FieldLabel } from './field-label';
+import { cn } from '@shadcn/lib/utils';
+
+interface FieldProps {
+ label?: string | React.ReactNode;
+ children: React.ReactNode;
+ description?: string | React.ReactNode;
+ errors?: string[];
+ className?: string;
+ labelClassName?: string;
+ tooltipInfo?: string | React.ReactNode;
+ isRequired?: boolean;
+}
+
+export const Field = ({
+ label,
+ children,
+ description,
+ errors,
+ className,
+ labelClassName,
+ tooltipInfo,
+ isRequired = false,
+}: FieldProps) => {
+ return (
+
+ {label && (
+
+ )}
+ {children}
+ {description &&
{description}
}
+ {errors && (
+
1 && 'list-disc pl-4'
+ )}>
+ {errors.map((error) => (
+
+ {error}
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/app/components/header/breadcrumb.tsx b/app/components/header/breadcrumb.tsx
index 65b1da66a..9b185f326 100644
--- a/app/components/header/breadcrumb.tsx
+++ b/app/components/header/breadcrumb.tsx
@@ -1,133 +1,133 @@
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { cn } from '@shadcn/lib/utils';
-import {
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbPage,
- BreadcrumbSeparator,
- Breadcrumb as BreadcrumbUI,
-} from '@shadcn/ui/breadcrumb';
-import { Home } from 'lucide-react';
-import React, { useMemo } from 'react';
-import { useLocation, useMatches } from 'react-router';
-
-/**
- * Type for route handle with breadcrumb options
- */
-interface _RouteHandleWithBreadcrumb {
- breadcrumb: (data?: unknown) => React.ReactNode;
- /**
- * Optional path function to define the breadcrumb link destination.
- * If provided, this will be used instead of the route's pathname.
- * Receives the route data as parameter (falls back to parent route data if not available).
- */
- path?: (data?: unknown) => string;
- /**
- * Optional flag (or resolver) to hide breadcrumb completely for a specific route.
- * When true, the breadcrumb navigation won't render for that route.
- */
- hideBreadcrumb?: boolean | ((data?: unknown) => boolean);
-}
-
-/**
- * Interface for breadcrumb item
- */
-interface BreadcrumbItem {
- key: string;
- path: string;
- content: React.ReactNode;
- isCurrentPath: boolean;
- isLast: boolean;
-}
-
-/**
- * Enhanced Breadcrumb component that gets route matches directly
- * and renders a breadcrumb navigation based on route data
- */
-export const Breadcrumb = ({ className }: { className?: string }): React.ReactElement | null => {
- const location = useLocation();
- const matches = useMatches();
-
- // Memoize the breadcrumb items to prevent unnecessary re-renders
- const items = useMemo(() => {
- const matchesWithBreadcrumb = matches.filter((match: any) => Boolean(match.handle?.breadcrumb));
-
- return matchesWithBreadcrumb.map((match: any, index) => {
- const isCurrentPath =
- match.pathname.includes(location.pathname) || match.pathname === location.pathname;
- const isLast = index === matchesWithBreadcrumb.length - 1;
-
- // Determine the path to use for this breadcrumb item
- let path = match.pathname;
-
- // Try to get data from current match, or fall back to parent match data
- const matchIndex = matches.indexOf(match);
- const fallbackData =
- matchIndex > 0
- ? [...matches]
- .slice(0, matchIndex)
- .reverse()
- .find((parentMatch) => parentMatch.data)?.data
- : undefined;
- const routeData = match.data ?? fallbackData;
-
- // If handle.path is defined, use it (explicit override)
- if (match.handle?.path) {
- path = match.handle.path(routeData);
- }
-
- return {
- key: `breadcrumb-${match.pathname || match.id || index}`,
- path,
- content: match.handle.breadcrumb(routeData),
- isCurrentPath,
- isLast,
- };
- });
- }, [matches, location.pathname]);
-
- // Check if breadcrumb should be hidden for the active route
- const activeMatch = matches[matches.length - 1];
- const activeHandle = activeMatch?.handle as _RouteHandleWithBreadcrumb | undefined;
- const shouldHideBreadcrumb =
- typeof activeHandle?.hideBreadcrumb === 'function'
- ? activeHandle.hideBreadcrumb(activeMatch.data)
- : Boolean(activeHandle?.hideBreadcrumb);
-
- if (shouldHideBreadcrumb) return null;
-
- // If there are no breadcrumb items, don't render anything
- if (items?.length <= 1) return null;
-
- return (
-
-
-
-
-
-
-
-
- {items.map((item) => (
-
-
- {item.isCurrentPath ? (
- {item.content}
- ) : (
-
- {item.content}
-
- )}
-
- {!item.isLast && / }
-
- ))}
-
-
- );
-};
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { cn } from '@shadcn/lib/utils';
+import {
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ Breadcrumb as BreadcrumbUI,
+} from '@shadcn/ui/breadcrumb';
+import { Home } from 'lucide-react';
+import React, { useMemo } from 'react';
+import { useLocation, useMatches } from 'react-router';
+
+/**
+ * Type for route handle with breadcrumb options
+ */
+interface _RouteHandleWithBreadcrumb {
+ breadcrumb: (data?: unknown) => React.ReactNode;
+ /**
+ * Optional path function to define the breadcrumb link destination.
+ * If provided, this will be used instead of the route's pathname.
+ * Receives the route data as parameter (falls back to parent route data if not available).
+ */
+ path?: (data?: unknown) => string;
+ /**
+ * Optional flag (or resolver) to hide breadcrumb completely for a specific route.
+ * When true, the breadcrumb navigation won't render for that route.
+ */
+ hideBreadcrumb?: boolean | ((data?: unknown) => boolean);
+}
+
+/**
+ * Interface for breadcrumb item
+ */
+interface BreadcrumbItem {
+ key: string;
+ path: string;
+ content: React.ReactNode;
+ isCurrentPath: boolean;
+ isLast: boolean;
+}
+
+/**
+ * Enhanced Breadcrumb component that gets route matches directly
+ * and renders a breadcrumb navigation based on route data
+ */
+export const Breadcrumb = ({ className }: { className?: string }): React.ReactElement | null => {
+ const location = useLocation();
+ const matches = useMatches();
+
+ // Memoize the breadcrumb items to prevent unnecessary re-renders
+ const items = useMemo(() => {
+ const matchesWithBreadcrumb = matches.filter((match: any) => Boolean(match.handle?.breadcrumb));
+
+ return matchesWithBreadcrumb.map((match: any, index) => {
+ const isCurrentPath =
+ match.pathname.includes(location.pathname) || match.pathname === location.pathname;
+ const isLast = index === matchesWithBreadcrumb.length - 1;
+
+ // Determine the path to use for this breadcrumb item
+ let path = match.pathname;
+
+ // Try to get data from current match, or fall back to parent match data
+ const matchIndex = matches.indexOf(match);
+ const fallbackData =
+ matchIndex > 0
+ ? [...matches]
+ .slice(0, matchIndex)
+ .reverse()
+ .find((parentMatch) => parentMatch.data)?.data
+ : undefined;
+ const routeData = match.data ?? fallbackData;
+
+ // If handle.path is defined, use it (explicit override)
+ if (match.handle?.path) {
+ path = match.handle.path(routeData);
+ }
+
+ return {
+ key: `breadcrumb-${match.pathname || match.id || index}`,
+ path,
+ content: match.handle.breadcrumb(routeData),
+ isCurrentPath,
+ isLast,
+ };
+ });
+ }, [matches, location.pathname]);
+
+ // Check if breadcrumb should be hidden for the active route
+ const activeMatch = matches[matches.length - 1];
+ const activeHandle = activeMatch?.handle as _RouteHandleWithBreadcrumb | undefined;
+ const shouldHideBreadcrumb =
+ typeof activeHandle?.hideBreadcrumb === 'function'
+ ? activeHandle.hideBreadcrumb(activeMatch.data)
+ : Boolean(activeHandle?.hideBreadcrumb);
+
+ if (shouldHideBreadcrumb) return null;
+
+ // If there are no breadcrumb items, don't render anything
+ if (items?.length <= 1) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ {items.map((item) => (
+
+
+ {item.isCurrentPath ? (
+ {item.content}
+ ) : (
+
+ {item.content}
+
+ )}
+
+ {!item.isLast && / }
+
+ ))}
+
+
+ );
+};
diff --git a/app/components/header/header.tsx b/app/components/header/header.tsx
index 7678b781a..ae13f069c 100644
--- a/app/components/header/header.tsx
+++ b/app/components/header/header.tsx
@@ -1,113 +1,113 @@
-import { MobileSwitcherBar } from './mobile-switcher-sheet';
-import { OrgProjectSwitcher } from './org-project-switcher';
-import { UserDropdown } from './user-dropdown';
-import { LogoIcon } from '@/components/logo/logo-icon';
-import { MobileMenu } from '@/components/mobile-menu';
-import { NotificationDropdown } from '@/components/notification';
-import { helpScoutAPI } from '@/modules/helpscout';
-import type { Organization } from '@/resources/organizations';
-import type { Project } from '@/resources/projects';
-import { paths } from '@/utils/config/paths.config';
-import { Button, Skeleton } from '@datum-ui/components';
-import { Tooltip } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { NavItem } from '@datum-ui/components/sidebar';
-import { TaskQueueDropdown } from '@datum-ui/components/task-queue';
-import { BookOpen, LifeBuoy } from 'lucide-react';
-import { Link } from 'react-router';
-
-export const Header = ({
- currentProject,
- currentOrg,
- switcherLoading = false,
- navItems = [],
-}: {
- currentProject?: Project;
- currentOrg?: Organization;
- switcherLoading?: boolean;
- navItems?: NavItem[];
-}) => {
- return (
-
-
-
- {/* Mobile: sub-bar with org/project switchers */}
-
-
- );
-};
+import { MobileSwitcherBar } from './mobile-switcher-sheet';
+import { OrgProjectSwitcher } from './org-project-switcher';
+import { UserDropdown } from './user-dropdown';
+import { LogoIcon } from '@/components/logo/logo-icon';
+import { MobileMenu } from '@/components/mobile-menu';
+import { NotificationDropdown } from '@/components/notification';
+import { helpScoutAPI } from '@/modules/helpscout';
+import type { Organization } from '@/resources/organizations';
+import type { Project } from '@/resources/projects';
+import { paths } from '@/utils/config/paths.config';
+import { Button, Skeleton } from '@datum-ui/components';
+import { Tooltip } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { NavItem } from '@datum-ui/components/sidebar';
+import { TaskQueueDropdown } from '@datum-ui/components/task-queue';
+import { BookOpen, LifeBuoy } from 'lucide-react';
+import { Link } from 'react-router';
+
+export const Header = ({
+ currentProject,
+ currentOrg,
+ switcherLoading = false,
+ navItems = [],
+}: {
+ currentProject?: Project;
+ currentOrg?: Organization;
+ switcherLoading?: boolean;
+ navItems?: NavItem[];
+}) => {
+ return (
+
+
+
+ {/* Mobile: sub-bar with org/project switchers */}
+
+
+ );
+};
diff --git a/app/components/header/index.ts b/app/components/header/index.ts
index 261817e9e..2b63d848e 100644
--- a/app/components/header/index.ts
+++ b/app/components/header/index.ts
@@ -1,7 +1,7 @@
-export { Breadcrumb } from './breadcrumb';
-export { Header } from './header';
-export { OrgProjectSwitcher } from './org-project-switcher';
-export { OrganizationSwitcher } from './org-switcher';
-export { ProjectSwitcher } from './project-switcher';
-export { default as SearchBar } from './search-bar';
-export { UserDropdown } from './user-dropdown';
+export { Breadcrumb } from './breadcrumb';
+export { Header } from './header';
+export { OrgProjectSwitcher } from './org-project-switcher';
+export { OrganizationSwitcher } from './org-switcher';
+export { ProjectSwitcher } from './project-switcher';
+export { default as SearchBar } from './search-bar';
+export { UserDropdown } from './user-dropdown';
diff --git a/app/components/header/mobile-switcher-sheet.tsx b/app/components/header/mobile-switcher-sheet.tsx
index 7122eb75f..38966a201 100644
--- a/app/components/header/mobile-switcher-sheet.tsx
+++ b/app/components/header/mobile-switcher-sheet.tsx
@@ -1,312 +1,319 @@
-import { OrganizationItem } from '@/components/select-organization/organization-item';
-import { useApp } from '@/providers/app.provider';
-import { type Organization, useOrganizationsGql } from '@/resources/organizations';
-import type { Project } from '@/resources/projects';
-import { useProjects } from '@/resources/projects/project.queries';
-import { paths } from '@/utils/config/paths.config';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { Skeleton, SpinnerIcon } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { MobileSheet } from '@datum-ui/components/mobile-sheet';
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@shadcn/ui/command';
-import { Building, CheckIcon, ChevronDown, FolderRoot, Settings2 } from 'lucide-react';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Link, useNavigate } from 'react-router';
-
-// ---------------------------------------------------------------------------
-// Org Switcher Sheet
-// ---------------------------------------------------------------------------
-
-function OrgSwitcherSheet({
- open,
- onOpenChange,
- currentOrg,
-}: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- currentOrg?: Organization;
-}) {
- const { setOrganization } = useApp();
- const navigate = useNavigate();
- const { data, isLoading } = useOrganizationsGql(undefined, { enabled: open });
-
- const organizations = useMemo(() => {
- const items = [...(data?.items ?? [])];
- return items.sort((a, b) => {
- if (a.name === currentOrg?.name) return -1;
- if (b.name === currentOrg?.name) return 1;
- return (a.displayName ?? a.name ?? '').localeCompare(b.displayName ?? b.name ?? '');
- });
- }, [data, currentOrg?.name]);
-
- const handleSelect = useCallback(
- (org: Organization) => {
- onOpenChange(false);
- if (org.name !== currentOrg?.name) {
- setOrganization(org);
- navigate(getPathWithParams(paths.org.detail.projects.root, { orgId: org.name }));
- }
- },
- [currentOrg?.name, setOrganization, navigate, onOpenChange]
- );
-
- return (
-
- {currentOrg?.name && (
-
-
- Settings
-
- )}
-
-
- All organizations
-
-
- }>
-
-
-
- No organizations found.
- {isLoading && organizations.length === 0 ? (
-
-
-
-
- Loading...
-
- ) : (
-
- {organizations.map((org) => {
- const isSelected = org.name === currentOrg?.name;
- return (
- handleSelect(org)}
- className="cursor-pointer justify-between px-3 py-2.5">
-
- {isSelected && }
-
- );
- })}
-
- )}
-
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Project Switcher Sheet
-// ---------------------------------------------------------------------------
-
-function ProjectSwitcherSheet({
- open,
- onOpenChange,
- currentProject,
-}: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- currentProject?: Project;
-}) {
- const { orgId } = useApp();
- const navigate = useNavigate();
- const { data, isLoading } = useProjects(orgId ?? '', undefined, { enabled: open && !!orgId });
-
- const projects = useMemo(() => {
- const items = data?.items ?? [];
- return [...items].sort((a, b) => {
- if (a.uid === currentProject?.uid) return -1;
- if (b.uid === currentProject?.uid) return 1;
- return (a.displayName ?? '').localeCompare(b.displayName ?? '');
- });
- }, [data, currentProject?.uid]);
-
- const handleSelect = useCallback(
- (project: Project) => {
- onOpenChange(false);
- if (project.uid !== currentProject?.uid) {
- navigate(getPathWithParams(paths.project.detail.home, { projectId: project.name }));
- }
- },
- [currentProject?.uid, navigate, onOpenChange]
- );
-
- return (
-
-
- Create project
-
- }>
-
-
-
- No projects found.
- {isLoading && projects.length === 0 ? (
-
-
-
-
- Loading...
-
- ) : (
-
- {projects.map((project) => {
- const isSelected = project.uid === currentProject?.uid;
- return (
- handleSelect(project)}
- className="cursor-pointer justify-between px-3 py-2.5">
- {project.displayName}
- {isSelected && }
-
- );
- })}
-
- )}
-
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Sub-bar (rendered below the header on mobile)
-// ---------------------------------------------------------------------------
-
-const SwitcherRowSkeleton = () => (
-
-
-
-
-
-);
-
-export function MobileSwitcherBar({
- currentOrg,
- currentProject,
- switcherLoading = false,
-}: {
- currentOrg?: Organization;
- currentProject?: Project;
- switcherLoading?: boolean;
-}) {
- const [orgSheetOpen, setOrgSheetOpen] = useState(false);
- const [projectSheetOpen, setProjectSheetOpen] = useState(false);
- const hasEverShown = useRef(false);
-
- const orgName = currentOrg?.displayName ?? currentOrg?.name;
- const orgReady = !!orgName;
-
- useEffect(() => {
- if (orgReady) {
- hasEverShown.current = true;
- }
- }, [orgReady]);
-
- // Show skeleton during loading or transitional gaps (org was shown before but data is re-fetching)
- const showSkeleton = !orgReady && (switcherLoading || hasEverShown.current);
-
- // Hide completely only on pages that genuinely have no org context
- if (!orgReady && !showSkeleton) return null;
-
- return (
- <>
-
- {/* Org row */}
- {!orgReady ? (
-
- ) : (
-
setOrgSheetOpen(true)}
- className="flex min-h-[36px] w-full items-center gap-2 px-4 py-1.5"
- aria-label="Switch organization">
-
- {orgName}
-
-
- )}
-
- {/* Project row */}
- {switcherLoading && !currentProject ? (
-
-
-
- ) : (
- currentProject && (
-
setProjectSheetOpen(true)}
- className="border-sidebar-border flex min-h-[36px] w-full items-center gap-2 border-t px-4 py-1.5"
- aria-label="Switch project">
-
-
- {currentProject.displayName}
-
-
-
- )
- )}
-
-
-
-
- {currentProject && (
-
- )}
- >
- );
-}
+import { OrganizationItem } from '@/components/select-organization/organization-item';
+import { useApp } from '@/providers/app.provider';
+import { useConfirmContextSwitch } from '@/providers/terminal-session.provider';
+import { type Organization, useOrganizationsGql } from '@/resources/organizations';
+import type { Project } from '@/resources/projects';
+import { useProjects } from '@/resources/projects/project.queries';
+import { paths } from '@/utils/config/paths.config';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { Skeleton, SpinnerIcon } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { MobileSheet } from '@datum-ui/components/mobile-sheet';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@shadcn/ui/command';
+import { Building, CheckIcon, ChevronDown, FolderRoot, Settings2 } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Link, useNavigate } from 'react-router';
+
+// ---------------------------------------------------------------------------
+// Org Switcher Sheet
+// ---------------------------------------------------------------------------
+
+function OrgSwitcherSheet({
+ open,
+ onOpenChange,
+ currentOrg,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ currentOrg?: Organization;
+}) {
+ const { setOrganization } = useApp();
+ const navigate = useNavigate();
+ const confirmContextSwitch = useConfirmContextSwitch();
+ const { data, isLoading } = useOrganizationsGql(undefined, { enabled: open });
+
+ const organizations = useMemo(() => {
+ const items = [...(data?.items ?? [])];
+ return items.sort((a, b) => {
+ if (a.name === currentOrg?.name) return -1;
+ if (b.name === currentOrg?.name) return 1;
+ return (a.displayName ?? a.name ?? '').localeCompare(b.displayName ?? b.name ?? '');
+ });
+ }, [data, currentOrg?.name]);
+
+ const handleSelect = useCallback(
+ async (org: Organization) => {
+ onOpenChange(false);
+ if (org.name === currentOrg?.name) return;
+ // Prompt if a datumctl terminal is live — switching org invalidates
+ // the server-pinned session.
+ if (!(await confirmContextSwitch())) return;
+ setOrganization(org);
+ navigate(getPathWithParams(paths.org.detail.projects.root, { orgId: org.name }));
+ },
+ [confirmContextSwitch, currentOrg?.name, setOrganization, navigate, onOpenChange]
+ );
+
+ return (
+
+ {currentOrg?.name && (
+
+
+ Settings
+
+ )}
+
+
+ All organizations
+
+
+ }>
+
+
+
+ No organizations found.
+ {isLoading && organizations.length === 0 ? (
+
+
+
+
+ Loading...
+
+ ) : (
+
+ {organizations.map((org) => {
+ const isSelected = org.name === currentOrg?.name;
+ return (
+ handleSelect(org)}
+ className="cursor-pointer justify-between px-3 py-2.5">
+
+ {isSelected && }
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Project Switcher Sheet
+// ---------------------------------------------------------------------------
+
+function ProjectSwitcherSheet({
+ open,
+ onOpenChange,
+ currentProject,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ currentProject?: Project;
+}) {
+ const { orgId } = useApp();
+ const navigate = useNavigate();
+ const confirmContextSwitch = useConfirmContextSwitch();
+ const { data, isLoading } = useProjects(orgId ?? '', undefined, { enabled: open && !!orgId });
+
+ const projects = useMemo(() => {
+ const items = data?.items ?? [];
+ return [...items].sort((a, b) => {
+ if (a.uid === currentProject?.uid) return -1;
+ if (b.uid === currentProject?.uid) return 1;
+ return (a.displayName ?? '').localeCompare(b.displayName ?? '');
+ });
+ }, [data, currentProject?.uid]);
+
+ const handleSelect = useCallback(
+ async (project: Project) => {
+ onOpenChange(false);
+ if (project.uid === currentProject?.uid) return;
+ // Prompt if a datumctl terminal is live — the server-side session is
+ // pinned to the current project and would be lost.
+ if (!(await confirmContextSwitch())) return;
+ navigate(getPathWithParams(paths.project.detail.home, { projectId: project.name }));
+ },
+ [confirmContextSwitch, currentProject?.uid, navigate, onOpenChange]
+ );
+
+ return (
+
+
+ Create project
+
+ }>
+
+
+
+ No projects found.
+ {isLoading && projects.length === 0 ? (
+
+
+
+
+ Loading...
+
+ ) : (
+
+ {projects.map((project) => {
+ const isSelected = project.uid === currentProject?.uid;
+ return (
+ handleSelect(project)}
+ className="cursor-pointer justify-between px-3 py-2.5">
+ {project.displayName}
+ {isSelected && }
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Sub-bar (rendered below the header on mobile)
+// ---------------------------------------------------------------------------
+
+const SwitcherRowSkeleton = () => (
+
+
+
+
+
+);
+
+export function MobileSwitcherBar({
+ currentOrg,
+ currentProject,
+ switcherLoading = false,
+}: {
+ currentOrg?: Organization;
+ currentProject?: Project;
+ switcherLoading?: boolean;
+}) {
+ const [orgSheetOpen, setOrgSheetOpen] = useState(false);
+ const [projectSheetOpen, setProjectSheetOpen] = useState(false);
+ const hasEverShown = useRef(false);
+
+ const orgName = currentOrg?.displayName ?? currentOrg?.name;
+ const orgReady = !!orgName;
+
+ useEffect(() => {
+ if (orgReady) {
+ hasEverShown.current = true;
+ }
+ }, [orgReady]);
+
+ // Show skeleton during loading or transitional gaps (org was shown before but data is re-fetching)
+ const showSkeleton = !orgReady && (switcherLoading || hasEverShown.current);
+
+ // Hide completely only on pages that genuinely have no org context
+ if (!orgReady && !showSkeleton) return null;
+
+ return (
+ <>
+
+ {/* Org row */}
+ {!orgReady ? (
+
+ ) : (
+
setOrgSheetOpen(true)}
+ className="flex min-h-[36px] w-full items-center gap-2 px-4 py-1.5"
+ aria-label="Switch organization">
+
+ {orgName}
+
+
+ )}
+
+ {/* Project row */}
+ {switcherLoading && !currentProject ? (
+
+
+
+ ) : (
+ currentProject && (
+
setProjectSheetOpen(true)}
+ className="border-sidebar-border flex min-h-[36px] w-full items-center gap-2 border-t px-4 py-1.5"
+ aria-label="Switch project">
+
+
+ {currentProject.displayName}
+
+
+
+ )
+ )}
+
+
+
+
+ {currentProject && (
+
+ )}
+ >
+ );
+}
diff --git a/app/components/header/org-project-switcher.tsx b/app/components/header/org-project-switcher.tsx
index 6a1e11524..d21954cb7 100644
--- a/app/components/header/org-project-switcher.tsx
+++ b/app/components/header/org-project-switcher.tsx
@@ -1,86 +1,86 @@
-import { OrganizationSwitcher } from './org-switcher';
-import { ProjectSwitcher } from './project-switcher';
-import type { Organization } from '@/resources/organizations';
-import type { Project } from '@/resources/projects';
-import { Skeleton } from '@datum-ui/components';
-
-export interface OrgProjectSwitcherProps {
- currentOrg?: Organization;
- currentProject?: Project;
- /** Optional class name for the project switcher chevron icon */
- projectSwitcherChevronClassName?: string;
- /** Show loading skeletons to prevent layout shift */
- switcherLoading?: boolean;
-}
-
-const OrgSkeleton = () => (
-
-
-
-
-
-);
-
-const ProjectSkeleton = () => (
-
-
-
-
-
-);
-
-const SeparatorIcon = () => (
-
-
-
-);
-
-export const OrgProjectSwitcher = ({
- currentOrg,
- currentProject,
- projectSwitcherChevronClassName = 'w-4 h-4',
- switcherLoading = false,
-}: OrgProjectSwitcherProps) => {
- const orgReady = currentOrg && (currentOrg.displayName || currentOrg.name);
- const showOrg = orgReady || switcherLoading;
- const showProject = currentProject || switcherLoading;
- const showProjectSkeleton = !orgReady || (!currentProject && switcherLoading);
-
- if (!showOrg && !showProject) {
- return null;
- }
-
- return (
-
- {!orgReady ?
:
}
- {showProject && (
- <>
-
- {showProjectSkeleton ? (
-
- ) : (
- currentProject && (
-
- )
- )}
- >
- )}
-
- );
-};
+import { OrganizationSwitcher } from './org-switcher';
+import { ProjectSwitcher } from './project-switcher';
+import type { Organization } from '@/resources/organizations';
+import type { Project } from '@/resources/projects';
+import { Skeleton } from '@datum-ui/components';
+
+export interface OrgProjectSwitcherProps {
+ currentOrg?: Organization;
+ currentProject?: Project;
+ /** Optional class name for the project switcher chevron icon */
+ projectSwitcherChevronClassName?: string;
+ /** Show loading skeletons to prevent layout shift */
+ switcherLoading?: boolean;
+}
+
+const OrgSkeleton = () => (
+
+
+
+
+
+);
+
+const ProjectSkeleton = () => (
+
+
+
+
+
+);
+
+const SeparatorIcon = () => (
+
+
+
+);
+
+export const OrgProjectSwitcher = ({
+ currentOrg,
+ currentProject,
+ projectSwitcherChevronClassName = 'w-4 h-4',
+ switcherLoading = false,
+}: OrgProjectSwitcherProps) => {
+ const orgReady = currentOrg && (currentOrg.displayName || currentOrg.name);
+ const showOrg = orgReady || switcherLoading;
+ const showProject = currentProject || switcherLoading;
+ const showProjectSkeleton = !orgReady || (!currentProject && switcherLoading);
+
+ if (!showOrg && !showProject) {
+ return null;
+ }
+
+ return (
+
+ {!orgReady ?
:
}
+ {showProject && (
+ <>
+
+ {showProjectSkeleton ? (
+
+ ) : (
+ currentProject && (
+
+ )
+ )}
+ >
+ )}
+
+ );
+};
diff --git a/app/components/header/org-switcher.tsx b/app/components/header/org-switcher.tsx
index 178821a51..ecf95dc22 100644
--- a/app/components/header/org-switcher.tsx
+++ b/app/components/header/org-switcher.tsx
@@ -1,37 +1,43 @@
-import { BadgeStatus } from '@/components/badge/badge-status';
-import { SelectOrganization } from '@/components/select-organization/select-organization';
-import { useApp } from '@/providers/app.provider';
-import type { Organization } from '@/resources/organizations';
-import { paths } from '@/utils/config/paths.config';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { Building } from 'lucide-react';
-import { useNavigate } from 'react-router';
-
-export const OrganizationSwitcher = ({ currentOrg }: { currentOrg: Organization }) => {
- const { setOrganization } = useApp();
- const navigate = useNavigate();
-
- return (
-
-
-
- {currentOrg?.displayName ?? currentOrg?.name}
-
- {currentOrg?.type === 'Personal' && (
-
- )}
- >
- }
- onSelect={(org: Organization) => {
- setOrganization(org);
- navigate(getPathWithParams(paths.org.detail.projects.root, { orgId: org.name }));
- }}
- />
- );
-};
+import { BadgeStatus } from '@/components/badge/badge-status';
+import { SelectOrganization } from '@/components/select-organization/select-organization';
+import { useApp } from '@/providers/app.provider';
+import { useConfirmContextSwitch } from '@/providers/terminal-session.provider';
+import type { Organization } from '@/resources/organizations';
+import { paths } from '@/utils/config/paths.config';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { Building } from 'lucide-react';
+import { useNavigate } from 'react-router';
+
+export const OrganizationSwitcher = ({ currentOrg }: { currentOrg: Organization }) => {
+ const { setOrganization } = useApp();
+ const navigate = useNavigate();
+ const confirmContextSwitch = useConfirmContextSwitch();
+
+ return (
+
+
+
+ {currentOrg?.displayName ?? currentOrg?.name}
+
+ {currentOrg?.type === 'Personal' && (
+
+ )}
+ >
+ }
+ onSelect={async (org: Organization) => {
+ // Block the switch if a datumctl terminal session is live — the
+ // session is pinned to this org on the server, so the switch would
+ // silently kill the running CLI.
+ if (!(await confirmContextSwitch())) return;
+ setOrganization(org);
+ navigate(getPathWithParams(paths.org.detail.projects.root, { orgId: org.name }));
+ }}
+ />
+ );
+};
diff --git a/app/components/header/project-switcher.tsx b/app/components/header/project-switcher.tsx
index 98203f3f3..9529f33ff 100644
--- a/app/components/header/project-switcher.tsx
+++ b/app/components/header/project-switcher.tsx
@@ -1,144 +1,149 @@
-import { useApp } from '@/providers/app.provider';
-import type { Project } from '@/resources/projects';
-import { useProjects } from '@/resources/projects/project.queries';
-import { paths } from '@/utils/config/paths.config';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { Button, SpinnerIcon } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { cn } from '@shadcn/lib/utils';
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- CommandSeparator,
-} from '@shadcn/ui/command';
-import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
-import { CheckIcon, ChevronDown, FolderRoot } from 'lucide-react';
-import { useMemo, useState } from 'react';
-import { Link, useNavigate } from 'react-router';
-
-const ProjectItem = ({ project }: { project: Project }) => {
- return (
-
- {project?.displayName}
-
- );
-};
-
-export const ProjectSwitcher = ({
- currentProject,
- chevronClassName,
-}: {
- currentProject: Project;
- chevronClassName?: string;
-}) => {
- const { orgId } = useApp();
- const navigate = useNavigate();
- const [open, setOpen] = useState(false);
-
- const { data, isLoading } = useProjects(orgId ?? '', undefined, {
- enabled: open && !!orgId,
- });
-
- const onSelect = (project: Project) => {
- navigate(getPathWithParams(paths.project.detail.home, { projectId: project.name }));
- };
-
- const projects: Project[] = useMemo(() => {
- const items = data?.items ?? [];
- return [...items].sort((a, b) => {
- if (a.uid === currentProject.uid) return -1;
- if (b.uid === currentProject.uid) return 1;
- return (a?.displayName ?? '').localeCompare(b?.displayName ?? '');
- });
- }, [data, currentProject.uid]);
-
- return (
-
-
-
-
-
-
- {currentProject?.displayName}
-
-
-
-
-
-
-
-
- No results found.
- {isLoading && projects.length === 0 ? (
-
-
-
-
- Loading
-
- ) : (
-
- {projects.map((project: Project) => {
- const isSelected = project.uid === currentProject.uid;
- return (
- {
- setOpen(false);
- if (!isSelected) {
- onSelect(project);
- }
- }}
- className="cursor-pointer justify-between px-3 py-2">
-
- {isSelected && }
-
- );
- })}
-
- )}
-
-
-
-
-
- Create project
-
-
-
-
-
-
-
- );
-};
+import { useApp } from '@/providers/app.provider';
+import { useConfirmContextSwitch } from '@/providers/terminal-session.provider';
+import type { Project } from '@/resources/projects';
+import { useProjects } from '@/resources/projects/project.queries';
+import { paths } from '@/utils/config/paths.config';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { Button, SpinnerIcon } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { cn } from '@shadcn/lib/utils';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from '@shadcn/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
+import { CheckIcon, ChevronDown, FolderRoot } from 'lucide-react';
+import { useMemo, useState } from 'react';
+import { Link, useNavigate } from 'react-router';
+
+const ProjectItem = ({ project }: { project: Project }) => {
+ return (
+
+ {project?.displayName}
+
+ );
+};
+
+export const ProjectSwitcher = ({
+ currentProject,
+ chevronClassName,
+}: {
+ currentProject: Project;
+ chevronClassName?: string;
+}) => {
+ const { orgId } = useApp();
+ const navigate = useNavigate();
+ const confirmContextSwitch = useConfirmContextSwitch();
+ const [open, setOpen] = useState(false);
+
+ const { data, isLoading } = useProjects(orgId ?? '', undefined, {
+ enabled: open && !!orgId,
+ });
+
+ const onSelect = async (project: Project) => {
+ // A running datumctl terminal is bound to the current project on the
+ // server — warn before navigating away and tearing the WS down.
+ if (!(await confirmContextSwitch())) return;
+ navigate(getPathWithParams(paths.project.detail.home, { projectId: project.name }));
+ };
+
+ const projects: Project[] = useMemo(() => {
+ const items = data?.items ?? [];
+ return [...items].sort((a, b) => {
+ if (a.uid === currentProject.uid) return -1;
+ if (b.uid === currentProject.uid) return 1;
+ return (a?.displayName ?? '').localeCompare(b?.displayName ?? '');
+ });
+ }, [data, currentProject.uid]);
+
+ return (
+
+
+
+
+
+
+ {currentProject?.displayName}
+
+
+
+
+
+
+
+
+ No results found.
+ {isLoading && projects.length === 0 ? (
+
+
+
+
+ Loading
+
+ ) : (
+
+ {projects.map((project: Project) => {
+ const isSelected = project.uid === currentProject.uid;
+ return (
+ {
+ setOpen(false);
+ if (!isSelected) {
+ onSelect(project);
+ }
+ }}
+ className="cursor-pointer justify-between px-3 py-2">
+
+ {isSelected && }
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ Create project
+
+
+
+
+
+
+
+ );
+};
diff --git a/app/components/header/search-bar.tsx b/app/components/header/search-bar.tsx
index cff418a4d..7b9149040 100644
--- a/app/components/header/search-bar.tsx
+++ b/app/components/header/search-bar.tsx
@@ -1,85 +1,85 @@
-import { useOs } from '@/hooks/useOs';
-import { paths } from '@/utils/config/paths.config';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { Button } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-import {
- CommandDialog,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@shadcn/ui/command';
-import { Search } from 'lucide-react';
-import { useEffect, useState } from 'react';
-import { Link, useParams } from 'react-router';
-
-export default function SearchBar({ className }: { className?: string }) {
- const { orgId } = useParams();
- const os = useOs();
-
- const [open, setOpen] = useState(false);
-
- useEffect(() => {
- const down = (e: KeyboardEvent) => {
- if (e.key === '/' && (e.metaKey || e.ctrlKey)) {
- e.preventDefault();
- setOpen((open) => !open);
- }
- };
- document.addEventListener('keydown', down);
- return () => document.removeEventListener('keydown', down);
- }, []);
-
- return (
- <>
- setOpen(true)}>
-
-
-
- Search...
-
-
-
- {os === 'macos' ? '⌘' : 'ctrl'} + /
-
-
-
-
-
-
- No results found.
-
- {orgId && (
- <>
-
-
- Projects
-
-
-
-
- Org Settings
-
-
- >
- )}
-
-
- Docs
-
-
-
-
-
- >
- );
-}
+import { useOs } from '@/hooks/useOs';
+import { paths } from '@/utils/config/paths.config';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { Button } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@shadcn/ui/command';
+import { Search } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { Link, useParams } from 'react-router';
+
+export default function SearchBar({ className }: { className?: string }) {
+ const { orgId } = useParams();
+ const os = useOs();
+
+ const [open, setOpen] = useState(false);
+
+ useEffect(() => {
+ const down = (e: KeyboardEvent) => {
+ if (e.key === '/' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ setOpen((open) => !open);
+ }
+ };
+ document.addEventListener('keydown', down);
+ return () => document.removeEventListener('keydown', down);
+ }, []);
+
+ return (
+ <>
+ setOpen(true)}>
+
+
+
+ Search...
+
+
+
+ {os === 'macos' ? '⌘' : 'ctrl'} + /
+
+
+
+
+
+
+ No results found.
+
+ {orgId && (
+ <>
+
+
+ Projects
+
+
+
+
+ Org Settings
+
+
+ >
+ )}
+
+
+ Docs
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/components/header/user-dropdown.tsx b/app/components/header/user-dropdown.tsx
index 96b8f3c75..a944472c8 100644
--- a/app/components/header/user-dropdown.tsx
+++ b/app/components/header/user-dropdown.tsx
@@ -1,136 +1,150 @@
-import { DarkModeIcon } from '@/components/icon/dark-mode';
-import { LightModeIcon } from '@/components/icon/light-mode';
-import { SystemModeIcon } from '@/components/icon/system-mode';
-import { useApp } from '@/providers/app.provider';
-import { ThemeValue, useUpdateUserPreferences } from '@/resources/users';
-import { paths } from '@/utils/config/paths.config';
-import { getInitials } from '@/utils/helpers/text.helper';
-import { useTheme } from '@datum-cloud/datum-ui/theme';
-import { Button } from '@datum-ui/components';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { cn } from '@shadcn/lib/utils';
-import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar';
-import { CheckIcon, LogOut, UserCogIcon } from 'lucide-react';
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router';
-
-const THEME_OPTIONS = [
- { value: 'light', label: 'Light', icon: LightModeIcon },
- { value: 'dark', label: 'Dark', icon: DarkModeIcon },
- { value: 'system', label: 'System', icon: SystemModeIcon },
-] as const;
-
-export const UserDropdown = ({ className }: { className?: string }) => {
- const navigate = useNavigate();
- const { resolvedTheme, setTheme } = useTheme();
- const { user, userPreferences, setUser } = useApp();
- const userId = user?.sub ?? 'me';
-
- const [currentTheme, setCurrentTheme] = useState(resolvedTheme as ThemeValue);
- const [open, setOpen] = useState(false);
-
- const updatePreferencesMutation = useUpdateUserPreferences(userId, {
- onSuccess: (data) => {
- setUser(data);
- },
- });
-
- const updateTheme = (theme: ThemeValue) => {
- setTheme(theme);
- setCurrentTheme(theme);
-
- updatePreferencesMutation.mutate({ theme });
- };
-
- useEffect(() => {
- if (userPreferences) {
- setCurrentTheme(userPreferences.theme);
- }
- }, [userPreferences]);
-
- return (
-
-
-
-
- {user?.avatarUrl && }
-
- {getInitials(user?.fullName || '')}
-
-
-
- {user?.fullName}
-
-
-
-
-
- {user?.fullName}
- {user?.email}
-
-
-
-
- {THEME_OPTIONS.map((option) => (
- updateTheme(option.value)}>
-
-
- {option.label}
-
- {currentTheme === option.value && (
-
- )}
-
- ))}
-
-
-
- navigate(paths.account.settings.general)}>
-
-
- Account Settings
-
-
- {
- navigate(paths.auth.logOut, { replace: true, preventScrollReset: true });
- }}>
-
-
- Log Out
-
-
-
-
-
- );
-};
+import { DarkModeIcon } from '@/components/icon/dark-mode';
+import { LightModeIcon } from '@/components/icon/light-mode';
+import { SystemModeIcon } from '@/components/icon/system-mode';
+import { useApp } from '@/providers/app.provider';
+import { useConfirmTerminalExit } from '@/providers/terminal-session.provider';
+import { ThemeValue, useUpdateUserPreferences } from '@/resources/users';
+import { paths } from '@/utils/config/paths.config';
+import { getInitials } from '@/utils/helpers/text.helper';
+import { useTheme } from '@datum-cloud/datum-ui/theme';
+import { Button } from '@datum-ui/components';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { cn } from '@shadcn/lib/utils';
+import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar';
+import { CheckIcon, LogOut, UserCogIcon } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router';
+
+const THEME_OPTIONS = [
+ { value: 'light', label: 'Light', icon: LightModeIcon },
+ { value: 'dark', label: 'Dark', icon: DarkModeIcon },
+ { value: 'system', label: 'System', icon: SystemModeIcon },
+] as const;
+
+export const UserDropdown = ({ className }: { className?: string }) => {
+ const navigate = useNavigate();
+ const { resolvedTheme, setTheme } = useTheme();
+ const { user, userPreferences, setUser } = useApp();
+ const confirmTerminalExit = useConfirmTerminalExit();
+ const userId = user?.sub ?? 'me';
+
+ const [currentTheme, setCurrentTheme] = useState(resolvedTheme as ThemeValue);
+ const [open, setOpen] = useState(false);
+
+ const updatePreferencesMutation = useUpdateUserPreferences(userId, {
+ onSuccess: (data) => {
+ setUser(data);
+ },
+ });
+
+ const updateTheme = (theme: ThemeValue) => {
+ setTheme(theme);
+ setCurrentTheme(theme);
+
+ updatePreferencesMutation.mutate({ theme });
+ };
+
+ useEffect(() => {
+ if (userPreferences) {
+ setCurrentTheme(userPreferences.theme);
+ }
+ }, [userPreferences]);
+
+ return (
+
+
+
+
+ {user?.avatarUrl && }
+
+ {getInitials(user?.fullName || '')}
+
+
+
+ {user?.fullName}
+
+
+
+
+
+ {user?.fullName}
+ {user?.email}
+
+
+
+
+ {THEME_OPTIONS.map((option) => (
+ updateTheme(option.value)}>
+
+
+ {option.label}
+
+ {currentTheme === option.value && (
+
+ )}
+
+ ))}
+
+
+
+ {
+ // Account Settings lives outside the project layout, which
+ // unmounts the terminal. Warn before tearing down a live
+ // datumctl session; on confirm the exit hook closes it
+ // cleanly so the socket doesn't linger through the nav.
+ if (!(await confirmTerminalExit())) return;
+ navigate(paths.account.settings.general);
+ }}>
+
+
+ Account Settings
+
+
+ {
+ // Logout ends the whole app session; warn first so the user
+ // can bail if a command is still running. The exit hook also
+ // closes the socket up-front instead of leaving it to
+ // whatever GC / idle-timeout race happens during navigation.
+ if (!(await confirmTerminalExit())) return;
+ navigate(paths.auth.logOut, { replace: true, preventScrollReset: true });
+ }}>
+
+
+ Log Out
+
+
+
+
+
+ );
+};
diff --git a/app/components/input-name/input-name.tsx b/app/components/input-name/input-name.tsx
index b6687452d..69abe05b1 100644
--- a/app/components/input-name/input-name.tsx
+++ b/app/components/input-name/input-name.tsx
@@ -1,113 +1,113 @@
-import { Field } from '../field/field';
-import { FieldLabel } from '@/components/field/field-label';
-import { generateId, generateRandomString } from '@/utils/helpers/text.helper';
-import { FieldMetadata, useInputControl } from '@conform-to/react';
-import { getInputProps } from '@conform-to/react';
-import { Tooltip } from '@datum-ui/components';
-import { Checkbox, Input, Label } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-import { CircleHelp } from 'lucide-react';
-import { useEffect, useMemo, useState } from 'react';
-
-interface InputNameProps {
- label?: string;
- description?: string;
- readOnly?: boolean;
- required?: boolean;
- field: FieldMetadata;
- inputRef?: React.RefObject;
- autoGenerate?: boolean;
- baseName?: string;
- className?: string;
- labelClassName?: string;
- showTooltip?: boolean;
- disabledRandomSuffix?: boolean;
- autoFocus?: boolean;
-}
-
-export const InputName = ({
- label = 'Resource Name',
- description = 'This unique resource name will be used to identify your resource and cannot be changed.',
- readOnly = false,
- required = true,
- inputRef,
- field,
- autoGenerate = true,
- baseName,
- className,
- labelClassName,
- showTooltip = true,
- disabledRandomSuffix = false,
- autoFocus = false,
-}: InputNameProps) => {
- const nameControl = useInputControl({
- key: field.id,
- name: field.name,
- formId: field.formId,
- initialValue: field.initialValue as string,
- });
-
- const [auto, setAuto] = useState(autoGenerate);
-
- const randomSuffix = useMemo(() => generateRandomString(6), []);
-
- // Auto generate name when base name is provided and auto generate is enabled
- useEffect(() => {
- if (baseName && auto) {
- nameControl.change(
- generateId(baseName, {
- randomText: randomSuffix,
- randomLength: disabledRandomSuffix ? 0 : 6,
- })
- );
- }
- }, [baseName, auto, disabledRandomSuffix]);
-
- return (
-
-
-
- {autoGenerate && (
-
-
- setAuto(checked)}
- />
-
- Auto-generate
-
- {showTooltip && }
-
-
- )}
-
-
-
-
-
- );
-};
+import { Field } from '../field/field';
+import { FieldLabel } from '@/components/field/field-label';
+import { generateId, generateRandomString } from '@/utils/helpers/text.helper';
+import { FieldMetadata, useInputControl } from '@conform-to/react';
+import { getInputProps } from '@conform-to/react';
+import { Tooltip } from '@datum-ui/components';
+import { Checkbox, Input, Label } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+import { CircleHelp } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+
+interface InputNameProps {
+ label?: string;
+ description?: string;
+ readOnly?: boolean;
+ required?: boolean;
+ field: FieldMetadata;
+ inputRef?: React.RefObject;
+ autoGenerate?: boolean;
+ baseName?: string;
+ className?: string;
+ labelClassName?: string;
+ showTooltip?: boolean;
+ disabledRandomSuffix?: boolean;
+ autoFocus?: boolean;
+}
+
+export const InputName = ({
+ label = 'Resource Name',
+ description = 'This unique resource name will be used to identify your resource and cannot be changed.',
+ readOnly = false,
+ required = true,
+ inputRef,
+ field,
+ autoGenerate = true,
+ baseName,
+ className,
+ labelClassName,
+ showTooltip = true,
+ disabledRandomSuffix = false,
+ autoFocus = false,
+}: InputNameProps) => {
+ const nameControl = useInputControl({
+ key: field.id,
+ name: field.name,
+ formId: field.formId,
+ initialValue: field.initialValue as string,
+ });
+
+ const [auto, setAuto] = useState(autoGenerate);
+
+ const randomSuffix = useMemo(() => generateRandomString(6), []);
+
+ // Auto generate name when base name is provided and auto generate is enabled
+ useEffect(() => {
+ if (baseName && auto) {
+ nameControl.change(
+ generateId(baseName, {
+ randomText: randomSuffix,
+ randomLength: disabledRandomSuffix ? 0 : 6,
+ })
+ );
+ }
+ }, [baseName, auto, disabledRandomSuffix]);
+
+ return (
+
+
+
+ {autoGenerate && (
+
+
+ setAuto(checked)}
+ />
+
+ Auto-generate
+
+ {showTooltip && }
+
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/app/components/key-value-form-dialog.tsx b/app/components/key-value-form-dialog.tsx
index 268e43628..9e8643888 100644
--- a/app/components/key-value-form-dialog.tsx
+++ b/app/components/key-value-form-dialog.tsx
@@ -1,76 +1,76 @@
-import { Form } from '@datum-ui/components/form';
-import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
-import type { z } from 'zod';
-
-type KeyValueData = { key: string; value: string };
-type KeyValueSchema = z.ZodType;
-
-export interface KeyValueFormDialogRef {
- show: (defaultValue?: KeyValueData) => void;
- hide: () => void;
-}
-
-interface KeyValueFormDialogProps {
- schema: KeyValueSchema;
- title: string;
- description: string;
- onSubmit: (data: KeyValueData) => void;
- onClose?: () => void;
-}
-
-export const KeyValueFormDialog = forwardRef(
- ({ schema, title, description, onSubmit, onClose }, ref) => {
- const [open, setOpen] = useState(false);
- const [defaultValues, setDefaultValues] = useState();
-
- const show = useCallback((value?: KeyValueData) => {
- setDefaultValues(value);
- setOpen(true);
- }, []);
-
- const hide = useCallback(() => setOpen(false), []);
-
- useImperativeHandle(ref, () => ({ show, hide }), [show, hide]);
-
- const handleSubmit = useCallback(
- (data: KeyValueData) => {
- onSubmit(data);
- setOpen(false);
- },
- [onSubmit]
- );
-
- const handleOpenChange = useCallback(
- (isOpen: boolean) => {
- if (!isOpen) onClose?.();
- setOpen(isOpen);
- },
- [onClose]
- );
-
- return (
-
-
-
-
-
-
-
-
-
-
- );
- }
-);
-
-KeyValueFormDialog.displayName = 'KeyValueFormDialog';
+import { Form } from '@datum-ui/components/form';
+import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
+import type { z } from 'zod';
+
+type KeyValueData = { key: string; value: string };
+type KeyValueSchema = z.ZodType;
+
+export interface KeyValueFormDialogRef {
+ show: (defaultValue?: KeyValueData) => void;
+ hide: () => void;
+}
+
+interface KeyValueFormDialogProps {
+ schema: KeyValueSchema;
+ title: string;
+ description: string;
+ onSubmit: (data: KeyValueData) => void;
+ onClose?: () => void;
+}
+
+export const KeyValueFormDialog = forwardRef(
+ ({ schema, title, description, onSubmit, onClose }, ref) => {
+ const [open, setOpen] = useState(false);
+ const [defaultValues, setDefaultValues] = useState();
+
+ const show = useCallback((value?: KeyValueData) => {
+ setDefaultValues(value);
+ setOpen(true);
+ }, []);
+
+ const hide = useCallback(() => setOpen(false), []);
+
+ useImperativeHandle(ref, () => ({ show, hide }), [show, hide]);
+
+ const handleSubmit = useCallback(
+ (data: KeyValueData) => {
+ onSubmit(data);
+ setOpen(false);
+ },
+ [onSubmit]
+ );
+
+ const handleOpenChange = useCallback(
+ (isOpen: boolean) => {
+ if (!isOpen) onClose?.();
+ setOpen(isOpen);
+ },
+ [onClose]
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+);
+
+KeyValueFormDialog.displayName = 'KeyValueFormDialog';
diff --git a/app/components/list/list.tsx b/app/components/list/list.tsx
index 7d9beb874..68330d74c 100644
--- a/app/components/list/list.tsx
+++ b/app/components/list/list.tsx
@@ -1,54 +1,54 @@
-import { cn } from '@shadcn/lib/utils';
-
-export interface ListItem {
- label?: React.ReactNode | string;
- content?: React.ReactNode | string;
- className?: string;
- hidden?: boolean;
-}
-
-interface ListProps {
- /**
- * Array of list items to display
- */
- items: ListItem[];
- /**
- * Optional className for the list container
- */
- className?: string;
- /**
- * Optional className applied to all list items
- */
- itemClassName?: string;
-
- labelClassName?: string;
-}
-
-export const List = ({ items, className, itemClassName, labelClassName }: ListProps) => {
- return (
-
- {items
- .filter((item) => !item.hidden)
- .map((item, index) => (
-
-
- {item.label}
-
-
- {item.content}
-
-
- ))}
-
- );
-};
+import { cn } from '@shadcn/lib/utils';
+
+export interface ListItem {
+ label?: React.ReactNode | string;
+ content?: React.ReactNode | string;
+ className?: string;
+ hidden?: boolean;
+}
+
+interface ListProps {
+ /**
+ * Array of list items to display
+ */
+ items: ListItem[];
+ /**
+ * Optional className for the list container
+ */
+ className?: string;
+ /**
+ * Optional className applied to all list items
+ */
+ itemClassName?: string;
+
+ labelClassName?: string;
+}
+
+export const List = ({ items, className, itemClassName, labelClassName }: ListProps) => {
+ return (
+
+ {items
+ .filter((item) => !item.hidden)
+ .map((item, index) => (
+
+
+ {item.label}
+
+
+ {item.content}
+
+
+ ))}
+
+ );
+};
diff --git a/app/components/logo/logo-icon.tsx b/app/components/logo/logo-icon.tsx
index f40052fbe..10719d439 100644
--- a/app/components/logo/logo-icon.tsx
+++ b/app/components/logo/logo-icon.tsx
@@ -1,42 +1,42 @@
-import { logoStyles } from './logo.styles';
-import { ClientOnly, useTheme } from '@datum-cloud/datum-ui/theme';
-import { cn } from '@shadcn/lib/utils';
-
-interface LogoIconProps {
- width?: number;
- className?: string;
-}
-
-const LogoIconSVG = ({
- width = 147,
- className,
- theme,
-}: LogoIconProps & { theme: 'light' | 'dark' }) => {
- const { base, icon } = logoStyles({ theme: theme ?? 'light' });
-
- return (
-
-
-
- );
-};
-
-export const LogoIcon = ({ width = 147, className }: LogoIconProps) => {
- const { resolvedTheme } = useTheme();
- const theme = (resolvedTheme as 'light' | 'dark') ?? 'light';
-
- return (
- }>
-
-
- );
-};
+import { logoStyles } from './logo.styles';
+import { ClientOnly, useTheme } from '@datum-cloud/datum-ui/theme';
+import { cn } from '@shadcn/lib/utils';
+
+interface LogoIconProps {
+ width?: number;
+ className?: string;
+}
+
+const LogoIconSVG = ({
+ width = 147,
+ className,
+ theme,
+}: LogoIconProps & { theme: 'light' | 'dark' }) => {
+ const { base, icon } = logoStyles({ theme: theme ?? 'light' });
+
+ return (
+
+
+
+ );
+};
+
+export const LogoIcon = ({ width = 147, className }: LogoIconProps) => {
+ const { resolvedTheme } = useTheme();
+ const theme = (resolvedTheme as 'light' | 'dark') ?? 'light';
+
+ return (
+ }>
+
+
+ );
+};
diff --git a/app/components/logo/logo-text.tsx b/app/components/logo/logo-text.tsx
index 15b21df9f..3387a8133 100644
--- a/app/components/logo/logo-text.tsx
+++ b/app/components/logo/logo-text.tsx
@@ -1,50 +1,50 @@
-import { logoStyles } from './logo.styles';
-import { ClientOnly, useTheme } from '@datum-cloud/datum-ui/theme';
-import { cn } from '@shadcn/lib/utils';
-
-const LogoTextSVG = ({
- theme,
- ...props
-}: React.SVGProps & { theme: 'light' | 'dark' }) => {
- const { base, text } = logoStyles({ theme: theme ?? 'light' });
- return (
-
-
-
-
-
-
-
- );
-};
-
-export const LogoText = (props: React.SVGProps) => {
- const { resolvedTheme } = useTheme();
- const theme = (resolvedTheme as 'light' | 'dark') ?? 'light';
-
- return (
- }>
-
-
- );
-};
+import { logoStyles } from './logo.styles';
+import { ClientOnly, useTheme } from '@datum-cloud/datum-ui/theme';
+import { cn } from '@shadcn/lib/utils';
+
+const LogoTextSVG = ({
+ theme,
+ ...props
+}: React.SVGProps & { theme: 'light' | 'dark' }) => {
+ const { base, text } = logoStyles({ theme: theme ?? 'light' });
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export const LogoText = (props: React.SVGProps) => {
+ const { resolvedTheme } = useTheme();
+ const theme = (resolvedTheme as 'light' | 'dark') ?? 'light';
+
+ return (
+ }>
+
+
+ );
+};
diff --git a/app/components/metadata/metadata-form.tsx b/app/components/metadata/metadata-form.tsx
index a2ebd7d1a..420757955 100644
--- a/app/components/metadata/metadata-form.tsx
+++ b/app/components/metadata/metadata-form.tsx
@@ -1,47 +1,47 @@
-import { InputName } from '@/components/input-name/input-name';
-import { SelectAnnotations } from '@/components/select-annotations/select-annotations';
-import { SelectLabels } from '@/components/select-labels/select-labels';
-import { Form } from '@datum-ui/components/form';
-
-export const MetadataForm = ({ isEdit = false }: { isEdit?: boolean }) => {
- return (
-
-
- {({ field }) => (
-
- )}
-
-
-
- {({ control }) => (
- control.change(value)}
- />
- )}
-
-
-
- {({ control }) => (
- control.change(value)}
- />
- )}
-
-
- );
-};
+import { InputName } from '@/components/input-name/input-name';
+import { SelectAnnotations } from '@/components/select-annotations/select-annotations';
+import { SelectLabels } from '@/components/select-labels/select-labels';
+import { Form } from '@datum-ui/components/form';
+
+export const MetadataForm = ({ isEdit = false }: { isEdit?: boolean }) => {
+ return (
+
+
+ {({ field }) => (
+
+ )}
+
+
+
+ {({ control }) => (
+ control.change(value)}
+ />
+ )}
+
+
+
+ {({ control }) => (
+ control.change(value)}
+ />
+ )}
+
+
+ );
+};
diff --git a/app/components/metadata/metadata-preview.tsx b/app/components/metadata/metadata-preview.tsx
index 71360b8dc..2822481df 100644
--- a/app/components/metadata/metadata-preview.tsx
+++ b/app/components/metadata/metadata-preview.tsx
@@ -1,44 +1,44 @@
-import { List, ListItem } from '@/components/list/list';
-import { MetadataSchema } from '@/resources/base';
-import { Badge } from '@datum-ui/components';
-import { useMemo } from 'react';
-
-export const MetadataPreview = ({ values }: { values: MetadataSchema }) => {
- const listItems: ListItem[] = useMemo(() => {
- if (values) {
- return [
- { label: 'Name', content: values.name },
- {
- label: 'Labels',
- hidden: (values.labels ?? []).length === 0,
- content: (
-
- {values.labels?.map((label) => (
-
- {label}
-
- ))}
-
- ),
- },
- {
- label: 'Annotations',
- hidden: (values.annotations ?? []).length === 0,
- content: (
-
- {values.annotations?.map((annotation) => (
-
- {annotation}
-
- ))}
-
- ),
- },
- ];
- }
-
- return [];
- }, [values]);
-
- return
;
-};
+import { List, ListItem } from '@/components/list/list';
+import { MetadataSchema } from '@/resources/base';
+import { Badge } from '@datum-ui/components';
+import { useMemo } from 'react';
+
+export const MetadataPreview = ({ values }: { values: MetadataSchema }) => {
+ const listItems: ListItem[] = useMemo(() => {
+ if (values) {
+ return [
+ { label: 'Name', content: values.name },
+ {
+ label: 'Labels',
+ hidden: (values.labels ?? []).length === 0,
+ content: (
+
+ {values.labels?.map((label) => (
+
+ {label}
+
+ ))}
+
+ ),
+ },
+ {
+ label: 'Annotations',
+ hidden: (values.annotations ?? []).length === 0,
+ content: (
+
+ {values.annotations?.map((annotation) => (
+
+ {annotation}
+
+ ))}
+
+ ),
+ },
+ ];
+ }
+
+ return [];
+ }, [values]);
+
+ return
;
+};
diff --git a/app/components/mobile-menu/mobile-menu.tsx b/app/components/mobile-menu/mobile-menu.tsx
index f67137840..69e43a17b 100644
--- a/app/components/mobile-menu/mobile-menu.tsx
+++ b/app/components/mobile-menu/mobile-menu.tsx
@@ -1,101 +1,101 @@
-import { LogoFlat } from '@/components/logo/logo-flat';
-import { Button, Sheet, SheetContent, SheetTrigger, SidebarProvider } from '@datum-ui/components';
-import { NavItem, NavMain } from '@datum-ui/components/sidebar';
-import React, { useEffect, useRef } from 'react';
-import { Link, useLocation } from 'react-router';
-
-const MenuIcon = () => (
-
-
-
-
-
-);
-
-/**
- * Renders sidebar nav inside the sheet. Closes the sheet when the user navigates.
- */
-const MobileNavSheetContent = ({
- navItems,
- onClose,
-}: {
- navItems: NavItem[];
- onClose: () => void;
-}) => {
- const { pathname } = useLocation();
- const prevPathname = useRef(pathname);
-
- useEffect(() => {
- if (prevPathname.current !== pathname) {
- prevPathname.current = pathname;
- onClose();
- }
- }, [pathname, onClose]);
-
- return (
-
-
-
- );
-};
-
-export function MobileMenu({ navItems }: { navItems: NavItem[] }) {
- const [open, setOpen] = React.useState(false);
-
- return (
-
-
-
-
-
-
-
-
- e.preventDefault()}
- className="bg-background border-sidebar-border h-svh w-75 max-w-[85vw] gap-0 border-r p-0">
-
-
-
-
- setOpen(false)} />
-
-
-
-
- );
-}
+import { LogoFlat } from '@/components/logo/logo-flat';
+import { Button, Sheet, SheetContent, SheetTrigger, SidebarProvider } from '@datum-ui/components';
+import { NavItem, NavMain } from '@datum-ui/components/sidebar';
+import React, { useEffect, useRef } from 'react';
+import { Link, useLocation } from 'react-router';
+
+const MenuIcon = () => (
+
+
+
+
+
+);
+
+/**
+ * Renders sidebar nav inside the sheet. Closes the sheet when the user navigates.
+ */
+const MobileNavSheetContent = ({
+ navItems,
+ onClose,
+}: {
+ navItems: NavItem[];
+ onClose: () => void;
+}) => {
+ const { pathname } = useLocation();
+ const prevPathname = useRef(pathname);
+
+ useEffect(() => {
+ if (prevPathname.current !== pathname) {
+ prevPathname.current = pathname;
+ onClose();
+ }
+ }, [pathname, onClose]);
+
+ return (
+
+
+
+ );
+};
+
+export function MobileMenu({ navItems }: { navItems: NavItem[] }) {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+ e.preventDefault()}
+ className="bg-background border-sidebar-border h-svh w-75 max-w-[85vw] gap-0 border-r p-0">
+
+
+
+
+ setOpen(false)} />
+
+
+
+
+ );
+}
diff --git a/app/components/multi-select/multi-select.tsx b/app/components/multi-select/multi-select.tsx
index a719c9c7d..6a22379cd 100644
--- a/app/components/multi-select/multi-select.tsx
+++ b/app/components/multi-select/multi-select.tsx
@@ -1,585 +1,585 @@
-// src/components/multi-select.tsx
-import { useBreakpoint } from '@/hooks/use-breakpoint';
-import { Badge } from '@datum-ui/components';
-import { LoaderOverlay } from '@datum-ui/components/loader-overlay';
-import { MobileSheet } from '@datum-ui/components/mobile-sheet';
-import { cn } from '@shadcn/lib/utils';
-import {
- Command,
- CommandGroup,
- CommandItem,
- CommandList,
- CommandSeparator,
-} from '@shadcn/ui/command';
-import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
-import { Separator } from '@shadcn/ui/separator';
-import { cva, type VariantProps } from 'class-variance-authority';
-import { CheckIcon, XCircle, ChevronDown, XIcon, WandSparkles } from 'lucide-react';
-import * as React from 'react';
-import { useEffect } from 'react';
-
-/**
- * Variants for the multi-select component to handle different styles.
- * Uses class-variance-authority (cva) to define different styles based on "variant" prop.
- */
-const multiSelectVariants = cva(
- 'flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors whitespace-nowrap',
- {
- variants: {
- variant: {
- default: 'border-input-border bg-input-background/80 text-input-foreground',
- secondary: 'border-secondary/40 bg-secondary/20 text-secondary-foreground',
- destructive: 'border-destructive/60 bg-destructive/20 text-destructive',
- inverted: 'border-transparent bg-foreground text-background',
- },
- },
- defaultVariants: {
- variant: 'default',
- },
- }
-);
-
-/**
- * Props for MultiSelect component
- */
-
-export interface MultiSelectOption {
- label: string;
- value: string;
- icon?: React.ComponentType<{ className?: string }>;
-}
-
-interface MultiSelectProps
- extends React.ButtonHTMLAttributes, VariantProps {
- /**
- * An array of option objects to be displayed in the multi-select component.
- * Each option object has a label, value, and an optional icon.
- */
- options: MultiSelectOption[];
-
- /**
- * Callback function triggered when the selected values change.
- * Receives an array of the new selected values.
- */
- onValueChange: (value: string[]) => void;
-
- /** The default selected values when the component mounts. */
- defaultValue?: string[];
-
- /** The controlled selected values. When provided, component becomes controlled. */
- value?: string[];
-
- /**
- * Placeholder text to be displayed when no values are selected.
- * Optional, defaults to "Select options".
- */
- placeholder?: string;
-
- /**
- * Animation duration in seconds for the visual effects (e.g., bouncing badges).
- * Optional, defaults to 0 (no animation).
- */
- animation?: number;
-
- /**
- * Maximum number of items to display. Extra selected items will be summarized.
- * Set to -1 for unlimited items.
- * Optional, defaults to 3.
- */
- maxCount?: number | -1;
-
- /**
- * The modality of the popover. When set to true, interaction with outside elements
- * will be disabled and only popover content will be visible to screen readers.
- * Optional, defaults to false.
- */
- modalPopover?: boolean;
-
- /**
- * If true, renders the multi-select component as a child of another component.
- * Optional, defaults to false.
- */
- asChild?: boolean;
-
- /**
- * Additional class names to apply custom styles to the multi-select component.
- * Optional, can be used to add custom styles.
- */
- className?: string;
-
- /**
- * Additional class names to apply custom styles to the box container.
- * Optional, can be used to add custom styles to the box wrapper.
- */
- boxClassName?: string;
-
- /**
- * Custom actions to be displayed in the multi-select popover.
- * Each action should have a label and an onClick handler.
- * Optional array of custom actions.
- */
- actions?: Array<{
- /** The label text to display for the action */
- label: string;
- /** Click handler for the action */
- onClick: () => void;
- /** Optional icon component to display alongside the action */
- icon?: React.ComponentType<{ className?: string }>;
- /** Optional class name for custom styling */
- className?: string;
- }>;
-
- /**
- * Callback function triggered when a selected badge is clicked.
- * Receives the value of the clicked badge as a parameter.
- * Optional, can be used to handle badge click interactions.
- */
- onBadgeClick?: (option: MultiSelectOption) => void;
-
- badgeClassName?: string;
-
- /**
- * Determines if badges should be clickable.
- * When true, badges will have hover and click interactions.
- * Optional, defaults to false.
- */
- clickableBadges?: boolean;
-
- /**
- * Controls the visibility of the close (X) button in the popover.
- * When false, the close button will be hidden.
- * Optional, defaults to true.
- */
- showCloseButton?: boolean;
-
- /**
- * Determines if the clear button should be shown in the popover.
- * When false, the clear button will be hidden.
- * Optional, defaults to true.
- */
- showClearButton?: boolean;
-
- /**
- * Controls whether to show the "Select All" option in the popover.
- * When true, a "Select All" option will be displayed at the top of the options list.
- * Optional, defaults to false.
- */
- showSelectAll?: boolean;
-
- /**
- * Controls whether the component is loading.
- * When true, the component will show a loading indicator.
- * Optional, defaults to false.
- */
- isLoading?: boolean;
-
- /**
- * The content to display when no options are found.
- * Optional, defaults to "No options found.".
- */
- emptyContent?: string;
-}
-
-export const MultiSelect = ({
- options,
- onValueChange,
- variant,
- defaultValue = [],
- value,
- placeholder = 'Select options',
- animation = 0,
- maxCount = 3,
- modalPopover = false,
-
- className,
- boxClassName,
- actions,
- onBadgeClick,
- badgeClassName,
- clickableBadges = false,
- showCloseButton = true,
- showClearButton = true,
- showSelectAll = false,
- disabled = false,
- id,
- name,
- isLoading = false,
- emptyContent = 'No results found.',
-}: MultiSelectProps) => {
- const [selectedValues, setSelectedValues] = React.useState(defaultValue);
- const breakpoint = useBreakpoint();
- const isMobile = breakpoint === 'mobile';
-
- // Use controlled value if provided, otherwise use internal state
- const currentSelectedValues = value !== undefined ? value : selectedValues;
- const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
- const [isAnimating, setIsAnimating] = React.useState(false);
-
- const toggleOption = (option: string) => {
- const newSelectedValues = currentSelectedValues.includes(option)
- ? currentSelectedValues.filter((value) => value !== option)
- : [...currentSelectedValues, option];
-
- // Only update internal state if not controlled
- if (value === undefined) {
- setSelectedValues(newSelectedValues);
- }
- onValueChange(newSelectedValues);
- };
-
- const handleClear = () => {
- if (value === undefined) {
- setSelectedValues([]);
- }
- onValueChange([]);
- };
-
- const handleTogglePopover = () => {
- setIsPopoverOpen((prev) => !prev);
- };
-
- const clearExtraOptions = () => {
- const newSelectedValues = currentSelectedValues.slice(0, maxCount);
- if (value === undefined) {
- setSelectedValues(newSelectedValues);
- }
- onValueChange(newSelectedValues);
- };
-
- const toggleAll = () => {
- if (currentSelectedValues.length === options.length) {
- handleClear();
- } else {
- const allValues = options.map((option) => option.value);
- if (value === undefined) {
- setSelectedValues(allValues);
- }
- onValueChange(allValues);
- }
- };
-
- // Only sync defaultValue to internal state when uncontrolled
- useEffect(() => {
- if (value === undefined && defaultValue) {
- setSelectedValues(defaultValue);
- }
- }, [defaultValue, value]);
-
- return (
- <>
-
-
-
- {currentSelectedValues.length > 0 && !isLoading && options.length > 0 ? (
-
-
- {currentSelectedValues
- .slice(0, maxCount === -1 ? undefined : maxCount)
- .map((value) => {
- const option = options.find((o) => o.value === value);
- const IconComponent = option?.icon;
- return (
- {
- if (clickableBadges) {
- event.stopPropagation();
- event.preventDefault();
- onBadgeClick?.(option ?? { label: '', value: '' });
- }
- }}>
- {IconComponent && (
-
- )}
- {option?.label}
- {
- event.stopPropagation();
- event.preventDefault();
- toggleOption(value);
- }}
- />
-
- );
- })}
- {currentSelectedValues.length > maxCount && maxCount !== -1 && (
-
- {`+ ${currentSelectedValues.length - maxCount} more`}
- {
- event.stopPropagation();
- clearExtraOptions();
- }}
- />
-
- )}
-
-
- {
- event.stopPropagation();
- handleClear();
- }}
- />
-
-
-
-
- ) : (
-
- {placeholder}
-
-
- )}
-
- {isLoading && }
-
-
- setIsPopoverOpen(false)}>
-
-
- {options.length > 0 ? (
-
- {showSelectAll && (
-
-
-
-
- (Select All)
-
- )}
- {options.map((option) => {
- const isSelected = currentSelectedValues.includes(option.value);
- return (
- toggleOption(option.value)}
- className="cursor-pointer">
-
-
-
- {option.icon && (
-
- )}
- {option.label}
-
- );
- })}
-
- ) : (
-
- {emptyContent}
-
- )}
- {actions && (
- <>
-
-
- {actions.map((action) => (
-
- {action.icon && (
-
- )}
- {action.label}
-
- ))}
-
- >
- )}
- {showCloseButton || (showClearButton && currentSelectedValues.length > 0) ? (
- <>
-
-
-
- {showClearButton && currentSelectedValues.length > 0 && (
- <>
-
- Clear
-
- {showCloseButton && (
-
- )}
- >
- )}
- {showCloseButton && (
- setIsPopoverOpen(false)}
- className="max-w-full flex-1 cursor-pointer justify-center">
- Close
-
- )}
-
-
- >
- ) : (
- <>>
- )}
-
-
-
- {animation > 0 && currentSelectedValues.length > 0 && (
- setIsAnimating(!isAnimating)}
- />
- )}
-
- {/* Mobile: bottom sheet with same options */}
- {isMobile && (
-
-
-
- {options.length > 0 ? (
-
- {showSelectAll && (
-
-
-
-
- (Select All)
-
- )}
- {options.map((option) => {
- const isSelected = currentSelectedValues.includes(option.value);
- return (
- toggleOption(option.value)}
- className="cursor-pointer">
-
-
-
- {option.icon && (
-
- )}
- {option.label}
-
- );
- })}
-
- ) : (
-
- {emptyContent}
-
- )}
- {actions && (
- <>
-
-
- {actions.map((action) => (
-
- {action.icon && (
-
- )}
- {action.label}
-
- ))}
-
- >
- )}
- {showClearButton && currentSelectedValues.length > 0 && (
- <>
-
-
-
- Clear
-
-
- >
- )}
-
-
-
- )}
- {/* Hidden input for form submission */}
- undefined}>
-
- {options.map((option, idx) => (
-
- ))}
-
- >
- );
-};
-
-MultiSelect.displayName = 'MultiSelect';
+// src/components/multi-select.tsx
+import { useBreakpoint } from '@/hooks/use-breakpoint';
+import { Badge } from '@datum-ui/components';
+import { LoaderOverlay } from '@datum-ui/components/loader-overlay';
+import { MobileSheet } from '@datum-ui/components/mobile-sheet';
+import { cn } from '@shadcn/lib/utils';
+import {
+ Command,
+ CommandGroup,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from '@shadcn/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
+import { Separator } from '@shadcn/ui/separator';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { CheckIcon, XCircle, ChevronDown, XIcon, WandSparkles } from 'lucide-react';
+import * as React from 'react';
+import { useEffect } from 'react';
+
+/**
+ * Variants for the multi-select component to handle different styles.
+ * Uses class-variance-authority (cva) to define different styles based on "variant" prop.
+ */
+const multiSelectVariants = cva(
+ 'flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors whitespace-nowrap',
+ {
+ variants: {
+ variant: {
+ default: 'border-input-border bg-input-background/80 text-input-foreground',
+ secondary: 'border-secondary/40 bg-secondary/20 text-secondary-foreground',
+ destructive: 'border-destructive/60 bg-destructive/20 text-destructive',
+ inverted: 'border-transparent bg-foreground text-background',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ }
+);
+
+/**
+ * Props for MultiSelect component
+ */
+
+export interface MultiSelectOption {
+ label: string;
+ value: string;
+ icon?: React.ComponentType<{ className?: string }>;
+}
+
+interface MultiSelectProps
+ extends React.ButtonHTMLAttributes, VariantProps {
+ /**
+ * An array of option objects to be displayed in the multi-select component.
+ * Each option object has a label, value, and an optional icon.
+ */
+ options: MultiSelectOption[];
+
+ /**
+ * Callback function triggered when the selected values change.
+ * Receives an array of the new selected values.
+ */
+ onValueChange: (value: string[]) => void;
+
+ /** The default selected values when the component mounts. */
+ defaultValue?: string[];
+
+ /** The controlled selected values. When provided, component becomes controlled. */
+ value?: string[];
+
+ /**
+ * Placeholder text to be displayed when no values are selected.
+ * Optional, defaults to "Select options".
+ */
+ placeholder?: string;
+
+ /**
+ * Animation duration in seconds for the visual effects (e.g., bouncing badges).
+ * Optional, defaults to 0 (no animation).
+ */
+ animation?: number;
+
+ /**
+ * Maximum number of items to display. Extra selected items will be summarized.
+ * Set to -1 for unlimited items.
+ * Optional, defaults to 3.
+ */
+ maxCount?: number | -1;
+
+ /**
+ * The modality of the popover. When set to true, interaction with outside elements
+ * will be disabled and only popover content will be visible to screen readers.
+ * Optional, defaults to false.
+ */
+ modalPopover?: boolean;
+
+ /**
+ * If true, renders the multi-select component as a child of another component.
+ * Optional, defaults to false.
+ */
+ asChild?: boolean;
+
+ /**
+ * Additional class names to apply custom styles to the multi-select component.
+ * Optional, can be used to add custom styles.
+ */
+ className?: string;
+
+ /**
+ * Additional class names to apply custom styles to the box container.
+ * Optional, can be used to add custom styles to the box wrapper.
+ */
+ boxClassName?: string;
+
+ /**
+ * Custom actions to be displayed in the multi-select popover.
+ * Each action should have a label and an onClick handler.
+ * Optional array of custom actions.
+ */
+ actions?: Array<{
+ /** The label text to display for the action */
+ label: string;
+ /** Click handler for the action */
+ onClick: () => void;
+ /** Optional icon component to display alongside the action */
+ icon?: React.ComponentType<{ className?: string }>;
+ /** Optional class name for custom styling */
+ className?: string;
+ }>;
+
+ /**
+ * Callback function triggered when a selected badge is clicked.
+ * Receives the value of the clicked badge as a parameter.
+ * Optional, can be used to handle badge click interactions.
+ */
+ onBadgeClick?: (option: MultiSelectOption) => void;
+
+ badgeClassName?: string;
+
+ /**
+ * Determines if badges should be clickable.
+ * When true, badges will have hover and click interactions.
+ * Optional, defaults to false.
+ */
+ clickableBadges?: boolean;
+
+ /**
+ * Controls the visibility of the close (X) button in the popover.
+ * When false, the close button will be hidden.
+ * Optional, defaults to true.
+ */
+ showCloseButton?: boolean;
+
+ /**
+ * Determines if the clear button should be shown in the popover.
+ * When false, the clear button will be hidden.
+ * Optional, defaults to true.
+ */
+ showClearButton?: boolean;
+
+ /**
+ * Controls whether to show the "Select All" option in the popover.
+ * When true, a "Select All" option will be displayed at the top of the options list.
+ * Optional, defaults to false.
+ */
+ showSelectAll?: boolean;
+
+ /**
+ * Controls whether the component is loading.
+ * When true, the component will show a loading indicator.
+ * Optional, defaults to false.
+ */
+ isLoading?: boolean;
+
+ /**
+ * The content to display when no options are found.
+ * Optional, defaults to "No options found.".
+ */
+ emptyContent?: string;
+}
+
+export const MultiSelect = ({
+ options,
+ onValueChange,
+ variant,
+ defaultValue = [],
+ value,
+ placeholder = 'Select options',
+ animation = 0,
+ maxCount = 3,
+ modalPopover = false,
+
+ className,
+ boxClassName,
+ actions,
+ onBadgeClick,
+ badgeClassName,
+ clickableBadges = false,
+ showCloseButton = true,
+ showClearButton = true,
+ showSelectAll = false,
+ disabled = false,
+ id,
+ name,
+ isLoading = false,
+ emptyContent = 'No results found.',
+}: MultiSelectProps) => {
+ const [selectedValues, setSelectedValues] = React.useState(defaultValue);
+ const breakpoint = useBreakpoint();
+ const isMobile = breakpoint === 'mobile';
+
+ // Use controlled value if provided, otherwise use internal state
+ const currentSelectedValues = value !== undefined ? value : selectedValues;
+ const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
+ const [isAnimating, setIsAnimating] = React.useState(false);
+
+ const toggleOption = (option: string) => {
+ const newSelectedValues = currentSelectedValues.includes(option)
+ ? currentSelectedValues.filter((value) => value !== option)
+ : [...currentSelectedValues, option];
+
+ // Only update internal state if not controlled
+ if (value === undefined) {
+ setSelectedValues(newSelectedValues);
+ }
+ onValueChange(newSelectedValues);
+ };
+
+ const handleClear = () => {
+ if (value === undefined) {
+ setSelectedValues([]);
+ }
+ onValueChange([]);
+ };
+
+ const handleTogglePopover = () => {
+ setIsPopoverOpen((prev) => !prev);
+ };
+
+ const clearExtraOptions = () => {
+ const newSelectedValues = currentSelectedValues.slice(0, maxCount);
+ if (value === undefined) {
+ setSelectedValues(newSelectedValues);
+ }
+ onValueChange(newSelectedValues);
+ };
+
+ const toggleAll = () => {
+ if (currentSelectedValues.length === options.length) {
+ handleClear();
+ } else {
+ const allValues = options.map((option) => option.value);
+ if (value === undefined) {
+ setSelectedValues(allValues);
+ }
+ onValueChange(allValues);
+ }
+ };
+
+ // Only sync defaultValue to internal state when uncontrolled
+ useEffect(() => {
+ if (value === undefined && defaultValue) {
+ setSelectedValues(defaultValue);
+ }
+ }, [defaultValue, value]);
+
+ return (
+ <>
+
+
+
+ {currentSelectedValues.length > 0 && !isLoading && options.length > 0 ? (
+
+
+ {currentSelectedValues
+ .slice(0, maxCount === -1 ? undefined : maxCount)
+ .map((value) => {
+ const option = options.find((o) => o.value === value);
+ const IconComponent = option?.icon;
+ return (
+ {
+ if (clickableBadges) {
+ event.stopPropagation();
+ event.preventDefault();
+ onBadgeClick?.(option ?? { label: '', value: '' });
+ }
+ }}>
+ {IconComponent && (
+
+ )}
+ {option?.label}
+ {
+ event.stopPropagation();
+ event.preventDefault();
+ toggleOption(value);
+ }}
+ />
+
+ );
+ })}
+ {currentSelectedValues.length > maxCount && maxCount !== -1 && (
+
+ {`+ ${currentSelectedValues.length - maxCount} more`}
+ {
+ event.stopPropagation();
+ clearExtraOptions();
+ }}
+ />
+
+ )}
+
+
+ {
+ event.stopPropagation();
+ handleClear();
+ }}
+ />
+
+
+
+
+ ) : (
+
+ {placeholder}
+
+
+ )}
+
+ {isLoading && }
+
+
+ setIsPopoverOpen(false)}>
+
+
+ {options.length > 0 ? (
+
+ {showSelectAll && (
+
+
+
+
+ (Select All)
+
+ )}
+ {options.map((option) => {
+ const isSelected = currentSelectedValues.includes(option.value);
+ return (
+ toggleOption(option.value)}
+ className="cursor-pointer">
+
+
+
+ {option.icon && (
+
+ )}
+ {option.label}
+
+ );
+ })}
+
+ ) : (
+
+ {emptyContent}
+
+ )}
+ {actions && (
+ <>
+
+
+ {actions.map((action) => (
+
+ {action.icon && (
+
+ )}
+ {action.label}
+
+ ))}
+
+ >
+ )}
+ {showCloseButton || (showClearButton && currentSelectedValues.length > 0) ? (
+ <>
+
+
+
+ {showClearButton && currentSelectedValues.length > 0 && (
+ <>
+
+ Clear
+
+ {showCloseButton && (
+
+ )}
+ >
+ )}
+ {showCloseButton && (
+ setIsPopoverOpen(false)}
+ className="max-w-full flex-1 cursor-pointer justify-center">
+ Close
+
+ )}
+
+
+ >
+ ) : (
+ <>>
+ )}
+
+
+
+ {animation > 0 && currentSelectedValues.length > 0 && (
+ setIsAnimating(!isAnimating)}
+ />
+ )}
+
+ {/* Mobile: bottom sheet with same options */}
+ {isMobile && (
+
+
+
+ {options.length > 0 ? (
+
+ {showSelectAll && (
+
+
+
+
+ (Select All)
+
+ )}
+ {options.map((option) => {
+ const isSelected = currentSelectedValues.includes(option.value);
+ return (
+ toggleOption(option.value)}
+ className="cursor-pointer">
+
+
+
+ {option.icon && (
+
+ )}
+ {option.label}
+
+ );
+ })}
+
+ ) : (
+
+ {emptyContent}
+
+ )}
+ {actions && (
+ <>
+
+
+ {actions.map((action) => (
+
+ {action.icon && (
+
+ )}
+ {action.label}
+
+ ))}
+
+ >
+ )}
+ {showClearButton && currentSelectedValues.length > 0 && (
+ <>
+
+
+
+ Clear
+
+
+ >
+ )}
+
+
+
+ )}
+ {/* Hidden input for form submission */}
+ undefined}>
+
+ {options.map((option, idx) => (
+
+ ))}
+
+ >
+ );
+};
+
+MultiSelect.displayName = 'MultiSelect';
diff --git a/app/components/note-card/note-card.tsx b/app/components/note-card/note-card.tsx
index 3543573ed..4837506bb 100644
--- a/app/components/note-card/note-card.tsx
+++ b/app/components/note-card/note-card.tsx
@@ -1,78 +1,78 @@
-import { Card, CardContent, CardHeader, CardTitle } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { cva, type VariantProps } from 'class-variance-authority';
-import { CircleXIcon } from 'lucide-react';
-
-const noteCardVariants = cva(
- 'bg-card-warning text-card-warning-foreground relative overflow-hidden border-none shadow-none',
- {
- variants: {
- size: {
- default: 'gap-2.5 py-6',
- sm: 'gap-2 py-3.5',
- },
- },
- defaultVariants: {
- size: 'default',
- },
- }
-);
-
-const noteCardHeaderVariants = cva('', {
- variants: {
- size: {
- default: 'px-8 pb-1',
- sm: 'px-5.5',
- },
- },
- defaultVariants: {
- size: 'default',
- },
-});
-
-const noteCardContentVariants = cva('', {
- variants: {
- size: {
- default: 'px-8',
- sm: 'px-5.5',
- },
- },
- defaultVariants: {
- size: 'default',
- },
-});
-
-interface NoteCardProps extends VariantProps {
- title?: string;
- description: string | React.ReactNode;
- icon?: React.ReactNode;
- closable?: boolean;
- onClose?: () => void;
-}
-
-export const NoteCard = ({ title, description, icon, closable, onClose, size }: NoteCardProps) => {
- return (
-
- {title && (
-
-
- {icon}
- {title}
- {closable && onClose && (
-
- )}
-
-
- )}
-
- {description}
-
-
-
-
- );
-};
+import { Card, CardContent, CardHeader, CardTitle } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { CircleXIcon } from 'lucide-react';
+
+const noteCardVariants = cva(
+ 'bg-card-warning text-card-warning-foreground relative overflow-hidden border-none shadow-none',
+ {
+ variants: {
+ size: {
+ default: 'gap-2.5 py-6',
+ sm: 'gap-2 py-3.5',
+ },
+ },
+ defaultVariants: {
+ size: 'default',
+ },
+ }
+);
+
+const noteCardHeaderVariants = cva('', {
+ variants: {
+ size: {
+ default: 'px-8 pb-1',
+ sm: 'px-5.5',
+ },
+ },
+ defaultVariants: {
+ size: 'default',
+ },
+});
+
+const noteCardContentVariants = cva('', {
+ variants: {
+ size: {
+ default: 'px-8',
+ sm: 'px-5.5',
+ },
+ },
+ defaultVariants: {
+ size: 'default',
+ },
+});
+
+interface NoteCardProps extends VariantProps {
+ title?: string;
+ description: string | React.ReactNode;
+ icon?: React.ReactNode;
+ closable?: boolean;
+ onClose?: () => void;
+}
+
+export const NoteCard = ({ title, description, icon, closable, onClose, size }: NoteCardProps) => {
+ return (
+
+ {title && (
+
+
+ {icon}
+ {title}
+ {closable && onClose && (
+
+ )}
+
+
+ )}
+
+ {description}
+
+
+
+
+ );
+};
diff --git a/app/components/notification-settings/notification-checkbox-item.tsx b/app/components/notification-settings/notification-checkbox-item.tsx
index 5c0bf94f1..ad723091b 100644
--- a/app/components/notification-settings/notification-checkbox-item.tsx
+++ b/app/components/notification-settings/notification-checkbox-item.tsx
@@ -1,21 +1,21 @@
-import type { RenderItemProps } from './types';
-import { Form } from '@datum-ui/components/form';
-
-export const NotificationCheckboxItem = ({ preference }: RenderItemProps) => {
- return (
-
-
-
-
-
-
-
- {preference.label}
- {preference.description && (
- {preference.description}
- )}
-
-
-
- );
-};
+import type { RenderItemProps } from './types';
+import { Form } from '@datum-ui/components/form';
+
+export const NotificationCheckboxItem = ({ preference }: RenderItemProps) => {
+ return (
+
+
+
+
+
+
+
+ {preference.label}
+ {preference.description && (
+ {preference.description}
+ )}
+
+
+
+ );
+};
diff --git a/app/components/notification-settings/notification-settings-card-skeleton.tsx b/app/components/notification-settings/notification-settings-card-skeleton.tsx
index 391dc986f..257d7c372 100644
--- a/app/components/notification-settings/notification-settings-card-skeleton.tsx
+++ b/app/components/notification-settings/notification-settings-card-skeleton.tsx
@@ -1,59 +1,59 @@
-import {
- Card,
- CardContent,
- CardFooter,
- CardHeader,
- CardTitle,
- Skeleton,
-} from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-
-export interface NotificationSettingsCardSkeletonProps {
- title: string;
- count?: number;
- showDescription?: boolean;
- className?: string;
-}
-
-/**
- * Skeleton loading state for NotificationSettingsCard
- * Matches the exact layout and styling of NotificationSettingsCard + NotificationCheckboxItem
- */
-export function NotificationSettingsCardSkeleton({
- title,
- count = 3,
- showDescription = true,
- className,
-}: NotificationSettingsCardSkeletonProps) {
- return (
-
-
- {title}
-
-
-
- {Array.from({ length: count }).map((_, index) => (
-
-
-
-
-
-
-
-
- {showDescription && }
-
-
-
- ))}
-
-
-
-
-
-
-
- );
-}
-
-NotificationSettingsCardSkeleton.displayName = 'NotificationSettingsCardSkeleton';
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+ Skeleton,
+} from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+
+export interface NotificationSettingsCardSkeletonProps {
+ title: string;
+ count?: number;
+ showDescription?: boolean;
+ className?: string;
+}
+
+/**
+ * Skeleton loading state for NotificationSettingsCard
+ * Matches the exact layout and styling of NotificationSettingsCard + NotificationCheckboxItem
+ */
+export function NotificationSettingsCardSkeleton({
+ title,
+ count = 3,
+ showDescription = true,
+ className,
+}: NotificationSettingsCardSkeletonProps) {
+ return (
+
+
+ {title}
+
+
+
+ {Array.from({ length: count }).map((_, index) => (
+
+
+
+
+
+
+
+
+ {showDescription && }
+
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+NotificationSettingsCardSkeleton.displayName = 'NotificationSettingsCardSkeleton';
diff --git a/app/components/notification-settings/notification-settings-card.tsx b/app/components/notification-settings/notification-settings-card.tsx
index bd43870e6..7ddebc1a2 100644
--- a/app/components/notification-settings/notification-settings-card.tsx
+++ b/app/components/notification-settings/notification-settings-card.tsx
@@ -1,60 +1,60 @@
-import { NotificationCheckboxItem } from './notification-checkbox-item';
-import type { NotificationSettingsCardProps } from './types';
-import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@datum-ui/components';
-import { Form } from '@datum-ui/components/form';
-import type { z } from 'zod';
-
-export function NotificationSettingsCard>({
- title,
- schema,
- defaultValues,
- preferences,
- onSubmit,
- isLoading,
- renderItem,
-}: NotificationSettingsCardProps) {
- const ItemRenderer = renderItem ?? NotificationCheckboxItem;
-
- return (
-
-
- {title}
-
-
-
- {({ form, isSubmitting }) => (
- <>
-
- {preferences.map((pref) => (
-
-
-
- ))}
-
-
-
- {
- form.reset();
- }}
- type="quaternary"
- theme="outline"
- disabled={isSubmitting}
- size="xs">
- Cancel
-
-
- Save
-
-
- >
- )}
-
-
- );
-}
+import { NotificationCheckboxItem } from './notification-checkbox-item';
+import type { NotificationSettingsCardProps } from './types';
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@datum-ui/components';
+import { Form } from '@datum-ui/components/form';
+import type { z } from 'zod';
+
+export function NotificationSettingsCard>({
+ title,
+ schema,
+ defaultValues,
+ preferences,
+ onSubmit,
+ isLoading,
+ renderItem,
+}: NotificationSettingsCardProps) {
+ const ItemRenderer = renderItem ?? NotificationCheckboxItem;
+
+ return (
+
+
+ {title}
+
+
+
+ {({ form, isSubmitting }) => (
+ <>
+
+ {preferences.map((pref) => (
+
+
+
+ ))}
+
+
+
+ {
+ form.reset();
+ }}
+ type="quaternary"
+ theme="outline"
+ disabled={isSubmitting}
+ size="xs">
+ Cancel
+
+
+ Save
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/app/components/notification/items/invitation-notification-item.tsx b/app/components/notification/items/invitation-notification-item.tsx
index e7e942510..711cd5e7d 100644
--- a/app/components/notification/items/invitation-notification-item.tsx
+++ b/app/components/notification/items/invitation-notification-item.tsx
@@ -1,139 +1,139 @@
-import { NotificationItemWrapper } from '../notification-item-wrapper';
-import type { ResourceNotificationItemProps } from '../types';
-import { DateTime } from '@/components/date-time';
-import {
- useAcceptInvitation,
- useRejectInvitation,
-} from '@/resources/invitations/invitation.queries';
-import { paths } from '@/utils/config/paths.config';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { getInitials } from '@/utils/helpers/text.helper';
-import { Button, toast } from '@datum-ui/components';
-import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar';
-import { useState } from 'react';
-import { useNavigate } from 'react-router';
-
-/**
- * InvitationNotificationItem - Handles invitation-specific rendering and actions
- *
- * Features:
- * - Uses React Query mutations for Accept/Decline actions
- * - Shows invitation-specific metadata (org name, role)
- * - Independent loading states per button
- * - Toast notifications on success/error
- * - Navigation to team page
- * - Watch stream updates cache automatically on MODIFIED/DELETED events
- */
-export function InvitationNotificationItem({ notification }: ResourceNotificationItemProps) {
- const navigate = useNavigate();
-
- const [action, setAction] = useState<'Accepted' | 'Declined'>();
-
- // Access the domain invitation data directly
- const invitation = notification.data;
-
- const acceptMutation = useAcceptInvitation({
- onSuccess: () => {
- navigate(
- getPathWithParams(paths.org.detail.root, {
- orgId: invitation.organizationName,
- })
- );
- },
- onError: (error) => {
- toast.error(error.message || 'Failed to accept invitation');
- },
- });
-
- const rejectMutation = useRejectInvitation({
- onSuccess: () => {
- // Watch stream removes the invitation from the cache automatically
- },
- onError: (error) => {
- toast.error(error.message || 'Failed to decline invitation');
- },
- });
-
- const handleStateUpdate = async (e: React.MouseEvent, state: 'Accepted' | 'Declined') => {
- e.stopPropagation();
- setAction(state);
-
- if (state === 'Accepted') {
- acceptMutation.mutate({
- orgId: invitation.organizationName,
- name: invitation.name,
- });
- } else {
- rejectMutation.mutate({
- orgId: invitation.organizationName,
- name: invitation.name,
- });
- }
- };
-
- // Handle navigation to invitation accept page
- const handleNavigate = () => {
- navigate(getPathWithParams(paths.invitationAccept, { invitationId: invitation.name }));
- };
-
- const isLoading = acceptMutation.isPending || rejectMutation.isPending;
-
- // Generate title from invitation data
- const inviterName = invitation.inviterUser?.displayName || invitation.invitedBy || 'Someone';
- const orgName = invitation.organization?.displayName || invitation.organizationName;
-
- return (
-
-
- {/* Avatar */}
-
-
- {getInitials(inviterName)}
-
-
-
-
- {inviterName} has invited you to join {orgName} {' '}
- organization
- {invitation.role ? (
- <>
- {' '}
- as {invitation.role}
- >
- ) : null}
-
-
- {/* Time + Expiry */}
- {invitation.createdAt && (
-
- )}
-
-
- handleStateUpdate(e, 'Declined')}
- disabled={isLoading}
- loading={isLoading && action === 'Declined'}>
- {isLoading && action === 'Declined' ? 'Declining...' : 'Decline'}
-
- handleStateUpdate(e, 'Accepted')}
- disabled={isLoading}
- loading={isLoading && action === 'Accepted'}>
- {isLoading && action === 'Accepted' ? 'Joining...' : 'Join Organization'}
-
-
-
-
-
- );
-}
+import { NotificationItemWrapper } from '../notification-item-wrapper';
+import type { ResourceNotificationItemProps } from '../types';
+import { DateTime } from '@/components/date-time';
+import {
+ useAcceptInvitation,
+ useRejectInvitation,
+} from '@/resources/invitations/invitation.queries';
+import { paths } from '@/utils/config/paths.config';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { getInitials } from '@/utils/helpers/text.helper';
+import { Button, toast } from '@datum-ui/components';
+import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar';
+import { useState } from 'react';
+import { useNavigate } from 'react-router';
+
+/**
+ * InvitationNotificationItem - Handles invitation-specific rendering and actions
+ *
+ * Features:
+ * - Uses React Query mutations for Accept/Decline actions
+ * - Shows invitation-specific metadata (org name, role)
+ * - Independent loading states per button
+ * - Toast notifications on success/error
+ * - Navigation to team page
+ * - Watch stream updates cache automatically on MODIFIED/DELETED events
+ */
+export function InvitationNotificationItem({ notification }: ResourceNotificationItemProps) {
+ const navigate = useNavigate();
+
+ const [action, setAction] = useState<'Accepted' | 'Declined'>();
+
+ // Access the domain invitation data directly
+ const invitation = notification.data;
+
+ const acceptMutation = useAcceptInvitation({
+ onSuccess: () => {
+ navigate(
+ getPathWithParams(paths.org.detail.root, {
+ orgId: invitation.organizationName,
+ })
+ );
+ },
+ onError: (error) => {
+ toast.error(error.message || 'Failed to accept invitation');
+ },
+ });
+
+ const rejectMutation = useRejectInvitation({
+ onSuccess: () => {
+ // Watch stream removes the invitation from the cache automatically
+ },
+ onError: (error) => {
+ toast.error(error.message || 'Failed to decline invitation');
+ },
+ });
+
+ const handleStateUpdate = async (e: React.MouseEvent, state: 'Accepted' | 'Declined') => {
+ e.stopPropagation();
+ setAction(state);
+
+ if (state === 'Accepted') {
+ acceptMutation.mutate({
+ orgId: invitation.organizationName,
+ name: invitation.name,
+ });
+ } else {
+ rejectMutation.mutate({
+ orgId: invitation.organizationName,
+ name: invitation.name,
+ });
+ }
+ };
+
+ // Handle navigation to invitation accept page
+ const handleNavigate = () => {
+ navigate(getPathWithParams(paths.invitationAccept, { invitationId: invitation.name }));
+ };
+
+ const isLoading = acceptMutation.isPending || rejectMutation.isPending;
+
+ // Generate title from invitation data
+ const inviterName = invitation.inviterUser?.displayName || invitation.invitedBy || 'Someone';
+ const orgName = invitation.organization?.displayName || invitation.organizationName;
+
+ return (
+
+
+ {/* Avatar */}
+
+
+ {getInitials(inviterName)}
+
+
+
+
+ {inviterName} has invited you to join {orgName} {' '}
+ organization
+ {invitation.role ? (
+ <>
+ {' '}
+ as {invitation.role}
+ >
+ ) : null}
+
+
+ {/* Time + Expiry */}
+ {invitation.createdAt && (
+
+ )}
+
+
+ handleStateUpdate(e, 'Declined')}
+ disabled={isLoading}
+ loading={isLoading && action === 'Declined'}>
+ {isLoading && action === 'Declined' ? 'Declining...' : 'Decline'}
+
+ handleStateUpdate(e, 'Accepted')}
+ disabled={isLoading}
+ loading={isLoading && action === 'Accepted'}>
+ {isLoading && action === 'Accepted' ? 'Joining...' : 'Join Organization'}
+
+
+
+
+
+ );
+}
diff --git a/app/components/notification/notification-bell.tsx b/app/components/notification/notification-bell.tsx
index e6927bb17..7d148502a 100644
--- a/app/components/notification/notification-bell.tsx
+++ b/app/components/notification/notification-bell.tsx
@@ -1,36 +1,36 @@
-import type { NotificationBellProps } from './types';
-import { Badge } from '@/modules/datum-ui/components/badge';
-import { Button } from '@/modules/datum-ui/components/button';
-import { Tooltip } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { Bell } from 'lucide-react';
-
-/**
- * NotificationBell component - displays a bell icon with a pending count badge
- */
-export function NotificationBell({ pendingCount }: NotificationBellProps) {
- const displayCount = pendingCount > 99 ? '99+' : pendingCount;
-
- return (
-
- 0 ? ` (${displayCount} pending)` : ''}`}>
-
- {pendingCount > 0 && (
-
- {displayCount}
-
- )}
- Notifications
-
-
- );
-}
+import type { NotificationBellProps } from './types';
+import { Badge } from '@/modules/datum-ui/components/badge';
+import { Button } from '@/modules/datum-ui/components/button';
+import { Tooltip } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { Bell } from 'lucide-react';
+
+/**
+ * NotificationBell component - displays a bell icon with a pending count badge
+ */
+export function NotificationBell({ pendingCount }: NotificationBellProps) {
+ const displayCount = pendingCount > 99 ? '99+' : pendingCount;
+
+ return (
+
+ 0 ? ` (${displayCount} pending)` : ''}`}>
+
+ {pendingCount > 0 && (
+
+ {displayCount}
+
+ )}
+ Notifications
+
+
+ );
+}
diff --git a/app/components/notification/notification-dropdown.tsx b/app/components/notification/notification-dropdown.tsx
index 14ef56b1c..a8def2698 100644
--- a/app/components/notification/notification-dropdown.tsx
+++ b/app/components/notification/notification-dropdown.tsx
@@ -1,75 +1,75 @@
-import { NotificationBell } from './notification-bell';
-import { NotificationEmpty } from './notification-empty';
-import { NotificationList } from './notification-list';
-import type { NotificationDropdownProps, NotificationSourceType, NotificationTab } from './types';
-import { useNotifications } from './use-notifications';
-import { ResponsiveDropdown } from '@datum-ui/components/responsive-dropdown';
-import { cn } from '@shadcn/lib/utils';
-import { useState } from 'react';
-
-/**
- * NotificationDropdown component - main dropdown with tabs for different notification sources
- */
-export function NotificationDropdown({ defaultTab = 'invitation' }: NotificationDropdownProps) {
- const [open, setOpen] = useState(false);
- const [activeTab, setActiveTab] = useState(defaultTab);
-
- const { notifications, pendingCount, error } = useNotifications();
-
- // Filter notifications by active tab
- const filteredNotifications = notifications.filter((n) => n.source === activeTab);
-
- // Tab configuration
- const tabs: NotificationTab[] = [
- {
- id: 'invitation',
- label: 'Invitations',
- enabled: true,
- emptyMessage: 'No pending invitations',
- },
- ];
-
- return (
-
-
-
- }>
- {/* Tabs */}
-
- {tabs
- .filter((tab) => tab.enabled)
- .map((tab) => (
- setActiveTab(tab.id)}
- className={cn(
- 'border-b-2 py-2.5 text-xs font-medium transition-colors',
- activeTab === tab.id
- ? 'border-primary text-foreground'
- : 'text-muted-foreground border-transparent'
- )}>
- {tab.label}
-
- ))}
-
-
- {/* Tab Content */}
-
- {error ? (
-
{error}
- ) : filteredNotifications.length === 0 ? (
-
t.id === activeTab)?.emptyMessage} />
- ) : (
-
- )}
-
-
- );
-}
+import { NotificationBell } from './notification-bell';
+import { NotificationEmpty } from './notification-empty';
+import { NotificationList } from './notification-list';
+import type { NotificationDropdownProps, NotificationSourceType, NotificationTab } from './types';
+import { useNotifications } from './use-notifications';
+import { ResponsiveDropdown } from '@datum-ui/components/responsive-dropdown';
+import { cn } from '@shadcn/lib/utils';
+import { useState } from 'react';
+
+/**
+ * NotificationDropdown component - main dropdown with tabs for different notification sources
+ */
+export function NotificationDropdown({ defaultTab = 'invitation' }: NotificationDropdownProps) {
+ const [open, setOpen] = useState(false);
+ const [activeTab, setActiveTab] = useState(defaultTab);
+
+ const { notifications, pendingCount, error } = useNotifications();
+
+ // Filter notifications by active tab
+ const filteredNotifications = notifications.filter((n) => n.source === activeTab);
+
+ // Tab configuration
+ const tabs: NotificationTab[] = [
+ {
+ id: 'invitation',
+ label: 'Invitations',
+ enabled: true,
+ emptyMessage: 'No pending invitations',
+ },
+ ];
+
+ return (
+
+
+
+ }>
+ {/* Tabs */}
+
+ {tabs
+ .filter((tab) => tab.enabled)
+ .map((tab) => (
+ setActiveTab(tab.id)}
+ className={cn(
+ 'border-b-2 py-2.5 text-xs font-medium transition-colors',
+ activeTab === tab.id
+ ? 'border-primary text-foreground'
+ : 'text-muted-foreground border-transparent'
+ )}>
+ {tab.label}
+
+ ))}
+
+
+ {/* Tab Content */}
+
+ {error ? (
+
{error}
+ ) : filteredNotifications.length === 0 ? (
+
t.id === activeTab)?.emptyMessage} />
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/app/components/notification/notification-item-wrapper.tsx b/app/components/notification/notification-item-wrapper.tsx
index 96621a1c9..dc674d242 100644
--- a/app/components/notification/notification-item-wrapper.tsx
+++ b/app/components/notification/notification-item-wrapper.tsx
@@ -1,29 +1,29 @@
-import type { NotificationItemWrapperProps } from './types';
-import { cn } from '@shadcn/lib/utils';
-
-/**
- * NotificationItemWrapper - Minimal base container for all notification items
- *
- * Provides:
- * - Container div with consistent styling
- * - Read/unread opacity
- * - Click handling for navigation
- * - Hover effects
- *
- * Does NOT provide:
- * - Avatar/icon rendering (managed by resource items)
- * - Content rendering (passed via children)
- * - Action handling (managed by resource items)
- */
-export function NotificationItemWrapper({ children, onNavigate }: NotificationItemWrapperProps) {
- return (
-
- {children}
-
- );
-}
+import type { NotificationItemWrapperProps } from './types';
+import { cn } from '@shadcn/lib/utils';
+
+/**
+ * NotificationItemWrapper - Minimal base container for all notification items
+ *
+ * Provides:
+ * - Container div with consistent styling
+ * - Read/unread opacity
+ * - Click handling for navigation
+ * - Hover effects
+ *
+ * Does NOT provide:
+ * - Avatar/icon rendering (managed by resource items)
+ * - Content rendering (passed via children)
+ * - Action handling (managed by resource items)
+ */
+export function NotificationItemWrapper({ children, onNavigate }: NotificationItemWrapperProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/components/personal-badge/personal-badge.tsx b/app/components/personal-badge/personal-badge.tsx
index c6690b7bb..6f0be2490 100644
--- a/app/components/personal-badge/personal-badge.tsx
+++ b/app/components/personal-badge/personal-badge.tsx
@@ -1,16 +1,16 @@
-import { Badge } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-
-export const PersonalBadge = ({ className }: { className?: string }) => {
- return (
-
- Personal
-
- );
-};
+import { Badge } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+
+export const PersonalBadge = ({ className }: { className?: string }) => {
+ return (
+
+ Personal
+
+ );
+};
diff --git a/app/components/profile-identity/profile-identity.tsx b/app/components/profile-identity/profile-identity.tsx
index e5dce9534..2f2d3deac 100644
--- a/app/components/profile-identity/profile-identity.tsx
+++ b/app/components/profile-identity/profile-identity.tsx
@@ -1,97 +1,97 @@
-import { getInitials } from '@/utils/helpers/text.helper';
-import { cn } from '@shadcn/lib/utils';
-import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar';
-import { UserRound } from 'lucide-react';
-
-interface ProfileIdentityProps {
- avatarSrc?: string;
- name?: string;
- subtitle?: string;
- size?: 'xs' | 'sm' | 'md' | 'lg';
- className?: string;
- fallbackIcon?: React.ComponentType<{ className?: string }>;
- fallbackText?: string;
- avatarOnly?: boolean;
- fallbackClassName?: string;
-}
-
-export function ProfileIdentity({
- avatarSrc,
- name,
- subtitle,
- size = 'md',
- className,
- fallbackIcon,
- fallbackText,
- avatarOnly = false,
- fallbackClassName,
-}: ProfileIdentityProps) {
- const sizeClasses = {
- xs: {
- avatar: 'size-8',
- name: 'text-xs font-medium',
- subtitle: 'text-xs text-muted-foreground',
- icon: 'size-3',
- },
- sm: {
- avatar: 'size-10',
- name: 'text-sm font-medium',
- subtitle: 'text-xs text-muted-foreground',
- icon: 'size-4',
- },
- md: {
- avatar: 'size-12',
- name: 'text-base font-semibold',
- subtitle: 'text-sm text-muted-foreground',
- icon: 'size-5',
- },
- lg: {
- avatar: 'size-14',
- name: 'text-lg font-semibold',
- subtitle: 'text-base text-muted-foreground',
- icon: 'size-6',
- },
- };
-
- const FallbackIcon = fallbackIcon || UserRound;
-
- const renderFallback = () => {
- if (fallbackText) {
- return fallbackText;
- }
- if (name && !fallbackIcon) {
- return getInitials(name);
- }
- return ;
- };
-
- if (avatarOnly) {
- return (
-
-
-
- {renderFallback()}
-
-
- );
- }
-
- return (
-
-
-
-
- {renderFallback()}
-
-
- {(name || subtitle) && (
-
- {name && {name} }
- {subtitle && {subtitle} }
-
- )}
-
- );
-}
+import { getInitials } from '@/utils/helpers/text.helper';
+import { cn } from '@shadcn/lib/utils';
+import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar';
+import { UserRound } from 'lucide-react';
+
+interface ProfileIdentityProps {
+ avatarSrc?: string;
+ name?: string;
+ subtitle?: string;
+ size?: 'xs' | 'sm' | 'md' | 'lg';
+ className?: string;
+ fallbackIcon?: React.ComponentType<{ className?: string }>;
+ fallbackText?: string;
+ avatarOnly?: boolean;
+ fallbackClassName?: string;
+}
+
+export function ProfileIdentity({
+ avatarSrc,
+ name,
+ subtitle,
+ size = 'md',
+ className,
+ fallbackIcon,
+ fallbackText,
+ avatarOnly = false,
+ fallbackClassName,
+}: ProfileIdentityProps) {
+ const sizeClasses = {
+ xs: {
+ avatar: 'size-8',
+ name: 'text-xs font-medium',
+ subtitle: 'text-xs text-muted-foreground',
+ icon: 'size-3',
+ },
+ sm: {
+ avatar: 'size-10',
+ name: 'text-sm font-medium',
+ subtitle: 'text-xs text-muted-foreground',
+ icon: 'size-4',
+ },
+ md: {
+ avatar: 'size-12',
+ name: 'text-base font-semibold',
+ subtitle: 'text-sm text-muted-foreground',
+ icon: 'size-5',
+ },
+ lg: {
+ avatar: 'size-14',
+ name: 'text-lg font-semibold',
+ subtitle: 'text-base text-muted-foreground',
+ icon: 'size-6',
+ },
+ };
+
+ const FallbackIcon = fallbackIcon || UserRound;
+
+ const renderFallback = () => {
+ if (fallbackText) {
+ return fallbackText;
+ }
+ if (name && !fallbackIcon) {
+ return getInitials(name);
+ }
+ return ;
+ };
+
+ if (avatarOnly) {
+ return (
+
+
+
+ {renderFallback()}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {renderFallback()}
+
+
+ {(name || subtitle) && (
+
+ {name && {name} }
+ {subtitle && {subtitle} }
+
+ )}
+
+ );
+}
diff --git a/app/components/select-annotations/select-annotations.tsx b/app/components/select-annotations/select-annotations.tsx
index 6b5bcfbb5..4906f74e3 100644
--- a/app/components/select-annotations/select-annotations.tsx
+++ b/app/components/select-annotations/select-annotations.tsx
@@ -1,123 +1,123 @@
-import { KeyValueFormDialog, KeyValueFormDialogRef } from '@/components/key-value-form-dialog';
-import { MultiSelect, MultiSelectOption } from '@/components/multi-select/multi-select';
-import { annotationFormSchema, AnnotationFormSchema } from '@/resources/base';
-import { splitOption } from '@/utils/helpers/object.helper';
-import { toast } from '@datum-ui/components';
-import { PlusIcon } from 'lucide-react';
-import { useEffect, useRef, useState } from 'react';
-
-export const SelectAnnotations = ({
- defaultValue,
- onChange,
-}: {
- defaultValue?: string[];
- onChange?: (value: string[]) => void;
-}) => {
- const dialogRef = useRef(null!);
- const [options, setOptions] = useState();
- const [selectedOptions, setSelectedOptions] = useState([]);
- const [activeOption, setActiveOption] = useState();
-
- useEffect(() => {
- if (defaultValue && !options) {
- const values = Array.isArray(defaultValue) ? defaultValue : [defaultValue];
- const initialOptions = values.map((option) => {
- const { key, value } = splitOption(option);
- return {
- label: `${key}:${value}`,
- value: `${key}:${value}`,
- };
- });
- setOptions(initialOptions);
- setSelectedOptions(initialOptions.map((option) => option.value));
- } else if (defaultValue) {
- // Only update selected options without replacing all options
- const values = Array.isArray(defaultValue) ? defaultValue : [defaultValue];
- setSelectedOptions(values);
- } else {
- setSelectedOptions([]);
- }
- }, [defaultValue, options]);
-
- const handleSubmit = (value: AnnotationFormSchema) => {
- const newOption = `${value.key}:${value.value}`;
- const newOptionObj = { label: newOption, value: newOption };
- const currentOptions = options ?? [];
-
- // Check if the option already exists (excluding the active one when editing)
- const isDuplicate = currentOptions.some(
- (opt) => opt.value === newOption && (!activeOption || opt.value !== activeOption)
- );
-
- if (isDuplicate) {
- toast.error('Annotation already exists');
- return;
- }
-
- if (activeOption !== undefined) {
- // Update existing option
- const updatedOptions = currentOptions.map((opt) =>
- opt.value === activeOption ? newOptionObj : opt
- );
-
- const updatedSelectedOptions = selectedOptions.map((opt) =>
- opt === activeOption ? newOption : opt
- );
-
- setOptions(updatedOptions);
- handleValueChange(updatedSelectedOptions);
- } else {
- // Add new option
- setOptions([...currentOptions, newOptionObj]);
- handleValueChange([...selectedOptions, newOption]);
- }
- };
-
- const handleValueChange = (values: string[]) => {
- setSelectedOptions(values);
- onChange?.(values);
- };
-
- return (
- <>
- {
- setActiveOption(undefined);
- dialogRef.current?.show();
- },
- },
- ]}
- onBadgeClick={(option) => {
- const { key, value } = splitOption(option.value);
-
- setActiveOption(option.value);
- dialogRef.current?.show({ key, value });
- }}
- />
-
- setActiveOption(undefined)}
- />
- >
- );
-};
+import { KeyValueFormDialog, KeyValueFormDialogRef } from '@/components/key-value-form-dialog';
+import { MultiSelect, MultiSelectOption } from '@/components/multi-select/multi-select';
+import { annotationFormSchema, AnnotationFormSchema } from '@/resources/base';
+import { splitOption } from '@/utils/helpers/object.helper';
+import { toast } from '@datum-ui/components';
+import { PlusIcon } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+
+export const SelectAnnotations = ({
+ defaultValue,
+ onChange,
+}: {
+ defaultValue?: string[];
+ onChange?: (value: string[]) => void;
+}) => {
+ const dialogRef = useRef(null!);
+ const [options, setOptions] = useState();
+ const [selectedOptions, setSelectedOptions] = useState([]);
+ const [activeOption, setActiveOption] = useState();
+
+ useEffect(() => {
+ if (defaultValue && !options) {
+ const values = Array.isArray(defaultValue) ? defaultValue : [defaultValue];
+ const initialOptions = values.map((option) => {
+ const { key, value } = splitOption(option);
+ return {
+ label: `${key}:${value}`,
+ value: `${key}:${value}`,
+ };
+ });
+ setOptions(initialOptions);
+ setSelectedOptions(initialOptions.map((option) => option.value));
+ } else if (defaultValue) {
+ // Only update selected options without replacing all options
+ const values = Array.isArray(defaultValue) ? defaultValue : [defaultValue];
+ setSelectedOptions(values);
+ } else {
+ setSelectedOptions([]);
+ }
+ }, [defaultValue, options]);
+
+ const handleSubmit = (value: AnnotationFormSchema) => {
+ const newOption = `${value.key}:${value.value}`;
+ const newOptionObj = { label: newOption, value: newOption };
+ const currentOptions = options ?? [];
+
+ // Check if the option already exists (excluding the active one when editing)
+ const isDuplicate = currentOptions.some(
+ (opt) => opt.value === newOption && (!activeOption || opt.value !== activeOption)
+ );
+
+ if (isDuplicate) {
+ toast.error('Annotation already exists');
+ return;
+ }
+
+ if (activeOption !== undefined) {
+ // Update existing option
+ const updatedOptions = currentOptions.map((opt) =>
+ opt.value === activeOption ? newOptionObj : opt
+ );
+
+ const updatedSelectedOptions = selectedOptions.map((opt) =>
+ opt === activeOption ? newOption : opt
+ );
+
+ setOptions(updatedOptions);
+ handleValueChange(updatedSelectedOptions);
+ } else {
+ // Add new option
+ setOptions([...currentOptions, newOptionObj]);
+ handleValueChange([...selectedOptions, newOption]);
+ }
+ };
+
+ const handleValueChange = (values: string[]) => {
+ setSelectedOptions(values);
+ onChange?.(values);
+ };
+
+ return (
+ <>
+ {
+ setActiveOption(undefined);
+ dialogRef.current?.show();
+ },
+ },
+ ]}
+ onBadgeClick={(option) => {
+ const { key, value } = splitOption(option.value);
+
+ setActiveOption(option.value);
+ dialogRef.current?.show({ key, value });
+ }}
+ />
+
+ setActiveOption(undefined)}
+ />
+ >
+ );
+};
diff --git a/app/components/select-autocomplete/select-autocomplete.tsx b/app/components/select-autocomplete/select-autocomplete.tsx
index 8d85bc7e4..3df125b07 100644
--- a/app/components/select-autocomplete/select-autocomplete.tsx
+++ b/app/components/select-autocomplete/select-autocomplete.tsx
@@ -1,123 +1,123 @@
-import { Option, SelectAutocompleteProps } from './select-autocomplete.types';
-import { VirtualizedList } from './virtualized-list';
-import { Button } from '@datum-ui/components';
-import { LoaderOverlay } from '@datum-ui/components/loader-overlay';
-import { cn } from '@shadcn/lib/utils';
-import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
-import { ChevronDown } from 'lucide-react';
-import React, { useEffect, useMemo, useState } from 'react';
-
-export const SelectAutocomplete = React.forwardRef<
- { showPopover: (open: boolean) => void },
- SelectAutocompleteProps
->(
- (
- {
- options,
- keyValue = 'value',
- selectedValue,
- onValueChange,
- placeholder = 'Select...',
- emptyContent = 'No results found',
- triggerClassName,
- contentClassName,
- itemPreview,
- itemContent,
- disabled = false,
- boxClassName,
- disableSearch = false,
- itemSize = 35,
- isLoading = false,
- footer,
- name,
- id,
- },
- ref
- ) => {
- const [open, setOpen] = useState(false);
- const [currentValue, setCurrentValue] = useState(undefined);
-
- const handleSelect = (option: Option) => {
- setCurrentValue(option[keyValue]);
- setOpen(false);
- onValueChange?.(option);
- };
-
- const triggerContent = useMemo(() => {
- if (!currentValue) return placeholder;
- return itemPreview?.(selectedValue!) ?? selectedValue?.label;
- }, [currentValue, placeholder, itemPreview, selectedValue]);
-
- useEffect(() => {
- if (selectedValue) {
- setCurrentValue(selectedValue[keyValue]);
- }
- }, [selectedValue]);
-
- // Expose showPopover method to parent via ref
- React.useImperativeHandle(
- ref,
- () => ({
- showPopover: (open: boolean) => setOpen(open),
- }),
- [setOpen]
- );
-
- return (
- <>
-
-
- span]:line-clamp-1',
- triggerClassName
- )}
- disabled={disabled || isLoading}>
- {isLoading && }
- {triggerContent}
-
-
-
-
-
-
- {footer && {footer}
}
-
-
- {/* Hidden input for form submission */}
- undefined}>
-
- {options.map((option, idx) => (
-
- {option.label}
-
- ))}
-
- >
- );
- }
-);
-
-SelectAutocomplete.displayName = 'SelectAutocomplete';
+import { Option, SelectAutocompleteProps } from './select-autocomplete.types';
+import { VirtualizedList } from './virtualized-list';
+import { Button } from '@datum-ui/components';
+import { LoaderOverlay } from '@datum-ui/components/loader-overlay';
+import { cn } from '@shadcn/lib/utils';
+import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
+import { ChevronDown } from 'lucide-react';
+import React, { useEffect, useMemo, useState } from 'react';
+
+export const SelectAutocomplete = React.forwardRef<
+ { showPopover: (open: boolean) => void },
+ SelectAutocompleteProps
+>(
+ (
+ {
+ options,
+ keyValue = 'value',
+ selectedValue,
+ onValueChange,
+ placeholder = 'Select...',
+ emptyContent = 'No results found',
+ triggerClassName,
+ contentClassName,
+ itemPreview,
+ itemContent,
+ disabled = false,
+ boxClassName,
+ disableSearch = false,
+ itemSize = 35,
+ isLoading = false,
+ footer,
+ name,
+ id,
+ },
+ ref
+ ) => {
+ const [open, setOpen] = useState(false);
+ const [currentValue, setCurrentValue] = useState(undefined);
+
+ const handleSelect = (option: Option) => {
+ setCurrentValue(option[keyValue]);
+ setOpen(false);
+ onValueChange?.(option);
+ };
+
+ const triggerContent = useMemo(() => {
+ if (!currentValue) return placeholder;
+ return itemPreview?.(selectedValue!) ?? selectedValue?.label;
+ }, [currentValue, placeholder, itemPreview, selectedValue]);
+
+ useEffect(() => {
+ if (selectedValue) {
+ setCurrentValue(selectedValue[keyValue]);
+ }
+ }, [selectedValue]);
+
+ // Expose showPopover method to parent via ref
+ React.useImperativeHandle(
+ ref,
+ () => ({
+ showPopover: (open: boolean) => setOpen(open),
+ }),
+ [setOpen]
+ );
+
+ return (
+ <>
+
+
+ span]:line-clamp-1',
+ triggerClassName
+ )}
+ disabled={disabled || isLoading}>
+ {isLoading && }
+ {triggerContent}
+
+
+
+
+
+
+ {footer && {footer}
}
+
+
+ {/* Hidden input for form submission */}
+ undefined}>
+
+ {options.map((option, idx) => (
+
+ {option.label}
+
+ ))}
+
+ >
+ );
+ }
+);
+
+SelectAutocomplete.displayName = 'SelectAutocomplete';
diff --git a/app/components/select-autocomplete/select-autocomplete.types.ts b/app/components/select-autocomplete/select-autocomplete.types.ts
index 5f406fa9a..f0fb9ff43 100644
--- a/app/components/select-autocomplete/select-autocomplete.types.ts
+++ b/app/components/select-autocomplete/select-autocomplete.types.ts
@@ -1,28 +1,28 @@
-export type Option = {
- value?: any;
- label?: string;
- disabled?: boolean;
- // Allow any additional string fields
- [key: string]: any;
-};
-
-export type SelectAutocompleteProps = {
- keyValue?: string;
- options: Option[];
- selectedValue?: Option;
- onValueChange?: (value: Option) => void;
- placeholder?: string;
- triggerClassName?: string;
- contentClassName?: string;
- itemPreview?: (option: Option) => React.ReactNode;
- itemContent?: (option: Option) => React.ReactNode;
- emptyContent?: string;
- disabled?: boolean;
- boxClassName?: string;
- disableSearch?: boolean;
- itemSize?: number;
- isLoading?: boolean;
- footer?: React.ReactNode;
- name?: string;
- id?: string;
-};
+export type Option = {
+ value?: any;
+ label?: string;
+ disabled?: boolean;
+ // Allow any additional string fields
+ [key: string]: any;
+};
+
+export type SelectAutocompleteProps = {
+ keyValue?: string;
+ options: Option[];
+ selectedValue?: Option;
+ onValueChange?: (value: Option) => void;
+ placeholder?: string;
+ triggerClassName?: string;
+ contentClassName?: string;
+ itemPreview?: (option: Option) => React.ReactNode;
+ itemContent?: (option: Option) => React.ReactNode;
+ emptyContent?: string;
+ disabled?: boolean;
+ boxClassName?: string;
+ disableSearch?: boolean;
+ itemSize?: number;
+ isLoading?: boolean;
+ footer?: React.ReactNode;
+ name?: string;
+ id?: string;
+};
diff --git a/app/components/select-autocomplete/virtualized-list.tsx b/app/components/select-autocomplete/virtualized-list.tsx
index 23f5313bf..69bc8df3a 100644
--- a/app/components/select-autocomplete/virtualized-list.tsx
+++ b/app/components/select-autocomplete/virtualized-list.tsx
@@ -1,172 +1,172 @@
-import { Option } from './select-autocomplete.types';
-import { cn } from '@shadcn/lib/utils';
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@shadcn/ui/command';
-import { useVirtualizer } from '@tanstack/react-virtual';
-import { CheckIcon } from 'lucide-react';
-import { useEffect, useRef, useState } from 'react';
-
-export const VirtualizedList = ({
- options,
- keyValue = 'value',
- selectedValue,
- onValueChange,
- itemContent,
- emptyContent = 'No results found.',
- boxClassName,
- disableSearch = false,
- itemSize = 35,
-}: {
- options: Option[];
- keyValue?: string;
- selectedValue?: Option;
- onValueChange?: (option: Option) => void;
- itemContent?: (option: Option) => React.ReactNode;
- emptyContent?: string;
- boxClassName?: string;
- disableSearch?: boolean;
- itemSize?: number;
-}) => {
- const [filteredOptions, setFilteredOptions] = useState(options);
- const [focusedIndex, setFocusedIndex] = useState(0);
- const [isKeyboardNavActive, setIsKeyboardNavActive] = useState(false);
-
- const parentRef = useRef(null);
-
- const virtualizer = useVirtualizer({
- count: filteredOptions.length,
- getScrollElement: () => parentRef.current,
- estimateSize: () => itemSize ?? 35,
- });
-
- const virtualOptions = virtualizer.getVirtualItems();
-
- const scrollToIndex = (index: number) => {
- virtualizer.scrollToIndex(index, {
- align: 'center',
- });
- };
-
- const handleSearch = (search: string) => {
- setIsKeyboardNavActive(false);
- setFilteredOptions(
- options.filter((option) => {
- const searchLower = search.toLowerCase();
-
- return Object.entries(option).some(([_, value]) => {
- // Only filter string values
- if (typeof value === 'string') {
- return value.toLowerCase().includes(searchLower);
- }
- return false;
- });
- })
- );
- };
-
- const handleKeyDown = (event: React.KeyboardEvent) => {
- switch (event.key) {
- case 'ArrowDown': {
- event.preventDefault();
- setIsKeyboardNavActive(true);
- setFocusedIndex((prev) => {
- const newIndex = prev === -1 ? 0 : Math.min(prev + 1, filteredOptions.length - 1);
- scrollToIndex(newIndex);
- return newIndex;
- });
- break;
- }
- case 'ArrowUp': {
- event.preventDefault();
- setIsKeyboardNavActive(true);
- setFocusedIndex((prev) => {
- const newIndex = prev === -1 ? filteredOptions.length - 1 : Math.max(prev - 1, 0);
- scrollToIndex(newIndex);
- return newIndex;
- });
- break;
- }
- case 'Enter': {
- event.preventDefault();
- if (filteredOptions[focusedIndex]) {
- onValueChange?.(filteredOptions[focusedIndex]);
- }
- break;
- }
- default:
- break;
- }
- };
-
- useEffect(() => {
- if (selectedValue) {
- const option = filteredOptions.find((option) => option[keyValue] === selectedValue[keyValue]);
- if (option) {
- const index = filteredOptions.indexOf(option);
- setFocusedIndex(index);
- virtualizer.scrollToIndex(index, {
- align: 'center',
- });
- }
- }
- }, [selectedValue, filteredOptions, virtualizer]);
-
- return (
-
- {!disableSearch && }
- setIsKeyboardNavActive(false)}
- onMouseMove={() => setIsKeyboardNavActive(false)}>
- {emptyContent}
-
-
-
- {virtualOptions.map((virtualOption) => {
- const option = filteredOptions[virtualOption.index];
- const isSelected = option[keyValue] === selectedValue?.[keyValue];
- return (
-
!isKeyboardNavActive && setFocusedIndex(virtualOption.index)}
- onMouseLeave={() => !isKeyboardNavActive && setFocusedIndex(-1)}
- onSelect={() => {
- onValueChange?.(option);
- }}
- className={cn(
- 'absolute top-0 left-0 w-full bg-transparent',
- isKeyboardNavActive &&
- focusedIndex !== virtualOption.index &&
- 'aria-selected:text-primary aria-selected:bg-transparent'
- )}
- style={{
- minHeight: `${virtualOption.size}px`,
- transform: `translateY(${virtualOption.start}px)`,
- }}>
-
-
{itemContent ? itemContent(option) : option.label}
-
- {isSelected && }
-
-
-
- );
- })}
-
-
-
-
- );
-};
+import { Option } from './select-autocomplete.types';
+import { cn } from '@shadcn/lib/utils';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@shadcn/ui/command';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { CheckIcon } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+
+export const VirtualizedList = ({
+ options,
+ keyValue = 'value',
+ selectedValue,
+ onValueChange,
+ itemContent,
+ emptyContent = 'No results found.',
+ boxClassName,
+ disableSearch = false,
+ itemSize = 35,
+}: {
+ options: Option[];
+ keyValue?: string;
+ selectedValue?: Option;
+ onValueChange?: (option: Option) => void;
+ itemContent?: (option: Option) => React.ReactNode;
+ emptyContent?: string;
+ boxClassName?: string;
+ disableSearch?: boolean;
+ itemSize?: number;
+}) => {
+ const [filteredOptions, setFilteredOptions] = useState (options);
+ const [focusedIndex, setFocusedIndex] = useState(0);
+ const [isKeyboardNavActive, setIsKeyboardNavActive] = useState(false);
+
+ const parentRef = useRef(null);
+
+ const virtualizer = useVirtualizer({
+ count: filteredOptions.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => itemSize ?? 35,
+ });
+
+ const virtualOptions = virtualizer.getVirtualItems();
+
+ const scrollToIndex = (index: number) => {
+ virtualizer.scrollToIndex(index, {
+ align: 'center',
+ });
+ };
+
+ const handleSearch = (search: string) => {
+ setIsKeyboardNavActive(false);
+ setFilteredOptions(
+ options.filter((option) => {
+ const searchLower = search.toLowerCase();
+
+ return Object.entries(option).some(([_, value]) => {
+ // Only filter string values
+ if (typeof value === 'string') {
+ return value.toLowerCase().includes(searchLower);
+ }
+ return false;
+ });
+ })
+ );
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ switch (event.key) {
+ case 'ArrowDown': {
+ event.preventDefault();
+ setIsKeyboardNavActive(true);
+ setFocusedIndex((prev) => {
+ const newIndex = prev === -1 ? 0 : Math.min(prev + 1, filteredOptions.length - 1);
+ scrollToIndex(newIndex);
+ return newIndex;
+ });
+ break;
+ }
+ case 'ArrowUp': {
+ event.preventDefault();
+ setIsKeyboardNavActive(true);
+ setFocusedIndex((prev) => {
+ const newIndex = prev === -1 ? filteredOptions.length - 1 : Math.max(prev - 1, 0);
+ scrollToIndex(newIndex);
+ return newIndex;
+ });
+ break;
+ }
+ case 'Enter': {
+ event.preventDefault();
+ if (filteredOptions[focusedIndex]) {
+ onValueChange?.(filteredOptions[focusedIndex]);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ };
+
+ useEffect(() => {
+ if (selectedValue) {
+ const option = filteredOptions.find((option) => option[keyValue] === selectedValue[keyValue]);
+ if (option) {
+ const index = filteredOptions.indexOf(option);
+ setFocusedIndex(index);
+ virtualizer.scrollToIndex(index, {
+ align: 'center',
+ });
+ }
+ }
+ }, [selectedValue, filteredOptions, virtualizer]);
+
+ return (
+
+ {!disableSearch && }
+ setIsKeyboardNavActive(false)}
+ onMouseMove={() => setIsKeyboardNavActive(false)}>
+ {emptyContent}
+
+
+
+ {virtualOptions.map((virtualOption) => {
+ const option = filteredOptions[virtualOption.index];
+ const isSelected = option[keyValue] === selectedValue?.[keyValue];
+ return (
+
!isKeyboardNavActive && setFocusedIndex(virtualOption.index)}
+ onMouseLeave={() => !isKeyboardNavActive && setFocusedIndex(-1)}
+ onSelect={() => {
+ onValueChange?.(option);
+ }}
+ className={cn(
+ 'absolute top-0 left-0 w-full bg-transparent',
+ isKeyboardNavActive &&
+ focusedIndex !== virtualOption.index &&
+ 'aria-selected:text-primary aria-selected:bg-transparent'
+ )}
+ style={{
+ minHeight: `${virtualOption.size}px`,
+ transform: `translateY(${virtualOption.start}px)`,
+ }}>
+
+
{itemContent ? itemContent(option) : option.label}
+
+ {isSelected && }
+
+
+
+ );
+ })}
+
+
+
+
+ );
+};
diff --git a/app/components/select-box/select-box.tsx b/app/components/select-box/select-box.tsx
index e1053f04e..6ca7edb4f 100644
--- a/app/components/select-box/select-box.tsx
+++ b/app/components/select-box/select-box.tsx
@@ -1,363 +1,363 @@
-import { LoaderOverlay } from '@datum-ui/components/loader-overlay';
-import { cn } from '@shadcn/lib/utils';
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@shadcn/ui/command';
-import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
-import { CheckIcon, ChevronDown } from 'lucide-react';
-import { useEffect, useMemo, useState } from 'react';
-
-/**
- * Represents a single option in a SelectBox dropdown
- *
- * @example
- * ```tsx
- * const option: SelectBoxOption = {
- * value: 'admin',
- * label: 'Administrator',
- * description: 'Full access to all resources',
- * disabled: false
- * };
- * ```
- */
-export interface SelectBoxOption {
- /** Unique identifier for the option - used in form submission */
- value: string;
-
- /** Display text shown in the dropdown and button */
- label: string;
-
- /** Whether the option is disabled and cannot be selected */
- disabled?: boolean;
-
- /** Optional description text shown below the label in the dropdown */
- description?: string;
-
- /** Additional custom properties can be stored here */
- [key: string]: any;
-}
-
-/**
- * Groups options together with a heading for better organization
- *
- * @example
- * ```tsx
- * const group: SelectBoxGroup = {
- * label: 'Database Access',
- * options: [
- * { value: 'db-read', label: 'Read Only' },
- * { value: 'db-write', label: 'Read/Write' }
- * ]
- * };
- * ```
- */
-export interface SelectBoxGroup {
- /** The heading text for this group */
- label: string;
-
- /** Array of options to display under this group heading */
- options: SelectBoxOption[];
-}
-
-/**
- * A customizable dropdown/select component with support for grouping, search, and descriptions
- *
- * Features:
- * - Single or multi-select modes
- * - Group options with category headings
- * - Case-insensitive search/filtering
- * - Optional descriptions under each option
- * - Disabled option support
- * - Custom styling via className
- *
- * @example
- * ```tsx
- * // Single-select with grouped options
- * setSelectedRole(option)}
- * groups={[
- * {
- * label: 'Admin Roles',
- * options: [
- * { value: 'admin', label: 'Administrator', description: 'Full access' }
- * ]
- * }
- * ]}
- * placeholder="Select a role"
- * />
- *
- * // Simple single-select with flat options
- * setSelectedProject(option)}
- * options={projects}
- * placeholder="Select a project"
- * />
- * ```
- */
-export const SelectBox = ({
- value,
- className,
- onChange,
- options,
- groups,
- name,
- id,
- placeholder = 'Select an option',
- disabled = false,
- isLoading = false,
- searchable = false,
- searchPlaceholder = 'Search...',
- itemPreview,
- triggerClassName,
- popoverClassName,
- emptyContent = 'No results found.',
- modal = false,
-}: {
- /** Currently selected option value (for single-select mode) */
- value?: string;
-
- /** Callback fired when a single option is selected */
- onChange?: (value: SelectBoxOption) => void;
-
- /** Flat array of options - use this OR groups, not both */
- options?: SelectBoxOption[];
-
- /** Array of grouped options - use this OR options, not both */
- groups?: SelectBoxGroup[];
-
- /** HTML name attribute for the hidden form select element */
- name?: string;
-
- /** HTML id attribute for the hidden form select element */
- id?: string;
-
- /** Placeholder text shown when no option is selected */
- placeholder?: string;
-
- /** Disable the entire select box */
- disabled?: boolean;
-
- /** Show loading spinner in the button */
- isLoading?: boolean;
-
- /** Enable search/filter functionality for options (default: false) */
- searchable?: boolean;
-
- /** Placeholder text for the search input (only used when searchable is true) */
- searchPlaceholder?: string;
-
- /** Custom render function for option preview in the trigger */
- itemPreview?: (option: SelectBoxOption) => React.ReactNode;
- /** Optional classes applied to both trigger and popover when more specific classes are not provided */
- className?: string;
- /** Optional classes applied to the trigger button */
- triggerClassName?: string;
- /** Optional classes applied to the popover content */
- popoverClassName?: string;
- /** Optional content to display when no options are found */
- emptyContent?: string;
- /** Whether the popover should be modal */
- modal?: boolean;
-}) => {
- const triggerClasses = triggerClassName ?? className;
- const popoverClasses = popoverClassName ?? className;
- const [open, setOpen] = useState(false);
- const [initValue, setInitValue] = useState(value);
-
- // Flatten all options from groups and regular options
- const allOptions = useMemo(() => {
- const flatOptions: SelectBoxOption[] = [];
- if (groups) {
- groups.forEach((group) => {
- flatOptions.push(...group.options);
- });
- }
- if (options) {
- flatOptions.push(...options);
- }
- return flatOptions;
- }, [groups, options]);
-
- const selectedValue = useMemo(() => {
- if (!initValue) return undefined;
- return allOptions.find((option) => option.value === initValue);
- }, [initValue, allOptions]);
-
- useEffect(() => {
- if (value) {
- setInitValue(value);
- }
- }, [value]);
-
- useEffect(() => {
- if (selectedValue && onChange) {
- onChange(selectedValue);
- }
- }, [selectedValue, onChange]);
-
- const previewHandler = (
- option: SelectBoxOption,
- className?: string,
- showDescription = false
- ): React.ReactNode => {
- return itemPreview ? (
- itemPreview(option)
- ) : showDescription && option.description ? (
-
-
- {option.label}
-
-
- {option.description}
-
-
- ) : (
-
- {option.label}
-
- );
- };
-
- return (
- <>
-
-
- setOpen(!open)}
- className={cn(
- 'text-input-foreground placeholder:text-input-placeholder',
- 'border-input-border bg-input-background/50 relative flex h-auto! min-h-10 w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-all',
- 'focus-visible:border-input-focus-border focus-visible:shadow-(--input-focus-shadow)',
- 'focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-hidden',
- (disabled || isLoading) && 'cursor-not-allowed opacity-50',
- triggerClasses
- )}>
-
- {selectedValue ? (
- previewHandler(selectedValue, 'line-clamp-1 text-ellipsis', true)
- ) : (
- {placeholder}
- )}
-
-
- {isLoading && }
-
-
- setOpen(false)}>
-
- {searchable && }
-
- {searchable && {emptyContent} }
- {allOptions.length > 0 ? (
-
- {groups && groups.length > 0 ? (
- // Render grouped options
- groups.map((group, index) => (
-
0 ? 'border-t pt-2' : ''}>
- {group.options.map((option: SelectBoxOption) => {
- const isSelected = selectedValue?.value === option.value;
- return (
- {
- setInitValue(option.value);
- setOpen(false);
- }}
- disabled={option.disabled}
- className="flex! flex-col! items-start!">
-
- {previewHandler(option)}
- {isSelected && (
-
- )}
-
- {option.description && (
-
- {option.description}
-
- )}
-
- );
- })}
-
- ))
- ) : (
- // Render flat options
-
- {options?.map((option: SelectBoxOption) => {
- const isSelected = selectedValue?.value === option.value;
- return (
- {
- setInitValue(option.value);
- setOpen(false);
- }}
- disabled={option.disabled}
- className="flex! flex-col! items-start!">
-
- {previewHandler(option)}
- {isSelected && (
-
- )}
-
- {option.description && (
-
- {option.description}
-
- )}
-
- );
- })}
-
- )}
-
- ) : (
-
- {emptyContent}
-
- )}
-
-
-
-
-
- {/* Hidden input for form submission */}
- undefined}>
-
- {allOptions.map((option, idx) => (
-
- {option.label}
-
- ))}
-
- >
- );
-};
+import { LoaderOverlay } from '@datum-ui/components/loader-overlay';
+import { cn } from '@shadcn/lib/utils';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@shadcn/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
+import { CheckIcon, ChevronDown } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+
+/**
+ * Represents a single option in a SelectBox dropdown
+ *
+ * @example
+ * ```tsx
+ * const option: SelectBoxOption = {
+ * value: 'admin',
+ * label: 'Administrator',
+ * description: 'Full access to all resources',
+ * disabled: false
+ * };
+ * ```
+ */
+export interface SelectBoxOption {
+ /** Unique identifier for the option - used in form submission */
+ value: string;
+
+ /** Display text shown in the dropdown and button */
+ label: string;
+
+ /** Whether the option is disabled and cannot be selected */
+ disabled?: boolean;
+
+ /** Optional description text shown below the label in the dropdown */
+ description?: string;
+
+ /** Additional custom properties can be stored here */
+ [key: string]: any;
+}
+
+/**
+ * Groups options together with a heading for better organization
+ *
+ * @example
+ * ```tsx
+ * const group: SelectBoxGroup = {
+ * label: 'Database Access',
+ * options: [
+ * { value: 'db-read', label: 'Read Only' },
+ * { value: 'db-write', label: 'Read/Write' }
+ * ]
+ * };
+ * ```
+ */
+export interface SelectBoxGroup {
+ /** The heading text for this group */
+ label: string;
+
+ /** Array of options to display under this group heading */
+ options: SelectBoxOption[];
+}
+
+/**
+ * A customizable dropdown/select component with support for grouping, search, and descriptions
+ *
+ * Features:
+ * - Single or multi-select modes
+ * - Group options with category headings
+ * - Case-insensitive search/filtering
+ * - Optional descriptions under each option
+ * - Disabled option support
+ * - Custom styling via className
+ *
+ * @example
+ * ```tsx
+ * // Single-select with grouped options
+ * setSelectedRole(option)}
+ * groups={[
+ * {
+ * label: 'Admin Roles',
+ * options: [
+ * { value: 'admin', label: 'Administrator', description: 'Full access' }
+ * ]
+ * }
+ * ]}
+ * placeholder="Select a role"
+ * />
+ *
+ * // Simple single-select with flat options
+ * setSelectedProject(option)}
+ * options={projects}
+ * placeholder="Select a project"
+ * />
+ * ```
+ */
+export const SelectBox = ({
+ value,
+ className,
+ onChange,
+ options,
+ groups,
+ name,
+ id,
+ placeholder = 'Select an option',
+ disabled = false,
+ isLoading = false,
+ searchable = false,
+ searchPlaceholder = 'Search...',
+ itemPreview,
+ triggerClassName,
+ popoverClassName,
+ emptyContent = 'No results found.',
+ modal = false,
+}: {
+ /** Currently selected option value (for single-select mode) */
+ value?: string;
+
+ /** Callback fired when a single option is selected */
+ onChange?: (value: SelectBoxOption) => void;
+
+ /** Flat array of options - use this OR groups, not both */
+ options?: SelectBoxOption[];
+
+ /** Array of grouped options - use this OR options, not both */
+ groups?: SelectBoxGroup[];
+
+ /** HTML name attribute for the hidden form select element */
+ name?: string;
+
+ /** HTML id attribute for the hidden form select element */
+ id?: string;
+
+ /** Placeholder text shown when no option is selected */
+ placeholder?: string;
+
+ /** Disable the entire select box */
+ disabled?: boolean;
+
+ /** Show loading spinner in the button */
+ isLoading?: boolean;
+
+ /** Enable search/filter functionality for options (default: false) */
+ searchable?: boolean;
+
+ /** Placeholder text for the search input (only used when searchable is true) */
+ searchPlaceholder?: string;
+
+ /** Custom render function for option preview in the trigger */
+ itemPreview?: (option: SelectBoxOption) => React.ReactNode;
+ /** Optional classes applied to both trigger and popover when more specific classes are not provided */
+ className?: string;
+ /** Optional classes applied to the trigger button */
+ triggerClassName?: string;
+ /** Optional classes applied to the popover content */
+ popoverClassName?: string;
+ /** Optional content to display when no options are found */
+ emptyContent?: string;
+ /** Whether the popover should be modal */
+ modal?: boolean;
+}) => {
+ const triggerClasses = triggerClassName ?? className;
+ const popoverClasses = popoverClassName ?? className;
+ const [open, setOpen] = useState(false);
+ const [initValue, setInitValue] = useState(value);
+
+ // Flatten all options from groups and regular options
+ const allOptions = useMemo(() => {
+ const flatOptions: SelectBoxOption[] = [];
+ if (groups) {
+ groups.forEach((group) => {
+ flatOptions.push(...group.options);
+ });
+ }
+ if (options) {
+ flatOptions.push(...options);
+ }
+ return flatOptions;
+ }, [groups, options]);
+
+ const selectedValue = useMemo(() => {
+ if (!initValue) return undefined;
+ return allOptions.find((option) => option.value === initValue);
+ }, [initValue, allOptions]);
+
+ useEffect(() => {
+ if (value) {
+ setInitValue(value);
+ }
+ }, [value]);
+
+ useEffect(() => {
+ if (selectedValue && onChange) {
+ onChange(selectedValue);
+ }
+ }, [selectedValue, onChange]);
+
+ const previewHandler = (
+ option: SelectBoxOption,
+ className?: string,
+ showDescription = false
+ ): React.ReactNode => {
+ return itemPreview ? (
+ itemPreview(option)
+ ) : showDescription && option.description ? (
+
+
+ {option.label}
+
+
+ {option.description}
+
+
+ ) : (
+
+ {option.label}
+
+ );
+ };
+
+ return (
+ <>
+
+
+ setOpen(!open)}
+ className={cn(
+ 'text-input-foreground placeholder:text-input-placeholder',
+ 'border-input-border bg-input-background/50 relative flex h-auto! min-h-10 w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-all',
+ 'focus-visible:border-input-focus-border focus-visible:shadow-(--input-focus-shadow)',
+ 'focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-hidden',
+ (disabled || isLoading) && 'cursor-not-allowed opacity-50',
+ triggerClasses
+ )}>
+
+ {selectedValue ? (
+ previewHandler(selectedValue, 'line-clamp-1 text-ellipsis', true)
+ ) : (
+ {placeholder}
+ )}
+
+
+ {isLoading && }
+
+
+ setOpen(false)}>
+
+ {searchable && }
+
+ {searchable && {emptyContent} }
+ {allOptions.length > 0 ? (
+
+ {groups && groups.length > 0 ? (
+ // Render grouped options
+ groups.map((group, index) => (
+
0 ? 'border-t pt-2' : ''}>
+ {group.options.map((option: SelectBoxOption) => {
+ const isSelected = selectedValue?.value === option.value;
+ return (
+ {
+ setInitValue(option.value);
+ setOpen(false);
+ }}
+ disabled={option.disabled}
+ className="flex! flex-col! items-start!">
+
+ {previewHandler(option)}
+ {isSelected && (
+
+ )}
+
+ {option.description && (
+
+ {option.description}
+
+ )}
+
+ );
+ })}
+
+ ))
+ ) : (
+ // Render flat options
+
+ {options?.map((option: SelectBoxOption) => {
+ const isSelected = selectedValue?.value === option.value;
+ return (
+ {
+ setInitValue(option.value);
+ setOpen(false);
+ }}
+ disabled={option.disabled}
+ className="flex! flex-col! items-start!">
+
+ {previewHandler(option)}
+ {isSelected && (
+
+ )}
+
+ {option.description && (
+
+ {option.description}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ ) : (
+
+ {emptyContent}
+
+ )}
+
+
+
+
+
+ {/* Hidden input for form submission */}
+ undefined}>
+
+ {allOptions.map((option, idx) => (
+
+ {option.label}
+
+ ))}
+
+ >
+ );
+};
diff --git a/app/components/select-group/select-group.tsx b/app/components/select-group/select-group.tsx
index 6d16c9b17..5efe8c88b 100644
--- a/app/components/select-group/select-group.tsx
+++ b/app/components/select-group/select-group.tsx
@@ -1,55 +1,55 @@
-import { SelectBox, SelectBoxOption } from '@/components/select-box/select-box';
-import { useGroups } from '@/resources/groups';
-import { toast } from '@datum-ui/components';
-import { useEffect, useMemo } from 'react';
-
-export const SelectGroup = ({
- orgId,
- defaultValue,
- className,
- onSelect,
- name,
- id,
-}: {
- orgId: string;
- defaultValue?: string;
- className?: string;
- onSelect: (value: SelectBoxOption) => void;
- name?: string;
- id?: string;
-}) => {
- const { data: groups = [], isLoading, error } = useGroups(orgId);
-
- useEffect(() => {
- if (error) {
- toast.error(error.message || 'Failed to load groups');
- }
- }, [error]);
-
- const options = useMemo(() => {
- return groups.map((group) => {
- return {
- value: group.name,
- label: group.name,
- ...group,
- };
- });
- }, [groups]);
-
- return (
- {
- if (value) {
- onSelect(value);
- }
- }}
- options={options}
- placeholder="Select a Group"
- isLoading={isLoading}
- />
- );
-};
+import { SelectBox, SelectBoxOption } from '@/components/select-box/select-box';
+import { useGroups } from '@/resources/groups';
+import { toast } from '@datum-ui/components';
+import { useEffect, useMemo } from 'react';
+
+export const SelectGroup = ({
+ orgId,
+ defaultValue,
+ className,
+ onSelect,
+ name,
+ id,
+}: {
+ orgId: string;
+ defaultValue?: string;
+ className?: string;
+ onSelect: (value: SelectBoxOption) => void;
+ name?: string;
+ id?: string;
+}) => {
+ const { data: groups = [], isLoading, error } = useGroups(orgId);
+
+ useEffect(() => {
+ if (error) {
+ toast.error(error.message || 'Failed to load groups');
+ }
+ }, [error]);
+
+ const options = useMemo(() => {
+ return groups.map((group) => {
+ return {
+ value: group.name,
+ label: group.name,
+ ...group,
+ };
+ });
+ }, [groups]);
+
+ return (
+ {
+ if (value) {
+ onSelect(value);
+ }
+ }}
+ options={options}
+ placeholder="Select a Group"
+ isLoading={isLoading}
+ />
+ );
+};
diff --git a/app/components/select-labels/select-labels.tsx b/app/components/select-labels/select-labels.tsx
index 367c6952b..46035701b 100644
--- a/app/components/select-labels/select-labels.tsx
+++ b/app/components/select-labels/select-labels.tsx
@@ -1,122 +1,122 @@
-import { KeyValueFormDialog, KeyValueFormDialogRef } from '@/components/key-value-form-dialog';
-import { MultiSelect, MultiSelectOption } from '@/components/multi-select/multi-select';
-import { labelFormSchema, LabelFormSchema } from '@/resources/base';
-import { splitOption } from '@/utils/helpers/object.helper';
-import { toast } from '@datum-ui/components';
-import { PlusIcon } from 'lucide-react';
-import { useEffect, useRef, useState } from 'react';
-
-export const SelectLabels = ({
- defaultValue,
- onChange,
-}: {
- defaultValue?: string[];
- onChange?: (value: string[]) => void;
-}) => {
- const dialogRef = useRef(null!);
- const [options, setOptions] = useState();
- const [selectedOptions, setSelectedOptions] = useState([]);
- const [activeOption, setActiveOption] = useState();
-
- useEffect(() => {
- if (defaultValue && !options) {
- const values = Array.isArray(defaultValue) ? defaultValue : [defaultValue];
- const initialOptions = values.map((option) => {
- const { key, value } = splitOption(option);
- return {
- label: `${key}:${value}`,
- value: `${key}:${value}`,
- };
- });
- setOptions(initialOptions);
- setSelectedOptions(initialOptions.map((option) => option.value));
- } else if (defaultValue) {
- // Only update selected options without replacing all options
- const values = Array.isArray(defaultValue) ? defaultValue : [defaultValue];
- setSelectedOptions(values);
- } else {
- setSelectedOptions([]);
- }
- }, [defaultValue, options]);
-
- const handleSubmit = (value: LabelFormSchema) => {
- const newOption = `${value.key}:${value.value}`;
- const newOptionObj = { label: newOption, value: newOption };
- const currentOptions = options ?? [];
-
- // Check if the option already exists (excluding the active one when editing)
- const isDuplicate = currentOptions.some(
- (opt) => opt.value === newOption && (!activeOption || opt.value !== activeOption)
- );
-
- if (isDuplicate) {
- toast.error('Label already exists');
- return;
- }
-
- if (activeOption !== undefined) {
- // Update existing option
- const updatedOptions = currentOptions.map((opt) =>
- opt.value === activeOption ? newOptionObj : opt
- );
-
- const updatedSelectedOptions = selectedOptions.map((opt) =>
- opt === activeOption ? newOption : opt
- );
-
- setOptions(updatedOptions);
- handleValueChange(updatedSelectedOptions);
- } else {
- // Add new option
- setOptions([...currentOptions, newOptionObj]);
- handleValueChange([...selectedOptions, newOption]);
- }
- };
-
- const handleValueChange = (values: string[]) => {
- setSelectedOptions(values);
- onChange?.(values);
- };
-
- return (
- <>
- {
- setActiveOption(undefined);
- dialogRef.current?.show();
- },
- },
- ]}
- onBadgeClick={(option) => {
- const { key, value } = splitOption(option.value);
-
- setActiveOption(option.value);
- dialogRef.current?.show({ key, value });
- }}
- />
-
- setActiveOption(undefined)}
- />
- >
- );
-};
+import { KeyValueFormDialog, KeyValueFormDialogRef } from '@/components/key-value-form-dialog';
+import { MultiSelect, MultiSelectOption } from '@/components/multi-select/multi-select';
+import { labelFormSchema, LabelFormSchema } from '@/resources/base';
+import { splitOption } from '@/utils/helpers/object.helper';
+import { toast } from '@datum-ui/components';
+import { PlusIcon } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+
+export const SelectLabels = ({
+ defaultValue,
+ onChange,
+}: {
+ defaultValue?: string[];
+ onChange?: (value: string[]) => void;
+}) => {
+ const dialogRef = useRef(null!);
+ const [options, setOptions] = useState();
+ const [selectedOptions, setSelectedOptions] = useState([]);
+ const [activeOption, setActiveOption] = useState();
+
+ useEffect(() => {
+ if (defaultValue && !options) {
+ const values = Array.isArray(defaultValue) ? defaultValue : [defaultValue];
+ const initialOptions = values.map((option) => {
+ const { key, value } = splitOption(option);
+ return {
+ label: `${key}:${value}`,
+ value: `${key}:${value}`,
+ };
+ });
+ setOptions(initialOptions);
+ setSelectedOptions(initialOptions.map((option) => option.value));
+ } else if (defaultValue) {
+ // Only update selected options without replacing all options
+ const values = Array.isArray(defaultValue) ? defaultValue : [defaultValue];
+ setSelectedOptions(values);
+ } else {
+ setSelectedOptions([]);
+ }
+ }, [defaultValue, options]);
+
+ const handleSubmit = (value: LabelFormSchema) => {
+ const newOption = `${value.key}:${value.value}`;
+ const newOptionObj = { label: newOption, value: newOption };
+ const currentOptions = options ?? [];
+
+ // Check if the option already exists (excluding the active one when editing)
+ const isDuplicate = currentOptions.some(
+ (opt) => opt.value === newOption && (!activeOption || opt.value !== activeOption)
+ );
+
+ if (isDuplicate) {
+ toast.error('Label already exists');
+ return;
+ }
+
+ if (activeOption !== undefined) {
+ // Update existing option
+ const updatedOptions = currentOptions.map((opt) =>
+ opt.value === activeOption ? newOptionObj : opt
+ );
+
+ const updatedSelectedOptions = selectedOptions.map((opt) =>
+ opt === activeOption ? newOption : opt
+ );
+
+ setOptions(updatedOptions);
+ handleValueChange(updatedSelectedOptions);
+ } else {
+ // Add new option
+ setOptions([...currentOptions, newOptionObj]);
+ handleValueChange([...selectedOptions, newOption]);
+ }
+ };
+
+ const handleValueChange = (values: string[]) => {
+ setSelectedOptions(values);
+ onChange?.(values);
+ };
+
+ return (
+ <>
+ {
+ setActiveOption(undefined);
+ dialogRef.current?.show();
+ },
+ },
+ ]}
+ onBadgeClick={(option) => {
+ const { key, value } = splitOption(option.value);
+
+ setActiveOption(option.value);
+ dialogRef.current?.show({ key, value });
+ }}
+ />
+
+ setActiveOption(undefined)}
+ />
+ >
+ );
+};
diff --git a/app/components/select-member/select-member.tsx b/app/components/select-member/select-member.tsx
index a1b4f2f83..152ba050f 100644
--- a/app/components/select-member/select-member.tsx
+++ b/app/components/select-member/select-member.tsx
@@ -1,60 +1,60 @@
-import { SelectBox, SelectBoxOption } from '@/components/select-box/select-box';
-import { useMembers } from '@/resources/members/member.queries';
-import { toast } from '@datum-ui/components';
-import { useEffect, useMemo } from 'react';
-
-export const SelectMember = ({
- orgId,
- defaultValue,
- className,
- onSelect,
- name,
- id,
- exceptItems = [],
-}: {
- orgId: string;
- defaultValue?: string;
- className?: string;
- onSelect: (value: SelectBoxOption) => void;
- name?: string;
- id?: string;
- exceptItems?: string[];
-}) => {
- const { data: members = [], isLoading, error } = useMembers(orgId);
-
- useEffect(() => {
- if (error) {
- toast.error(error.message || 'Failed to load members');
- }
- }, [error]);
-
- const options = useMemo(() => {
- return members.map((member) => {
- const id = member.user.id ?? '';
- const label = `${member?.user?.givenName ?? ''} ${member?.user?.familyName ?? ''}`.trim();
- return {
- disabled: exceptItems.includes(id),
- value: id,
- label: label ? `${label} (${member.user.email ?? ''})` : (member.user.email ?? ''),
- ...member,
- };
- });
- }, [members, exceptItems]);
-
- return (
- {
- if (value) {
- onSelect(value);
- }
- }}
- options={options}
- placeholder="Select a User"
- isLoading={isLoading}
- />
- );
-};
+import { SelectBox, SelectBoxOption } from '@/components/select-box/select-box';
+import { useMembers } from '@/resources/members/member.queries';
+import { toast } from '@datum-ui/components';
+import { useEffect, useMemo } from 'react';
+
+export const SelectMember = ({
+ orgId,
+ defaultValue,
+ className,
+ onSelect,
+ name,
+ id,
+ exceptItems = [],
+}: {
+ orgId: string;
+ defaultValue?: string;
+ className?: string;
+ onSelect: (value: SelectBoxOption) => void;
+ name?: string;
+ id?: string;
+ exceptItems?: string[];
+}) => {
+ const { data: members = [], isLoading, error } = useMembers(orgId);
+
+ useEffect(() => {
+ if (error) {
+ toast.error(error.message || 'Failed to load members');
+ }
+ }, [error]);
+
+ const options = useMemo(() => {
+ return members.map((member) => {
+ const id = member.user.id ?? '';
+ const label = `${member?.user?.givenName ?? ''} ${member?.user?.familyName ?? ''}`.trim();
+ return {
+ disabled: exceptItems.includes(id),
+ value: id,
+ label: label ? `${label} (${member.user.email ?? ''})` : (member.user.email ?? ''),
+ ...member,
+ };
+ });
+ }, [members, exceptItems]);
+
+ return (
+ {
+ if (value) {
+ onSelect(value);
+ }
+ }}
+ options={options}
+ placeholder="Select a User"
+ isLoading={isLoading}
+ />
+ );
+};
diff --git a/app/components/select-organization/organization-item.tsx b/app/components/select-organization/organization-item.tsx
index 849d218fd..17f46c07e 100644
--- a/app/components/select-organization/organization-item.tsx
+++ b/app/components/select-organization/organization-item.tsx
@@ -1,21 +1,21 @@
-import { PersonalBadge } from '@/components/personal-badge/personal-badge';
-import type { Organization } from '@/resources/organizations';
-import { cn } from '@shadcn/lib/utils';
-
-export const OrganizationItem = ({
- org,
- className,
-}: {
- org: Partial;
- className?: string;
-}) => {
- const isPersonal = org?.type === 'Personal';
- return (
-
-
- {org?.displayName ?? org?.name}
-
- {isPersonal &&
}
-
- );
-};
+import { PersonalBadge } from '@/components/personal-badge/personal-badge';
+import type { Organization } from '@/resources/organizations';
+import { cn } from '@shadcn/lib/utils';
+
+export const OrganizationItem = ({
+ org,
+ className,
+}: {
+ org: Partial;
+ className?: string;
+}) => {
+ const isPersonal = org?.type === 'Personal';
+ return (
+
+
+ {org?.displayName ?? org?.name}
+
+ {isPersonal &&
}
+
+ );
+};
diff --git a/app/components/select-organization/select-organization.tsx b/app/components/select-organization/select-organization.tsx
index 6aced69b3..ba698f69f 100644
--- a/app/components/select-organization/select-organization.tsx
+++ b/app/components/select-organization/select-organization.tsx
@@ -1,151 +1,151 @@
-import { OrganizationItem } from './organization-item';
-import { useOrganizationsGql, type Organization } from '@/resources/organizations';
-import { paths } from '@/utils/config/paths.config';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { Button, SpinnerIcon } from '@datum-ui/components';
-import { toast } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { cn } from '@shadcn/lib/utils';
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- CommandSeparator,
-} from '@shadcn/ui/command';
-import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
-import { BuildingIcon, CheckIcon, ChevronDown, Settings2 } from 'lucide-react';
-import { useEffect, useState } from 'react';
-import { Link } from 'react-router';
-
-export const SelectOrganization = ({
- currentOrg,
- onSelect,
- selectedContent,
- triggerClassName,
- hideContent = false,
- hideNewOrganization = false,
- disabled = false,
-}: {
- currentOrg: Partial;
- onSelect?: (org: Organization) => void;
- selectedContent?: React.ReactNode;
- triggerClassName?: string;
- hideContent?: boolean;
- hideNewOrganization?: boolean;
- disabled?: boolean;
-}) => {
- const [open, setOpen] = useState(false);
-
- const { data, isLoading, error } = useOrganizationsGql(undefined, {
- enabled: open,
- });
- const organizations = [...(data?.items ?? [])].sort((a, b) => {
- if (a.name === currentOrg?.name) return -1;
- if (b.name === currentOrg?.name) return 1;
- return (a.displayName ?? a.name ?? '').localeCompare(b.displayName ?? b.name ?? '');
- });
-
- useEffect(() => {
- if (error) {
- toast.error(error.message || 'Failed to load organizations');
- }
- }, [error]);
-
- return (
-
-
-
- {!hideContent &&
- (selectedContent ?? )}
-
-
-
-
-
-
-
- No results found.
- {isLoading && organizations.length === 0 ? (
-
-
-
-
- Loading...
-
- ) : (
-
- {organizations.length > 0 &&
- organizations.map((org: Organization) => {
- const isSelected = org.name === currentOrg?.name;
- return (
- {
- setOpen(false);
- if (!isSelected) {
- onSelect?.(org);
- }
- }}
- className="cursor-pointer justify-between px-3 py-2">
-
- {isSelected && }
-
- );
- })}
-
- )}
-
- {!hideNewOrganization && (
- <>
-
- {currentOrg?.name && (
- setOpen(false)}>
-
-
- Organization settings
-
-
- )}
-
-
-
- Organizations
-
-
- >
- )}
-
-
-
-
- );
-};
+import { OrganizationItem } from './organization-item';
+import { useOrganizationsGql, type Organization } from '@/resources/organizations';
+import { paths } from '@/utils/config/paths.config';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { Button, SpinnerIcon } from '@datum-ui/components';
+import { toast } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { cn } from '@shadcn/lib/utils';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from '@shadcn/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
+import { BuildingIcon, CheckIcon, ChevronDown, Settings2 } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { Link } from 'react-router';
+
+export const SelectOrganization = ({
+ currentOrg,
+ onSelect,
+ selectedContent,
+ triggerClassName,
+ hideContent = false,
+ hideNewOrganization = false,
+ disabled = false,
+}: {
+ currentOrg: Partial;
+ onSelect?: (org: Organization) => void;
+ selectedContent?: React.ReactNode;
+ triggerClassName?: string;
+ hideContent?: boolean;
+ hideNewOrganization?: boolean;
+ disabled?: boolean;
+}) => {
+ const [open, setOpen] = useState(false);
+
+ const { data, isLoading, error } = useOrganizationsGql(undefined, {
+ enabled: open,
+ });
+ const organizations = [...(data?.items ?? [])].sort((a, b) => {
+ if (a.name === currentOrg?.name) return -1;
+ if (b.name === currentOrg?.name) return 1;
+ return (a.displayName ?? a.name ?? '').localeCompare(b.displayName ?? b.name ?? '');
+ });
+
+ useEffect(() => {
+ if (error) {
+ toast.error(error.message || 'Failed to load organizations');
+ }
+ }, [error]);
+
+ return (
+
+
+
+ {!hideContent &&
+ (selectedContent ?? )}
+
+
+
+
+
+
+
+ No results found.
+ {isLoading && organizations.length === 0 ? (
+
+
+
+
+ Loading...
+
+ ) : (
+
+ {organizations.length > 0 &&
+ organizations.map((org: Organization) => {
+ const isSelected = org.name === currentOrg?.name;
+ return (
+ {
+ setOpen(false);
+ if (!isSelected) {
+ onSelect?.(org);
+ }
+ }}
+ className="cursor-pointer justify-between px-3 py-2">
+
+ {isSelected && }
+
+ );
+ })}
+
+ )}
+
+ {!hideNewOrganization && (
+ <>
+
+ {currentOrg?.name && (
+ setOpen(false)}>
+
+
+ Organization settings
+
+
+ )}
+
+
+
+ Organizations
+
+
+ >
+ )}
+
+
+
+
+ );
+};
diff --git a/app/components/select-project/select-project.tsx b/app/components/select-project/select-project.tsx
index c443c832f..f3f17ddce 100644
--- a/app/components/select-project/select-project.tsx
+++ b/app/components/select-project/select-project.tsx
@@ -1,57 +1,57 @@
-import { SelectBox, SelectBoxOption } from '@/components/select-box/select-box';
-import { useProjects } from '@/resources/projects/project.queries';
-import { toast } from '@datum-ui/components';
-import { useEffect, useMemo } from 'react';
-
-export const SelectProject = ({
- orgId,
- defaultValue,
- className,
- onSelect,
- name,
- id,
- disabled = false,
-}: {
- orgId: string;
- defaultValue?: string;
- className?: string;
- onSelect: (value: SelectBoxOption) => void;
- name?: string;
- id?: string;
- disabled?: boolean;
-}) => {
- const { data, isLoading, error } = useProjects(orgId);
- const projects = data?.items ?? [];
-
- useEffect(() => {
- if (error) {
- toast.error(error.message || 'Failed to load projects');
- }
- }, [error]);
-
- const options = useMemo(() => {
- return projects.map((project) => ({
- value: project.name,
- label: project.displayName,
- ...project,
- }));
- }, [projects]);
-
- return (
- {
- if (value) {
- onSelect(value);
- }
- }}
- options={options}
- placeholder="Select a Project"
- isLoading={isLoading}
- disabled={disabled}
- />
- );
-};
+import { SelectBox, SelectBoxOption } from '@/components/select-box/select-box';
+import { useProjects } from '@/resources/projects/project.queries';
+import { toast } from '@datum-ui/components';
+import { useEffect, useMemo } from 'react';
+
+export const SelectProject = ({
+ orgId,
+ defaultValue,
+ className,
+ onSelect,
+ name,
+ id,
+ disabled = false,
+}: {
+ orgId: string;
+ defaultValue?: string;
+ className?: string;
+ onSelect: (value: SelectBoxOption) => void;
+ name?: string;
+ id?: string;
+ disabled?: boolean;
+}) => {
+ const { data, isLoading, error } = useProjects(orgId);
+ const projects = data?.items ?? [];
+
+ useEffect(() => {
+ if (error) {
+ toast.error(error.message || 'Failed to load projects');
+ }
+ }, [error]);
+
+ const options = useMemo(() => {
+ return projects.map((project) => ({
+ value: project.name,
+ label: project.displayName,
+ ...project,
+ }));
+ }, [projects]);
+
+ return (
+ {
+ if (value) {
+ onSelect(value);
+ }
+ }}
+ options={options}
+ placeholder="Select a Project"
+ isLoading={isLoading}
+ disabled={disabled}
+ />
+ );
+};
diff --git a/app/components/select-role/select-role.tsx b/app/components/select-role/select-role.tsx
index e0a88e2a4..302e07f01 100644
--- a/app/components/select-role/select-role.tsx
+++ b/app/components/select-role/select-role.tsx
@@ -1,104 +1,104 @@
-import { SelectBox, SelectBoxOption } from '@/components/select-box/select-box';
-import { useRoles } from '@/resources/roles';
-import { toast } from '@datum-ui/components';
-import { useEffect, useMemo } from 'react';
-
-export const SelectRole = ({
- defaultValue,
- className,
- onSelect,
- name,
- id,
- disabled,
- modal = false,
-}: {
- defaultValue?: string;
- className?: string;
- onSelect: (value: SelectBoxOption) => void;
- name?: string;
- id?: string;
- disabled?: boolean;
- modal?: boolean;
-}) => {
- const { data: roles = [], isLoading, error } = useRoles();
-
- useEffect(() => {
- if (error) {
- toast.error(error.message || 'Failed to load roles');
- }
- }, [error]);
-
- const groups = useMemo(() => {
- // Create options from API-fetched roles
- const apiRoleOptions = roles.map((role) => {
- const displayName =
- role.annotations?.['kubernetes.io/display-name'] ?? role.displayName ?? role.name;
- const description = role.annotations?.['kubernetes.io/description'] ?? role.description ?? '';
-
- return {
- value: role.name,
- label: displayName,
- description,
- product: role.annotations?.['taxonomy.miloapis.com/product'] ?? 'Other',
- sortOrder: parseInt(role.annotations?.['taxonomy.miloapis.com/sort-order'] ?? '999', 10),
- ...role,
- };
- });
-
- // Group roles by product
- const groupedRoles = new Map();
- apiRoleOptions.forEach((role) => {
- if (!groupedRoles.has(role.product)) {
- groupedRoles.set(role.product, []);
- }
- groupedRoles.get(role.product)!.push(role);
- });
-
- // Sort roles within each group by sort-order, then by label
- groupedRoles.forEach((groupRoles) => {
- groupRoles.sort((a, b) => {
- if (a.sortOrder !== b.sortOrder) {
- return a.sortOrder - b.sortOrder;
- }
- return a.label.localeCompare(b.label);
- });
- });
-
- // Sort groups by minimum sort-order within each group, then by product name
- const sortedGroups = Array.from(groupedRoles.entries())
- .sort(([productA, optionsA], [productB, optionsB]) => {
- const minSortOrderA = Math.min(...optionsA.map((opt) => opt.sortOrder));
- const minSortOrderB = Math.min(...optionsB.map((opt) => opt.sortOrder));
- if (minSortOrderA !== minSortOrderB) {
- return minSortOrderA - minSortOrderB;
- }
- return productA.localeCompare(productB);
- })
- .map(([product, options]) => ({
- label: product,
- options: options as SelectBoxOption[],
- }));
-
- return sortedGroups;
- }, [roles]);
-
- return (
- {
- if (value) {
- onSelect(value);
- }
- }}
- groups={groups}
- placeholder="Select a Role"
- searchable={false}
- isLoading={isLoading}
- modal={modal}
- />
- );
-};
+import { SelectBox, SelectBoxOption } from '@/components/select-box/select-box';
+import { useRoles } from '@/resources/roles';
+import { toast } from '@datum-ui/components';
+import { useEffect, useMemo } from 'react';
+
+export const SelectRole = ({
+ defaultValue,
+ className,
+ onSelect,
+ name,
+ id,
+ disabled,
+ modal = false,
+}: {
+ defaultValue?: string;
+ className?: string;
+ onSelect: (value: SelectBoxOption) => void;
+ name?: string;
+ id?: string;
+ disabled?: boolean;
+ modal?: boolean;
+}) => {
+ const { data: roles = [], isLoading, error } = useRoles();
+
+ useEffect(() => {
+ if (error) {
+ toast.error(error.message || 'Failed to load roles');
+ }
+ }, [error]);
+
+ const groups = useMemo(() => {
+ // Create options from API-fetched roles
+ const apiRoleOptions = roles.map((role) => {
+ const displayName =
+ role.annotations?.['kubernetes.io/display-name'] ?? role.displayName ?? role.name;
+ const description = role.annotations?.['kubernetes.io/description'] ?? role.description ?? '';
+
+ return {
+ value: role.name,
+ label: displayName,
+ description,
+ product: role.annotations?.['taxonomy.miloapis.com/product'] ?? 'Other',
+ sortOrder: parseInt(role.annotations?.['taxonomy.miloapis.com/sort-order'] ?? '999', 10),
+ ...role,
+ };
+ });
+
+ // Group roles by product
+ const groupedRoles = new Map();
+ apiRoleOptions.forEach((role) => {
+ if (!groupedRoles.has(role.product)) {
+ groupedRoles.set(role.product, []);
+ }
+ groupedRoles.get(role.product)!.push(role);
+ });
+
+ // Sort roles within each group by sort-order, then by label
+ groupedRoles.forEach((groupRoles) => {
+ groupRoles.sort((a, b) => {
+ if (a.sortOrder !== b.sortOrder) {
+ return a.sortOrder - b.sortOrder;
+ }
+ return a.label.localeCompare(b.label);
+ });
+ });
+
+ // Sort groups by minimum sort-order within each group, then by product name
+ const sortedGroups = Array.from(groupedRoles.entries())
+ .sort(([productA, optionsA], [productB, optionsB]) => {
+ const minSortOrderA = Math.min(...optionsA.map((opt) => opt.sortOrder));
+ const minSortOrderB = Math.min(...optionsB.map((opt) => opt.sortOrder));
+ if (minSortOrderA !== minSortOrderB) {
+ return minSortOrderA - minSortOrderB;
+ }
+ return productA.localeCompare(productB);
+ })
+ .map(([product, options]) => ({
+ label: product,
+ options: options as SelectBoxOption[],
+ }));
+
+ return sortedGroups;
+ }, [roles]);
+
+ return (
+ {
+ if (value) {
+ onSelect(value);
+ }
+ }}
+ groups={groups}
+ placeholder="Select a Role"
+ searchable={false}
+ isLoading={isLoading}
+ modal={modal}
+ />
+ );
+};
diff --git a/app/components/select-secret/select-secret.tsx b/app/components/select-secret/select-secret.tsx
index c9bc17604..c00dd315a 100644
--- a/app/components/select-secret/select-secret.tsx
+++ b/app/components/select-secret/select-secret.tsx
@@ -1,57 +1,57 @@
-import { SelectBox, SelectBoxOption } from '../select-box/select-box';
-import { useSecrets, type Secret } from '@/resources/secrets';
-import { useMemo } from 'react';
-
-export const SelectSecret = ({
- projectId,
- defaultValue,
- className,
- onValueChange,
- name,
- id,
- filter,
-}: {
- projectId?: string;
- defaultValue?: string;
- className?: string;
- onValueChange: (value?: SelectBoxOption) => void;
- name?: string;
- id?: string;
- filter?: Record;
-}) => {
- const { data: secrets = [], isLoading } = useSecrets(projectId ?? '', {
- enabled: !!projectId,
- });
-
- const options = useMemo(() => {
- return secrets
- .filter((secret: Secret) => {
- if (!filter) return true;
- return Object.entries(filter).every(
- ([key, value]) => secret[key as keyof Secret] === value
- );
- })
- .map((secret: Secret) => ({
- value: secret.name,
- label: secret.name,
- ...secret,
- }));
- }, [secrets, filter]);
-
- return (
- {
- if (value) {
- onValueChange(value);
- }
- }}
- options={options}
- name={name}
- id={id}
- placeholder="Select a Secret"
- isLoading={isLoading}
- />
- );
-};
+import { SelectBox, SelectBoxOption } from '../select-box/select-box';
+import { useSecrets, type Secret } from '@/resources/secrets';
+import { useMemo } from 'react';
+
+export const SelectSecret = ({
+ projectId,
+ defaultValue,
+ className,
+ onValueChange,
+ name,
+ id,
+ filter,
+}: {
+ projectId?: string;
+ defaultValue?: string;
+ className?: string;
+ onValueChange: (value?: SelectBoxOption) => void;
+ name?: string;
+ id?: string;
+ filter?: Record;
+}) => {
+ const { data: secrets = [], isLoading } = useSecrets(projectId ?? '', {
+ enabled: !!projectId,
+ });
+
+ const options = useMemo(() => {
+ return secrets
+ .filter((secret: Secret) => {
+ if (!filter) return true;
+ return Object.entries(filter).every(
+ ([key, value]) => secret[key as keyof Secret] === value
+ );
+ })
+ .map((secret: Secret) => ({
+ value: secret.name,
+ label: secret.name,
+ ...secret,
+ }));
+ }, [secrets, filter]);
+
+ return (
+ {
+ if (value) {
+ onValueChange(value);
+ }
+ }}
+ options={options}
+ name={name}
+ id={id}
+ placeholder="Select a Secret"
+ isLoading={isLoading}
+ />
+ );
+};
diff --git a/app/components/select-timezone/select-timezone.tsx b/app/components/select-timezone/select-timezone.tsx
index 6acedb461..7e62f072c 100644
--- a/app/components/select-timezone/select-timezone.tsx
+++ b/app/components/select-timezone/select-timezone.tsx
@@ -1,96 +1,96 @@
-import timezonesData from './timezones.json';
-import { SelectAutocomplete } from '@/components/select-autocomplete/select-autocomplete';
-import { Option } from '@/components/select-autocomplete/select-autocomplete.types';
-import React, { useCallback, useMemo } from 'react';
-
-/**
- * Timezone data structure from the JSON file
- */
-export interface TimezoneData {
- label: string;
- tzCode: string;
- name: string;
- utc: string;
-}
-
-/**
- * Props for the SelectTimezone component
- */
-export interface SelectTimezoneProps {
- selectedValue?: Option | string;
- onValueChange?: (value: Option) => void;
- placeholder?: string;
- disabled?: boolean;
- name?: string;
- id?: string;
-}
-
-/**
- * Transform timezone data to Option format for SelectAutocomplete
- */
-const transformTimezoneToOption = (timezone: TimezoneData): Option => ({
- value: timezone.tzCode,
- label: timezone.label.replace('_', ' '),
- tzCode: timezone.tzCode,
- name: timezone.name,
- utc: timezone.utc,
-});
-
-/**
- * SelectTimezone component that uses SelectAutocomplete with timezone data
- */
-export const SelectTimezone = ({
- selectedValue,
- onValueChange,
- placeholder = 'Select timezone...',
- disabled = false,
- name = 'timezone',
- id = 'timezone',
-}: SelectTimezoneProps) => {
- // Transform timezone data to options
- const options: Option[] = useMemo(() => {
- return timezonesData.map(transformTimezoneToOption);
- }, []);
-
- // Find timezone option by tzCode string
- const findTimezoneByCode = useCallback(
- (tzCode: string): Option | undefined => {
- return options.find((option) => option.tzCode === tzCode);
- },
- [options]
- );
-
- // Resolve the actual selected value (handle both Option and string types)
- const resolvedSelectedValue: Option | undefined = useMemo(() => {
- if (selectedValue) {
- if (typeof selectedValue === 'string') {
- return findTimezoneByCode(selectedValue);
- }
- return selectedValue;
- }
- return undefined;
- }, [selectedValue, findTimezoneByCode]);
-
- // Custom item preview to show timezone code and UTC offset
- const itemPreview = useCallback((option: Option) => {
- return option.label;
- }, []);
-
- return (
-
- );
-};
+import timezonesData from './timezones.json';
+import { SelectAutocomplete } from '@/components/select-autocomplete/select-autocomplete';
+import { Option } from '@/components/select-autocomplete/select-autocomplete.types';
+import React, { useCallback, useMemo } from 'react';
+
+/**
+ * Timezone data structure from the JSON file
+ */
+export interface TimezoneData {
+ label: string;
+ tzCode: string;
+ name: string;
+ utc: string;
+}
+
+/**
+ * Props for the SelectTimezone component
+ */
+export interface SelectTimezoneProps {
+ selectedValue?: Option | string;
+ onValueChange?: (value: Option) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ name?: string;
+ id?: string;
+}
+
+/**
+ * Transform timezone data to Option format for SelectAutocomplete
+ */
+const transformTimezoneToOption = (timezone: TimezoneData): Option => ({
+ value: timezone.tzCode,
+ label: timezone.label.replace('_', ' '),
+ tzCode: timezone.tzCode,
+ name: timezone.name,
+ utc: timezone.utc,
+});
+
+/**
+ * SelectTimezone component that uses SelectAutocomplete with timezone data
+ */
+export const SelectTimezone = ({
+ selectedValue,
+ onValueChange,
+ placeholder = 'Select timezone...',
+ disabled = false,
+ name = 'timezone',
+ id = 'timezone',
+}: SelectTimezoneProps) => {
+ // Transform timezone data to options
+ const options: Option[] = useMemo(() => {
+ return timezonesData.map(transformTimezoneToOption);
+ }, []);
+
+ // Find timezone option by tzCode string
+ const findTimezoneByCode = useCallback(
+ (tzCode: string): Option | undefined => {
+ return options.find((option) => option.tzCode === tzCode);
+ },
+ [options]
+ );
+
+ // Resolve the actual selected value (handle both Option and string types)
+ const resolvedSelectedValue: Option | undefined = useMemo(() => {
+ if (selectedValue) {
+ if (typeof selectedValue === 'string') {
+ return findTimezoneByCode(selectedValue);
+ }
+ return selectedValue;
+ }
+ return undefined;
+ }, [selectedValue, findTimezoneByCode]);
+
+ // Custom item preview to show timezone code and UTC offset
+ const itemPreview = useCallback((option: Option) => {
+ return option.label;
+ }, []);
+
+ return (
+
+ );
+};
diff --git a/app/components/select-timezone/timezones.json b/app/components/select-timezone/timezones.json
index a4f46280a..ea38a7667 100644
--- a/app/components/select-timezone/timezones.json
+++ b/app/components/select-timezone/timezones.json
@@ -1,2498 +1,2498 @@
-[
- {
- "label": "Pacific/Midway (GMT-11:00)",
- "tzCode": "Pacific/Midway",
- "name": "(GMT-11:00) Coordinated Universal Time-11",
- "utc": "-11:00"
- },
- {
- "label": "Pacific/Niue (GMT-11:00)",
- "tzCode": "Pacific/Niue",
- "name": "(GMT-11:00) Coordinated Universal Time-11",
- "utc": "-11:00"
- },
- {
- "label": "Pacific/Pago_Pago (GMT-11:00)",
- "tzCode": "Pacific/Pago_Pago",
- "name": "(GMT-11:00) Coordinated Universal Time-11",
- "utc": "-11:00"
- },
- {
- "label": "Pacific/Honolulu (GMT-10:00)",
- "tzCode": "Pacific/Honolulu",
- "name": "(GMT-10:00) Hawaii",
- "utc": "-10:00"
- },
- {
- "label": "Pacific/Johnston (GMT-10:00)",
- "tzCode": "Pacific/Johnston",
- "name": "(GMT-10:00) Hawaii",
- "utc": "-10:00"
- },
- {
- "label": "Pacific/Rarotonga (GMT-10:00)",
- "tzCode": "Pacific/Rarotonga",
- "name": "(GMT-10:00) Hawaii",
- "utc": "-10:00"
- },
- {
- "label": "Pacific/Tahiti (GMT-10:00)",
- "tzCode": "Pacific/Tahiti",
- "name": "(GMT-10:00) Hawaii",
- "utc": "-10:00"
- },
- {
- "label": "America/Anchorage (GMT-08:00)",
- "tzCode": "America/Anchorage",
- "name": "(GMT-09:00) Alaska",
- "utc": "-08:00"
- },
- {
- "label": "America/Juneau (GMT-08:00)",
- "tzCode": "America/Juneau",
- "name": "(GMT-09:00) Alaska",
- "utc": "-08:00"
- },
- {
- "label": "America/Los_Angeles (GMT-08:00)",
- "tzCode": "America/Los_Angeles",
- "name": "(GMT-08:00) Pacific Standard Time (US & Canada)",
- "utc": "-08:00"
- },
- {
- "label": "America/Nome (GMT-08:00)",
- "tzCode": "America/Nome",
- "name": "(GMT-09:00) Alaska",
- "utc": "-08:00"
- },
- {
- "label": "America/Sitka (GMT-08:00)",
- "tzCode": "America/Sitka",
- "name": "(GMT-09:00) Alaska",
- "utc": "-08:00"
- },
- {
- "label": "America/Tijuana (GMT-08:00)",
- "tzCode": "America/Tijuana",
- "name": "(GMT-08:00) Pacific Standard Time (US & Canada)",
- "utc": "-08:00"
- },
- {
- "label": "America/Vancouver (GMT-08:00)",
- "tzCode": "America/Vancouver",
- "name": "(GMT-08:00) Pacific Standard Time (US & Canada)",
- "utc": "-08:00"
- },
- {
- "label": "America/Yakutat (GMT-08:00)",
- "tzCode": "America/Yakutat",
- "name": "(GMT-09:00) Alaska",
- "utc": "-08:00"
- },
- {
- "label": "PST8PDT (GMT-08:00)",
- "tzCode": "PST8PDT",
- "name": "(GMT-08:00) Pacific Standard Time (US & Canada)",
- "utc": "-08:00"
- },
- {
- "label": "America/Creston (GMT-07:00)",
- "tzCode": "America/Creston",
- "name": "(GMT-07:00) Arizona",
- "utc": "-07:00"
- },
- {
- "label": "America/Dawson (GMT-07:00)",
- "tzCode": "America/Dawson",
- "name": "(GMT-07:00) Arizona",
- "utc": "-07:00"
- },
- {
- "label": "America/Dawson_Creek (GMT-07:00)",
- "tzCode": "America/Dawson_Creek",
- "name": "(GMT-07:00) Arizona",
- "utc": "-07:00"
- },
- {
- "label": "America/Hermosillo (GMT-07:00)",
- "tzCode": "America/Hermosillo",
- "name": "(GMT-07:00) Arizona",
- "utc": "-07:00"
- },
- {
- "label": "America/Phoenix (GMT-07:00)",
- "tzCode": "America/Phoenix",
- "name": "(GMT-07:00) Arizona",
- "utc": "-07:00"
- },
- {
- "label": "America/Santa_Isabel (GMT-07:00)",
- "tzCode": "America/Santa_Isabel",
- "name": "(GMT-08:00) Baja California",
- "utc": "-07:00"
- },
- {
- "label": "America/Whitehorse (GMT-07:00)",
- "tzCode": "America/Whitehorse",
- "name": "(GMT-07:00) Arizona",
- "utc": "-07:00"
- },
- {
- "label": "America/Belize (GMT-06:00)",
- "tzCode": "America/Belize",
- "name": "(GMT-06:00) Central America",
- "utc": "-06:00"
- },
- {
- "label": "America/Boise (GMT-06:00)",
- "tzCode": "America/Boise",
- "name": "(GMT-07:00) Mountain Time (US & Canada)",
- "utc": "-06:00"
- },
- {
- "label": "America/Cambridge_Bay (GMT-06:00)",
- "tzCode": "America/Cambridge_Bay",
- "name": "(GMT-07:00) Mountain Time (US & Canada)",
- "utc": "-06:00"
- },
- {
- "label": "America/Chihuahua (GMT-06:00)",
- "tzCode": "America/Chihuahua",
- "name": "(GMT-07:00) Chihuahua, La Paz, Mazatlan",
- "utc": "-06:00"
- },
- {
- "label": "America/Costa_Rica (GMT-06:00)",
- "tzCode": "America/Costa_Rica",
- "name": "(GMT-06:00) Central America",
- "utc": "-06:00"
- },
- {
- "label": "America/Denver (GMT-06:00)",
- "tzCode": "America/Denver",
- "name": "(GMT-07:00) Mountain Time (US & Canada)",
- "utc": "-06:00"
- },
- {
- "label": "America/Edmonton (GMT-06:00)",
- "tzCode": "America/Edmonton",
- "name": "(GMT-07:00) Mountain Time (US & Canada)",
- "utc": "-06:00"
- },
- {
- "label": "America/El_Salvador (GMT-06:00)",
- "tzCode": "America/El_Salvador",
- "name": "(GMT-06:00) Central America",
- "utc": "-06:00"
- },
- {
- "label": "America/Guatemala (GMT-06:00)",
- "tzCode": "America/Guatemala",
- "name": "(GMT-06:00) Central America",
- "utc": "-06:00"
- },
- {
- "label": "America/Inuvik (GMT-06:00)",
- "tzCode": "America/Inuvik",
- "name": "(GMT-07:00) Mountain Time (US & Canada)",
- "utc": "-06:00"
- },
- {
- "label": "America/Managua (GMT-06:00)",
- "tzCode": "America/Managua",
- "name": "(GMT-06:00) Central America",
- "utc": "-06:00"
- },
- {
- "label": "America/Mazatlan (GMT-06:00)",
- "tzCode": "America/Mazatlan",
- "name": "(GMT-07:00) Chihuahua, La Paz, Mazatlan",
- "utc": "-06:00"
- },
- {
- "label": "America/Ojinaga (GMT-06:00)",
- "tzCode": "America/Ojinaga",
- "name": "(GMT-07:00) Mountain Time (US & Canada)",
- "utc": "-06:00"
- },
- {
- "label": "America/Regina (GMT-06:00)",
- "tzCode": "America/Regina",
- "name": "(GMT-06:00) Saskatchewan",
- "utc": "-06:00"
- },
- {
- "label": "America/Swift_Current (GMT-06:00)",
- "tzCode": "America/Swift_Current",
- "name": "(GMT-06:00) Saskatchewan",
- "utc": "-06:00"
- },
- {
- "label": "America/Tegucigalpa (GMT-06:00)",
- "tzCode": "America/Tegucigalpa",
- "name": "(GMT-06:00) Central America",
- "utc": "-06:00"
- },
- {
- "label": "America/Yellowknife (GMT-06:00)",
- "tzCode": "America/Yellowknife",
- "name": "(GMT-07:00) Mountain Time (US & Canada)",
- "utc": "-06:00"
- },
- {
- "label": "MST7MDT (GMT-06:00)",
- "tzCode": "MST7MDT",
- "name": "(GMT-07:00) Mountain Time (US & Canada)",
- "utc": "-06:00"
- },
- {
- "label": "Pacific/Galapagos (GMT-06:00)",
- "tzCode": "Pacific/Galapagos",
- "name": "(GMT-06:00) Central America",
- "utc": "-06:00"
- },
- {
- "label": "America/Bahia_Banderas (GMT-05:00)",
- "tzCode": "America/Bahia_Banderas",
- "name": "(GMT-06:00) Guadalajara, Mexico City, Monterrey",
- "utc": "-05:00"
- },
- {
- "label": "America/Bogota (GMT-05:00)",
- "tzCode": "America/Bogota",
- "name": "(GMT-05:00) Bogota, Lima, Quito",
- "utc": "-05:00"
- },
- {
- "label": "America/Cancun (GMT-05:00)",
- "tzCode": "America/Cancun",
- "name": "(GMT-06:00) Guadalajara, Mexico City, Monterrey",
- "utc": "-05:00"
- },
- {
- "label": "America/Cayman (GMT-05:00)",
- "tzCode": "America/Cayman",
- "name": "(GMT-05:00) Bogota, Lima, Quito",
- "utc": "-05:00"
- },
- {
- "label": "America/Chicago (GMT-05:00)",
- "tzCode": "America/Chicago",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Coral_Harbour (GMT-05:00)",
- "tzCode": "America/Coral_Harbour",
- "name": "(GMT-05:00) Bogota, Lima, Quito",
- "utc": "-05:00"
- },
- {
- "label": "America/Detroit (GMT-05:00)",
- "tzCode": "America/Detroit",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Eirunepe (GMT-05:00)",
- "tzCode": "America/Eirunepe",
- "name": "(GMT-05:00) Bogota, Lima, Quito",
- "utc": "-05:00"
- },
- {
- "label": "America/Guayaquil (GMT-05:00)",
- "tzCode": "America/Guayaquil",
- "name": "(GMT-05:00) Bogota, Lima, Quito",
- "utc": "-05:00"
- },
- {
- "label": "America/Havana (GMT-05:00)",
- "tzCode": "America/Havana",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Indiana/Knox (GMT-05:00)",
- "tzCode": "America/Indiana/Knox",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Indiana/Marengo (GMT-05:00)",
- "tzCode": "America/Indiana/Marengo",
- "name": "(GMT-05:00) Indiana (East)",
- "utc": "-05:00"
- },
- {
- "label": "America/Indiana/Petersburg (GMT-05:00)",
- "tzCode": "America/Indiana/Petersburg",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Indiana/Tell_City (GMT-05:00)",
- "tzCode": "America/Indiana/Tell_City",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Indiana/Vevay (GMT-05:00)",
- "tzCode": "America/Indiana/Vevay",
- "name": "(GMT-05:00) Indiana (East)",
- "utc": "-05:00"
- },
- {
- "label": "America/Indiana/Vincennes (GMT-05:00)",
- "tzCode": "America/Indiana/Vincennes",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Indiana/Winamac (GMT-05:00)",
- "tzCode": "America/Indiana/Winamac",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Indianapolis (GMT-05:00)",
- "tzCode": "America/Indianapolis",
- "name": "(GMT-05:00) Indiana (East)",
- "utc": "-05:00"
- },
- {
- "label": "America/Iqaluit (GMT-05:00)",
- "tzCode": "America/Iqaluit",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Jamaica (GMT-05:00)",
- "tzCode": "America/Jamaica",
- "name": "(GMT-05:00) Bogota, Lima, Quito",
- "utc": "-05:00"
- },
- {
- "label": "America/Kentucky/Monticello (GMT-05:00)",
- "tzCode": "America/Kentucky/Monticello",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Lima (GMT-05:00)",
- "tzCode": "America/Lima",
- "name": "(GMT-05:00) Bogota, Lima, Quito",
- "utc": "-05:00"
- },
- {
- "label": "America/Louisville (GMT-05:00)",
- "tzCode": "America/Louisville",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Matamoros (GMT-05:00)",
- "tzCode": "America/Matamoros",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Menominee (GMT-05:00)",
- "tzCode": "America/Menominee",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Merida (GMT-05:00)",
- "tzCode": "America/Merida",
- "name": "(GMT-06:00) Guadalajara, Mexico City, Monterrey",
- "utc": "-05:00"
- },
- {
- "label": "America/Mexico_City (GMT-05:00)",
- "tzCode": "America/Mexico_City",
- "name": "(GMT-06:00) Guadalajara, Mexico City, Monterrey",
- "utc": "-05:00"
- },
- {
- "label": "America/Monterrey (GMT-05:00)",
- "tzCode": "America/Monterrey",
- "name": "(GMT-06:00) Guadalajara, Mexico City, Monterrey",
- "utc": "-05:00"
- },
- {
- "label": "America/Montreal (GMT-05:00)",
- "tzCode": "America/Montreal",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Nassau (GMT-05:00)",
- "tzCode": "America/Nassau",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/New_York (GMT-05:00)",
- "tzCode": "America/New_York",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Nipigon (GMT-05:00)",
- "tzCode": "America/Nipigon",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/North_Dakota/Beulah (GMT-05:00)",
- "tzCode": "America/North_Dakota/Beulah",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/North_Dakota/Center (GMT-05:00)",
- "tzCode": "America/North_Dakota/Center",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/North_Dakota/New_Salem (GMT-05:00)",
- "tzCode": "America/North_Dakota/New_Salem",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Panama (GMT-05:00)",
- "tzCode": "America/Panama",
- "name": "(GMT-05:00) Bogota, Lima, Quito",
- "utc": "-05:00"
- },
- {
- "label": "America/Pangnirtung (GMT-05:00)",
- "tzCode": "America/Pangnirtung",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Port-au-Prince (GMT-05:00)",
- "tzCode": "America/Port-au-Prince",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Rainy_River (GMT-05:00)",
- "tzCode": "America/Rainy_River",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Rankin_Inlet (GMT-05:00)",
- "tzCode": "America/Rankin_Inlet",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Resolute (GMT-05:00)",
- "tzCode": "America/Resolute",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Rio_Branco (GMT-05:00)",
- "tzCode": "America/Rio_Branco",
- "name": "(GMT-05:00) Bogota, Lima, Quito",
- "utc": "-05:00"
- },
- {
- "label": "America/Thunder_Bay (GMT-05:00)",
- "tzCode": "America/Thunder_Bay",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Toronto (GMT-05:00)",
- "tzCode": "America/Toronto",
- "name": "(GMT-05:00) Eastern Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Winnipeg (GMT-05:00)",
- "tzCode": "America/Winnipeg",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "CST6CDT (GMT-05:00)",
- "tzCode": "CST6CDT",
- "name": "(GMT-06:00) Central Time (US & Canada)",
- "utc": "-05:00"
- },
- {
- "label": "America/Caracas (GMT-4.5:00)",
- "tzCode": "America/Caracas",
- "name": "(GMT-04:30) Caracas",
- "utc": "-4.5:00"
- },
- {
- "label": "America/Anguilla (GMT-04:00)",
- "tzCode": "America/Anguilla",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Antigua (GMT-04:00)",
- "tzCode": "America/Antigua",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Aruba (GMT-04:00)",
- "tzCode": "America/Aruba",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Asuncion (GMT-04:00)",
- "tzCode": "America/Asuncion",
- "name": "(GMT-04:00) Asuncion",
- "utc": "-04:00"
- },
- {
- "label": "America/Barbados (GMT-04:00)",
- "tzCode": "America/Barbados",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Blanc-Sablon (GMT-04:00)",
- "tzCode": "America/Blanc-Sablon",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Boa_Vista (GMT-04:00)",
- "tzCode": "America/Boa_Vista",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Campo_Grande (GMT-04:00)",
- "tzCode": "America/Campo_Grande",
- "name": "(GMT-04:00) Cuiaba",
- "utc": "-04:00"
- },
- {
- "label": "America/Cuiaba (GMT-04:00)",
- "tzCode": "America/Cuiaba",
- "name": "(GMT-04:00) Cuiaba",
- "utc": "-04:00"
- },
- {
- "label": "America/Curacao (GMT-04:00)",
- "tzCode": "America/Curacao",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Dominica (GMT-04:00)",
- "tzCode": "America/Dominica",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Grand_Turk (GMT-04:00)",
- "tzCode": "America/Grand_Turk",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Grenada (GMT-04:00)",
- "tzCode": "America/Grenada",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Guadeloupe (GMT-04:00)",
- "tzCode": "America/Guadeloupe",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Guyana (GMT-04:00)",
- "tzCode": "America/Guyana",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Kralendijk (GMT-04:00)",
- "tzCode": "America/Kralendijk",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/La_Paz (GMT-04:00)",
- "tzCode": "America/La_Paz",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Lower_Princes (GMT-04:00)",
- "tzCode": "America/Lower_Princes",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Manaus (GMT-04:00)",
- "tzCode": "America/Manaus",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Marigot (GMT-04:00)",
- "tzCode": "America/Marigot",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Martinique (GMT-04:00)",
- "tzCode": "America/Martinique",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Montserrat (GMT-04:00)",
- "tzCode": "America/Montserrat",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Port_of_Spain (GMT-04:00)",
- "tzCode": "America/Port_of_Spain",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Porto_Velho (GMT-04:00)",
- "tzCode": "America/Porto_Velho",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Puerto_Rico (GMT-04:00)",
- "tzCode": "America/Puerto_Rico",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Santiago (GMT-04:00)",
- "tzCode": "America/Santiago",
- "name": "(GMT-04:00) Santiago",
- "utc": "-04:00"
- },
- {
- "label": "America/Santo_Domingo (GMT-04:00)",
- "tzCode": "America/Santo_Domingo",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/St_Barthelemy (GMT-04:00)",
- "tzCode": "America/St_Barthelemy",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/St_Kitts (GMT-04:00)",
- "tzCode": "America/St_Kitts",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/St_Lucia (GMT-04:00)",
- "tzCode": "America/St_Lucia",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/St_Thomas (GMT-04:00)",
- "tzCode": "America/St_Thomas",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/St_Vincent (GMT-04:00)",
- "tzCode": "America/St_Vincent",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "America/Tortola (GMT-04:00)",
- "tzCode": "America/Tortola",
- "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
- "utc": "-04:00"
- },
- {
- "label": "Antarctica/Palmer (GMT-04:00)",
- "tzCode": "Antarctica/Palmer",
- "name": "(GMT-04:00) Santiago",
- "utc": "-04:00"
- },
- {
- "label": "America/Araguaina (GMT-03:00)",
- "tzCode": "America/Araguaina",
- "name": "(GMT-03:00) Cayenne, Fortaleza",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/Buenos_Aires (GMT-03:00)",
- "tzCode": "America/Argentina/Buenos_Aires",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/Catamarca (GMT-03:00)",
- "tzCode": "America/Argentina/Catamarca",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/Cordoba (GMT-03:00)",
- "tzCode": "America/Argentina/Cordoba",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/Jujuy (GMT-03:00)",
- "tzCode": "America/Argentina/Jujuy",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/La_Rioja (GMT-03:00)",
- "tzCode": "America/Argentina/La_Rioja",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/Mendoza (GMT-03:00)",
- "tzCode": "America/Argentina/Mendoza",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/Rio_Gallegos (GMT-03:00)",
- "tzCode": "America/Argentina/Rio_Gallegos",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/Salta (GMT-03:00)",
- "tzCode": "America/Argentina/Salta",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/San_Juan (GMT-03:00)",
- "tzCode": "America/Argentina/San_Juan",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/San_Luis (GMT-03:00)",
- "tzCode": "America/Argentina/San_Luis",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/Tucuman (GMT-03:00)",
- "tzCode": "America/Argentina/Tucuman",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Argentina/Ushuaia (GMT-03:00)",
- "tzCode": "America/Argentina/Ushuaia",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Bahia (GMT-03:00)",
- "tzCode": "America/Bahia",
- "name": "(GMT-03:00) Salvador",
- "utc": "-03:00"
- },
- {
- "label": "America/Belem (GMT-03:00)",
- "tzCode": "America/Belem",
- "name": "(GMT-03:00) Cayenne, Fortaleza",
- "utc": "-03:00"
- },
- {
- "label": "America/Buenos_Aires (GMT-03:00)",
- "tzCode": "America/Buenos_Aires",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Catamarca (GMT-03:00)",
- "tzCode": "America/Catamarca",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Cayenne (GMT-03:00)",
- "tzCode": "America/Cayenne",
- "name": "(GMT-03:00) Cayenne, Fortaleza",
- "utc": "-03:00"
- },
- {
- "label": "America/Cordoba (GMT-03:00)",
- "tzCode": "America/Cordoba",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Fortaleza (GMT-03:00)",
- "tzCode": "America/Fortaleza",
- "name": "(GMT-03:00) Cayenne, Fortaleza",
- "utc": "-03:00"
- },
- {
- "label": "America/Glace_Bay (GMT-03:00)",
- "tzCode": "America/Glace_Bay",
- "name": "(GMT-04:00) Atlantic Time (Canada)",
- "utc": "-03:00"
- },
- {
- "label": "America/Godthab (GMT-03:00)",
- "tzCode": "America/Godthab",
- "name": "(GMT-03:00) Greenland",
- "utc": "-03:00"
- },
- {
- "label": "America/Goose_Bay (GMT-03:00)",
- "tzCode": "America/Goose_Bay",
- "name": "(GMT-04:00) Atlantic Time (Canada)",
- "utc": "-03:00"
- },
- {
- "label": "America/Halifax (GMT-03:00)",
- "tzCode": "America/Halifax",
- "name": "(GMT-04:00) Atlantic Time (Canada)",
- "utc": "-03:00"
- },
- {
- "label": "America/Jujuy (GMT-03:00)",
- "tzCode": "America/Jujuy",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Maceio (GMT-03:00)",
- "tzCode": "America/Maceio",
- "name": "(GMT-03:00) Cayenne, Fortaleza",
- "utc": "-03:00"
- },
- {
- "label": "America/Mendoza (GMT-03:00)",
- "tzCode": "America/Mendoza",
- "name": "(GMT-03:00) Buenos Aires",
- "utc": "-03:00"
- },
- {
- "label": "America/Moncton (GMT-03:00)",
- "tzCode": "America/Moncton",
- "name": "(GMT-04:00) Atlantic Time (Canada)",
- "utc": "-03:00"
- },
- {
- "label": "America/Montevideo (GMT-03:00)",
- "tzCode": "America/Montevideo",
- "name": "(GMT-03:00) Montevideo",
- "utc": "-03:00"
- },
- {
- "label": "America/Paramaribo (GMT-03:00)",
- "tzCode": "America/Paramaribo",
- "name": "(GMT-03:00) Cayenne, Fortaleza",
- "utc": "-03:00"
- },
- {
- "label": "America/Recife (GMT-03:00)",
- "tzCode": "America/Recife",
- "name": "(GMT-03:00) Cayenne, Fortaleza",
- "utc": "-03:00"
- },
- {
- "label": "America/Santarem (GMT-03:00)",
- "tzCode": "America/Santarem",
- "name": "(GMT-03:00) Cayenne, Fortaleza",
- "utc": "-03:00"
- },
- {
- "label": "America/Sao_Paulo (GMT-03:00)",
- "tzCode": "America/Sao_Paulo",
- "name": "(GMT-03:00) Brasilia",
- "utc": "-03:00"
- },
- {
- "label": "America/Thule (GMT-03:00)",
- "tzCode": "America/Thule",
- "name": "(GMT-04:00) Atlantic Time (Canada)",
- "utc": "-03:00"
- },
- {
- "label": "Antarctica/Rothera (GMT-03:00)",
- "tzCode": "Antarctica/Rothera",
- "name": "(GMT-03:00) Cayenne, Fortaleza",
- "utc": "-03:00"
- },
- {
- "label": "Atlantic/Bermuda (GMT-03:00)",
- "tzCode": "Atlantic/Bermuda",
- "name": "(GMT-04:00) Atlantic Time (Canada)",
- "utc": "-03:00"
- },
- {
- "label": "Atlantic/Stanley (GMT-03:00)",
- "tzCode": "Atlantic/Stanley",
- "name": "(GMT-03:00) Cayenne, Fortaleza",
- "utc": "-03:00"
- },
- {
- "label": "America/St_Johns (GMT-2.5:00)",
- "tzCode": "America/St_Johns",
- "name": "(GMT-03:30) Newfoundland",
- "utc": "-2.5:00"
- },
- {
- "label": "America/Noronha (GMT-02:00)",
- "tzCode": "America/Noronha",
- "name": "(GMT-02:00) Coordinated Universal Time-02",
- "utc": "-02:00"
- },
- {
- "label": "Atlantic/South_Georgia (GMT-02:00)",
- "tzCode": "Atlantic/South_Georgia",
- "name": "(GMT-02:00) Coordinated Universal Time-02",
- "utc": "-02:00"
- },
- {
- "label": "Atlantic/Cape_Verde (GMT-01:00)",
- "tzCode": "Atlantic/Cape_Verde",
- "name": "(GMT-01:00) Cape Verde Is.",
- "utc": "-01:00"
- },
- {
- "label": "Africa/Abidjan (GMT+00:00)",
- "tzCode": "Africa/Abidjan",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Accra (GMT+00:00)",
- "tzCode": "Africa/Accra",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Bamako (GMT+00:00)",
- "tzCode": "Africa/Bamako",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Banjul (GMT+00:00)",
- "tzCode": "Africa/Banjul",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Bissau (GMT+00:00)",
- "tzCode": "Africa/Bissau",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Conakry (GMT+00:00)",
- "tzCode": "Africa/Conakry",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Dakar (GMT+00:00)",
- "tzCode": "Africa/Dakar",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Freetown (GMT+00:00)",
- "tzCode": "Africa/Freetown",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Lome (GMT+00:00)",
- "tzCode": "Africa/Lome",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Monrovia (GMT+00:00)",
- "tzCode": "Africa/Monrovia",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Nouakchott (GMT+00:00)",
- "tzCode": "Africa/Nouakchott",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Ouagadougou (GMT+00:00)",
- "tzCode": "Africa/Ouagadougou",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Sao_Tome (GMT+00:00)",
- "tzCode": "Africa/Sao_Tome",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "America/Danmarkshavn (GMT+00:00)",
- "tzCode": "America/Danmarkshavn",
- "name": "(GMT) Coordinated Universal Time",
- "utc": "+00:00"
- },
- {
- "label": "America/Scoresbysund (GMT+00:00)",
- "tzCode": "America/Scoresbysund",
- "name": "(GMT-01:00) Azores",
- "utc": "+00:00"
- },
- {
- "label": "Atlantic/Azores (GMT+00:00)",
- "tzCode": "Atlantic/Azores",
- "name": "(GMT-01:00) Azores",
- "utc": "+00:00"
- },
- {
- "label": "Atlantic/Reykjavik (GMT+00:00)",
- "tzCode": "Atlantic/Reykjavik",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Atlantic/St_Helena (GMT+00:00)",
- "tzCode": "Atlantic/St_Helena",
- "name": "(GMT) Monrovia, Reykjavik",
- "utc": "+00:00"
- },
- {
- "label": "Europe/Guernsey (GMT+00:00)",
- "tzCode": "Europe/Guernsey",
- "name": "(GMT) Edinburgh, London",
- "utc": "+00:00"
- },
- {
- "label": "Europe/Isle_of_Man (GMT+00:00)",
- "tzCode": "Europe/Isle_of_Man",
- "name": "(GMT) Edinburgh, London",
- "utc": "+00:00"
- },
- {
- "label": "Europe/Jersey (GMT+00:00)",
- "tzCode": "Europe/Jersey",
- "name": "(GMT) Edinburgh, London",
- "utc": "+00:00"
- },
- {
- "label": "Europe/London (GMT+00:00)",
- "tzCode": "Europe/London",
- "name": "(GMT) Edinburgh, London",
- "utc": "+00:00"
- },
- {
- "label": "Africa/Algiers (GMT+01:00)",
- "tzCode": "Africa/Algiers",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Bangui (GMT+01:00)",
- "tzCode": "Africa/Bangui",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Brazzaville (GMT+01:00)",
- "tzCode": "Africa/Brazzaville",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Casablanca (GMT+01:00)",
- "tzCode": "Africa/Casablanca",
- "name": "(GMT) Casablanca",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Douala (GMT+01:00)",
- "tzCode": "Africa/Douala",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/El_Aaiun (GMT+01:00)",
- "tzCode": "Africa/El_Aaiun",
- "name": "(GMT) Casablanca",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Kinshasa (GMT+01:00)",
- "tzCode": "Africa/Kinshasa",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Lagos (GMT+01:00)",
- "tzCode": "Africa/Lagos",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Libreville (GMT+01:00)",
- "tzCode": "Africa/Libreville",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Luanda (GMT+01:00)",
- "tzCode": "Africa/Luanda",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Malabo (GMT+01:00)",
- "tzCode": "Africa/Malabo",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Ndjamena (GMT+01:00)",
- "tzCode": "Africa/Ndjamena",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Niamey (GMT+01:00)",
- "tzCode": "Africa/Niamey",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Porto-Novo (GMT+01:00)",
- "tzCode": "Africa/Porto-Novo",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Tunis (GMT+01:00)",
- "tzCode": "Africa/Tunis",
- "name": "(GMT+01:00) West Central Africa",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Windhoek (GMT+01:00)",
- "tzCode": "Africa/Windhoek",
- "name": "(GMT+01:00) Windhoek",
- "utc": "+01:00"
- },
- {
- "label": "Atlantic/Canary (GMT+01:00)",
- "tzCode": "Atlantic/Canary",
- "name": "(GMT) Dublin, Lisbon",
- "utc": "+01:00"
- },
- {
- "label": "Atlantic/Faeroe (GMT+01:00)",
- "tzCode": "Atlantic/Faeroe",
- "name": "(GMT) Dublin, Lisbon",
- "utc": "+01:00"
- },
- {
- "label": "Atlantic/Madeira (GMT+01:00)",
- "tzCode": "Atlantic/Madeira",
- "name": "(GMT) Dublin, Lisbon",
- "utc": "+01:00"
- },
- {
- "label": "Europe/Dublin (GMT+01:00)",
- "tzCode": "Europe/Dublin",
- "name": "(GMT) Dublin, Lisbon",
- "utc": "+01:00"
- },
- {
- "label": "Europe/Lisbon (GMT+01:00)",
- "tzCode": "Europe/Lisbon",
- "name": "(GMT) Dublin, Lisbon",
- "utc": "+01:00"
- },
- {
- "label": "Africa/Blantyre (GMT+02:00)",
- "tzCode": "Africa/Blantyre",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Bujumbura (GMT+02:00)",
- "tzCode": "Africa/Bujumbura",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Cairo (GMT+02:00)",
- "tzCode": "Africa/Cairo",
- "name": "(GMT+02:00) Cairo",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Ceuta (GMT+02:00)",
- "tzCode": "Africa/Ceuta",
- "name": "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Gaborone (GMT+02:00)",
- "tzCode": "Africa/Gaborone",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Harare (GMT+02:00)",
- "tzCode": "Africa/Harare",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Johannesburg (GMT+02:00)",
- "tzCode": "Africa/Johannesburg",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Kigali (GMT+02:00)",
- "tzCode": "Africa/Kigali",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Lubumbashi (GMT+02:00)",
- "tzCode": "Africa/Lubumbashi",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Lusaka (GMT+02:00)",
- "tzCode": "Africa/Lusaka",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Maputo (GMT+02:00)",
- "tzCode": "Africa/Maputo",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Maseru (GMT+02:00)",
- "tzCode": "Africa/Maseru",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Mbabane (GMT+02:00)",
- "tzCode": "Africa/Mbabane",
- "name": "(GMT+02:00) Harare, Pretoria",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Tripoli (GMT+02:00)",
- "tzCode": "Africa/Tripoli",
- "name": "(GMT+02:00) Tripoli",
- "utc": "+02:00"
- },
- {
- "label": "Arctic/Longyearbyen (GMT+02:00)",
- "tzCode": "Arctic/Longyearbyen",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Amsterdam (GMT+02:00)",
- "tzCode": "Europe/Amsterdam",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Andorra (GMT+02:00)",
- "tzCode": "Europe/Andorra",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Belgrade (GMT+02:00)",
- "tzCode": "Europe/Belgrade",
- "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Berlin (GMT+02:00)",
- "tzCode": "Europe/Berlin",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Bratislava (GMT+02:00)",
- "tzCode": "Europe/Bratislava",
- "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Brussels (GMT+02:00)",
- "tzCode": "Europe/Brussels",
- "name": "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Budapest (GMT+02:00)",
- "tzCode": "Europe/Budapest",
- "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Busingen (GMT+02:00)",
- "tzCode": "Europe/Busingen",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Copenhagen (GMT+02:00)",
- "tzCode": "Europe/Copenhagen",
- "name": "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Gibraltar (GMT+02:00)",
- "tzCode": "Europe/Gibraltar",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Ljubljana (GMT+02:00)",
- "tzCode": "Europe/Ljubljana",
- "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Luxembourg (GMT+02:00)",
- "tzCode": "Europe/Luxembourg",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Madrid (GMT+02:00)",
- "tzCode": "Europe/Madrid",
- "name": "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Malta (GMT+02:00)",
- "tzCode": "Europe/Malta",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Monaco (GMT+02:00)",
- "tzCode": "Europe/Monaco",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Oslo (GMT+02:00)",
- "tzCode": "Europe/Oslo",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Paris (GMT+02:00)",
- "tzCode": "Europe/Paris",
- "name": "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Podgorica (GMT+02:00)",
- "tzCode": "Europe/Podgorica",
- "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Prague (GMT+02:00)",
- "tzCode": "Europe/Prague",
- "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Rome (GMT+02:00)",
- "tzCode": "Europe/Rome",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/San_Marino (GMT+02:00)",
- "tzCode": "Europe/San_Marino",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Sarajevo (GMT+02:00)",
- "tzCode": "Europe/Sarajevo",
- "name": "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Skopje (GMT+02:00)",
- "tzCode": "Europe/Skopje",
- "name": "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Stockholm (GMT+02:00)",
- "tzCode": "Europe/Stockholm",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Tirane (GMT+02:00)",
- "tzCode": "Europe/Tirane",
- "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Vaduz (GMT+02:00)",
- "tzCode": "Europe/Vaduz",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Vatican (GMT+02:00)",
- "tzCode": "Europe/Vatican",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Vienna (GMT+02:00)",
- "tzCode": "Europe/Vienna",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Warsaw (GMT+02:00)",
- "tzCode": "Europe/Warsaw",
- "name": "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Zagreb (GMT+02:00)",
- "tzCode": "Europe/Zagreb",
- "name": "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb",
- "utc": "+02:00"
- },
- {
- "label": "Europe/Zurich (GMT+02:00)",
- "tzCode": "Europe/Zurich",
- "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
- "utc": "+02:00"
- },
- {
- "label": "Africa/Addis_Ababa (GMT+03:00)",
- "tzCode": "Africa/Addis_Ababa",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Africa/Asmera (GMT+03:00)",
- "tzCode": "Africa/Asmera",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Africa/Dar_es_Salaam (GMT+03:00)",
- "tzCode": "Africa/Dar_es_Salaam",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Africa/Djibouti (GMT+03:00)",
- "tzCode": "Africa/Djibouti",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Africa/Juba (GMT+03:00)",
- "tzCode": "Africa/Juba",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Africa/Kampala (GMT+03:00)",
- "tzCode": "Africa/Kampala",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Africa/Khartoum (GMT+03:00)",
- "tzCode": "Africa/Khartoum",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Africa/Mogadishu (GMT+03:00)",
- "tzCode": "Africa/Mogadishu",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Africa/Nairobi (GMT+03:00)",
- "tzCode": "Africa/Nairobi",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Antarctica/Syowa (GMT+03:00)",
- "tzCode": "Antarctica/Syowa",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Aden (GMT+03:00)",
- "tzCode": "Asia/Aden",
- "name": "(GMT+03:00) Kuwait, Riyadh",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Amman (GMT+03:00)",
- "tzCode": "Asia/Amman",
- "name": "(GMT+03:00) Amman",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Baghdad (GMT+03:00)",
- "tzCode": "Asia/Baghdad",
- "name": "(GMT+03:00) Baghdad",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Bahrain (GMT+03:00)",
- "tzCode": "Asia/Bahrain",
- "name": "(GMT+03:00) Kuwait, Riyadh",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Beirut (GMT+03:00)",
- "tzCode": "Asia/Beirut",
- "name": "(GMT+02:00) Beirut",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Damascus (GMT+03:00)",
- "tzCode": "Asia/Damascus",
- "name": "(GMT+02:00) Damascus",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Jerusalem (GMT+03:00)",
- "tzCode": "Asia/Jerusalem",
- "name": "(GMT+02:00) Jerusalem",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Kuwait (GMT+03:00)",
- "tzCode": "Asia/Kuwait",
- "name": "(GMT+03:00) Kuwait, Riyadh",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Nicosia (GMT+03:00)",
- "tzCode": "Asia/Nicosia",
- "name": "(GMT+02:00) Athens, Bucharest",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Qatar (GMT+03:00)",
- "tzCode": "Asia/Qatar",
- "name": "(GMT+03:00) Kuwait, Riyadh",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Riyadh (GMT+03:00)",
- "tzCode": "Asia/Riyadh",
- "name": "(GMT+03:00) Kuwait, Riyadh",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Athens (GMT+03:00)",
- "tzCode": "Europe/Athens",
- "name": "(GMT+02:00) Athens, Bucharest",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Bucharest (GMT+03:00)",
- "tzCode": "Europe/Bucharest",
- "name": "(GMT+02:00) Athens, Bucharest",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Chisinau (GMT+03:00)",
- "tzCode": "Europe/Chisinau",
- "name": "(GMT+02:00) Athens, Bucharest",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Helsinki (GMT+03:00)",
- "tzCode": "Europe/Helsinki",
- "name": "(GMT+02:00) E. Europe",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Istanbul (GMT+03:00)",
- "tzCode": "Europe/Istanbul",
- "name": "(GMT+03:00) Istanbul",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Kaliningrad (GMT+03:00)",
- "tzCode": "Europe/Kaliningrad",
- "name": "(GMT+02:00) Kaliningrad",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Kirov (GMT+03:00)",
- "tzCode": "Europe/Kirov",
- "name": "(GMT+03:00) Moscow, St. Petersburg, Volgograd, Minsk",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Kyiv (GMT+03:00)",
- "tzCode": "Europe/Kyiv",
- "name": "(GMT+02:00) E. Europe",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Mariehamn (GMT+03:00)",
- "tzCode": "Europe/Mariehamn",
- "name": "(GMT+02:00) E. Europe",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Minsk (GMT+03:00)",
- "tzCode": "Europe/Minsk",
- "name": "(GMT+03:00) Moscow, St. Petersburg, Volgograd, Minsk",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Moscow (GMT+03:00)",
- "tzCode": "Europe/Moscow",
- "name": "(GMT+03:00) Moscow, St. Petersburg, Volgograd, Minsk",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Nicosia (GMT+03:00)",
- "tzCode": "Europe/Nicosia",
- "name": "(GMT+02:00) E. Europe",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Riga (GMT+03:00)",
- "tzCode": "Europe/Riga",
- "name": "(GMT+02:00) E. Europe",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Simferopol (GMT+03:00)",
- "tzCode": "Europe/Simferopol",
- "name": "(GMT+03:00) Moscow, St. Petersburg, Volgograd, Minsk",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Sofia (GMT+03:00)",
- "tzCode": "Europe/Sofia",
- "name": "(GMT+02:00) E. Europe",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Tallinn (GMT+03:00)",
- "tzCode": "Europe/Tallinn",
- "name": "(GMT+02:00) E. Europe",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Uzhhorod (GMT+03:00)",
- "tzCode": "Europe/Uzhhorod",
- "name": "(GMT+02:00) E. Europe",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Vilnius (GMT+03:00)",
- "tzCode": "Europe/Vilnius",
- "name": "(GMT+02:00) E. Europe",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Volgograd (GMT+03:00)",
- "tzCode": "Europe/Volgograd",
- "name": "(GMT+03:00) Moscow, St. Petersburg, Volgograd, Minsk",
- "utc": "+03:00"
- },
- {
- "label": "Europe/Zaporizhzhia (GMT+03:00)",
- "tzCode": "Europe/Zaporizhzhia",
- "name": "(GMT+02:00) E. Europe",
- "utc": "+03:00"
- },
- {
- "label": "Indian/Antananarivo (GMT+03:00)",
- "tzCode": "Indian/Antananarivo",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Indian/Comoro (GMT+03:00)",
- "tzCode": "Indian/Comoro",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Indian/Mayotte (GMT+03:00)",
- "tzCode": "Indian/Mayotte",
- "name": "(GMT+03:00) Nairobi",
- "utc": "+03:00"
- },
- {
- "label": "Asia/Dubai (GMT+04:00)",
- "tzCode": "Asia/Dubai",
- "name": "(GMT+04:00) Abu Dhabi, Muscat",
- "utc": "+04:00"
- },
- {
- "label": "Asia/Muscat (GMT+04:00)",
- "tzCode": "Asia/Muscat",
- "name": "(GMT+04:00) Abu Dhabi, Muscat",
- "utc": "+04:00"
- },
- {
- "label": "Asia/Tbilisi (GMT+04:00)",
- "tzCode": "Asia/Tbilisi",
- "name": "(GMT+04:00) Tbilisi",
- "utc": "+04:00"
- },
- {
- "label": "Asia/Yerevan (GMT+04:00)",
- "tzCode": "Asia/Yerevan",
- "name": "(GMT+04:00) Yerevan",
- "utc": "+04:00"
- },
- {
- "label": "Europe/Astrakhan (GMT+04:00)",
- "tzCode": "Europe/Astrakhan",
- "name": "(GMT+04:00) Samara, Ulyanovsk, Saratov",
- "utc": "+04:00"
- },
- {
- "label": "Europe/Samara (GMT+04:00)",
- "tzCode": "Europe/Samara",
- "name": "(GMT+04:00) Samara, Ulyanovsk, Saratov",
- "utc": "+04:00"
- },
- {
- "label": "Europe/Ulyanovsk (GMT+04:00)",
- "tzCode": "Europe/Ulyanovsk",
- "name": "(GMT+04:00) Samara, Ulyanovsk, Saratov",
- "utc": "+04:00"
- },
- {
- "label": "Indian/Mahe (GMT+04:00)",
- "tzCode": "Indian/Mahe",
- "name": "(GMT+04:00) Port Louis",
- "utc": "+04:00"
- },
- {
- "label": "Indian/Mauritius (GMT+04:00)",
- "tzCode": "Indian/Mauritius",
- "name": "(GMT+04:00) Port Louis",
- "utc": "+04:00"
- },
- {
- "label": "Indian/Reunion (GMT+04:00)",
- "tzCode": "Indian/Reunion",
- "name": "(GMT+04:00) Port Louis",
- "utc": "+04:00"
- },
- {
- "label": "Asia/Kabul (GMT+4.5:00)",
- "tzCode": "Asia/Kabul",
- "name": "(GMT+04:30) Kabul",
- "utc": "+4.5:00"
- },
- {
- "label": "Asia/Tehran (GMT+4.5:00)",
- "tzCode": "Asia/Tehran",
- "name": "(GMT+03:30) Tehran",
- "utc": "+4.5:00"
- },
- {
- "label": "Antarctica/Mawson (GMT+05:00)",
- "tzCode": "Antarctica/Mawson",
- "name": "(GMT+05:00) Ashgabat, Tashkent",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Aqtau (GMT+05:00)",
- "tzCode": "Asia/Aqtau",
- "name": "(GMT+05:00) Ashgabat, Tashkent",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Aqtobe (GMT+05:00)",
- "tzCode": "Asia/Aqtobe",
- "name": "(GMT+05:00) Ashgabat, Tashkent",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Ashgabat (GMT+05:00)",
- "tzCode": "Asia/Ashgabat",
- "name": "(GMT+05:00) Ashgabat, Tashkent",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Baku (GMT+05:00)",
- "tzCode": "Asia/Baku",
- "name": "(GMT+04:00) Baku",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Dushanbe (GMT+05:00)",
- "tzCode": "Asia/Dushanbe",
- "name": "(GMT+05:00) Ashgabat, Tashkent",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Karachi (GMT+05:00)",
- "tzCode": "Asia/Karachi",
- "name": "(GMT+05:00) Islamabad, Karachi",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Oral (GMT+05:00)",
- "tzCode": "Asia/Oral",
- "name": "(GMT+05:00) Ashgabat, Tashkent",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Samarkand (GMT+05:00)",
- "tzCode": "Asia/Samarkand",
- "name": "(GMT+05:00) Ashgabat, Tashkent",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Tashkent (GMT+05:00)",
- "tzCode": "Asia/Tashkent",
- "name": "(GMT+05:00) Ashgabat, Tashkent",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Yekaterinburg (GMT+05:00)",
- "tzCode": "Asia/Yekaterinburg",
- "name": "(GMT+05:00) Yekaterinburg",
- "utc": "+05:00"
- },
- {
- "label": "Indian/Kerguelen (GMT+05:00)",
- "tzCode": "Indian/Kerguelen",
- "name": "(GMT+05:00) Ashgabat, Tashkent",
- "utc": "+05:00"
- },
- {
- "label": "Indian/Maldives (GMT+05:00)",
- "tzCode": "Indian/Maldives",
- "name": "(GMT+05:00) Ashgabat, Tashkent",
- "utc": "+05:00"
- },
- {
- "label": "Asia/Calcutta (GMT+5.5:00)",
- "tzCode": "Asia/Calcutta",
- "name": "(GMT+05:30) Chennai, Kolkata, Mumbai, New Delhi",
- "utc": "+5.5:00"
- },
- {
- "label": "Asia/Colombo (GMT+5.5:00)",
- "tzCode": "Asia/Colombo",
- "name": "(GMT+05:30) Sri Jayawardenepura",
- "utc": "+5.5:00"
- },
- {
- "label": "Asia/Kolkata (GMT+5.5:00)",
- "tzCode": "Asia/Kolkata",
- "name": "(GMT+05:30) Chennai, Kolkata, Mumbai, New Delhi",
- "utc": "+5.5:00"
- },
- {
- "label": "Asia/Kathmandu (GMT+5.75:00)",
- "tzCode": "Asia/Kathmandu",
- "name": "(GMT+05:45) Kathmandu",
- "utc": "+5.75:00"
- },
- {
- "label": "Antarctica/Vostok (GMT+06:00)",
- "tzCode": "Antarctica/Vostok",
- "name": "(GMT+06:00) Nur-Sultan (Astana)",
- "utc": "+06:00"
- },
- {
- "label": "Asia/Almaty (GMT+06:00)",
- "tzCode": "Asia/Almaty",
- "name": "(GMT+06:00) Nur-Sultan (Astana)",
- "utc": "+06:00"
- },
- {
- "label": "Asia/Bishkek (GMT+06:00)",
- "tzCode": "Asia/Bishkek",
- "name": "(GMT+06:00) Nur-Sultan (Astana)",
- "utc": "+06:00"
- },
- {
- "label": "Asia/Dhaka (GMT+06:00)",
- "tzCode": "Asia/Dhaka",
- "name": "(GMT+06:00) Dhaka",
- "utc": "+06:00"
- },
- {
- "label": "Asia/Qyzylorda (GMT+06:00)",
- "tzCode": "Asia/Qyzylorda",
- "name": "(GMT+06:00) Nur-Sultan (Astana)",
- "utc": "+06:00"
- },
- {
- "label": "Asia/Thimphu (GMT+06:00)",
- "tzCode": "Asia/Thimphu",
- "name": "(GMT+06:00) Dhaka",
- "utc": "+06:00"
- },
- {
- "label": "Asia/Urumqi (GMT+06:00)",
- "tzCode": "Asia/Urumqi",
- "name": "(GMT+06:00) Nur-Sultan (Astana)",
- "utc": "+06:00"
- },
- {
- "label": "Indian/Chagos (GMT+06:00)",
- "tzCode": "Indian/Chagos",
- "name": "(GMT+06:00) Nur-Sultan (Astana)",
- "utc": "+06:00"
- },
- {
- "label": "Asia/Rangoon (GMT+6.5:00)",
- "tzCode": "Asia/Rangoon",
- "name": "(GMT+06:30) Yangon (Rangoon)",
- "utc": "+6.5:00"
- },
- {
- "label": "Indian/Cocos (GMT+6.5:00)",
- "tzCode": "Indian/Cocos",
- "name": "(GMT+06:30) Yangon (Rangoon)",
- "utc": "+6.5:00"
- },
- {
- "label": "Antarctica/Davis (GMT+07:00)",
- "tzCode": "Antarctica/Davis",
- "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Bangkok (GMT+07:00)",
- "tzCode": "Asia/Bangkok",
- "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Hovd (GMT+07:00)",
- "tzCode": "Asia/Hovd",
- "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Jakarta (GMT+07:00)",
- "tzCode": "Asia/Jakarta",
- "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Novokuznetsk (GMT+07:00)",
- "tzCode": "Asia/Novokuznetsk",
- "name": "(GMT+07:00) Novosibirsk",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Novosibirsk (GMT+07:00)",
- "tzCode": "Asia/Novosibirsk",
- "name": "(GMT+07:00) Novosibirsk",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Omsk (GMT+07:00)",
- "tzCode": "Asia/Omsk",
- "name": "(GMT+07:00) Novosibirsk",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Phnom_Penh (GMT+07:00)",
- "tzCode": "Asia/Phnom_Penh",
- "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Pontianak (GMT+07:00)",
- "tzCode": "Asia/Pontianak",
- "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Saigon (GMT+07:00)",
- "tzCode": "Asia/Saigon",
- "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Tomsk (GMT+07:00)",
- "tzCode": "Asia/Tomsk",
- "name": "(GMT+07:00) Novosibirsk",
- "utc": "+07:00"
- },
- {
- "label": "Asia/Vientiane (GMT+07:00)",
- "tzCode": "Asia/Vientiane",
- "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
- "utc": "+07:00"
- },
- {
- "label": "Indian/Christmas (GMT+07:00)",
- "tzCode": "Indian/Christmas",
- "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
- "utc": "+07:00"
- },
- {
- "label": "Antarctica/Casey (GMT+08:00)",
- "tzCode": "Antarctica/Casey",
- "name": "(GMT+08:00) Perth",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Brunei (GMT+08:00)",
- "tzCode": "Asia/Brunei",
- "name": "(GMT+08:00) Kuala Lumpur, Singapore",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Choibalsan (GMT+08:00)",
- "tzCode": "Asia/Choibalsan",
- "name": "(GMT+08:00) Ulaanbaatar",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Hong_Kong (GMT+08:00)",
- "tzCode": "Asia/Hong_Kong",
- "name": "(GMT+08:00) Beijing, Chongqing, Hong Kong, Urumqi",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Irkutsk (GMT+08:00)",
- "tzCode": "Asia/Irkutsk",
- "name": "(GMT+08:00) Irkutsk",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Krasnoyarsk (GMT+08:00)",
- "tzCode": "Asia/Krasnoyarsk",
- "name": "(GMT+08:00) Krasnoyarsk",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Kuala_Lumpur (GMT+08:00)",
- "tzCode": "Asia/Kuala_Lumpur",
- "name": "(GMT+08:00) Kuala Lumpur, Singapore",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Kuching (GMT+08:00)",
- "tzCode": "Asia/Kuching",
- "name": "(GMT+08:00) Kuala Lumpur, Singapore",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Macau (GMT+08:00)",
- "tzCode": "Asia/Macau",
- "name": "(GMT+08:00) Beijing, Chongqing, Hong Kong, Urumqi",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Makassar (GMT+08:00)",
- "tzCode": "Asia/Makassar",
- "name": "(GMT+08:00) Kuala Lumpur, Singapore",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Manila (GMT+08:00)",
- "tzCode": "Asia/Manila",
- "name": "(GMT+08:00) Kuala Lumpur, Singapore",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Shanghai (GMT+08:00)",
- "tzCode": "Asia/Shanghai",
- "name": "(GMT+08:00) Beijing, Chongqing, Hong Kong, Urumqi",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Singapore (GMT+08:00)",
- "tzCode": "Asia/Singapore",
- "name": "(GMT+08:00) Kuala Lumpur, Singapore",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Taipei (GMT+08:00)",
- "tzCode": "Asia/Taipei",
- "name": "(GMT+08:00) Taipei",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Ulaanbaatar (GMT+08:00)",
- "tzCode": "Asia/Ulaanbaatar",
- "name": "(GMT+08:00) Ulaanbaatar",
- "utc": "+08:00"
- },
- {
- "label": "Australia/Perth (GMT+08:00)",
- "tzCode": "Australia/Perth",
- "name": "(GMT+08:00) Perth",
- "utc": "+08:00"
- },
- {
- "label": "Asia/Chita (GMT+09:00)",
- "tzCode": "Asia/Chita",
- "name": "(GMT+09:00) Yakutsk",
- "utc": "+09:00"
- },
- {
- "label": "Asia/Dili (GMT+09:00)",
- "tzCode": "Asia/Dili",
- "name": "(GMT+09:00) Osaka, Sapporo, Tokyo",
- "utc": "+09:00"
- },
- {
- "label": "Asia/Jayapura (GMT+09:00)",
- "tzCode": "Asia/Jayapura",
- "name": "(GMT+09:00) Osaka, Sapporo, Tokyo",
- "utc": "+09:00"
- },
- {
- "label": "Asia/Khandyga (GMT+09:00)",
- "tzCode": "Asia/Khandyga",
- "name": "(GMT+09:00) Yakutsk",
- "utc": "+09:00"
- },
- {
- "label": "Asia/Pyongyang (GMT+09:00)",
- "tzCode": "Asia/Pyongyang",
- "name": "(GMT+09:00) Seoul",
- "utc": "+09:00"
- },
- {
- "label": "Asia/Seoul (GMT+09:00)",
- "tzCode": "Asia/Seoul",
- "name": "(GMT+09:00) Seoul",
- "utc": "+09:00"
- },
- {
- "label": "Asia/Tokyo (GMT+09:00)",
- "tzCode": "Asia/Tokyo",
- "name": "(GMT+09:00) Osaka, Sapporo, Tokyo",
- "utc": "+09:00"
- },
- {
- "label": "Asia/Yakutsk (GMT+09:00)",
- "tzCode": "Asia/Yakutsk",
- "name": "(GMT+09:00) Yakutsk",
- "utc": "+09:00"
- },
- {
- "label": "Pacific/Palau (GMT+09:00)",
- "tzCode": "Pacific/Palau",
- "name": "(GMT+09:00) Osaka, Sapporo, Tokyo",
- "utc": "+09:00"
- },
- {
- "label": "Australia/Adelaide (GMT+9.5:00)",
- "tzCode": "Australia/Adelaide",
- "name": "(GMT+09:30) Adelaide",
- "utc": "+9.5:00"
- },
- {
- "label": "Australia/Broken_Hill (GMT+9.5:00)",
- "tzCode": "Australia/Broken_Hill",
- "name": "(GMT+09:30) Adelaide",
- "utc": "+9.5:00"
- },
- {
- "label": "Australia/Darwin (GMT+9.5:00)",
- "tzCode": "Australia/Darwin",
- "name": "(GMT+09:30) Darwin",
- "utc": "+9.5:00"
- },
- {
- "label": "Antarctica/DumontDUrville (GMT+10:00)",
- "tzCode": "Antarctica/DumontDUrville",
- "name": "(GMT+10:00) Guam, Port Moresby",
- "utc": "+10:00"
- },
- {
- "label": "Asia/Ust-Nera (GMT+10:00)",
- "tzCode": "Asia/Ust-Nera",
- "name": "(GMT+10:00) Vladivostok",
- "utc": "+10:00"
- },
- {
- "label": "Asia/Vladivostok (GMT+10:00)",
- "tzCode": "Asia/Vladivostok",
- "name": "(GMT+10:00) Vladivostok",
- "utc": "+10:00"
- },
- {
- "label": "Australia/Brisbane (GMT+10:00)",
- "tzCode": "Australia/Brisbane",
- "name": "(GMT+10:00) Brisbane",
- "utc": "+10:00"
- },
- {
- "label": "Australia/Currie (GMT+10:00)",
- "tzCode": "Australia/Currie",
- "name": "(GMT+10:00) Hobart",
- "utc": "+10:00"
- },
- {
- "label": "Australia/Hobart (GMT+10:00)",
- "tzCode": "Australia/Hobart",
- "name": "(GMT+10:00) Hobart",
- "utc": "+10:00"
- },
- {
- "label": "Australia/Lindeman (GMT+10:00)",
- "tzCode": "Australia/Lindeman",
- "name": "(GMT+10:00) Brisbane",
- "utc": "+10:00"
- },
- {
- "label": "Australia/Melbourne (GMT+10:00)",
- "tzCode": "Australia/Melbourne",
- "name": "(GMT+10:00) Canberra, Melbourne, Sydney",
- "utc": "+10:00"
- },
- {
- "label": "Australia/Sydney (GMT+10:00)",
- "tzCode": "Australia/Sydney",
- "name": "(GMT+10:00) Canberra, Melbourne, Sydney",
- "utc": "+10:00"
- },
- {
- "label": "Pacific/Guam (GMT+10:00)",
- "tzCode": "Pacific/Guam",
- "name": "(GMT+10:00) Guam, Port Moresby",
- "utc": "+10:00"
- },
- {
- "label": "Pacific/Port_Moresby (GMT+10:00)",
- "tzCode": "Pacific/Port_Moresby",
- "name": "(GMT+10:00) Guam, Port Moresby",
- "utc": "+10:00"
- },
- {
- "label": "Pacific/Saipan (GMT+10:00)",
- "tzCode": "Pacific/Saipan",
- "name": "(GMT+10:00) Guam, Port Moresby",
- "utc": "+10:00"
- },
- {
- "label": "Pacific/Truk (GMT+10:00)",
- "tzCode": "Pacific/Truk",
- "name": "(GMT+10:00) Guam, Port Moresby",
- "utc": "+10:00"
- },
- {
- "label": "Antarctica/Macquarie (GMT+11:00)",
- "tzCode": "Antarctica/Macquarie",
- "name": "(GMT+11:00) Solomon Is., New Caledonia",
- "utc": "+11:00"
- },
- {
- "label": "Asia/Sakhalin (GMT+11:00)",
- "tzCode": "Asia/Sakhalin",
- "name": "(GMT+11:00) Sakhalin",
- "utc": "+11:00"
- },
- {
- "label": "Pacific/Efate (GMT+11:00)",
- "tzCode": "Pacific/Efate",
- "name": "(GMT+11:00) Solomon Is., New Caledonia",
- "utc": "+11:00"
- },
- {
- "label": "Pacific/Guadalcanal (GMT+11:00)",
- "tzCode": "Pacific/Guadalcanal",
- "name": "(GMT+11:00) Solomon Is., New Caledonia",
- "utc": "+11:00"
- },
- {
- "label": "Pacific/Kosrae (GMT+11:00)",
- "tzCode": "Pacific/Kosrae",
- "name": "(GMT+11:00) Solomon Is., New Caledonia",
- "utc": "+11:00"
- },
- {
- "label": "Pacific/Noumea (GMT+11:00)",
- "tzCode": "Pacific/Noumea",
- "name": "(GMT+11:00) Solomon Is., New Caledonia",
- "utc": "+11:00"
- },
- {
- "label": "Pacific/Ponape (GMT+11:00)",
- "tzCode": "Pacific/Ponape",
- "name": "(GMT+11:00) Solomon Is., New Caledonia",
- "utc": "+11:00"
- },
- {
- "label": "Antarctica/McMurdo (GMT+12:00)",
- "tzCode": "Antarctica/McMurdo",
- "name": "(GMT+12:00) Auckland, Wellington",
- "utc": "+12:00"
- },
- {
- "label": "Asia/Anadyr (GMT+12:00)",
- "tzCode": "Asia/Anadyr",
- "name": "(GMT+12:00) Magadan",
- "utc": "+12:00"
- },
- {
- "label": "Asia/Kamchatka (GMT+12:00)",
- "tzCode": "Asia/Kamchatka",
- "name": "(GMT+12:00) Magadan",
- "utc": "+12:00"
- },
- {
- "label": "Asia/Magadan (GMT+12:00)",
- "tzCode": "Asia/Magadan",
- "name": "(GMT+12:00) Magadan",
- "utc": "+12:00"
- },
- {
- "label": "Asia/Srednekolymsk (GMT+12:00)",
- "tzCode": "Asia/Srednekolymsk",
- "name": "(GMT+12:00) Magadan",
- "utc": "+12:00"
- },
- {
- "label": "Pacific/Auckland (GMT+12:00)",
- "tzCode": "Pacific/Auckland",
- "name": "(GMT+12:00) Auckland, Wellington",
- "utc": "+12:00"
- },
- {
- "label": "Pacific/Fiji (GMT+12:00)",
- "tzCode": "Pacific/Fiji",
- "name": "(GMT+12:00) Fiji",
- "utc": "+12:00"
- },
- {
- "label": "Pacific/Funafuti (GMT+12:00)",
- "tzCode": "Pacific/Funafuti",
- "name": "(GMT+12:00) Coordinated Universal Time+12",
- "utc": "+12:00"
- },
- {
- "label": "Pacific/Kwajalein (GMT+12:00)",
- "tzCode": "Pacific/Kwajalein",
- "name": "(GMT+12:00) Coordinated Universal Time+12",
- "utc": "+12:00"
- },
- {
- "label": "Pacific/Majuro (GMT+12:00)",
- "tzCode": "Pacific/Majuro",
- "name": "(GMT+12:00) Coordinated Universal Time+12",
- "utc": "+12:00"
- },
- {
- "label": "Pacific/Nauru (GMT+12:00)",
- "tzCode": "Pacific/Nauru",
- "name": "(GMT+12:00) Coordinated Universal Time+12",
- "utc": "+12:00"
- },
- {
- "label": "Pacific/Tarawa (GMT+12:00)",
- "tzCode": "Pacific/Tarawa",
- "name": "(GMT+12:00) Coordinated Universal Time+12",
- "utc": "+12:00"
- },
- {
- "label": "Pacific/Wake (GMT+12:00)",
- "tzCode": "Pacific/Wake",
- "name": "(GMT+12:00) Coordinated Universal Time+12",
- "utc": "+12:00"
- },
- {
- "label": "Pacific/Wallis (GMT+12:00)",
- "tzCode": "Pacific/Wallis",
- "name": "(GMT+12:00) Coordinated Universal Time+12",
- "utc": "+12:00"
- },
- {
- "label": "Pacific/Apia (GMT+13:00)",
- "tzCode": "Pacific/Apia",
- "name": "(GMT+13:00) Samoa",
- "utc": "+13:00"
- },
- {
- "label": "Pacific/Enderbury (GMT+13:00)",
- "tzCode": "Pacific/Enderbury",
- "name": "(GMT+13:00) Nuku'alofa",
- "utc": "+13:00"
- },
- {
- "label": "Pacific/Fakaofo (GMT+13:00)",
- "tzCode": "Pacific/Fakaofo",
- "name": "(GMT+13:00) Nuku'alofa",
- "utc": "+13:00"
- },
- {
- "label": "Pacific/Tongatapu (GMT+13:00)",
- "tzCode": "Pacific/Tongatapu",
- "name": "(GMT+13:00) Nuku'alofa",
- "utc": "+13:00"
- },
- {
- "label": "UTC (GMT+00:00)",
- "tzCode": "Etc/GMT",
- "name": "(GMT+00:00) Coordinated Universal Time",
- "utc": "+00:00"
- }
-]
+[
+ {
+ "label": "Pacific/Midway (GMT-11:00)",
+ "tzCode": "Pacific/Midway",
+ "name": "(GMT-11:00) Coordinated Universal Time-11",
+ "utc": "-11:00"
+ },
+ {
+ "label": "Pacific/Niue (GMT-11:00)",
+ "tzCode": "Pacific/Niue",
+ "name": "(GMT-11:00) Coordinated Universal Time-11",
+ "utc": "-11:00"
+ },
+ {
+ "label": "Pacific/Pago_Pago (GMT-11:00)",
+ "tzCode": "Pacific/Pago_Pago",
+ "name": "(GMT-11:00) Coordinated Universal Time-11",
+ "utc": "-11:00"
+ },
+ {
+ "label": "Pacific/Honolulu (GMT-10:00)",
+ "tzCode": "Pacific/Honolulu",
+ "name": "(GMT-10:00) Hawaii",
+ "utc": "-10:00"
+ },
+ {
+ "label": "Pacific/Johnston (GMT-10:00)",
+ "tzCode": "Pacific/Johnston",
+ "name": "(GMT-10:00) Hawaii",
+ "utc": "-10:00"
+ },
+ {
+ "label": "Pacific/Rarotonga (GMT-10:00)",
+ "tzCode": "Pacific/Rarotonga",
+ "name": "(GMT-10:00) Hawaii",
+ "utc": "-10:00"
+ },
+ {
+ "label": "Pacific/Tahiti (GMT-10:00)",
+ "tzCode": "Pacific/Tahiti",
+ "name": "(GMT-10:00) Hawaii",
+ "utc": "-10:00"
+ },
+ {
+ "label": "America/Anchorage (GMT-08:00)",
+ "tzCode": "America/Anchorage",
+ "name": "(GMT-09:00) Alaska",
+ "utc": "-08:00"
+ },
+ {
+ "label": "America/Juneau (GMT-08:00)",
+ "tzCode": "America/Juneau",
+ "name": "(GMT-09:00) Alaska",
+ "utc": "-08:00"
+ },
+ {
+ "label": "America/Los_Angeles (GMT-08:00)",
+ "tzCode": "America/Los_Angeles",
+ "name": "(GMT-08:00) Pacific Standard Time (US & Canada)",
+ "utc": "-08:00"
+ },
+ {
+ "label": "America/Nome (GMT-08:00)",
+ "tzCode": "America/Nome",
+ "name": "(GMT-09:00) Alaska",
+ "utc": "-08:00"
+ },
+ {
+ "label": "America/Sitka (GMT-08:00)",
+ "tzCode": "America/Sitka",
+ "name": "(GMT-09:00) Alaska",
+ "utc": "-08:00"
+ },
+ {
+ "label": "America/Tijuana (GMT-08:00)",
+ "tzCode": "America/Tijuana",
+ "name": "(GMT-08:00) Pacific Standard Time (US & Canada)",
+ "utc": "-08:00"
+ },
+ {
+ "label": "America/Vancouver (GMT-08:00)",
+ "tzCode": "America/Vancouver",
+ "name": "(GMT-08:00) Pacific Standard Time (US & Canada)",
+ "utc": "-08:00"
+ },
+ {
+ "label": "America/Yakutat (GMT-08:00)",
+ "tzCode": "America/Yakutat",
+ "name": "(GMT-09:00) Alaska",
+ "utc": "-08:00"
+ },
+ {
+ "label": "PST8PDT (GMT-08:00)",
+ "tzCode": "PST8PDT",
+ "name": "(GMT-08:00) Pacific Standard Time (US & Canada)",
+ "utc": "-08:00"
+ },
+ {
+ "label": "America/Creston (GMT-07:00)",
+ "tzCode": "America/Creston",
+ "name": "(GMT-07:00) Arizona",
+ "utc": "-07:00"
+ },
+ {
+ "label": "America/Dawson (GMT-07:00)",
+ "tzCode": "America/Dawson",
+ "name": "(GMT-07:00) Arizona",
+ "utc": "-07:00"
+ },
+ {
+ "label": "America/Dawson_Creek (GMT-07:00)",
+ "tzCode": "America/Dawson_Creek",
+ "name": "(GMT-07:00) Arizona",
+ "utc": "-07:00"
+ },
+ {
+ "label": "America/Hermosillo (GMT-07:00)",
+ "tzCode": "America/Hermosillo",
+ "name": "(GMT-07:00) Arizona",
+ "utc": "-07:00"
+ },
+ {
+ "label": "America/Phoenix (GMT-07:00)",
+ "tzCode": "America/Phoenix",
+ "name": "(GMT-07:00) Arizona",
+ "utc": "-07:00"
+ },
+ {
+ "label": "America/Santa_Isabel (GMT-07:00)",
+ "tzCode": "America/Santa_Isabel",
+ "name": "(GMT-08:00) Baja California",
+ "utc": "-07:00"
+ },
+ {
+ "label": "America/Whitehorse (GMT-07:00)",
+ "tzCode": "America/Whitehorse",
+ "name": "(GMT-07:00) Arizona",
+ "utc": "-07:00"
+ },
+ {
+ "label": "America/Belize (GMT-06:00)",
+ "tzCode": "America/Belize",
+ "name": "(GMT-06:00) Central America",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Boise (GMT-06:00)",
+ "tzCode": "America/Boise",
+ "name": "(GMT-07:00) Mountain Time (US & Canada)",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Cambridge_Bay (GMT-06:00)",
+ "tzCode": "America/Cambridge_Bay",
+ "name": "(GMT-07:00) Mountain Time (US & Canada)",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Chihuahua (GMT-06:00)",
+ "tzCode": "America/Chihuahua",
+ "name": "(GMT-07:00) Chihuahua, La Paz, Mazatlan",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Costa_Rica (GMT-06:00)",
+ "tzCode": "America/Costa_Rica",
+ "name": "(GMT-06:00) Central America",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Denver (GMT-06:00)",
+ "tzCode": "America/Denver",
+ "name": "(GMT-07:00) Mountain Time (US & Canada)",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Edmonton (GMT-06:00)",
+ "tzCode": "America/Edmonton",
+ "name": "(GMT-07:00) Mountain Time (US & Canada)",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/El_Salvador (GMT-06:00)",
+ "tzCode": "America/El_Salvador",
+ "name": "(GMT-06:00) Central America",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Guatemala (GMT-06:00)",
+ "tzCode": "America/Guatemala",
+ "name": "(GMT-06:00) Central America",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Inuvik (GMT-06:00)",
+ "tzCode": "America/Inuvik",
+ "name": "(GMT-07:00) Mountain Time (US & Canada)",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Managua (GMT-06:00)",
+ "tzCode": "America/Managua",
+ "name": "(GMT-06:00) Central America",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Mazatlan (GMT-06:00)",
+ "tzCode": "America/Mazatlan",
+ "name": "(GMT-07:00) Chihuahua, La Paz, Mazatlan",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Ojinaga (GMT-06:00)",
+ "tzCode": "America/Ojinaga",
+ "name": "(GMT-07:00) Mountain Time (US & Canada)",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Regina (GMT-06:00)",
+ "tzCode": "America/Regina",
+ "name": "(GMT-06:00) Saskatchewan",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Swift_Current (GMT-06:00)",
+ "tzCode": "America/Swift_Current",
+ "name": "(GMT-06:00) Saskatchewan",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Tegucigalpa (GMT-06:00)",
+ "tzCode": "America/Tegucigalpa",
+ "name": "(GMT-06:00) Central America",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Yellowknife (GMT-06:00)",
+ "tzCode": "America/Yellowknife",
+ "name": "(GMT-07:00) Mountain Time (US & Canada)",
+ "utc": "-06:00"
+ },
+ {
+ "label": "MST7MDT (GMT-06:00)",
+ "tzCode": "MST7MDT",
+ "name": "(GMT-07:00) Mountain Time (US & Canada)",
+ "utc": "-06:00"
+ },
+ {
+ "label": "Pacific/Galapagos (GMT-06:00)",
+ "tzCode": "Pacific/Galapagos",
+ "name": "(GMT-06:00) Central America",
+ "utc": "-06:00"
+ },
+ {
+ "label": "America/Bahia_Banderas (GMT-05:00)",
+ "tzCode": "America/Bahia_Banderas",
+ "name": "(GMT-06:00) Guadalajara, Mexico City, Monterrey",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Bogota (GMT-05:00)",
+ "tzCode": "America/Bogota",
+ "name": "(GMT-05:00) Bogota, Lima, Quito",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Cancun (GMT-05:00)",
+ "tzCode": "America/Cancun",
+ "name": "(GMT-06:00) Guadalajara, Mexico City, Monterrey",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Cayman (GMT-05:00)",
+ "tzCode": "America/Cayman",
+ "name": "(GMT-05:00) Bogota, Lima, Quito",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Chicago (GMT-05:00)",
+ "tzCode": "America/Chicago",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Coral_Harbour (GMT-05:00)",
+ "tzCode": "America/Coral_Harbour",
+ "name": "(GMT-05:00) Bogota, Lima, Quito",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Detroit (GMT-05:00)",
+ "tzCode": "America/Detroit",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Eirunepe (GMT-05:00)",
+ "tzCode": "America/Eirunepe",
+ "name": "(GMT-05:00) Bogota, Lima, Quito",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Guayaquil (GMT-05:00)",
+ "tzCode": "America/Guayaquil",
+ "name": "(GMT-05:00) Bogota, Lima, Quito",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Havana (GMT-05:00)",
+ "tzCode": "America/Havana",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Indiana/Knox (GMT-05:00)",
+ "tzCode": "America/Indiana/Knox",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Indiana/Marengo (GMT-05:00)",
+ "tzCode": "America/Indiana/Marengo",
+ "name": "(GMT-05:00) Indiana (East)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Indiana/Petersburg (GMT-05:00)",
+ "tzCode": "America/Indiana/Petersburg",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Indiana/Tell_City (GMT-05:00)",
+ "tzCode": "America/Indiana/Tell_City",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Indiana/Vevay (GMT-05:00)",
+ "tzCode": "America/Indiana/Vevay",
+ "name": "(GMT-05:00) Indiana (East)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Indiana/Vincennes (GMT-05:00)",
+ "tzCode": "America/Indiana/Vincennes",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Indiana/Winamac (GMT-05:00)",
+ "tzCode": "America/Indiana/Winamac",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Indianapolis (GMT-05:00)",
+ "tzCode": "America/Indianapolis",
+ "name": "(GMT-05:00) Indiana (East)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Iqaluit (GMT-05:00)",
+ "tzCode": "America/Iqaluit",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Jamaica (GMT-05:00)",
+ "tzCode": "America/Jamaica",
+ "name": "(GMT-05:00) Bogota, Lima, Quito",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Kentucky/Monticello (GMT-05:00)",
+ "tzCode": "America/Kentucky/Monticello",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Lima (GMT-05:00)",
+ "tzCode": "America/Lima",
+ "name": "(GMT-05:00) Bogota, Lima, Quito",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Louisville (GMT-05:00)",
+ "tzCode": "America/Louisville",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Matamoros (GMT-05:00)",
+ "tzCode": "America/Matamoros",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Menominee (GMT-05:00)",
+ "tzCode": "America/Menominee",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Merida (GMT-05:00)",
+ "tzCode": "America/Merida",
+ "name": "(GMT-06:00) Guadalajara, Mexico City, Monterrey",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Mexico_City (GMT-05:00)",
+ "tzCode": "America/Mexico_City",
+ "name": "(GMT-06:00) Guadalajara, Mexico City, Monterrey",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Monterrey (GMT-05:00)",
+ "tzCode": "America/Monterrey",
+ "name": "(GMT-06:00) Guadalajara, Mexico City, Monterrey",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Montreal (GMT-05:00)",
+ "tzCode": "America/Montreal",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Nassau (GMT-05:00)",
+ "tzCode": "America/Nassau",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/New_York (GMT-05:00)",
+ "tzCode": "America/New_York",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Nipigon (GMT-05:00)",
+ "tzCode": "America/Nipigon",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/North_Dakota/Beulah (GMT-05:00)",
+ "tzCode": "America/North_Dakota/Beulah",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/North_Dakota/Center (GMT-05:00)",
+ "tzCode": "America/North_Dakota/Center",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/North_Dakota/New_Salem (GMT-05:00)",
+ "tzCode": "America/North_Dakota/New_Salem",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Panama (GMT-05:00)",
+ "tzCode": "America/Panama",
+ "name": "(GMT-05:00) Bogota, Lima, Quito",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Pangnirtung (GMT-05:00)",
+ "tzCode": "America/Pangnirtung",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Port-au-Prince (GMT-05:00)",
+ "tzCode": "America/Port-au-Prince",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Rainy_River (GMT-05:00)",
+ "tzCode": "America/Rainy_River",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Rankin_Inlet (GMT-05:00)",
+ "tzCode": "America/Rankin_Inlet",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Resolute (GMT-05:00)",
+ "tzCode": "America/Resolute",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Rio_Branco (GMT-05:00)",
+ "tzCode": "America/Rio_Branco",
+ "name": "(GMT-05:00) Bogota, Lima, Quito",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Thunder_Bay (GMT-05:00)",
+ "tzCode": "America/Thunder_Bay",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Toronto (GMT-05:00)",
+ "tzCode": "America/Toronto",
+ "name": "(GMT-05:00) Eastern Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Winnipeg (GMT-05:00)",
+ "tzCode": "America/Winnipeg",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "CST6CDT (GMT-05:00)",
+ "tzCode": "CST6CDT",
+ "name": "(GMT-06:00) Central Time (US & Canada)",
+ "utc": "-05:00"
+ },
+ {
+ "label": "America/Caracas (GMT-4.5:00)",
+ "tzCode": "America/Caracas",
+ "name": "(GMT-04:30) Caracas",
+ "utc": "-4.5:00"
+ },
+ {
+ "label": "America/Anguilla (GMT-04:00)",
+ "tzCode": "America/Anguilla",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Antigua (GMT-04:00)",
+ "tzCode": "America/Antigua",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Aruba (GMT-04:00)",
+ "tzCode": "America/Aruba",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Asuncion (GMT-04:00)",
+ "tzCode": "America/Asuncion",
+ "name": "(GMT-04:00) Asuncion",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Barbados (GMT-04:00)",
+ "tzCode": "America/Barbados",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Blanc-Sablon (GMT-04:00)",
+ "tzCode": "America/Blanc-Sablon",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Boa_Vista (GMT-04:00)",
+ "tzCode": "America/Boa_Vista",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Campo_Grande (GMT-04:00)",
+ "tzCode": "America/Campo_Grande",
+ "name": "(GMT-04:00) Cuiaba",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Cuiaba (GMT-04:00)",
+ "tzCode": "America/Cuiaba",
+ "name": "(GMT-04:00) Cuiaba",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Curacao (GMT-04:00)",
+ "tzCode": "America/Curacao",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Dominica (GMT-04:00)",
+ "tzCode": "America/Dominica",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Grand_Turk (GMT-04:00)",
+ "tzCode": "America/Grand_Turk",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Grenada (GMT-04:00)",
+ "tzCode": "America/Grenada",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Guadeloupe (GMT-04:00)",
+ "tzCode": "America/Guadeloupe",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Guyana (GMT-04:00)",
+ "tzCode": "America/Guyana",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Kralendijk (GMT-04:00)",
+ "tzCode": "America/Kralendijk",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/La_Paz (GMT-04:00)",
+ "tzCode": "America/La_Paz",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Lower_Princes (GMT-04:00)",
+ "tzCode": "America/Lower_Princes",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Manaus (GMT-04:00)",
+ "tzCode": "America/Manaus",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Marigot (GMT-04:00)",
+ "tzCode": "America/Marigot",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Martinique (GMT-04:00)",
+ "tzCode": "America/Martinique",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Montserrat (GMT-04:00)",
+ "tzCode": "America/Montserrat",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Port_of_Spain (GMT-04:00)",
+ "tzCode": "America/Port_of_Spain",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Porto_Velho (GMT-04:00)",
+ "tzCode": "America/Porto_Velho",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Puerto_Rico (GMT-04:00)",
+ "tzCode": "America/Puerto_Rico",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Santiago (GMT-04:00)",
+ "tzCode": "America/Santiago",
+ "name": "(GMT-04:00) Santiago",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Santo_Domingo (GMT-04:00)",
+ "tzCode": "America/Santo_Domingo",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/St_Barthelemy (GMT-04:00)",
+ "tzCode": "America/St_Barthelemy",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/St_Kitts (GMT-04:00)",
+ "tzCode": "America/St_Kitts",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/St_Lucia (GMT-04:00)",
+ "tzCode": "America/St_Lucia",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/St_Thomas (GMT-04:00)",
+ "tzCode": "America/St_Thomas",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/St_Vincent (GMT-04:00)",
+ "tzCode": "America/St_Vincent",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Tortola (GMT-04:00)",
+ "tzCode": "America/Tortola",
+ "name": "(GMT-04:00) Georgetown, La Paz, Manaus, San Juan",
+ "utc": "-04:00"
+ },
+ {
+ "label": "Antarctica/Palmer (GMT-04:00)",
+ "tzCode": "Antarctica/Palmer",
+ "name": "(GMT-04:00) Santiago",
+ "utc": "-04:00"
+ },
+ {
+ "label": "America/Araguaina (GMT-03:00)",
+ "tzCode": "America/Araguaina",
+ "name": "(GMT-03:00) Cayenne, Fortaleza",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/Buenos_Aires (GMT-03:00)",
+ "tzCode": "America/Argentina/Buenos_Aires",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/Catamarca (GMT-03:00)",
+ "tzCode": "America/Argentina/Catamarca",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/Cordoba (GMT-03:00)",
+ "tzCode": "America/Argentina/Cordoba",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/Jujuy (GMT-03:00)",
+ "tzCode": "America/Argentina/Jujuy",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/La_Rioja (GMT-03:00)",
+ "tzCode": "America/Argentina/La_Rioja",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/Mendoza (GMT-03:00)",
+ "tzCode": "America/Argentina/Mendoza",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/Rio_Gallegos (GMT-03:00)",
+ "tzCode": "America/Argentina/Rio_Gallegos",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/Salta (GMT-03:00)",
+ "tzCode": "America/Argentina/Salta",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/San_Juan (GMT-03:00)",
+ "tzCode": "America/Argentina/San_Juan",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/San_Luis (GMT-03:00)",
+ "tzCode": "America/Argentina/San_Luis",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/Tucuman (GMT-03:00)",
+ "tzCode": "America/Argentina/Tucuman",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Argentina/Ushuaia (GMT-03:00)",
+ "tzCode": "America/Argentina/Ushuaia",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Bahia (GMT-03:00)",
+ "tzCode": "America/Bahia",
+ "name": "(GMT-03:00) Salvador",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Belem (GMT-03:00)",
+ "tzCode": "America/Belem",
+ "name": "(GMT-03:00) Cayenne, Fortaleza",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Buenos_Aires (GMT-03:00)",
+ "tzCode": "America/Buenos_Aires",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Catamarca (GMT-03:00)",
+ "tzCode": "America/Catamarca",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Cayenne (GMT-03:00)",
+ "tzCode": "America/Cayenne",
+ "name": "(GMT-03:00) Cayenne, Fortaleza",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Cordoba (GMT-03:00)",
+ "tzCode": "America/Cordoba",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Fortaleza (GMT-03:00)",
+ "tzCode": "America/Fortaleza",
+ "name": "(GMT-03:00) Cayenne, Fortaleza",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Glace_Bay (GMT-03:00)",
+ "tzCode": "America/Glace_Bay",
+ "name": "(GMT-04:00) Atlantic Time (Canada)",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Godthab (GMT-03:00)",
+ "tzCode": "America/Godthab",
+ "name": "(GMT-03:00) Greenland",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Goose_Bay (GMT-03:00)",
+ "tzCode": "America/Goose_Bay",
+ "name": "(GMT-04:00) Atlantic Time (Canada)",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Halifax (GMT-03:00)",
+ "tzCode": "America/Halifax",
+ "name": "(GMT-04:00) Atlantic Time (Canada)",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Jujuy (GMT-03:00)",
+ "tzCode": "America/Jujuy",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Maceio (GMT-03:00)",
+ "tzCode": "America/Maceio",
+ "name": "(GMT-03:00) Cayenne, Fortaleza",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Mendoza (GMT-03:00)",
+ "tzCode": "America/Mendoza",
+ "name": "(GMT-03:00) Buenos Aires",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Moncton (GMT-03:00)",
+ "tzCode": "America/Moncton",
+ "name": "(GMT-04:00) Atlantic Time (Canada)",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Montevideo (GMT-03:00)",
+ "tzCode": "America/Montevideo",
+ "name": "(GMT-03:00) Montevideo",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Paramaribo (GMT-03:00)",
+ "tzCode": "America/Paramaribo",
+ "name": "(GMT-03:00) Cayenne, Fortaleza",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Recife (GMT-03:00)",
+ "tzCode": "America/Recife",
+ "name": "(GMT-03:00) Cayenne, Fortaleza",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Santarem (GMT-03:00)",
+ "tzCode": "America/Santarem",
+ "name": "(GMT-03:00) Cayenne, Fortaleza",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Sao_Paulo (GMT-03:00)",
+ "tzCode": "America/Sao_Paulo",
+ "name": "(GMT-03:00) Brasilia",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/Thule (GMT-03:00)",
+ "tzCode": "America/Thule",
+ "name": "(GMT-04:00) Atlantic Time (Canada)",
+ "utc": "-03:00"
+ },
+ {
+ "label": "Antarctica/Rothera (GMT-03:00)",
+ "tzCode": "Antarctica/Rothera",
+ "name": "(GMT-03:00) Cayenne, Fortaleza",
+ "utc": "-03:00"
+ },
+ {
+ "label": "Atlantic/Bermuda (GMT-03:00)",
+ "tzCode": "Atlantic/Bermuda",
+ "name": "(GMT-04:00) Atlantic Time (Canada)",
+ "utc": "-03:00"
+ },
+ {
+ "label": "Atlantic/Stanley (GMT-03:00)",
+ "tzCode": "Atlantic/Stanley",
+ "name": "(GMT-03:00) Cayenne, Fortaleza",
+ "utc": "-03:00"
+ },
+ {
+ "label": "America/St_Johns (GMT-2.5:00)",
+ "tzCode": "America/St_Johns",
+ "name": "(GMT-03:30) Newfoundland",
+ "utc": "-2.5:00"
+ },
+ {
+ "label": "America/Noronha (GMT-02:00)",
+ "tzCode": "America/Noronha",
+ "name": "(GMT-02:00) Coordinated Universal Time-02",
+ "utc": "-02:00"
+ },
+ {
+ "label": "Atlantic/South_Georgia (GMT-02:00)",
+ "tzCode": "Atlantic/South_Georgia",
+ "name": "(GMT-02:00) Coordinated Universal Time-02",
+ "utc": "-02:00"
+ },
+ {
+ "label": "Atlantic/Cape_Verde (GMT-01:00)",
+ "tzCode": "Atlantic/Cape_Verde",
+ "name": "(GMT-01:00) Cape Verde Is.",
+ "utc": "-01:00"
+ },
+ {
+ "label": "Africa/Abidjan (GMT+00:00)",
+ "tzCode": "Africa/Abidjan",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Accra (GMT+00:00)",
+ "tzCode": "Africa/Accra",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Bamako (GMT+00:00)",
+ "tzCode": "Africa/Bamako",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Banjul (GMT+00:00)",
+ "tzCode": "Africa/Banjul",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Bissau (GMT+00:00)",
+ "tzCode": "Africa/Bissau",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Conakry (GMT+00:00)",
+ "tzCode": "Africa/Conakry",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Dakar (GMT+00:00)",
+ "tzCode": "Africa/Dakar",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Freetown (GMT+00:00)",
+ "tzCode": "Africa/Freetown",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Lome (GMT+00:00)",
+ "tzCode": "Africa/Lome",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Monrovia (GMT+00:00)",
+ "tzCode": "Africa/Monrovia",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Nouakchott (GMT+00:00)",
+ "tzCode": "Africa/Nouakchott",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Ouagadougou (GMT+00:00)",
+ "tzCode": "Africa/Ouagadougou",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Sao_Tome (GMT+00:00)",
+ "tzCode": "Africa/Sao_Tome",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "America/Danmarkshavn (GMT+00:00)",
+ "tzCode": "America/Danmarkshavn",
+ "name": "(GMT) Coordinated Universal Time",
+ "utc": "+00:00"
+ },
+ {
+ "label": "America/Scoresbysund (GMT+00:00)",
+ "tzCode": "America/Scoresbysund",
+ "name": "(GMT-01:00) Azores",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Atlantic/Azores (GMT+00:00)",
+ "tzCode": "Atlantic/Azores",
+ "name": "(GMT-01:00) Azores",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Atlantic/Reykjavik (GMT+00:00)",
+ "tzCode": "Atlantic/Reykjavik",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Atlantic/St_Helena (GMT+00:00)",
+ "tzCode": "Atlantic/St_Helena",
+ "name": "(GMT) Monrovia, Reykjavik",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Europe/Guernsey (GMT+00:00)",
+ "tzCode": "Europe/Guernsey",
+ "name": "(GMT) Edinburgh, London",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Europe/Isle_of_Man (GMT+00:00)",
+ "tzCode": "Europe/Isle_of_Man",
+ "name": "(GMT) Edinburgh, London",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Europe/Jersey (GMT+00:00)",
+ "tzCode": "Europe/Jersey",
+ "name": "(GMT) Edinburgh, London",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Europe/London (GMT+00:00)",
+ "tzCode": "Europe/London",
+ "name": "(GMT) Edinburgh, London",
+ "utc": "+00:00"
+ },
+ {
+ "label": "Africa/Algiers (GMT+01:00)",
+ "tzCode": "Africa/Algiers",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Bangui (GMT+01:00)",
+ "tzCode": "Africa/Bangui",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Brazzaville (GMT+01:00)",
+ "tzCode": "Africa/Brazzaville",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Casablanca (GMT+01:00)",
+ "tzCode": "Africa/Casablanca",
+ "name": "(GMT) Casablanca",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Douala (GMT+01:00)",
+ "tzCode": "Africa/Douala",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/El_Aaiun (GMT+01:00)",
+ "tzCode": "Africa/El_Aaiun",
+ "name": "(GMT) Casablanca",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Kinshasa (GMT+01:00)",
+ "tzCode": "Africa/Kinshasa",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Lagos (GMT+01:00)",
+ "tzCode": "Africa/Lagos",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Libreville (GMT+01:00)",
+ "tzCode": "Africa/Libreville",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Luanda (GMT+01:00)",
+ "tzCode": "Africa/Luanda",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Malabo (GMT+01:00)",
+ "tzCode": "Africa/Malabo",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Ndjamena (GMT+01:00)",
+ "tzCode": "Africa/Ndjamena",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Niamey (GMT+01:00)",
+ "tzCode": "Africa/Niamey",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Porto-Novo (GMT+01:00)",
+ "tzCode": "Africa/Porto-Novo",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Tunis (GMT+01:00)",
+ "tzCode": "Africa/Tunis",
+ "name": "(GMT+01:00) West Central Africa",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Windhoek (GMT+01:00)",
+ "tzCode": "Africa/Windhoek",
+ "name": "(GMT+01:00) Windhoek",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Atlantic/Canary (GMT+01:00)",
+ "tzCode": "Atlantic/Canary",
+ "name": "(GMT) Dublin, Lisbon",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Atlantic/Faeroe (GMT+01:00)",
+ "tzCode": "Atlantic/Faeroe",
+ "name": "(GMT) Dublin, Lisbon",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Atlantic/Madeira (GMT+01:00)",
+ "tzCode": "Atlantic/Madeira",
+ "name": "(GMT) Dublin, Lisbon",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Europe/Dublin (GMT+01:00)",
+ "tzCode": "Europe/Dublin",
+ "name": "(GMT) Dublin, Lisbon",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Europe/Lisbon (GMT+01:00)",
+ "tzCode": "Europe/Lisbon",
+ "name": "(GMT) Dublin, Lisbon",
+ "utc": "+01:00"
+ },
+ {
+ "label": "Africa/Blantyre (GMT+02:00)",
+ "tzCode": "Africa/Blantyre",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Bujumbura (GMT+02:00)",
+ "tzCode": "Africa/Bujumbura",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Cairo (GMT+02:00)",
+ "tzCode": "Africa/Cairo",
+ "name": "(GMT+02:00) Cairo",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Ceuta (GMT+02:00)",
+ "tzCode": "Africa/Ceuta",
+ "name": "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Gaborone (GMT+02:00)",
+ "tzCode": "Africa/Gaborone",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Harare (GMT+02:00)",
+ "tzCode": "Africa/Harare",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Johannesburg (GMT+02:00)",
+ "tzCode": "Africa/Johannesburg",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Kigali (GMT+02:00)",
+ "tzCode": "Africa/Kigali",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Lubumbashi (GMT+02:00)",
+ "tzCode": "Africa/Lubumbashi",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Lusaka (GMT+02:00)",
+ "tzCode": "Africa/Lusaka",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Maputo (GMT+02:00)",
+ "tzCode": "Africa/Maputo",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Maseru (GMT+02:00)",
+ "tzCode": "Africa/Maseru",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Mbabane (GMT+02:00)",
+ "tzCode": "Africa/Mbabane",
+ "name": "(GMT+02:00) Harare, Pretoria",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Tripoli (GMT+02:00)",
+ "tzCode": "Africa/Tripoli",
+ "name": "(GMT+02:00) Tripoli",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Arctic/Longyearbyen (GMT+02:00)",
+ "tzCode": "Arctic/Longyearbyen",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Amsterdam (GMT+02:00)",
+ "tzCode": "Europe/Amsterdam",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Andorra (GMT+02:00)",
+ "tzCode": "Europe/Andorra",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Belgrade (GMT+02:00)",
+ "tzCode": "Europe/Belgrade",
+ "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Berlin (GMT+02:00)",
+ "tzCode": "Europe/Berlin",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Bratislava (GMT+02:00)",
+ "tzCode": "Europe/Bratislava",
+ "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Brussels (GMT+02:00)",
+ "tzCode": "Europe/Brussels",
+ "name": "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Budapest (GMT+02:00)",
+ "tzCode": "Europe/Budapest",
+ "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Busingen (GMT+02:00)",
+ "tzCode": "Europe/Busingen",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Copenhagen (GMT+02:00)",
+ "tzCode": "Europe/Copenhagen",
+ "name": "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Gibraltar (GMT+02:00)",
+ "tzCode": "Europe/Gibraltar",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Ljubljana (GMT+02:00)",
+ "tzCode": "Europe/Ljubljana",
+ "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Luxembourg (GMT+02:00)",
+ "tzCode": "Europe/Luxembourg",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Madrid (GMT+02:00)",
+ "tzCode": "Europe/Madrid",
+ "name": "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Malta (GMT+02:00)",
+ "tzCode": "Europe/Malta",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Monaco (GMT+02:00)",
+ "tzCode": "Europe/Monaco",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Oslo (GMT+02:00)",
+ "tzCode": "Europe/Oslo",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Paris (GMT+02:00)",
+ "tzCode": "Europe/Paris",
+ "name": "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Podgorica (GMT+02:00)",
+ "tzCode": "Europe/Podgorica",
+ "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Prague (GMT+02:00)",
+ "tzCode": "Europe/Prague",
+ "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Rome (GMT+02:00)",
+ "tzCode": "Europe/Rome",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/San_Marino (GMT+02:00)",
+ "tzCode": "Europe/San_Marino",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Sarajevo (GMT+02:00)",
+ "tzCode": "Europe/Sarajevo",
+ "name": "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Skopje (GMT+02:00)",
+ "tzCode": "Europe/Skopje",
+ "name": "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Stockholm (GMT+02:00)",
+ "tzCode": "Europe/Stockholm",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Tirane (GMT+02:00)",
+ "tzCode": "Europe/Tirane",
+ "name": "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Vaduz (GMT+02:00)",
+ "tzCode": "Europe/Vaduz",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Vatican (GMT+02:00)",
+ "tzCode": "Europe/Vatican",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Vienna (GMT+02:00)",
+ "tzCode": "Europe/Vienna",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Warsaw (GMT+02:00)",
+ "tzCode": "Europe/Warsaw",
+ "name": "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Zagreb (GMT+02:00)",
+ "tzCode": "Europe/Zagreb",
+ "name": "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Europe/Zurich (GMT+02:00)",
+ "tzCode": "Europe/Zurich",
+ "name": "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
+ "utc": "+02:00"
+ },
+ {
+ "label": "Africa/Addis_Ababa (GMT+03:00)",
+ "tzCode": "Africa/Addis_Ababa",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Africa/Asmera (GMT+03:00)",
+ "tzCode": "Africa/Asmera",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Africa/Dar_es_Salaam (GMT+03:00)",
+ "tzCode": "Africa/Dar_es_Salaam",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Africa/Djibouti (GMT+03:00)",
+ "tzCode": "Africa/Djibouti",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Africa/Juba (GMT+03:00)",
+ "tzCode": "Africa/Juba",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Africa/Kampala (GMT+03:00)",
+ "tzCode": "Africa/Kampala",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Africa/Khartoum (GMT+03:00)",
+ "tzCode": "Africa/Khartoum",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Africa/Mogadishu (GMT+03:00)",
+ "tzCode": "Africa/Mogadishu",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Africa/Nairobi (GMT+03:00)",
+ "tzCode": "Africa/Nairobi",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Antarctica/Syowa (GMT+03:00)",
+ "tzCode": "Antarctica/Syowa",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Aden (GMT+03:00)",
+ "tzCode": "Asia/Aden",
+ "name": "(GMT+03:00) Kuwait, Riyadh",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Amman (GMT+03:00)",
+ "tzCode": "Asia/Amman",
+ "name": "(GMT+03:00) Amman",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Baghdad (GMT+03:00)",
+ "tzCode": "Asia/Baghdad",
+ "name": "(GMT+03:00) Baghdad",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Bahrain (GMT+03:00)",
+ "tzCode": "Asia/Bahrain",
+ "name": "(GMT+03:00) Kuwait, Riyadh",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Beirut (GMT+03:00)",
+ "tzCode": "Asia/Beirut",
+ "name": "(GMT+02:00) Beirut",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Damascus (GMT+03:00)",
+ "tzCode": "Asia/Damascus",
+ "name": "(GMT+02:00) Damascus",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Jerusalem (GMT+03:00)",
+ "tzCode": "Asia/Jerusalem",
+ "name": "(GMT+02:00) Jerusalem",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Kuwait (GMT+03:00)",
+ "tzCode": "Asia/Kuwait",
+ "name": "(GMT+03:00) Kuwait, Riyadh",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Nicosia (GMT+03:00)",
+ "tzCode": "Asia/Nicosia",
+ "name": "(GMT+02:00) Athens, Bucharest",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Qatar (GMT+03:00)",
+ "tzCode": "Asia/Qatar",
+ "name": "(GMT+03:00) Kuwait, Riyadh",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Riyadh (GMT+03:00)",
+ "tzCode": "Asia/Riyadh",
+ "name": "(GMT+03:00) Kuwait, Riyadh",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Athens (GMT+03:00)",
+ "tzCode": "Europe/Athens",
+ "name": "(GMT+02:00) Athens, Bucharest",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Bucharest (GMT+03:00)",
+ "tzCode": "Europe/Bucharest",
+ "name": "(GMT+02:00) Athens, Bucharest",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Chisinau (GMT+03:00)",
+ "tzCode": "Europe/Chisinau",
+ "name": "(GMT+02:00) Athens, Bucharest",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Helsinki (GMT+03:00)",
+ "tzCode": "Europe/Helsinki",
+ "name": "(GMT+02:00) E. Europe",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Istanbul (GMT+03:00)",
+ "tzCode": "Europe/Istanbul",
+ "name": "(GMT+03:00) Istanbul",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Kaliningrad (GMT+03:00)",
+ "tzCode": "Europe/Kaliningrad",
+ "name": "(GMT+02:00) Kaliningrad",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Kirov (GMT+03:00)",
+ "tzCode": "Europe/Kirov",
+ "name": "(GMT+03:00) Moscow, St. Petersburg, Volgograd, Minsk",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Kyiv (GMT+03:00)",
+ "tzCode": "Europe/Kyiv",
+ "name": "(GMT+02:00) E. Europe",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Mariehamn (GMT+03:00)",
+ "tzCode": "Europe/Mariehamn",
+ "name": "(GMT+02:00) E. Europe",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Minsk (GMT+03:00)",
+ "tzCode": "Europe/Minsk",
+ "name": "(GMT+03:00) Moscow, St. Petersburg, Volgograd, Minsk",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Moscow (GMT+03:00)",
+ "tzCode": "Europe/Moscow",
+ "name": "(GMT+03:00) Moscow, St. Petersburg, Volgograd, Minsk",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Nicosia (GMT+03:00)",
+ "tzCode": "Europe/Nicosia",
+ "name": "(GMT+02:00) E. Europe",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Riga (GMT+03:00)",
+ "tzCode": "Europe/Riga",
+ "name": "(GMT+02:00) E. Europe",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Simferopol (GMT+03:00)",
+ "tzCode": "Europe/Simferopol",
+ "name": "(GMT+03:00) Moscow, St. Petersburg, Volgograd, Minsk",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Sofia (GMT+03:00)",
+ "tzCode": "Europe/Sofia",
+ "name": "(GMT+02:00) E. Europe",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Tallinn (GMT+03:00)",
+ "tzCode": "Europe/Tallinn",
+ "name": "(GMT+02:00) E. Europe",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Uzhhorod (GMT+03:00)",
+ "tzCode": "Europe/Uzhhorod",
+ "name": "(GMT+02:00) E. Europe",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Vilnius (GMT+03:00)",
+ "tzCode": "Europe/Vilnius",
+ "name": "(GMT+02:00) E. Europe",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Volgograd (GMT+03:00)",
+ "tzCode": "Europe/Volgograd",
+ "name": "(GMT+03:00) Moscow, St. Petersburg, Volgograd, Minsk",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Europe/Zaporizhzhia (GMT+03:00)",
+ "tzCode": "Europe/Zaporizhzhia",
+ "name": "(GMT+02:00) E. Europe",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Indian/Antananarivo (GMT+03:00)",
+ "tzCode": "Indian/Antananarivo",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Indian/Comoro (GMT+03:00)",
+ "tzCode": "Indian/Comoro",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Indian/Mayotte (GMT+03:00)",
+ "tzCode": "Indian/Mayotte",
+ "name": "(GMT+03:00) Nairobi",
+ "utc": "+03:00"
+ },
+ {
+ "label": "Asia/Dubai (GMT+04:00)",
+ "tzCode": "Asia/Dubai",
+ "name": "(GMT+04:00) Abu Dhabi, Muscat",
+ "utc": "+04:00"
+ },
+ {
+ "label": "Asia/Muscat (GMT+04:00)",
+ "tzCode": "Asia/Muscat",
+ "name": "(GMT+04:00) Abu Dhabi, Muscat",
+ "utc": "+04:00"
+ },
+ {
+ "label": "Asia/Tbilisi (GMT+04:00)",
+ "tzCode": "Asia/Tbilisi",
+ "name": "(GMT+04:00) Tbilisi",
+ "utc": "+04:00"
+ },
+ {
+ "label": "Asia/Yerevan (GMT+04:00)",
+ "tzCode": "Asia/Yerevan",
+ "name": "(GMT+04:00) Yerevan",
+ "utc": "+04:00"
+ },
+ {
+ "label": "Europe/Astrakhan (GMT+04:00)",
+ "tzCode": "Europe/Astrakhan",
+ "name": "(GMT+04:00) Samara, Ulyanovsk, Saratov",
+ "utc": "+04:00"
+ },
+ {
+ "label": "Europe/Samara (GMT+04:00)",
+ "tzCode": "Europe/Samara",
+ "name": "(GMT+04:00) Samara, Ulyanovsk, Saratov",
+ "utc": "+04:00"
+ },
+ {
+ "label": "Europe/Ulyanovsk (GMT+04:00)",
+ "tzCode": "Europe/Ulyanovsk",
+ "name": "(GMT+04:00) Samara, Ulyanovsk, Saratov",
+ "utc": "+04:00"
+ },
+ {
+ "label": "Indian/Mahe (GMT+04:00)",
+ "tzCode": "Indian/Mahe",
+ "name": "(GMT+04:00) Port Louis",
+ "utc": "+04:00"
+ },
+ {
+ "label": "Indian/Mauritius (GMT+04:00)",
+ "tzCode": "Indian/Mauritius",
+ "name": "(GMT+04:00) Port Louis",
+ "utc": "+04:00"
+ },
+ {
+ "label": "Indian/Reunion (GMT+04:00)",
+ "tzCode": "Indian/Reunion",
+ "name": "(GMT+04:00) Port Louis",
+ "utc": "+04:00"
+ },
+ {
+ "label": "Asia/Kabul (GMT+4.5:00)",
+ "tzCode": "Asia/Kabul",
+ "name": "(GMT+04:30) Kabul",
+ "utc": "+4.5:00"
+ },
+ {
+ "label": "Asia/Tehran (GMT+4.5:00)",
+ "tzCode": "Asia/Tehran",
+ "name": "(GMT+03:30) Tehran",
+ "utc": "+4.5:00"
+ },
+ {
+ "label": "Antarctica/Mawson (GMT+05:00)",
+ "tzCode": "Antarctica/Mawson",
+ "name": "(GMT+05:00) Ashgabat, Tashkent",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Aqtau (GMT+05:00)",
+ "tzCode": "Asia/Aqtau",
+ "name": "(GMT+05:00) Ashgabat, Tashkent",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Aqtobe (GMT+05:00)",
+ "tzCode": "Asia/Aqtobe",
+ "name": "(GMT+05:00) Ashgabat, Tashkent",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Ashgabat (GMT+05:00)",
+ "tzCode": "Asia/Ashgabat",
+ "name": "(GMT+05:00) Ashgabat, Tashkent",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Baku (GMT+05:00)",
+ "tzCode": "Asia/Baku",
+ "name": "(GMT+04:00) Baku",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Dushanbe (GMT+05:00)",
+ "tzCode": "Asia/Dushanbe",
+ "name": "(GMT+05:00) Ashgabat, Tashkent",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Karachi (GMT+05:00)",
+ "tzCode": "Asia/Karachi",
+ "name": "(GMT+05:00) Islamabad, Karachi",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Oral (GMT+05:00)",
+ "tzCode": "Asia/Oral",
+ "name": "(GMT+05:00) Ashgabat, Tashkent",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Samarkand (GMT+05:00)",
+ "tzCode": "Asia/Samarkand",
+ "name": "(GMT+05:00) Ashgabat, Tashkent",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Tashkent (GMT+05:00)",
+ "tzCode": "Asia/Tashkent",
+ "name": "(GMT+05:00) Ashgabat, Tashkent",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Yekaterinburg (GMT+05:00)",
+ "tzCode": "Asia/Yekaterinburg",
+ "name": "(GMT+05:00) Yekaterinburg",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Indian/Kerguelen (GMT+05:00)",
+ "tzCode": "Indian/Kerguelen",
+ "name": "(GMT+05:00) Ashgabat, Tashkent",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Indian/Maldives (GMT+05:00)",
+ "tzCode": "Indian/Maldives",
+ "name": "(GMT+05:00) Ashgabat, Tashkent",
+ "utc": "+05:00"
+ },
+ {
+ "label": "Asia/Calcutta (GMT+5.5:00)",
+ "tzCode": "Asia/Calcutta",
+ "name": "(GMT+05:30) Chennai, Kolkata, Mumbai, New Delhi",
+ "utc": "+5.5:00"
+ },
+ {
+ "label": "Asia/Colombo (GMT+5.5:00)",
+ "tzCode": "Asia/Colombo",
+ "name": "(GMT+05:30) Sri Jayawardenepura",
+ "utc": "+5.5:00"
+ },
+ {
+ "label": "Asia/Kolkata (GMT+5.5:00)",
+ "tzCode": "Asia/Kolkata",
+ "name": "(GMT+05:30) Chennai, Kolkata, Mumbai, New Delhi",
+ "utc": "+5.5:00"
+ },
+ {
+ "label": "Asia/Kathmandu (GMT+5.75:00)",
+ "tzCode": "Asia/Kathmandu",
+ "name": "(GMT+05:45) Kathmandu",
+ "utc": "+5.75:00"
+ },
+ {
+ "label": "Antarctica/Vostok (GMT+06:00)",
+ "tzCode": "Antarctica/Vostok",
+ "name": "(GMT+06:00) Nur-Sultan (Astana)",
+ "utc": "+06:00"
+ },
+ {
+ "label": "Asia/Almaty (GMT+06:00)",
+ "tzCode": "Asia/Almaty",
+ "name": "(GMT+06:00) Nur-Sultan (Astana)",
+ "utc": "+06:00"
+ },
+ {
+ "label": "Asia/Bishkek (GMT+06:00)",
+ "tzCode": "Asia/Bishkek",
+ "name": "(GMT+06:00) Nur-Sultan (Astana)",
+ "utc": "+06:00"
+ },
+ {
+ "label": "Asia/Dhaka (GMT+06:00)",
+ "tzCode": "Asia/Dhaka",
+ "name": "(GMT+06:00) Dhaka",
+ "utc": "+06:00"
+ },
+ {
+ "label": "Asia/Qyzylorda (GMT+06:00)",
+ "tzCode": "Asia/Qyzylorda",
+ "name": "(GMT+06:00) Nur-Sultan (Astana)",
+ "utc": "+06:00"
+ },
+ {
+ "label": "Asia/Thimphu (GMT+06:00)",
+ "tzCode": "Asia/Thimphu",
+ "name": "(GMT+06:00) Dhaka",
+ "utc": "+06:00"
+ },
+ {
+ "label": "Asia/Urumqi (GMT+06:00)",
+ "tzCode": "Asia/Urumqi",
+ "name": "(GMT+06:00) Nur-Sultan (Astana)",
+ "utc": "+06:00"
+ },
+ {
+ "label": "Indian/Chagos (GMT+06:00)",
+ "tzCode": "Indian/Chagos",
+ "name": "(GMT+06:00) Nur-Sultan (Astana)",
+ "utc": "+06:00"
+ },
+ {
+ "label": "Asia/Rangoon (GMT+6.5:00)",
+ "tzCode": "Asia/Rangoon",
+ "name": "(GMT+06:30) Yangon (Rangoon)",
+ "utc": "+6.5:00"
+ },
+ {
+ "label": "Indian/Cocos (GMT+6.5:00)",
+ "tzCode": "Indian/Cocos",
+ "name": "(GMT+06:30) Yangon (Rangoon)",
+ "utc": "+6.5:00"
+ },
+ {
+ "label": "Antarctica/Davis (GMT+07:00)",
+ "tzCode": "Antarctica/Davis",
+ "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Bangkok (GMT+07:00)",
+ "tzCode": "Asia/Bangkok",
+ "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Hovd (GMT+07:00)",
+ "tzCode": "Asia/Hovd",
+ "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Jakarta (GMT+07:00)",
+ "tzCode": "Asia/Jakarta",
+ "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Novokuznetsk (GMT+07:00)",
+ "tzCode": "Asia/Novokuznetsk",
+ "name": "(GMT+07:00) Novosibirsk",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Novosibirsk (GMT+07:00)",
+ "tzCode": "Asia/Novosibirsk",
+ "name": "(GMT+07:00) Novosibirsk",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Omsk (GMT+07:00)",
+ "tzCode": "Asia/Omsk",
+ "name": "(GMT+07:00) Novosibirsk",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Phnom_Penh (GMT+07:00)",
+ "tzCode": "Asia/Phnom_Penh",
+ "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Pontianak (GMT+07:00)",
+ "tzCode": "Asia/Pontianak",
+ "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Saigon (GMT+07:00)",
+ "tzCode": "Asia/Saigon",
+ "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Tomsk (GMT+07:00)",
+ "tzCode": "Asia/Tomsk",
+ "name": "(GMT+07:00) Novosibirsk",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Asia/Vientiane (GMT+07:00)",
+ "tzCode": "Asia/Vientiane",
+ "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Indian/Christmas (GMT+07:00)",
+ "tzCode": "Indian/Christmas",
+ "name": "(GMT+07:00) Bangkok, Hanoi, Jakarta",
+ "utc": "+07:00"
+ },
+ {
+ "label": "Antarctica/Casey (GMT+08:00)",
+ "tzCode": "Antarctica/Casey",
+ "name": "(GMT+08:00) Perth",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Brunei (GMT+08:00)",
+ "tzCode": "Asia/Brunei",
+ "name": "(GMT+08:00) Kuala Lumpur, Singapore",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Choibalsan (GMT+08:00)",
+ "tzCode": "Asia/Choibalsan",
+ "name": "(GMT+08:00) Ulaanbaatar",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Hong_Kong (GMT+08:00)",
+ "tzCode": "Asia/Hong_Kong",
+ "name": "(GMT+08:00) Beijing, Chongqing, Hong Kong, Urumqi",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Irkutsk (GMT+08:00)",
+ "tzCode": "Asia/Irkutsk",
+ "name": "(GMT+08:00) Irkutsk",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Krasnoyarsk (GMT+08:00)",
+ "tzCode": "Asia/Krasnoyarsk",
+ "name": "(GMT+08:00) Krasnoyarsk",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Kuala_Lumpur (GMT+08:00)",
+ "tzCode": "Asia/Kuala_Lumpur",
+ "name": "(GMT+08:00) Kuala Lumpur, Singapore",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Kuching (GMT+08:00)",
+ "tzCode": "Asia/Kuching",
+ "name": "(GMT+08:00) Kuala Lumpur, Singapore",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Macau (GMT+08:00)",
+ "tzCode": "Asia/Macau",
+ "name": "(GMT+08:00) Beijing, Chongqing, Hong Kong, Urumqi",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Makassar (GMT+08:00)",
+ "tzCode": "Asia/Makassar",
+ "name": "(GMT+08:00) Kuala Lumpur, Singapore",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Manila (GMT+08:00)",
+ "tzCode": "Asia/Manila",
+ "name": "(GMT+08:00) Kuala Lumpur, Singapore",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Shanghai (GMT+08:00)",
+ "tzCode": "Asia/Shanghai",
+ "name": "(GMT+08:00) Beijing, Chongqing, Hong Kong, Urumqi",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Singapore (GMT+08:00)",
+ "tzCode": "Asia/Singapore",
+ "name": "(GMT+08:00) Kuala Lumpur, Singapore",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Taipei (GMT+08:00)",
+ "tzCode": "Asia/Taipei",
+ "name": "(GMT+08:00) Taipei",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Ulaanbaatar (GMT+08:00)",
+ "tzCode": "Asia/Ulaanbaatar",
+ "name": "(GMT+08:00) Ulaanbaatar",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Australia/Perth (GMT+08:00)",
+ "tzCode": "Australia/Perth",
+ "name": "(GMT+08:00) Perth",
+ "utc": "+08:00"
+ },
+ {
+ "label": "Asia/Chita (GMT+09:00)",
+ "tzCode": "Asia/Chita",
+ "name": "(GMT+09:00) Yakutsk",
+ "utc": "+09:00"
+ },
+ {
+ "label": "Asia/Dili (GMT+09:00)",
+ "tzCode": "Asia/Dili",
+ "name": "(GMT+09:00) Osaka, Sapporo, Tokyo",
+ "utc": "+09:00"
+ },
+ {
+ "label": "Asia/Jayapura (GMT+09:00)",
+ "tzCode": "Asia/Jayapura",
+ "name": "(GMT+09:00) Osaka, Sapporo, Tokyo",
+ "utc": "+09:00"
+ },
+ {
+ "label": "Asia/Khandyga (GMT+09:00)",
+ "tzCode": "Asia/Khandyga",
+ "name": "(GMT+09:00) Yakutsk",
+ "utc": "+09:00"
+ },
+ {
+ "label": "Asia/Pyongyang (GMT+09:00)",
+ "tzCode": "Asia/Pyongyang",
+ "name": "(GMT+09:00) Seoul",
+ "utc": "+09:00"
+ },
+ {
+ "label": "Asia/Seoul (GMT+09:00)",
+ "tzCode": "Asia/Seoul",
+ "name": "(GMT+09:00) Seoul",
+ "utc": "+09:00"
+ },
+ {
+ "label": "Asia/Tokyo (GMT+09:00)",
+ "tzCode": "Asia/Tokyo",
+ "name": "(GMT+09:00) Osaka, Sapporo, Tokyo",
+ "utc": "+09:00"
+ },
+ {
+ "label": "Asia/Yakutsk (GMT+09:00)",
+ "tzCode": "Asia/Yakutsk",
+ "name": "(GMT+09:00) Yakutsk",
+ "utc": "+09:00"
+ },
+ {
+ "label": "Pacific/Palau (GMT+09:00)",
+ "tzCode": "Pacific/Palau",
+ "name": "(GMT+09:00) Osaka, Sapporo, Tokyo",
+ "utc": "+09:00"
+ },
+ {
+ "label": "Australia/Adelaide (GMT+9.5:00)",
+ "tzCode": "Australia/Adelaide",
+ "name": "(GMT+09:30) Adelaide",
+ "utc": "+9.5:00"
+ },
+ {
+ "label": "Australia/Broken_Hill (GMT+9.5:00)",
+ "tzCode": "Australia/Broken_Hill",
+ "name": "(GMT+09:30) Adelaide",
+ "utc": "+9.5:00"
+ },
+ {
+ "label": "Australia/Darwin (GMT+9.5:00)",
+ "tzCode": "Australia/Darwin",
+ "name": "(GMT+09:30) Darwin",
+ "utc": "+9.5:00"
+ },
+ {
+ "label": "Antarctica/DumontDUrville (GMT+10:00)",
+ "tzCode": "Antarctica/DumontDUrville",
+ "name": "(GMT+10:00) Guam, Port Moresby",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Asia/Ust-Nera (GMT+10:00)",
+ "tzCode": "Asia/Ust-Nera",
+ "name": "(GMT+10:00) Vladivostok",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Asia/Vladivostok (GMT+10:00)",
+ "tzCode": "Asia/Vladivostok",
+ "name": "(GMT+10:00) Vladivostok",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Australia/Brisbane (GMT+10:00)",
+ "tzCode": "Australia/Brisbane",
+ "name": "(GMT+10:00) Brisbane",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Australia/Currie (GMT+10:00)",
+ "tzCode": "Australia/Currie",
+ "name": "(GMT+10:00) Hobart",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Australia/Hobart (GMT+10:00)",
+ "tzCode": "Australia/Hobart",
+ "name": "(GMT+10:00) Hobart",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Australia/Lindeman (GMT+10:00)",
+ "tzCode": "Australia/Lindeman",
+ "name": "(GMT+10:00) Brisbane",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Australia/Melbourne (GMT+10:00)",
+ "tzCode": "Australia/Melbourne",
+ "name": "(GMT+10:00) Canberra, Melbourne, Sydney",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Australia/Sydney (GMT+10:00)",
+ "tzCode": "Australia/Sydney",
+ "name": "(GMT+10:00) Canberra, Melbourne, Sydney",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Pacific/Guam (GMT+10:00)",
+ "tzCode": "Pacific/Guam",
+ "name": "(GMT+10:00) Guam, Port Moresby",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Pacific/Port_Moresby (GMT+10:00)",
+ "tzCode": "Pacific/Port_Moresby",
+ "name": "(GMT+10:00) Guam, Port Moresby",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Pacific/Saipan (GMT+10:00)",
+ "tzCode": "Pacific/Saipan",
+ "name": "(GMT+10:00) Guam, Port Moresby",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Pacific/Truk (GMT+10:00)",
+ "tzCode": "Pacific/Truk",
+ "name": "(GMT+10:00) Guam, Port Moresby",
+ "utc": "+10:00"
+ },
+ {
+ "label": "Antarctica/Macquarie (GMT+11:00)",
+ "tzCode": "Antarctica/Macquarie",
+ "name": "(GMT+11:00) Solomon Is., New Caledonia",
+ "utc": "+11:00"
+ },
+ {
+ "label": "Asia/Sakhalin (GMT+11:00)",
+ "tzCode": "Asia/Sakhalin",
+ "name": "(GMT+11:00) Sakhalin",
+ "utc": "+11:00"
+ },
+ {
+ "label": "Pacific/Efate (GMT+11:00)",
+ "tzCode": "Pacific/Efate",
+ "name": "(GMT+11:00) Solomon Is., New Caledonia",
+ "utc": "+11:00"
+ },
+ {
+ "label": "Pacific/Guadalcanal (GMT+11:00)",
+ "tzCode": "Pacific/Guadalcanal",
+ "name": "(GMT+11:00) Solomon Is., New Caledonia",
+ "utc": "+11:00"
+ },
+ {
+ "label": "Pacific/Kosrae (GMT+11:00)",
+ "tzCode": "Pacific/Kosrae",
+ "name": "(GMT+11:00) Solomon Is., New Caledonia",
+ "utc": "+11:00"
+ },
+ {
+ "label": "Pacific/Noumea (GMT+11:00)",
+ "tzCode": "Pacific/Noumea",
+ "name": "(GMT+11:00) Solomon Is., New Caledonia",
+ "utc": "+11:00"
+ },
+ {
+ "label": "Pacific/Ponape (GMT+11:00)",
+ "tzCode": "Pacific/Ponape",
+ "name": "(GMT+11:00) Solomon Is., New Caledonia",
+ "utc": "+11:00"
+ },
+ {
+ "label": "Antarctica/McMurdo (GMT+12:00)",
+ "tzCode": "Antarctica/McMurdo",
+ "name": "(GMT+12:00) Auckland, Wellington",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Asia/Anadyr (GMT+12:00)",
+ "tzCode": "Asia/Anadyr",
+ "name": "(GMT+12:00) Magadan",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Asia/Kamchatka (GMT+12:00)",
+ "tzCode": "Asia/Kamchatka",
+ "name": "(GMT+12:00) Magadan",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Asia/Magadan (GMT+12:00)",
+ "tzCode": "Asia/Magadan",
+ "name": "(GMT+12:00) Magadan",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Asia/Srednekolymsk (GMT+12:00)",
+ "tzCode": "Asia/Srednekolymsk",
+ "name": "(GMT+12:00) Magadan",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Pacific/Auckland (GMT+12:00)",
+ "tzCode": "Pacific/Auckland",
+ "name": "(GMT+12:00) Auckland, Wellington",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Pacific/Fiji (GMT+12:00)",
+ "tzCode": "Pacific/Fiji",
+ "name": "(GMT+12:00) Fiji",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Pacific/Funafuti (GMT+12:00)",
+ "tzCode": "Pacific/Funafuti",
+ "name": "(GMT+12:00) Coordinated Universal Time+12",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Pacific/Kwajalein (GMT+12:00)",
+ "tzCode": "Pacific/Kwajalein",
+ "name": "(GMT+12:00) Coordinated Universal Time+12",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Pacific/Majuro (GMT+12:00)",
+ "tzCode": "Pacific/Majuro",
+ "name": "(GMT+12:00) Coordinated Universal Time+12",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Pacific/Nauru (GMT+12:00)",
+ "tzCode": "Pacific/Nauru",
+ "name": "(GMT+12:00) Coordinated Universal Time+12",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Pacific/Tarawa (GMT+12:00)",
+ "tzCode": "Pacific/Tarawa",
+ "name": "(GMT+12:00) Coordinated Universal Time+12",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Pacific/Wake (GMT+12:00)",
+ "tzCode": "Pacific/Wake",
+ "name": "(GMT+12:00) Coordinated Universal Time+12",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Pacific/Wallis (GMT+12:00)",
+ "tzCode": "Pacific/Wallis",
+ "name": "(GMT+12:00) Coordinated Universal Time+12",
+ "utc": "+12:00"
+ },
+ {
+ "label": "Pacific/Apia (GMT+13:00)",
+ "tzCode": "Pacific/Apia",
+ "name": "(GMT+13:00) Samoa",
+ "utc": "+13:00"
+ },
+ {
+ "label": "Pacific/Enderbury (GMT+13:00)",
+ "tzCode": "Pacific/Enderbury",
+ "name": "(GMT+13:00) Nuku'alofa",
+ "utc": "+13:00"
+ },
+ {
+ "label": "Pacific/Fakaofo (GMT+13:00)",
+ "tzCode": "Pacific/Fakaofo",
+ "name": "(GMT+13:00) Nuku'alofa",
+ "utc": "+13:00"
+ },
+ {
+ "label": "Pacific/Tongatapu (GMT+13:00)",
+ "tzCode": "Pacific/Tongatapu",
+ "name": "(GMT+13:00) Nuku'alofa",
+ "utc": "+13:00"
+ },
+ {
+ "label": "UTC (GMT+00:00)",
+ "tzCode": "Etc/GMT",
+ "name": "(GMT+00:00) Coordinated Universal Time",
+ "utc": "+00:00"
+ }
+]
diff --git a/app/components/status-pulse-dot/status-pulse-dot.tsx b/app/components/status-pulse-dot/status-pulse-dot.tsx
index 2ee71f9e5..1ae0774e2 100644
--- a/app/components/status-pulse-dot/status-pulse-dot.tsx
+++ b/app/components/status-pulse-dot/status-pulse-dot.tsx
@@ -1,32 +1,32 @@
-import { cn } from '@shadcn/lib/utils';
-
-type StatusPulseDotVariant = 'active' | 'offline';
-
-const variantStyles: Record = {
- active: {
- dot: 'bg-green-500',
- ring: 'shadow-[0_0_0_3px_rgba(34,197,94,0.4)]',
- },
- offline: {
- dot: 'bg-red-500',
- ring: 'shadow-[0_0_0_3px_rgba(239,68,68,0.4)]',
- },
-};
-
-export interface StatusPulseDotProps {
- variant: StatusPulseDotVariant;
- className?: string;
-}
-
-export function StatusPulseDot({ variant, className }: StatusPulseDotProps) {
- const { dot, ring } = variantStyles[variant];
- return (
-
-
-
-
- );
-}
+import { cn } from '@shadcn/lib/utils';
+
+type StatusPulseDotVariant = 'active' | 'offline';
+
+const variantStyles: Record = {
+ active: {
+ dot: 'bg-green-500',
+ ring: 'shadow-[0_0_0_3px_rgba(34,197,94,0.4)]',
+ },
+ offline: {
+ dot: 'bg-red-500',
+ ring: 'shadow-[0_0_0_3px_rgba(239,68,68,0.4)]',
+ },
+};
+
+export interface StatusPulseDotProps {
+ variant: StatusPulseDotVariant;
+ className?: string;
+}
+
+export function StatusPulseDot({ variant, className }: StatusPulseDotProps) {
+ const { dot, ring } = variantStyles[variant];
+ return (
+
+
+
+
+ );
+}
diff --git a/app/components/sub-navigation/sub-navigation-tabs.tsx b/app/components/sub-navigation/sub-navigation-tabs.tsx
index 33be211ca..84e3a5844 100644
--- a/app/components/sub-navigation/sub-navigation-tabs.tsx
+++ b/app/components/sub-navigation/sub-navigation-tabs.tsx
@@ -1,72 +1,72 @@
-import { Tabs, TabsLinkTrigger, TabsList } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-import type { LucideIcon } from 'lucide-react';
-import { useMemo } from 'react';
-import { Link, useLocation } from 'react-router';
-
-export interface SubNavigationTab {
- label: string;
- href: string;
- icon?: LucideIcon;
- hidden?: boolean;
-}
-
-interface SubNavigationTabsProps {
- tabs: SubNavigationTab[];
- className?: string;
- containerClassName?: string;
-}
-
-/**
- * Horizontal scrollable tab bar for sub-navigation.
- * Used on mobile/tablet as SubLayout replacement, and on all breakpoints for settings pages.
- *
- * Active tab: prefix match on pathname with longest-match-wins.
- * Hidden when no visible tabs exist.
- */
-export function SubNavigationTabs({ tabs, className, containerClassName }: SubNavigationTabsProps) {
- const { pathname } = useLocation();
-
- const visibleTabs = useMemo(() => tabs.filter((t) => !t.hidden), [tabs]);
-
- // Find active tab href via longest prefix match — return a stable string, not an object
- const activeHref = useMemo(() => {
- let bestHref = '';
- for (const tab of visibleTabs) {
- if (pathname.startsWith(tab.href) && tab.href.length > bestHref.length) {
- bestHref = tab.href;
- }
- }
- return bestHref;
- }, [pathname, visibleTabs]);
-
- if (visibleTabs.length === 0) return null;
-
- return (
-
-
-
-
- {visibleTabs.map((tab) => (
-
- {tab.icon && }
- {tab.label}
-
- ))}
-
-
-
-
- );
-}
+import { Tabs, TabsLinkTrigger, TabsList } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+import type { LucideIcon } from 'lucide-react';
+import { useMemo } from 'react';
+import { Link, useLocation } from 'react-router';
+
+export interface SubNavigationTab {
+ label: string;
+ href: string;
+ icon?: LucideIcon;
+ hidden?: boolean;
+}
+
+interface SubNavigationTabsProps {
+ tabs: SubNavigationTab[];
+ className?: string;
+ containerClassName?: string;
+}
+
+/**
+ * Horizontal scrollable tab bar for sub-navigation.
+ * Used on mobile/tablet as SubLayout replacement, and on all breakpoints for settings pages.
+ *
+ * Active tab: prefix match on pathname with longest-match-wins.
+ * Hidden when no visible tabs exist.
+ */
+export function SubNavigationTabs({ tabs, className, containerClassName }: SubNavigationTabsProps) {
+ const { pathname } = useLocation();
+
+ const visibleTabs = useMemo(() => tabs.filter((t) => !t.hidden), [tabs]);
+
+ // Find active tab href via longest prefix match — return a stable string, not an object
+ const activeHref = useMemo(() => {
+ let bestHref = '';
+ for (const tab of visibleTabs) {
+ if (pathname.startsWith(tab.href) && tab.href.length > bestHref.length) {
+ bestHref = tab.href;
+ }
+ }
+ return bestHref;
+ }, [pathname, visibleTabs]);
+
+ if (visibleTabs.length === 0) return null;
+
+ return (
+
+
+
+
+ {visibleTabs.map((tab) => (
+
+ {tab.icon && }
+ {tab.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/app/components/text-copy/text-copy-box.tsx b/app/components/text-copy/text-copy-box.tsx
index 0bf7d705f..bc9bc3421 100644
--- a/app/components/text-copy/text-copy-box.tsx
+++ b/app/components/text-copy/text-copy-box.tsx
@@ -1,76 +1,76 @@
-import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
-import { Button, toast } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-import { CheckIcon, CopyIcon } from 'lucide-react';
-import { useState } from 'react';
-
-interface TextCopyBoxProps {
- value: string;
- className?: string;
- contentClassName?: string;
- buttonClassName?: string;
- /** Button variant: 'default' shows text with border, 'icon-only' shows just the icon */
- buttonVariant?: 'default' | 'icon-only';
-}
-
-export const TextCopyBox = ({
- value,
- className,
- contentClassName,
- buttonClassName,
- buttonVariant = 'default',
-}: TextCopyBoxProps) => {
- const [_, copy] = useCopyToClipboard();
- const [copied, setCopied] = useState(false);
-
- const copyToClipboard = () => {
- if (!value) return;
-
- copy(value).then(() => {
- toast.success('Copied to clipboard');
- setCopied(true);
- setTimeout(() => {
- setCopied(false);
- }, 2000);
- });
- };
-
- return (
-
-
- {value}
-
-
- {buttonVariant === 'icon-only' ? (
-
- {copied ? : }
-
- ) : (
-
-
- {copied ? 'Copied' : 'Copy'}
-
- )}
-
-
- );
-};
+import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
+import { Button, toast } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+import { CheckIcon, CopyIcon } from 'lucide-react';
+import { useState } from 'react';
+
+interface TextCopyBoxProps {
+ value: string;
+ className?: string;
+ contentClassName?: string;
+ buttonClassName?: string;
+ /** Button variant: 'default' shows text with border, 'icon-only' shows just the icon */
+ buttonVariant?: 'default' | 'icon-only';
+}
+
+export const TextCopyBox = ({
+ value,
+ className,
+ contentClassName,
+ buttonClassName,
+ buttonVariant = 'default',
+}: TextCopyBoxProps) => {
+ const [_, copy] = useCopyToClipboard();
+ const [copied, setCopied] = useState(false);
+
+ const copyToClipboard = () => {
+ if (!value) return;
+
+ copy(value).then(() => {
+ toast.success('Copied to clipboard');
+ setCopied(true);
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ });
+ };
+
+ return (
+
+
+ {value}
+
+
+ {buttonVariant === 'icon-only' ? (
+
+ {copied ? : }
+
+ ) : (
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+ )}
+
+
+ );
+};
diff --git a/app/components/text-copy/text-copy.tsx b/app/components/text-copy/text-copy.tsx
index 4f9bcb23f..f6c78e3b0 100644
--- a/app/components/text-copy/text-copy.tsx
+++ b/app/components/text-copy/text-copy.tsx
@@ -1,53 +1,53 @@
-import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
-import { Button, toast } from '@datum-ui/components';
-import { Tooltip } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-import { CopyIcon } from 'lucide-react';
-import { useState } from 'react';
-
-export const TextCopy = ({
- value,
- text,
- className,
- buttonClassName,
-}: {
- value: string;
- text?: string;
- className?: string;
- buttonClassName?: string;
-}) => {
- const [_, copy] = useCopyToClipboard();
- const [copied, setCopied] = useState(false);
-
- const copyToClipboard = () => {
- if (!value) return;
-
- copy(value).then(() => {
- toast.success('Copied to clipboard');
- setCopied(true);
- setTimeout(() => {
- setCopied(false);
- }, 2000);
- });
- };
-
- return (
-
- {text ?? value}
-
- {
- event.preventDefault();
- event.stopPropagation();
- copyToClipboard();
- }}>
-
-
-
-
- );
-};
+import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
+import { Button, toast } from '@datum-ui/components';
+import { Tooltip } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+import { CopyIcon } from 'lucide-react';
+import { useState } from 'react';
+
+export const TextCopy = ({
+ value,
+ text,
+ className,
+ buttonClassName,
+}: {
+ value: string;
+ text?: string;
+ className?: string;
+ buttonClassName?: string;
+}) => {
+ const [_, copy] = useCopyToClipboard();
+ const [copied, setCopied] = useState(false);
+
+ const copyToClipboard = () => {
+ if (!value) return;
+
+ copy(value).then(() => {
+ toast.success('Copied to clipboard');
+ setCopied(true);
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ });
+ };
+
+ return (
+
+ {text ?? value}
+
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ copyToClipboard();
+ }}>
+
+
+
+
+ );
+};
diff --git a/app/components/waiting-page/waiting-page.tsx b/app/components/waiting-page/waiting-page.tsx
index bd2756434..8e736b565 100644
--- a/app/components/waiting-page/waiting-page.tsx
+++ b/app/components/waiting-page/waiting-page.tsx
@@ -1,29 +1,29 @@
-import { LogoIcon } from '@/components/logo/logo-icon';
-import { Card, CardContent, CardFooter, SpinnerIcon } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-
-export const WaitingPage = ({ title, className }: { title: string; className?: string }) => {
- return (
-
-
-
-
-
-
-
-
- );
-};
+import { LogoIcon } from '@/components/logo/logo-icon';
+import { Card, CardContent, CardFooter, SpinnerIcon } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+
+export const WaitingPage = ({ title, className }: { title: string; className?: string }) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index f5e279167..63b70acfd 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -1,104 +1,104 @@
-// Configure @hey-api clients before any React code runs
-import '@/modules/control-plane/setup.client';
-import { isKnownSystemEvent } from '@/modules/sentry/filters';
-import { env } from '@/utils/env';
-import * as Sentry from '@sentry/react-router';
-import { StrictMode, startTransition } from 'react';
-import { hydrateRoot } from 'react-dom/client';
-import { HydratedRouter } from 'react-router/dom';
-
-Sentry.init({
- dsn: env.public.sentryDsn ?? '',
-
- // Environment configuration
- environment: env.public.sentryEnv ?? 'development',
-
- // Adds request headers and IP for users, for more info visit:
- // https://docs.sentry.io/platforms/javascript/guides/react-router/configuration/options/#sendDefaultPii
- sendDefaultPii: true,
-
- integrations: [
- // Performance
- Sentry.reactRouterTracingIntegration(),
- // Session replay with sensitive data masking
- Sentry.replayIntegration({
- maskAllText: false,
- maskAllInputs: false,
- blockAllMedia: false,
-
- // Mask sensitive fields
- mask: [
- '[data-sentry-mask]',
- 'input[type="password"]',
- '[name*="secret"]',
- '[name*="token"]',
- '[name*="key"]',
- '[name*="credential"]',
- ],
- // Block entire sections from replay
- block: ['[data-sentry-block]'],
- }),
- // User feedback - disabled to remove "Report a Bug" button
- // Sentry.feedbackIntegration({
- // // Additional SDK configuration goes in here, for example:
- // colorScheme: 'system',
- // }),
- ],
-
- // Enable logs to be sent to Sentry
- enableLogs: true,
-
- tracesSampleRate: env.isProd ? 0.1 : 1.0, // Capture transactions
-
- // Set `tracePropagationTargets` to declare which URL(s) should have trace propagation enabled
- tracePropagationTargets: [/^\//, new RegExp(window.location.origin)],
-
- // Capture Replay for 50% of all sessions for proactive UX monitoring,
- // plus 100% of sessions with an error
- replaysSessionSampleRate: 0.5,
- replaysOnErrorSampleRate: 1.0,
-
- // Release name
- release: env.public.version || 'dev',
-
- beforeSend: (event) => {
- if (isKnownSystemEvent(event)) return null;
- return event;
- },
-});
-
-// Global handler for chunk load failures (stale deployments).
-// When a lazy import fails because the chunk hash changed after a deployment,
-// reload the page once to get fresh entry points. Uses sessionStorage to
-// prevent infinite reload loops.
-window.addEventListener('error', (event) => {
- const msg = event.message ?? '';
- if (
- msg.includes('Importing a module script failed') ||
- msg.includes('Failed to fetch dynamically imported module')
- ) {
- const key = 'chunk-reload-attempted';
- if (!sessionStorage.getItem(key)) {
- sessionStorage.setItem(key, '1');
- window.location.reload();
- }
- }
-});
-
-// Clear the chunk reload flag on successful page load
-window.addEventListener('load', () => {
- sessionStorage.removeItem('chunk-reload-attempted');
-});
-
-async function main() {
- startTransition(() => {
- hydrateRoot(
- document,
-
-
-
- );
- });
-}
-
-main().catch((error) => console.error(error));
+// Configure @hey-api clients before any React code runs
+import '@/modules/control-plane/setup.client';
+import { isKnownSystemEvent } from '@/modules/sentry/filters';
+import { env } from '@/utils/env';
+import * as Sentry from '@sentry/react-router';
+import { StrictMode, startTransition } from 'react';
+import { hydrateRoot } from 'react-dom/client';
+import { HydratedRouter } from 'react-router/dom';
+
+Sentry.init({
+ dsn: env.public.sentryDsn ?? '',
+
+ // Environment configuration
+ environment: env.public.sentryEnv ?? 'development',
+
+ // Adds request headers and IP for users, for more info visit:
+ // https://docs.sentry.io/platforms/javascript/guides/react-router/configuration/options/#sendDefaultPii
+ sendDefaultPii: true,
+
+ integrations: [
+ // Performance
+ Sentry.reactRouterTracingIntegration(),
+ // Session replay with sensitive data masking
+ Sentry.replayIntegration({
+ maskAllText: false,
+ maskAllInputs: false,
+ blockAllMedia: false,
+
+ // Mask sensitive fields
+ mask: [
+ '[data-sentry-mask]',
+ 'input[type="password"]',
+ '[name*="secret"]',
+ '[name*="token"]',
+ '[name*="key"]',
+ '[name*="credential"]',
+ ],
+ // Block entire sections from replay
+ block: ['[data-sentry-block]'],
+ }),
+ // User feedback - disabled to remove "Report a Bug" button
+ // Sentry.feedbackIntegration({
+ // // Additional SDK configuration goes in here, for example:
+ // colorScheme: 'system',
+ // }),
+ ],
+
+ // Enable logs to be sent to Sentry
+ enableLogs: true,
+
+ tracesSampleRate: env.isProd ? 0.1 : 1.0, // Capture transactions
+
+ // Set `tracePropagationTargets` to declare which URL(s) should have trace propagation enabled
+ tracePropagationTargets: [/^\//, new RegExp(window.location.origin)],
+
+ // Capture Replay for 50% of all sessions for proactive UX monitoring,
+ // plus 100% of sessions with an error
+ replaysSessionSampleRate: 0.5,
+ replaysOnErrorSampleRate: 1.0,
+
+ // Release name
+ release: env.public.version || 'dev',
+
+ beforeSend: (event) => {
+ if (isKnownSystemEvent(event)) return null;
+ return event;
+ },
+});
+
+// Global handler for chunk load failures (stale deployments).
+// When a lazy import fails because the chunk hash changed after a deployment,
+// reload the page once to get fresh entry points. Uses sessionStorage to
+// prevent infinite reload loops.
+window.addEventListener('error', (event) => {
+ const msg = event.message ?? '';
+ if (
+ msg.includes('Importing a module script failed') ||
+ msg.includes('Failed to fetch dynamically imported module')
+ ) {
+ const key = 'chunk-reload-attempted';
+ if (!sessionStorage.getItem(key)) {
+ sessionStorage.setItem(key, '1');
+ window.location.reload();
+ }
+ }
+});
+
+// Clear the chunk reload flag on successful page load
+window.addEventListener('load', () => {
+ sessionStorage.removeItem('chunk-reload-attempted');
+});
+
+async function main() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+main().catch((error) => console.error(error));
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
index 86b8f821a..bd13edc54 100644
--- a/app/entry.server.tsx
+++ b/app/entry.server.tsx
@@ -1,74 +1,74 @@
-import { NonceProvider } from '@/hooks/useNonce';
-import { createReadableStreamFromReadable } from '@react-router/node';
-import * as Sentry from '@sentry/react-router';
-import { isbot } from 'isbot';
-import { PassThrough } from 'node:stream';
-import { renderToPipeableStream } from 'react-dom/server';
-import type { AppLoadContext, EntryContext } from 'react-router';
-import { ServerRouter } from 'react-router';
-
-const ABORT_DELAY = 5_000;
-
-async function handleRequest(
- request: Request,
- responseStatusCode: number,
- responseHeaders: Headers,
- reactRouterContext: EntryContext,
- loadContext: AppLoadContext
-) {
- const callbackName = isbot(request.headers.get('user-agent')) ? 'onAllReady' : 'onShellReady';
-
- /**
- * Content Security Policy.
- * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
- */
- const nonce = String(loadContext.cspNonce) ?? undefined;
-
- return new Promise((resolve, reject) => {
- let shellRendered = false;
- const { pipe, abort } = renderToPipeableStream(
-
-
- ,
- {
- [callbackName]: () => {
- shellRendered = true;
- const body = new PassThrough();
- const stream = createReadableStreamFromReadable(body);
-
- responseHeaders.set('Content-Type', 'text/html');
-
- resolve(
- new Response(stream, {
- headers: responseHeaders,
- status: responseStatusCode,
- })
- );
-
- // This enables distributed tracing between client and server
- pipe(Sentry.getMetaTagTransformer(body));
- },
- onShellError(error: unknown) {
- reject(error);
- },
- onError(error: unknown) {
- responseStatusCode = 500;
- if (shellRendered) {
- console.error(error);
- }
- },
- nonce,
- }
- );
-
- setTimeout(abort, ABORT_DELAY);
- });
-}
-
-// Wrap the handleRequest function with Sentry
-export default Sentry.wrapSentryHandleRequest(handleRequest);
-
-// Export handleError for Sentry error capture
-export const handleError = Sentry.createSentryHandleError({
- logErrors: false,
-});
+import { NonceProvider } from '@/hooks/useNonce';
+import { createReadableStreamFromReadable } from '@react-router/node';
+import * as Sentry from '@sentry/react-router';
+import { isbot } from 'isbot';
+import { PassThrough } from 'node:stream';
+import { renderToPipeableStream } from 'react-dom/server';
+import type { AppLoadContext, EntryContext } from 'react-router';
+import { ServerRouter } from 'react-router';
+
+const ABORT_DELAY = 5_000;
+
+async function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ reactRouterContext: EntryContext,
+ loadContext: AppLoadContext
+) {
+ const callbackName = isbot(request.headers.get('user-agent')) ? 'onAllReady' : 'onShellReady';
+
+ /**
+ * Content Security Policy.
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
+ */
+ const nonce = String(loadContext.cspNonce) ?? undefined;
+
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ const { pipe, abort } = renderToPipeableStream(
+
+
+ ,
+ {
+ [callbackName]: () => {
+ shellRendered = true;
+ const body = new PassThrough();
+ const stream = createReadableStreamFromReadable(body);
+
+ responseHeaders.set('Content-Type', 'text/html');
+
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ // This enables distributed tracing between client and server
+ pipe(Sentry.getMetaTagTransformer(body));
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ if (shellRendered) {
+ console.error(error);
+ }
+ },
+ nonce,
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+// Wrap the handleRequest function with Sentry
+export default Sentry.wrapSentryHandleRequest(handleRequest);
+
+// Export handleError for Sentry error capture
+export const handleError = Sentry.createSentryHandleError({
+ logErrors: false,
+});
diff --git a/app/features/account/cards/2fa-card.tsx b/app/features/account/cards/2fa-card.tsx
index ce8ffa44f..4cf2520c4 100644
--- a/app/features/account/cards/2fa-card.tsx
+++ b/app/features/account/cards/2fa-card.tsx
@@ -1,73 +1,73 @@
-import { IdentityItem } from '@/features/account/identity-item';
-import {
- Button,
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { KeyRoundIcon, LockOpenIcon } from 'lucide-react';
-
-const SIGN_IN_METHODS = [
- {
- icon: ,
- label: 'Authenticator App (TOTP)',
- sublabel: 'Generate codes using an app like Google Authenticator or Okta Verify.',
- rightContent: (
-
- Add
-
- ),
- },
- {
- icon: ,
- label: 'Passkeys',
- sublabel:
- 'You can use the same passkeys you use for login as a second factor of authentication.',
- rightContent: (
-
- Added
-
- ),
- },
- {
- icon: ,
- label: 'Recovery Codes',
- sublabel: 'Security codes when you cannot access any of your other two-factor methods.',
- rightContent: (
-
- Regenerate
-
- ),
- },
-];
-
-export const Account2FACard = () => {
- return (
-
-
- Two-factor Authentication
-
- Add an additional layer of security by requiring at least two methods of authentication to
- sign in.
-
-
-
-
- {SIGN_IN_METHODS.map((method) => (
-
-
-
- ))}
-
-
-
- );
-};
+import { IdentityItem } from '@/features/account/identity-item';
+import {
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { KeyRoundIcon, LockOpenIcon } from 'lucide-react';
+
+const SIGN_IN_METHODS = [
+ {
+ icon: ,
+ label: 'Authenticator App (TOTP)',
+ sublabel: 'Generate codes using an app like Google Authenticator or Okta Verify.',
+ rightContent: (
+
+ Add
+
+ ),
+ },
+ {
+ icon: ,
+ label: 'Passkeys',
+ sublabel:
+ 'You can use the same passkeys you use for login as a second factor of authentication.',
+ rightContent: (
+
+ Added
+
+ ),
+ },
+ {
+ icon: ,
+ label: 'Recovery Codes',
+ sublabel: 'Security codes when you cannot access any of your other two-factor methods.',
+ rightContent: (
+
+ Regenerate
+
+ ),
+ },
+];
+
+export const Account2FACard = () => {
+ return (
+
+
+ Two-factor Authentication
+
+ Add an additional layer of security by requiring at least two methods of authentication to
+ sign in.
+
+
+
+
+ {SIGN_IN_METHODS.map((method) => (
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/app/features/account/cards/danger-card.tsx b/app/features/account/cards/danger-card.tsx
index 5c02c7c0d..d635f2cd2 100644
--- a/app/features/account/cards/danger-card.tsx
+++ b/app/features/account/cards/danger-card.tsx
@@ -1,52 +1,52 @@
-import { useConfirmationDialog } from '@/components/confirmation-dialog/confirmation-dialog.provider';
-import { DangerCard } from '@/components/danger-card/danger-card';
-import { useApp } from '@/providers/app.provider';
-import { useDeleteUser } from '@/resources/users';
-import { paths } from '@/utils/config/paths.config';
-import { toast } from '@datum-ui/components';
-import { useNavigate } from 'react-router';
-
-export const AccountDangerSettingsCard = () => {
- const { user } = useApp();
- const { confirm } = useConfirmationDialog();
- const userId = user?.sub ?? 'me';
- const navigate = useNavigate();
-
- const deleteUserMutation = useDeleteUser({
- onSuccess: () => {
- toast.success('Account', {
- description: 'Your account has been deleted successfully',
- });
- navigate(paths.auth.logOut, { replace: true });
- },
- onError: (error) => {
- toast.error(error.message);
- },
- });
-
- const deleteAccount = async () => {
- await confirm({
- title: 'Delete Account',
- description: Are you sure you want to delete your account? ,
- submitText: 'Delete',
- cancelText: 'Cancel',
- variant: 'destructive',
- showConfirmInput: true,
- confirmValue: user?.email,
- confirmInputLabel: `Type "${user?.email}" to confirm.`,
- onSubmit: async () => {
- deleteUserMutation.mutate(userId);
- },
- });
- };
-
- return (
-
- );
-};
+import { useConfirmationDialog } from '@/components/confirmation-dialog/confirmation-dialog.provider';
+import { DangerCard } from '@/components/danger-card/danger-card';
+import { useApp } from '@/providers/app.provider';
+import { useDeleteUser } from '@/resources/users';
+import { paths } from '@/utils/config/paths.config';
+import { toast } from '@datum-ui/components';
+import { useNavigate } from 'react-router';
+
+export const AccountDangerSettingsCard = () => {
+ const { user } = useApp();
+ const { confirm } = useConfirmationDialog();
+ const userId = user?.sub ?? 'me';
+ const navigate = useNavigate();
+
+ const deleteUserMutation = useDeleteUser({
+ onSuccess: () => {
+ toast.success('Account', {
+ description: 'Your account has been deleted successfully',
+ });
+ navigate(paths.auth.logOut, { replace: true });
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ });
+
+ const deleteAccount = async () => {
+ await confirm({
+ title: 'Delete Account',
+ description: Are you sure you want to delete your account? ,
+ submitText: 'Delete',
+ cancelText: 'Cancel',
+ variant: 'destructive',
+ showConfirmInput: true,
+ confirmValue: user?.email,
+ confirmInputLabel: `Type "${user?.email}" to confirm.`,
+ onSubmit: async () => {
+ deleteUserMutation.mutate(userId);
+ },
+ });
+ };
+
+ return (
+
+ );
+};
diff --git a/app/features/account/cards/identity-card.tsx b/app/features/account/cards/identity-card.tsx
index ad2b7b70c..45885f88b 100644
--- a/app/features/account/cards/identity-card.tsx
+++ b/app/features/account/cards/identity-card.tsx
@@ -1,118 +1,118 @@
-import { IdentityItem } from '../identity-item';
-import { IdentityItemSkeleton } from '../identity-item-skeleton';
-import GitHubIcon from '@/components/icon/github';
-import GoogleIcon from '@/components/icon/google';
-import { useApp } from '@/providers/app.provider';
-import { useUserIdentities } from '@/resources/users';
-import { env } from '@/utils/env';
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
- LinkButton,
- Tooltip,
-} from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { CircleAlertIcon, ExternalLinkIcon, MailIcon } from 'lucide-react';
-import { ComponentType, SVGProps } from 'react';
-
-const PROVIDERS: Record> }> = {
- email: { label: 'Email', Icon: MailIcon },
- google: { label: 'Google', Icon: GoogleIcon },
- github: { label: 'GitHub', Icon: GitHubIcon },
-};
-
-export const AccountIdentitySettingsCard = () => {
- const { user } = useApp();
- const { data: identities, isLoading: isLoadingIdentities } = useUserIdentities(user?.sub ?? 'me');
-
- return (
-
-
- Account Identities
-
-
- {isLoadingIdentities ? (
-
- ) : (
-
- {identities?.map((identity) => {
- const provider = identity.providerName?.toLowerCase() ?? 'email';
- const providerMeta = PROVIDERS?.[provider];
- return (
-
-
- ) : (
-
- )
- }
- label={providerMeta?.label ?? 'Email address'}
- sublabel={identity.username}
- middleContent={
- // TODO: Enable this when we have a way to get the last used date
- //
Last used Jun 4
- undefined
- }
- rightContent={
- <>
- {provider === 'github' && (
-
-
- Updating email addresses for GitHub identities
-
-
- Email addresses for GitHub identities should be updated through
- GitHub
-
-
- Log out of Datum
- Change your Primary Email in GitHub (your primary email)
- Log out of GitHub
-
- Log back into GitHub (with the new, desired email set as
- primary)
-
- Log back into Datum
-
-
- }
- contentClassName="bg-card rounded-xl shadow-tooltip text-foreground max-w-[380px] border p-0"
- arrowClassName="fill-card">
-
-
-
- How to update your GitHub email
-
-
-
- )}
-
}
- iconPosition="right">
- Manage
-
- >
- }
- />
-
- );
- })}
-
- )}
-
-
- );
-};
+import { IdentityItem } from '../identity-item';
+import { IdentityItemSkeleton } from '../identity-item-skeleton';
+import GitHubIcon from '@/components/icon/github';
+import GoogleIcon from '@/components/icon/google';
+import { useApp } from '@/providers/app.provider';
+import { useUserIdentities } from '@/resources/users';
+import { env } from '@/utils/env';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ LinkButton,
+ Tooltip,
+} from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { CircleAlertIcon, ExternalLinkIcon, MailIcon } from 'lucide-react';
+import { ComponentType, SVGProps } from 'react';
+
+const PROVIDERS: Record> }> = {
+ email: { label: 'Email', Icon: MailIcon },
+ google: { label: 'Google', Icon: GoogleIcon },
+ github: { label: 'GitHub', Icon: GitHubIcon },
+};
+
+export const AccountIdentitySettingsCard = () => {
+ const { user } = useApp();
+ const { data: identities, isLoading: isLoadingIdentities } = useUserIdentities(user?.sub ?? 'me');
+
+ return (
+
+
+ Account Identities
+
+
+ {isLoadingIdentities ? (
+
+ ) : (
+
+ {identities?.map((identity) => {
+ const provider = identity.providerName?.toLowerCase() ?? 'email';
+ const providerMeta = PROVIDERS?.[provider];
+ return (
+
+
+ ) : (
+
+ )
+ }
+ label={providerMeta?.label ?? 'Email address'}
+ sublabel={identity.username}
+ middleContent={
+ // TODO: Enable this when we have a way to get the last used date
+ //
Last used Jun 4
+ undefined
+ }
+ rightContent={
+ <>
+ {provider === 'github' && (
+
+
+ Updating email addresses for GitHub identities
+
+
+ Email addresses for GitHub identities should be updated through
+ GitHub
+
+
+ Log out of Datum
+ Change your Primary Email in GitHub (your primary email)
+ Log out of GitHub
+
+ Log back into GitHub (with the new, desired email set as
+ primary)
+
+ Log back into Datum
+
+
+ }
+ contentClassName="bg-card rounded-xl shadow-tooltip text-foreground max-w-[380px] border p-0"
+ arrowClassName="fill-card">
+
+
+
+ How to update your GitHub email
+
+
+
+ )}
+
}
+ iconPosition="right">
+ Manage
+
+ >
+ }
+ />
+
+ );
+ })}
+
+ )}
+
+
+ );
+};
diff --git a/app/features/account/cards/newsletter-card.tsx b/app/features/account/cards/newsletter-card.tsx
index 27700afe8..cc178d7fc 100644
--- a/app/features/account/cards/newsletter-card.tsx
+++ b/app/features/account/cards/newsletter-card.tsx
@@ -1,44 +1,44 @@
-import { useApp } from '@/providers/app.provider';
-import { useUpdateUserPreferences } from '@/resources/users';
-import { Card, CardContent, CardHeader, CardTitle } from '@datum-ui/components';
-import { Label } from '@datum-ui/components';
-import { Switch } from '@datum-ui/components';
-import { useEffect, useState } from 'react';
-
-export const AccountNewsletterSettingsCard = () => {
- const { user, userPreferences } = useApp();
- const userId = user?.sub ?? 'me';
- const [emailNewsletter, setEmailNewsletter] = useState(false);
-
- const updatePreferencesMutation = useUpdateUserPreferences(userId);
-
- useEffect(() => {
- if (userPreferences) {
- setEmailNewsletter(userPreferences.newsletter);
- }
- }, [userPreferences]);
-
- const updatePreferences = (value: boolean) => {
- setEmailNewsletter(value);
- updatePreferencesMutation.mutate({ newsletter: value });
- };
-
- return (
-
-
- Email Preferences
-
-
-
-
-
Newsletter
-
- Receive updates about new features and product announcements
-
-
-
-
-
-
- );
-};
+import { useApp } from '@/providers/app.provider';
+import { useUpdateUserPreferences } from '@/resources/users';
+import { Card, CardContent, CardHeader, CardTitle } from '@datum-ui/components';
+import { Label } from '@datum-ui/components';
+import { Switch } from '@datum-ui/components';
+import { useEffect, useState } from 'react';
+
+export const AccountNewsletterSettingsCard = () => {
+ const { user, userPreferences } = useApp();
+ const userId = user?.sub ?? 'me';
+ const [emailNewsletter, setEmailNewsletter] = useState(false);
+
+ const updatePreferencesMutation = useUpdateUserPreferences(userId);
+
+ useEffect(() => {
+ if (userPreferences) {
+ setEmailNewsletter(userPreferences.newsletter);
+ }
+ }, [userPreferences]);
+
+ const updatePreferences = (value: boolean) => {
+ setEmailNewsletter(value);
+ updatePreferencesMutation.mutate({ newsletter: value });
+ };
+
+ return (
+
+
+ Email Preferences
+
+
+
+
+
Newsletter
+
+ Receive updates about new features and product announcements
+
+
+
+
+
+
+ );
+};
diff --git a/app/features/account/cards/notification-card.tsx b/app/features/account/cards/notification-card.tsx
index e8a4916a7..19f952d9f 100644
--- a/app/features/account/cards/notification-card.tsx
+++ b/app/features/account/cards/notification-card.tsx
@@ -1,200 +1,200 @@
-import {
- NotificationSettingsCard,
- NotificationSettingsCardSkeleton,
-} from '@/components/notification-settings';
-import type { NotificationPreference } from '@/components/notification-settings';
-import {
- useCreateNotificationContactGroupMembership,
- useCreateNotificationContactGroupMembershipRemoval,
- useNotificationContactGroupMemberships,
- useNotificationContactGroupMembershipRemovals,
- useNotificationContactGroups,
- useNotificationContacts,
- useDeleteNotificationContactGroupMembershipRemoval,
-} from '@/resources/notifications';
-import { generateId } from '@/utils/helpers/text.helper';
-import { toast } from '@datum-ui/components';
-import { useMemo } from 'react';
-import { z } from 'zod';
-
-export const AccountNotificationSettingsCard = () => {
- const scope = { type: 'user' as const };
-
- const { data: contactGroups } = useNotificationContactGroups(scope);
- const { data: contactGroupMemberships } = useNotificationContactGroupMemberships(scope);
- const { data: contactGroupMembershipRemovals } =
- useNotificationContactGroupMembershipRemovals(scope);
- const { data: contacts } = useNotificationContacts(scope);
- const createMembership = useCreateNotificationContactGroupMembership(scope, {
- onError: (error) => {
- toast.error(error.message);
- },
- onSuccess: () => {
- toast.success('Marketing & Events Notifications updated');
- },
- });
- const createRemoval = useCreateNotificationContactGroupMembershipRemoval(scope, {
- onError: (error) => {
- toast.error(error.message);
- },
- onSuccess: () => {
- toast.success('Marketing & Events Notifications updated');
- },
- });
- const deleteRemoval = useDeleteNotificationContactGroupMembershipRemoval(scope, {
- onError: (error) => {
- toast.error(error.message);
- },
- });
-
- const contactGroupPreferences: NotificationPreference[] = useMemo(
- () =>
- (contactGroups ?? []).map((contactGroup) => ({
- name: contactGroup.name,
- label: contactGroup.displayName,
- description: contactGroup.description ?? '',
- })),
- [contactGroups]
- );
-
- const schema = useMemo((): z.ZodObject>> => {
- const shape = Object.fromEntries(
- (contactGroups ?? []).map((group) => [group.name, z.boolean().default(false)])
- ) as Record>;
-
- return z.object(shape);
- }, [contactGroups]);
-
- const defaultValues = useMemo(() => {
- const membershipGroupNames = new Set(
- (contactGroupMemberships ?? []).map((m) => m.contactGroupName)
- );
- const removalGroupNames = new Set(
- (contactGroupMembershipRemovals ?? []).map((r) => r.contactGroupName)
- );
-
- return Object.fromEntries(
- (contactGroups ?? []).map((group) => [
- group.name,
- membershipGroupNames.has(group.name) && !removalGroupNames.has(group.name),
- ])
- ) as Record;
- }, [contactGroups, contactGroupMemberships, contactGroupMembershipRemovals]);
-
- const contactContext = useMemo(() => {
- const contactName = contacts?.[0]?.name;
- const namespace = contacts?.[0]?.namespace;
- return { contactName, namespace };
- }, [contacts, contactGroupMemberships, contactGroupMembershipRemovals]);
-
- const isSaving = createMembership.isPending || createRemoval.isPending || deleteRemoval.isPending;
-
- const isReady =
- !!contactGroups &&
- contactGroupMemberships !== undefined &&
- contactGroupMembershipRemovals !== undefined;
-
- const handleSubmit = async (data: Record) => {
- try {
- const { contactName, namespace: contactNamespace } = contactContext;
-
- if (!contactName || !contactNamespace) {
- toast.error('Unable to update notification groups', {
- description: 'No contact was found for this user.',
- });
- return;
- }
-
- const allGroups = contactGroups ?? [];
- const groupNamespaceByName = new Map(allGroups.map((g) => [g.name, g.namespace] as const));
-
- const desiredGroupNames = Object.entries(data)
- .filter(([, enabled]) => enabled === true)
- .map(([groupName]) => groupName);
-
- const desiredSet = new Set(desiredGroupNames);
-
- const existingMembershipGroupNames = new Set(
- (contactGroupMemberships ?? []).map((m) => m.contactGroupName)
- );
-
- const removalByGroupName = new Map(
- (contactGroupMembershipRemovals ?? []).map((r) => [
- r.contactGroupName,
- { name: r.name, namespace: r.namespace },
- ])
- );
-
- const toEnable = allGroups
- .map((g) => g.name)
- .filter((groupName) => desiredSet.has(groupName));
- const toDisable = allGroups
- .map((g) => g.name)
- .filter((groupName) => !desiredSet.has(groupName));
-
- await Promise.all(
- toEnable.map(async (contactGroupName) => {
- const removal = removalByGroupName.get(contactGroupName);
- if (removal) {
- const removalNamespace =
- removal.namespace ?? groupNamespaceByName.get(contactGroupName);
- await deleteRemoval.mutateAsync({
- name: removal.name,
- namespace: removalNamespace,
- });
- }
-
- if (!existingMembershipGroupNames.has(contactGroupName)) {
- const contactGroupNamespace = groupNamespaceByName.get(contactGroupName);
- await createMembership.mutateAsync({
- name: generateId(contactGroupName),
- contactGroupName,
- contactName,
- namespace: contactGroupNamespace,
- contactNamespace,
- });
- }
- })
- );
-
- await Promise.all(
- toDisable.map(async (contactGroupName) => {
- if (!existingMembershipGroupNames.has(contactGroupName)) return;
- if (removalByGroupName.has(contactGroupName)) return;
-
- const contactGroupNamespace = groupNamespaceByName.get(contactGroupName);
- await createRemoval.mutateAsync({
- name: generateId(contactGroupName, { prefix: 'cgmr' }),
- contactGroupName,
- contactName,
- namespace: contactGroupNamespace,
- contactNamespace,
- });
- })
- );
- } catch {
- // errors are surfaced via the mutation onError handlers
- }
- };
-
- if (!isReady) {
- return (
-
- );
- }
-
- return (
-
- );
-};
+import {
+ NotificationSettingsCard,
+ NotificationSettingsCardSkeleton,
+} from '@/components/notification-settings';
+import type { NotificationPreference } from '@/components/notification-settings';
+import {
+ useCreateNotificationContactGroupMembership,
+ useCreateNotificationContactGroupMembershipRemoval,
+ useNotificationContactGroupMemberships,
+ useNotificationContactGroupMembershipRemovals,
+ useNotificationContactGroups,
+ useNotificationContacts,
+ useDeleteNotificationContactGroupMembershipRemoval,
+} from '@/resources/notifications';
+import { generateId } from '@/utils/helpers/text.helper';
+import { toast } from '@datum-ui/components';
+import { useMemo } from 'react';
+import { z } from 'zod';
+
+export const AccountNotificationSettingsCard = () => {
+ const scope = { type: 'user' as const };
+
+ const { data: contactGroups } = useNotificationContactGroups(scope);
+ const { data: contactGroupMemberships } = useNotificationContactGroupMemberships(scope);
+ const { data: contactGroupMembershipRemovals } =
+ useNotificationContactGroupMembershipRemovals(scope);
+ const { data: contacts } = useNotificationContacts(scope);
+ const createMembership = useCreateNotificationContactGroupMembership(scope, {
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ onSuccess: () => {
+ toast.success('Marketing & Events Notifications updated');
+ },
+ });
+ const createRemoval = useCreateNotificationContactGroupMembershipRemoval(scope, {
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ onSuccess: () => {
+ toast.success('Marketing & Events Notifications updated');
+ },
+ });
+ const deleteRemoval = useDeleteNotificationContactGroupMembershipRemoval(scope, {
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ });
+
+ const contactGroupPreferences: NotificationPreference[] = useMemo(
+ () =>
+ (contactGroups ?? []).map((contactGroup) => ({
+ name: contactGroup.name,
+ label: contactGroup.displayName,
+ description: contactGroup.description ?? '',
+ })),
+ [contactGroups]
+ );
+
+ const schema = useMemo((): z.ZodObject>> => {
+ const shape = Object.fromEntries(
+ (contactGroups ?? []).map((group) => [group.name, z.boolean().default(false)])
+ ) as Record>;
+
+ return z.object(shape);
+ }, [contactGroups]);
+
+ const defaultValues = useMemo(() => {
+ const membershipGroupNames = new Set(
+ (contactGroupMemberships ?? []).map((m) => m.contactGroupName)
+ );
+ const removalGroupNames = new Set(
+ (contactGroupMembershipRemovals ?? []).map((r) => r.contactGroupName)
+ );
+
+ return Object.fromEntries(
+ (contactGroups ?? []).map((group) => [
+ group.name,
+ membershipGroupNames.has(group.name) && !removalGroupNames.has(group.name),
+ ])
+ ) as Record;
+ }, [contactGroups, contactGroupMemberships, contactGroupMembershipRemovals]);
+
+ const contactContext = useMemo(() => {
+ const contactName = contacts?.[0]?.name;
+ const namespace = contacts?.[0]?.namespace;
+ return { contactName, namespace };
+ }, [contacts, contactGroupMemberships, contactGroupMembershipRemovals]);
+
+ const isSaving = createMembership.isPending || createRemoval.isPending || deleteRemoval.isPending;
+
+ const isReady =
+ !!contactGroups &&
+ contactGroupMemberships !== undefined &&
+ contactGroupMembershipRemovals !== undefined;
+
+ const handleSubmit = async (data: Record) => {
+ try {
+ const { contactName, namespace: contactNamespace } = contactContext;
+
+ if (!contactName || !contactNamespace) {
+ toast.error('Unable to update notification groups', {
+ description: 'No contact was found for this user.',
+ });
+ return;
+ }
+
+ const allGroups = contactGroups ?? [];
+ const groupNamespaceByName = new Map(allGroups.map((g) => [g.name, g.namespace] as const));
+
+ const desiredGroupNames = Object.entries(data)
+ .filter(([, enabled]) => enabled === true)
+ .map(([groupName]) => groupName);
+
+ const desiredSet = new Set(desiredGroupNames);
+
+ const existingMembershipGroupNames = new Set(
+ (contactGroupMemberships ?? []).map((m) => m.contactGroupName)
+ );
+
+ const removalByGroupName = new Map(
+ (contactGroupMembershipRemovals ?? []).map((r) => [
+ r.contactGroupName,
+ { name: r.name, namespace: r.namespace },
+ ])
+ );
+
+ const toEnable = allGroups
+ .map((g) => g.name)
+ .filter((groupName) => desiredSet.has(groupName));
+ const toDisable = allGroups
+ .map((g) => g.name)
+ .filter((groupName) => !desiredSet.has(groupName));
+
+ await Promise.all(
+ toEnable.map(async (contactGroupName) => {
+ const removal = removalByGroupName.get(contactGroupName);
+ if (removal) {
+ const removalNamespace =
+ removal.namespace ?? groupNamespaceByName.get(contactGroupName);
+ await deleteRemoval.mutateAsync({
+ name: removal.name,
+ namespace: removalNamespace,
+ });
+ }
+
+ if (!existingMembershipGroupNames.has(contactGroupName)) {
+ const contactGroupNamespace = groupNamespaceByName.get(contactGroupName);
+ await createMembership.mutateAsync({
+ name: generateId(contactGroupName),
+ contactGroupName,
+ contactName,
+ namespace: contactGroupNamespace,
+ contactNamespace,
+ });
+ }
+ })
+ );
+
+ await Promise.all(
+ toDisable.map(async (contactGroupName) => {
+ if (!existingMembershipGroupNames.has(contactGroupName)) return;
+ if (removalByGroupName.has(contactGroupName)) return;
+
+ const contactGroupNamespace = groupNamespaceByName.get(contactGroupName);
+ await createRemoval.mutateAsync({
+ name: generateId(contactGroupName, { prefix: 'cgmr' }),
+ contactGroupName,
+ contactName,
+ namespace: contactGroupNamespace,
+ contactNamespace,
+ });
+ })
+ );
+ } catch {
+ // errors are surfaced via the mutation onError handlers
+ }
+ };
+
+ if (!isReady) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/app/features/account/cards/profile-card.tsx b/app/features/account/cards/profile-card.tsx
index 4d0ffb276..af7741bc5 100644
--- a/app/features/account/cards/profile-card.tsx
+++ b/app/features/account/cards/profile-card.tsx
@@ -1,103 +1,103 @@
-import { useApp } from '@/providers/app.provider';
-import { useUpdateUser, userSchema } from '@/resources/users';
-import { Button, toast } from '@datum-ui/components';
-import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@datum-ui/components';
-import { Form } from '@datum-ui/components/form';
-
-/**
- * Account Profile Settings Card Component
- * Displays and allows editing of general account settings
- */
-export const AccountProfileSettingsCard = () => {
- const { user, setUser } = useApp();
-
- const updateMutation = useUpdateUser(user?.sub ?? 'me', {
- onSuccess: (updatedUser) => {
- setUser(updatedUser);
- toast.success('Profile', {
- description: 'The profile has been updated successfully',
- });
- },
- onError: (error) => {
- toast.error('Profile', {
- description: error.message ?? 'Failed to update profile',
- });
- },
- });
-
- return (
-
-
- Profile Information
-
-
- {
- updateMutation.mutate({
- firstName: data.firstName,
- lastName: data.lastName,
- email: data.email,
- });
- }}
- className="flex flex-col space-y-0">
- {({ form, isSubmitting }) => (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- {user?.email}
-
-
-
-
-
- {
- form.update({
- value: {
- firstName: user?.givenName ?? '',
- lastName: user?.familyName ?? '',
- email: user?.email ?? '',
- },
- });
- }}>
- Cancel
-
-
- Save
-
-
- >
- )}
-
-
- );
-};
+import { useApp } from '@/providers/app.provider';
+import { useUpdateUser, userSchema } from '@/resources/users';
+import { Button, toast } from '@datum-ui/components';
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@datum-ui/components';
+import { Form } from '@datum-ui/components/form';
+
+/**
+ * Account Profile Settings Card Component
+ * Displays and allows editing of general account settings
+ */
+export const AccountProfileSettingsCard = () => {
+ const { user, setUser } = useApp();
+
+ const updateMutation = useUpdateUser(user?.sub ?? 'me', {
+ onSuccess: (updatedUser) => {
+ setUser(updatedUser);
+ toast.success('Profile', {
+ description: 'The profile has been updated successfully',
+ });
+ },
+ onError: (error) => {
+ toast.error('Profile', {
+ description: error.message ?? 'Failed to update profile',
+ });
+ },
+ });
+
+ return (
+
+
+ Profile Information
+
+
+ {
+ updateMutation.mutate({
+ firstName: data.firstName,
+ lastName: data.lastName,
+ email: data.email,
+ });
+ }}
+ className="flex flex-col space-y-0">
+ {({ form, isSubmitting }) => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ {user?.email}
+
+
+
+
+
+ {
+ form.update({
+ value: {
+ firstName: user?.givenName ?? '',
+ lastName: user?.familyName ?? '',
+ email: user?.email ?? '',
+ },
+ });
+ }}>
+ Cancel
+
+
+ Save
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/app/features/account/cards/sign-in-method-card.tsx b/app/features/account/cards/sign-in-method-card.tsx
index fc4a49d5a..33553856c 100644
--- a/app/features/account/cards/sign-in-method-card.tsx
+++ b/app/features/account/cards/sign-in-method-card.tsx
@@ -1,87 +1,87 @@
-import { GitHubLineIcon } from '@/components/icon/github-line';
-import GoogleIcon from '@/components/icon/google';
-import { IdentityItem } from '@/features/account/identity-item';
-import {
- Button,
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { EllipsisIcon, KeyRoundIcon, MailIcon } from 'lucide-react';
-
-const SIGN_IN_METHODS = [
- {
- icon: ,
- label: 'Google',
- sublabel: 'cberridge@datum.net',
- rightContent: (
-
- Last used today
-
-
-
-
- ),
- },
- {
- icon: ,
- label: 'Email',
- sublabel: 'cberridge@datum.net',
- rightContent: (
-
- Manage
-
- ),
- },
- {
- icon: ,
- label: 'Passkeys',
- sublabel: '1 passkey registered',
- rightContent: (
-
- Add
-
- ),
- },
- {
- icon: ,
- label: 'GitHub',
- sublabel: 'Connect your GitHub account',
- rightContent: (
-
- Connect
-
- ),
- },
-];
-
-export const AccountSignInMethodSettingsCard = () => {
- return (
-
-
- Sign-in Methods
-
- Customize how you access your account. Link your Git profiles and set up passkeys for
- seamless, secure authentication.
-
-
-
-
- {SIGN_IN_METHODS.map((method) => (
-
-
-
- ))}
-
-
-
- );
-};
+import { GitHubLineIcon } from '@/components/icon/github-line';
+import GoogleIcon from '@/components/icon/google';
+import { IdentityItem } from '@/features/account/identity-item';
+import {
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { EllipsisIcon, KeyRoundIcon, MailIcon } from 'lucide-react';
+
+const SIGN_IN_METHODS = [
+ {
+ icon: ,
+ label: 'Google',
+ sublabel: 'cberridge@datum.net',
+ rightContent: (
+
+ Last used today
+
+
+
+
+ ),
+ },
+ {
+ icon: ,
+ label: 'Email',
+ sublabel: 'cberridge@datum.net',
+ rightContent: (
+
+ Manage
+
+ ),
+ },
+ {
+ icon: ,
+ label: 'Passkeys',
+ sublabel: '1 passkey registered',
+ rightContent: (
+
+ Add
+
+ ),
+ },
+ {
+ icon: ,
+ label: 'GitHub',
+ sublabel: 'Connect your GitHub account',
+ rightContent: (
+
+ Connect
+
+ ),
+ },
+];
+
+export const AccountSignInMethodSettingsCard = () => {
+ return (
+
+
+ Sign-in Methods
+
+ Customize how you access your account. Link your Git profiles and set up passkeys for
+ seamless, secure authentication.
+
+
+
+
+ {SIGN_IN_METHODS.map((method) => (
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/app/features/account/cards/team-auth-card.tsx b/app/features/account/cards/team-auth-card.tsx
index cb82dd241..0c3419a57 100644
--- a/app/features/account/cards/team-auth-card.tsx
+++ b/app/features/account/cards/team-auth-card.tsx
@@ -1,20 +1,20 @@
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
-
-export const AccountTeamAuthCard = () => {
- return (
-
-
- Two-factor Authentication
-
- Add an additional layer of security by requiring at least two methods of authentication to
- sign in.
-
-
-
-
- If any of your teams have SAML enabled you'll see them here to connect.
-
-
-
- );
-};
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-ui/components';
+
+export const AccountTeamAuthCard = () => {
+ return (
+
+
+ Two-factor Authentication
+
+ Add an additional layer of security by requiring at least two methods of authentication to
+ sign in.
+
+
+
+
+ If any of your teams have SAML enabled you'll see them here to connect.
+
+
+
+ );
+};
diff --git a/app/features/account/identity-item-skeleton.tsx b/app/features/account/identity-item-skeleton.tsx
index d53c9f7d3..d951c251b 100644
--- a/app/features/account/identity-item-skeleton.tsx
+++ b/app/features/account/identity-item-skeleton.tsx
@@ -1,64 +1,64 @@
-import { Skeleton } from '@datum-ui/components';
-import { cn } from '@shadcn/lib/utils';
-
-interface IdentityItemSkeletonProps {
- /**
- * Number of skeleton items to render
- */
- count?: number;
-
- /**
- * Show skeleton for right action buttons
- */
- showActions?: boolean;
-
- /**
- * Optional className for custom styling
- */
- className?: string;
-}
-
-/**
- * Skeleton loading state for IdentityItem component
- * Matches the exact layout and styling of IdentityItem
- */
-export const IdentityItemSkeleton = ({
- count = 1,
- showActions = true,
- className,
-}: IdentityItemSkeletonProps) => {
- return (
-
- {Array.from({ length: count }).map((_, index) => (
-
- {/* Left Section */}
-
- {/* Icon skeleton - matches: bg-badge-muted dark:bg-background flex size-[34px] items-center justify-center rounded-xl */}
-
-
-
-
- {/* Label + Sublabel skeleton - matches: text-1xs flex flex-col space-y-0.5 text-left */}
-
- {/* Label skeleton - matches: font-medium */}
-
- {/* Sublabel skeleton - matches: text-foreground/80 */}
-
-
-
-
- {/* Right Section - matches: flex items-center justify-end gap-10 */}
- {showActions && (
-
-
-
- )}
-
- ))}
-
- );
-};
-
-IdentityItemSkeleton.displayName = 'IdentityItemSkeleton';
+import { Skeleton } from '@datum-ui/components';
+import { cn } from '@shadcn/lib/utils';
+
+interface IdentityItemSkeletonProps {
+ /**
+ * Number of skeleton items to render
+ */
+ count?: number;
+
+ /**
+ * Show skeleton for right action buttons
+ */
+ showActions?: boolean;
+
+ /**
+ * Optional className for custom styling
+ */
+ className?: string;
+}
+
+/**
+ * Skeleton loading state for IdentityItem component
+ * Matches the exact layout and styling of IdentityItem
+ */
+export const IdentityItemSkeleton = ({
+ count = 1,
+ showActions = true,
+ className,
+}: IdentityItemSkeletonProps) => {
+ return (
+
+ {Array.from({ length: count }).map((_, index) => (
+
+ {/* Left Section */}
+
+ {/* Icon skeleton - matches: bg-badge-muted dark:bg-background flex size-[34px] items-center justify-center rounded-xl */}
+
+
+
+
+ {/* Label + Sublabel skeleton - matches: text-1xs flex flex-col space-y-0.5 text-left */}
+
+ {/* Label skeleton - matches: font-medium */}
+
+ {/* Sublabel skeleton - matches: text-foreground/80 */}
+
+
+
+
+ {/* Right Section - matches: flex items-center justify-end gap-10 */}
+ {showActions && (
+
+
+
+ )}
+
+ ))}
+
+ );
+};
+
+IdentityItemSkeleton.displayName = 'IdentityItemSkeleton';
diff --git a/app/features/account/identity-item.tsx b/app/features/account/identity-item.tsx
index d11239ff1..3ce8e82df 100644
--- a/app/features/account/identity-item.tsx
+++ b/app/features/account/identity-item.tsx
@@ -1,67 +1,67 @@
-import { cn } from '@shadcn/lib/utils';
-import { ReactNode } from 'react';
-
-export interface IdentityItemProps {
- /**
- * Icon to display in the left section
- * Can be a Lucide icon wrapped in Icon component or custom SVG component
- */
- icon: ReactNode;
-
- /**
- * Primary label text
- */
- label: string;
-
- /**
- * Optional secondary label text (typically email, description, etc.)
- */
- sublabel?: string;
-
- /**
- * Optional content for the middle section
- * Useful for timestamps, status badges, etc.
- */
- middleContent?: ReactNode;
-
- /**
- * Optional content for the right section
- * Typically buttons, links, or action menus
- */
- rightContent?: ReactNode;
-
- /**
- * Optional className for custom styling
- */
- className?: string;
-}
-
-export const IdentityItem = ({
- icon,
- label,
- sublabel,
- middleContent,
- rightContent,
- className,
-}: IdentityItemProps) => {
- return (
-
- {/* Left Section */}
-
-
- {icon}
-
-
- {label}
- {sublabel && {sublabel} }
-
-
-
- {/* Middle Section */}
- {middleContent &&
{middleContent}
}
-
- {/* Right Section */}
- {rightContent &&
{rightContent}
}
-
- );
-};
+import { cn } from '@shadcn/lib/utils';
+import { ReactNode } from 'react';
+
+export interface IdentityItemProps {
+ /**
+ * Icon to display in the left section
+ * Can be a Lucide icon wrapped in Icon component or custom SVG component
+ */
+ icon: ReactNode;
+
+ /**
+ * Primary label text
+ */
+ label: string;
+
+ /**
+ * Optional secondary label text (typically email, description, etc.)
+ */
+ sublabel?: string;
+
+ /**
+ * Optional content for the middle section
+ * Useful for timestamps, status badges, etc.
+ */
+ middleContent?: ReactNode;
+
+ /**
+ * Optional content for the right section
+ * Typically buttons, links, or action menus
+ */
+ rightContent?: ReactNode;
+
+ /**
+ * Optional className for custom styling
+ */
+ className?: string;
+}
+
+export const IdentityItem = ({
+ icon,
+ label,
+ sublabel,
+ middleContent,
+ rightContent,
+ className,
+}: IdentityItemProps) => {
+ return (
+
+ {/* Left Section */}
+
+
+ {icon}
+
+
+ {label}
+ {sublabel && {sublabel} }
+
+
+
+ {/* Middle Section */}
+ {middleContent &&
{middleContent}
}
+
+ {/* Right Section */}
+ {rightContent &&
{rightContent}
}
+
+ );
+};
diff --git a/app/features/activity-log/activity-log-columns.tsx b/app/features/activity-log/activity-log-columns.tsx
index 46fe8f11c..8fa1c79a4 100644
--- a/app/features/activity-log/activity-log-columns.tsx
+++ b/app/features/activity-log/activity-log-columns.tsx
@@ -1,114 +1,114 @@
-import { DateTime } from '@/components/date-time';
-import type { ActivityLog } from '@/resources/activity-logs';
-import { User } from '@/resources/users';
-import { Badge } from '@datum-ui/components';
-import { ColumnDef } from '@tanstack/react-table';
-
-export interface ActivityLogColumnsOptions {
- /** Current user for "You" badge display */
- user?: User;
- /** Hide the User column (useful for account/user scope where it's always the same user) */
- hideUserColumn?: boolean;
-}
-
-/**
- * Returns column definitions for the Activity Log table.
- *
- * Columns:
- * - User: Who performed the action (email or system account) - can be hidden
- * - Action: Humanized action with error badge if failed
- * - Details: Resource type and name
- * - Date: Relative timestamp with tooltip for absolute
- */
-export function getActivityLogColumns(
- options: ActivityLogColumnsOptions = {}
-): ColumnDef[] {
- const { user, hideUserColumn = false } = options;
-
- const columns: ColumnDef[] = [];
-
- // User column (optional)
- if (!hideUserColumn) {
- columns.push({
- id: 'user',
- header: 'User',
- accessorKey: 'user',
- cell: ({ row }) => {
- const { user: userName, userId } = row.original;
- return (
-
-
- {userName ?? '-'}
-
- {user && userId === user?.sub && (
-
- You
-
- )}
-
- );
- },
- });
- }
-
- // Action column
- columns.push({
- id: 'action',
- header: 'Action',
- accessorKey: 'action',
- size: 180,
- cell: ({ row }) => {
- // If user column is hidden, add activity-card to action column (first column)
- if (hideUserColumn) {
- return (
-
-
- {row.original.action}
-
-
- );
- }
- return (
-
- {row.original.action}
-
- );
- },
- });
-
- // Details/Target column
- columns.push({
- id: 'details',
- header: 'Target',
- accessorKey: 'details',
- cell: ({ row }) => {
- const { details, resourceName } = row.original;
- return (
-
- {details}
-
- );
- },
- });
-
- // Date column
- columns.push({
- id: 'date',
- header: 'Date',
- accessorKey: 'timestamp',
- size: 150,
- cell: ({ row }) => {
- const { timestamp } = row.original;
- return (
-
-
-
- );
- },
- });
-
- return columns;
-}
+import { DateTime } from '@/components/date-time';
+import type { ActivityLog } from '@/resources/activity-logs';
+import { User } from '@/resources/users';
+import { Badge } from '@datum-ui/components';
+import { ColumnDef } from '@tanstack/react-table';
+
+export interface ActivityLogColumnsOptions {
+ /** Current user for "You" badge display */
+ user?: User;
+ /** Hide the User column (useful for account/user scope where it's always the same user) */
+ hideUserColumn?: boolean;
+}
+
+/**
+ * Returns column definitions for the Activity Log table.
+ *
+ * Columns:
+ * - User: Who performed the action (email or system account) - can be hidden
+ * - Action: Humanized action with error badge if failed
+ * - Details: Resource type and name
+ * - Date: Relative timestamp with tooltip for absolute
+ */
+export function getActivityLogColumns(
+ options: ActivityLogColumnsOptions = {}
+): ColumnDef[] {
+ const { user, hideUserColumn = false } = options;
+
+ const columns: ColumnDef[] = [];
+
+ // User column (optional)
+ if (!hideUserColumn) {
+ columns.push({
+ id: 'user',
+ header: 'User',
+ accessorKey: 'user',
+ cell: ({ row }) => {
+ const { user: userName, userId } = row.original;
+ return (
+
+
+ {userName ?? '-'}
+
+ {user && userId === user?.sub && (
+
+ You
+
+ )}
+
+ );
+ },
+ });
+ }
+
+ // Action column
+ columns.push({
+ id: 'action',
+ header: 'Action',
+ accessorKey: 'action',
+ size: 180,
+ cell: ({ row }) => {
+ // If user column is hidden, add activity-card to action column (first column)
+ if (hideUserColumn) {
+ return (
+
+
+ {row.original.action}
+
+
+ );
+ }
+ return (
+
+ {row.original.action}
+
+ );
+ },
+ });
+
+ // Details/Target column
+ columns.push({
+ id: 'details',
+ header: 'Target',
+ accessorKey: 'details',
+ cell: ({ row }) => {
+ const { details, resourceName } = row.original;
+ return (
+
+ {details}
+
+ );
+ },
+ });
+
+ // Date column
+ columns.push({
+ id: 'date',
+ header: 'Date',
+ accessorKey: 'timestamp',
+ size: 150,
+ cell: ({ row }) => {
+ const { timestamp } = row.original;
+ return (
+
+
+
+ );
+ },
+ });
+
+ return columns;
+}
diff --git a/app/features/activity-log/activity-log-filters.ts b/app/features/activity-log/activity-log-filters.ts
index a4d20f089..036149810 100644
--- a/app/features/activity-log/activity-log-filters.ts
+++ b/app/features/activity-log/activity-log-filters.ts
@@ -1,9 +1,9 @@
-/**
- * Re-export filter options from the activity-logs resource.
- * Centralized in @/resources/activity-logs for reusability.
- */
-export {
- getActionFilterOptions,
- getResourceFilterOptions,
- type FilterOption,
-} from '@/resources/activity-logs';
+/**
+ * Re-export filter options from the activity-logs resource.
+ * Centralized in @/resources/activity-logs for reusability.
+ */
+export {
+ getActionFilterOptions,
+ getResourceFilterOptions,
+ type FilterOption,
+} from '@/resources/activity-logs';
diff --git a/app/features/activity-log/activity-log-table.tsx b/app/features/activity-log/activity-log-table.tsx
index b2c19f220..161951df2 100644
--- a/app/features/activity-log/activity-log-table.tsx
+++ b/app/features/activity-log/activity-log-table.tsx
@@ -1,293 +1,293 @@
-import { getActivityLogColumns } from './activity-log-columns';
-import { getResourceFilterOptions, getActionFilterOptions } from './activity-log-filters';
-import { useActivityLogTable } from './use-activity-log-table';
-import {
- DataTable,
- DataTableFilter,
- DataTableToolbarConfig,
-} from '@/modules/datum-ui/components/data-table';
-import { useApp } from '@/providers/app.provider';
-import type { ActivityLogScope } from '@/resources/activity-logs';
-import { Button, Icon, toast } from '@datum-ui/components';
-import { RefreshCcw } from 'lucide-react';
-import { useMemo, useEffect, useRef } from 'react';
-
-// ============================================
-// HELPERS
-// ============================================
-
-/**
- * Extracts a user-friendly error message from API errors.
- * Handles Kubernetes-style API error responses.
- */
-function extractErrorMessage(error: Error): string {
- try {
- // Try to parse the error message as JSON (API errors often include JSON in message)
- const messageMatch = error.message?.match(/\{[\s\S]*\}/);
- if (messageMatch) {
- const parsed = JSON.parse(messageMatch[0]);
-
- // Kubernetes API error format
- if (parsed.message) {
- // Extract the core message, removing technical details
- const msg = parsed.message;
-
- // Check for time range error
- if (msg.includes('time range') && msg.includes('exceeds maximum')) {
- return 'Time range exceeds maximum of 30 days. Please select a shorter period.';
- }
-
- // Return the message, truncated if too long
- return msg.length > 200 ? msg.slice(0, 200) + '...' : msg;
- }
- }
- } catch {
- // JSON parsing failed, use raw message
- }
-
- return error.message || 'An unexpected error occurred';
-}
-
-// ============================================
-// TYPES
-// ============================================
-
-export interface ActivityLogTableProps {
- /** The scope to query activity logs for */
- scope: ActivityLogScope;
- /** Optional CSS class name */
- className?: string;
- /**
- * Default page size. Defaults to 20.
- * Use smaller values (e.g., 10) for compact views like dashboards.
- * @default 20
- */
- defaultPageSize?: number;
- /**
- * Whether to hide the pagination controls.
- * Useful for compact/preview views.
- * @default false
- */
- hidePagination?: boolean;
- /**
- * Whether to hide the filter controls.
- * Useful for compact/preview views.
- * @default false
- */
- hideFilters?: boolean;
- /**
- * Default resource filter(s) to apply.
- * When set, the resource filter UI is hidden and only these resources are shown.
- * Useful for resource-specific activity views (e.g., DNS zones page).
- *
- * @example
- * ```tsx
- * // Show only DNS zone activity
- *
- *
- * // Show only domain and DNS record activity
- *
- * ```
- */
- defaultResource?: string | string[];
- /**
- * Initial action filter(s) for the filter UI.
- * Sets a default action filter that users can change.
- *
- * @example
- * ```tsx
- * // Default to modify operations but allow users to change
- *
- * ```
- */
- initialActions?: string | string[];
-}
-
-// ============================================
-// COMPONENT
-// ============================================
-
-/**
- * Activity Log Table component with full server-side support.
- *
- * Features:
- * - Server-side filtering (search, action, resource, date range)
- * - Server-side pagination with cached pages
- * - Scope-aware resource filter options
- * - Humanized action messages
- *
- * @example
- * ```tsx
- * // Project Home - compact view
- *
- *
- * // Project Activity Page - full view
- *
- * ```
- */
-export function ActivityLogTable({
- scope,
- className,
- defaultPageSize = 20,
- hidePagination = false,
- hideFilters = false,
- defaultResource,
- initialActions,
-}: ActivityLogTableProps) {
- const { user, organization } = useApp();
-
- // All state management delegated to hook
- const table = useActivityLogTable({
- scope,
- defaultPageSize,
- defaultResource,
- initialActions,
- hideFilters,
- });
-
- // Track shown errors to prevent duplicate toasts
- const shownErrorRef = useRef(null);
-
- // Show toast on error
- useEffect(() => {
- if (table.error) {
- // Extract user-friendly message from the error
- const errorMessage = extractErrorMessage(table.error);
-
- // Only show toast if this is a new error (prevent duplicates)
- if (shownErrorRef.current !== errorMessage) {
- shownErrorRef.current = errorMessage;
- toast.error('Activity Log', {
- description: errorMessage,
- });
- }
- } else {
- // Clear tracked error when error is resolved
- shownErrorRef.current = null;
- }
- }, [table.error]);
-
- // Columns - hide User column for 'user' scope (it's always the logged-in user)
- const columns = useMemo(() => {
- const currentUser = organization?.type !== 'Personal' ? user : undefined;
- const hideUserColumn = scope.type === 'user';
- return getActivityLogColumns({ user: currentUser, hideUserColumn });
- }, [user, organization, scope.type]);
-
- // Scope-aware filter options
- const resourceOptions = useMemo(() => getResourceFilterOptions(scope.type), [scope.type]);
- const actionOptions = useMemo(() => getActionFilterOptions(), []);
-
- // Toolbar configuration
- const toolbarConfig = useMemo(
- () => ({
- layout: 'compact',
- includeSearch: hideFilters
- ? false
- : {
- placeholder: 'Search activity',
- },
- filtersDisplay: 'auto',
- maxInlineFilters: 1,
- primaryFilters: ['period'],
- alwaysShowSearchAndFilters: !hideFilters,
- }),
- [hideFilters]
- );
-
- return (
- table.refetch()}
- icon={ }
- iconPosition="left"
- loading={table.isFetching}>
- Refresh
-
- ),
- }
- : undefined
- }
- isLoading={table.isLoading || table.isFetching}
- loadingText="Loading..."
- emptyContent={{
- title: 'No activity found',
- subtitle: hideFilters ? undefined : 'Try adjusting your filters.',
- }}
- // Server-side filtering
- serverSideFiltering={!hideFilters}
- defaultFilters={hideFilters ? undefined : table.filters}
- onFiltersChange={hideFilters ? undefined : table.setFilters}
- // Server-side pagination
- serverSidePagination={!hidePagination}
- hidePagination={hidePagination}
- hasNextPage={table.hasNextPage}
- hasPrevPage={table.hasPrevPage}
- onPageChange={(newPage) => {
- if (newPage > table.page) table.goToNextPage();
- else if (newPage < table.page) table.goToPrevPage();
- }}
- onPageSizeChange={(size) => table.setPageSize(size)}
- controlledPageIndex={table.page}
- controlledPageSize={table.pageSize}
- disableShowAll
- // Toolbar & filters
- toolbar={toolbarConfig}
- filters={
- hideFilters ? undefined : (
- <>
- {/* TimeRange filter - inline (primaryFilter) */}
-
-
- {/* Action filter - in dropdown */}
- a.label.localeCompare(b.label))}
- />
- {/* Resource filter - in dropdown (hidden when defaultResource is set) */}
- {!defaultResource && (
- a.label.localeCompare(b.label))}
- />
- )}
- >
- )
- }
- />
- );
-}
+import { getActivityLogColumns } from './activity-log-columns';
+import { getResourceFilterOptions, getActionFilterOptions } from './activity-log-filters';
+import { useActivityLogTable } from './use-activity-log-table';
+import {
+ DataTable,
+ DataTableFilter,
+ DataTableToolbarConfig,
+} from '@/modules/datum-ui/components/data-table';
+import { useApp } from '@/providers/app.provider';
+import type { ActivityLogScope } from '@/resources/activity-logs';
+import { Button, Icon, toast } from '@datum-ui/components';
+import { RefreshCcw } from 'lucide-react';
+import { useMemo, useEffect, useRef } from 'react';
+
+// ============================================
+// HELPERS
+// ============================================
+
+/**
+ * Extracts a user-friendly error message from API errors.
+ * Handles Kubernetes-style API error responses.
+ */
+function extractErrorMessage(error: Error): string {
+ try {
+ // Try to parse the error message as JSON (API errors often include JSON in message)
+ const messageMatch = error.message?.match(/\{[\s\S]*\}/);
+ if (messageMatch) {
+ const parsed = JSON.parse(messageMatch[0]);
+
+ // Kubernetes API error format
+ if (parsed.message) {
+ // Extract the core message, removing technical details
+ const msg = parsed.message;
+
+ // Check for time range error
+ if (msg.includes('time range') && msg.includes('exceeds maximum')) {
+ return 'Time range exceeds maximum of 30 days. Please select a shorter period.';
+ }
+
+ // Return the message, truncated if too long
+ return msg.length > 200 ? msg.slice(0, 200) + '...' : msg;
+ }
+ }
+ } catch {
+ // JSON parsing failed, use raw message
+ }
+
+ return error.message || 'An unexpected error occurred';
+}
+
+// ============================================
+// TYPES
+// ============================================
+
+export interface ActivityLogTableProps {
+ /** The scope to query activity logs for */
+ scope: ActivityLogScope;
+ /** Optional CSS class name */
+ className?: string;
+ /**
+ * Default page size. Defaults to 20.
+ * Use smaller values (e.g., 10) for compact views like dashboards.
+ * @default 20
+ */
+ defaultPageSize?: number;
+ /**
+ * Whether to hide the pagination controls.
+ * Useful for compact/preview views.
+ * @default false
+ */
+ hidePagination?: boolean;
+ /**
+ * Whether to hide the filter controls.
+ * Useful for compact/preview views.
+ * @default false
+ */
+ hideFilters?: boolean;
+ /**
+ * Default resource filter(s) to apply.
+ * When set, the resource filter UI is hidden and only these resources are shown.
+ * Useful for resource-specific activity views (e.g., DNS zones page).
+ *
+ * @example
+ * ```tsx
+ * // Show only DNS zone activity
+ *
+ *
+ * // Show only domain and DNS record activity
+ *
+ * ```
+ */
+ defaultResource?: string | string[];
+ /**
+ * Initial action filter(s) for the filter UI.
+ * Sets a default action filter that users can change.
+ *
+ * @example
+ * ```tsx
+ * // Default to modify operations but allow users to change
+ *
+ * ```
+ */
+ initialActions?: string | string[];
+}
+
+// ============================================
+// COMPONENT
+// ============================================
+
+/**
+ * Activity Log Table component with full server-side support.
+ *
+ * Features:
+ * - Server-side filtering (search, action, resource, date range)
+ * - Server-side pagination with cached pages
+ * - Scope-aware resource filter options
+ * - Humanized action messages
+ *
+ * @example
+ * ```tsx
+ * // Project Home - compact view
+ *
+ *
+ * // Project Activity Page - full view
+ *
+ * ```
+ */
+export function ActivityLogTable({
+ scope,
+ className,
+ defaultPageSize = 20,
+ hidePagination = false,
+ hideFilters = false,
+ defaultResource,
+ initialActions,
+}: ActivityLogTableProps) {
+ const { user, organization } = useApp();
+
+ // All state management delegated to hook
+ const table = useActivityLogTable({
+ scope,
+ defaultPageSize,
+ defaultResource,
+ initialActions,
+ hideFilters,
+ });
+
+ // Track shown errors to prevent duplicate toasts
+ const shownErrorRef = useRef(null);
+
+ // Show toast on error
+ useEffect(() => {
+ if (table.error) {
+ // Extract user-friendly message from the error
+ const errorMessage = extractErrorMessage(table.error);
+
+ // Only show toast if this is a new error (prevent duplicates)
+ if (shownErrorRef.current !== errorMessage) {
+ shownErrorRef.current = errorMessage;
+ toast.error('Activity Log', {
+ description: errorMessage,
+ });
+ }
+ } else {
+ // Clear tracked error when error is resolved
+ shownErrorRef.current = null;
+ }
+ }, [table.error]);
+
+ // Columns - hide User column for 'user' scope (it's always the logged-in user)
+ const columns = useMemo(() => {
+ const currentUser = organization?.type !== 'Personal' ? user : undefined;
+ const hideUserColumn = scope.type === 'user';
+ return getActivityLogColumns({ user: currentUser, hideUserColumn });
+ }, [user, organization, scope.type]);
+
+ // Scope-aware filter options
+ const resourceOptions = useMemo(() => getResourceFilterOptions(scope.type), [scope.type]);
+ const actionOptions = useMemo(() => getActionFilterOptions(), []);
+
+ // Toolbar configuration
+ const toolbarConfig = useMemo(
+ () => ({
+ layout: 'compact',
+ includeSearch: hideFilters
+ ? false
+ : {
+ placeholder: 'Search activity',
+ },
+ filtersDisplay: 'auto',
+ maxInlineFilters: 1,
+ primaryFilters: ['period'],
+ alwaysShowSearchAndFilters: !hideFilters,
+ }),
+ [hideFilters]
+ );
+
+ return (
+ table.refetch()}
+ icon={ }
+ iconPosition="left"
+ loading={table.isFetching}>
+ Refresh
+
+ ),
+ }
+ : undefined
+ }
+ isLoading={table.isLoading || table.isFetching}
+ loadingText="Loading..."
+ emptyContent={{
+ title: 'No activity found',
+ subtitle: hideFilters ? undefined : 'Try adjusting your filters.',
+ }}
+ // Server-side filtering
+ serverSideFiltering={!hideFilters}
+ defaultFilters={hideFilters ? undefined : table.filters}
+ onFiltersChange={hideFilters ? undefined : table.setFilters}
+ // Server-side pagination
+ serverSidePagination={!hidePagination}
+ hidePagination={hidePagination}
+ hasNextPage={table.hasNextPage}
+ hasPrevPage={table.hasPrevPage}
+ onPageChange={(newPage) => {
+ if (newPage > table.page) table.goToNextPage();
+ else if (newPage < table.page) table.goToPrevPage();
+ }}
+ onPageSizeChange={(size) => table.setPageSize(size)}
+ controlledPageIndex={table.page}
+ controlledPageSize={table.pageSize}
+ disableShowAll
+ // Toolbar & filters
+ toolbar={toolbarConfig}
+ filters={
+ hideFilters ? undefined : (
+ <>
+ {/* TimeRange filter - inline (primaryFilter) */}
+
+
+ {/* Action filter - in dropdown */}
+ a.label.localeCompare(b.label))}
+ />
+ {/* Resource filter - in dropdown (hidden when defaultResource is set) */}
+ {!defaultResource && (
+ a.label.localeCompare(b.label))}
+ />
+ )}
+ >
+ )
+ }
+ />
+ );
+}
diff --git a/app/features/activity-log/index.ts b/app/features/activity-log/index.ts
index 9f1d150fa..012e7db39 100644
--- a/app/features/activity-log/index.ts
+++ b/app/features/activity-log/index.ts
@@ -1,15 +1,15 @@
-// Main component
-export { ActivityLogTable, type ActivityLogTableProps } from './activity-log-table';
-
-// Hook (for advanced customization)
-export {
- useActivityLogTable,
- type UseActivityLogTableOptions,
- type UseActivityLogTableReturn,
-} from './use-activity-log-table';
-
-// Columns (for advanced customization)
-export { getActivityLogColumns } from './activity-log-columns';
-
-// Filters (for advanced customization)
-export { getActionFilterOptions, getResourceFilterOptions } from './activity-log-filters';
+// Main component
+export { ActivityLogTable, type ActivityLogTableProps } from './activity-log-table';
+
+// Hook (for advanced customization)
+export {
+ useActivityLogTable,
+ type UseActivityLogTableOptions,
+ type UseActivityLogTableReturn,
+} from './use-activity-log-table';
+
+// Columns (for advanced customization)
+export { getActivityLogColumns } from './activity-log-columns';
+
+// Filters (for advanced customization)
+export { getActionFilterOptions, getResourceFilterOptions } from './activity-log-filters';
diff --git a/app/features/activity-log/use-activity-log-table.ts b/app/features/activity-log/use-activity-log-table.ts
index d94ba1ce8..5fe495df5 100644
--- a/app/features/activity-log/use-activity-log-table.ts
+++ b/app/features/activity-log/use-activity-log-table.ts
@@ -1,353 +1,353 @@
-import { useApp } from '@/providers/app.provider';
-import { useActivityLogs, type ActivityLogScope } from '@/resources/activity-logs';
-import { deserializeTimeRange } from '@datum-ui/components/data-table/utils/time-range-serialization';
-import {
- type TimeRangeValue,
- toApiTimeRange,
- getBrowserTimezone,
- getPresetByKey,
- getPresetRange,
- DEFAULT_PRESETS,
-} from '@datum-ui/components/time-range-picker';
-import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
-
-// ============================================
-// TYPES
-// ============================================
-
-interface FilterState {
- q?: string;
- actions?: string[];
- resources?: string[];
- period?: TimeRangeValue;
-}
-
-export interface UseActivityLogTableOptions {
- /** The scope to query activity logs for */
- scope: ActivityLogScope;
- /** Default page size. @default 20 */
- defaultPageSize?: number;
- /**
- * Default resource filter(s) to apply.
- * When set, only these resources are shown.
- */
- defaultResource?: string | string[];
- /**
- * Initial action filter(s) for the filter UI.
- * Sets a default action filter that users can change.
- */
- initialActions?: string | string[];
- /** Whether filters are hidden (disables filter state management) */
- hideFilters?: boolean;
-}
-
-export interface UseActivityLogTableReturn {
- // Data
- data: ReturnType['data'];
-
- // Refetch
- refetch: () => void;
-
- // Loading states
- isLoading: boolean;
- isFetching: boolean;
-
- // Error state
- error: Error | null;
-
- // Filter state
- filters: FilterState;
- setFilters: (filters: FilterState) => void;
-
- // Time range
- startTime: string;
- endTime: string;
-
- // Pagination state
- pageSize: number;
- setPageSize: (size: number) => void;
- page: number;
- hasNextPage: boolean;
- hasPrevPage: boolean;
-
- // Pagination actions
- goToNextPage: () => void;
- goToPrevPage: () => void;
- resetPagination: () => void;
-
- // Computed values
- effectiveResources: string[] | undefined;
-}
-
-// ============================================
-// HOOK
-// ============================================
-
-/**
- * Manages all state for the ActivityLogTable component.
- *
- * Extracts filter management, date range handling, pagination state,
- * and the useActivityLogs query into a single reusable hook.
- *
- * @example
- * ```tsx
- * const table = useActivityLogTable({
- * scope: { type: 'project', projectId },
- * defaultPageSize: 20,
- * });
- *
- * return (
- *
- * );
- * ```
- */
-/**
- * Read initial filters from URL params
- * This ensures the first API call uses the correct filters
- */
-function getInitialFiltersFromUrl(timezone: string): FilterState {
- if (typeof window === 'undefined') return {};
-
- const urlParams = new URLSearchParams(window.location.search);
- const initialFilters: FilterState = {};
-
- // Parse 'q' (search query)
- const q = urlParams.get('q');
- if (q) {
- initialFilters.q = q;
- }
-
- // Parse 'period' (time range)
- const periodParam = urlParams.get('period');
- if (periodParam) {
- const deserialized = deserializeTimeRange(periodParam);
- if (deserialized) {
- // If it's a preset without timestamps, calculate them now
- if (
- deserialized.type === 'preset' &&
- deserialized.preset &&
- (!deserialized.from || !deserialized.to)
- ) {
- const preset = getPresetByKey(deserialized.preset, DEFAULT_PRESETS);
- if (preset) {
- const range = getPresetRange(preset, timezone);
- initialFilters.period = {
- type: 'preset',
- preset: deserialized.preset,
- from: range.from,
- to: range.to,
- };
- }
- } else {
- initialFilters.period = deserialized;
- }
- }
- }
-
- // Parse 'actions' (array)
- const actions = urlParams.get('actions');
- if (actions) {
- initialFilters.actions = actions.split(',').filter(Boolean);
- }
-
- // Parse 'resources' (array)
- const resources = urlParams.get('resources');
- if (resources) {
- initialFilters.resources = resources.split(',').filter(Boolean);
- }
-
- return initialFilters;
-}
-
-export function useActivityLogTable(
- options: UseActivityLogTableOptions
-): UseActivityLogTableReturn {
- const {
- scope,
- defaultPageSize = 20,
- defaultResource,
- initialActions,
- hideFilters = false,
- } = options;
-
- // ----------------------------------------
- // Time range handling
- // ----------------------------------------
- const { userPreferences } = useApp();
- const timezone = userPreferences?.timezone ?? getBrowserTimezone();
-
- // ----------------------------------------
- // Filter state - initialize from URL params, then initialActions
- // ----------------------------------------
- const [filters, setFilters] = useState(() => {
- const urlFilters = getInitialFiltersFromUrl(timezone);
-
- // If no URL params, use initialActions
- if (!urlFilters.actions && initialActions) {
- const actionsArray = Array.isArray(initialActions) ? initialActions : [initialActions];
- return { ...urlFilters, actions: actionsArray };
- }
-
- return urlFilters;
- });
-
- // Normalize defaultResource to array
- const effectiveResources = useMemo(() => {
- if (defaultResource) {
- return Array.isArray(defaultResource) ? defaultResource : [defaultResource];
- }
- return filters.resources;
- }, [defaultResource, filters.resources]);
-
- // Convert period filter to API format
- const { startTime, endTime } = useMemo(() => {
- // Pass null if no period - toApiTimeRange will use default preset
- return toApiTimeRange(filters.period ?? null, timezone);
- }, [filters.period, timezone]);
-
- // ----------------------------------------
- // Pagination state
- // ----------------------------------------
- const [pageSize, setPageSizeState] = useState(defaultPageSize);
-
- // ----------------------------------------
- // Query activity logs
- // ----------------------------------------
- const query = useActivityLogs(scope, {
- filters: {
- search: filters.q,
- actions: filters.actions,
- resources: effectiveResources,
- },
- startTime,
- endTime,
- pageSize,
- });
-
- // ----------------------------------------
- // Reset pagination when filters change
- // ----------------------------------------
- const prevFiltersRef = useRef({
- startTime,
- endTime,
- q: filters.q,
- actions: filters.actions,
- resources: filters.resources,
- });
-
- useEffect(() => {
- const prev = prevFiltersRef.current;
- const hasFilterChanged =
- prev.startTime !== startTime ||
- prev.endTime !== endTime ||
- prev.q !== filters.q ||
- JSON.stringify(prev.actions) !== JSON.stringify(filters.actions) ||
- JSON.stringify(prev.resources) !== JSON.stringify(filters.resources);
-
- if (hasFilterChanged) {
- query.resetPagination();
- prevFiltersRef.current = {
- startTime,
- endTime,
- q: filters.q,
- actions: filters.actions,
- resources: filters.resources,
- };
- }
- }, [startTime, endTime, filters.q, filters.actions, filters.resources, query]);
-
- // Page size change handler (resets pagination)
- const setPageSize = useCallback(
- (size: number) => {
- setPageSizeState(size);
- query.resetPagination();
- },
- [query]
- );
-
- // ----------------------------------------
- // Smart filter setter that properly handles period deserialization
- // ----------------------------------------
- const handleSetFilters = useCallback(
- (newFilters: FilterState) => {
- setFilters((prevFilters) => {
- const merged = { ...prevFilters, ...newFilters };
-
- // If period is a raw string from DataTableContext, deserialize it
- if (merged.period && typeof merged.period === 'string') {
- const deserialized = deserializeTimeRange(merged.period as unknown as string);
- if (deserialized) {
- // If it's a preset without timestamps, calculate them
- if (
- deserialized.type === 'preset' &&
- deserialized.preset &&
- (!deserialized.from || !deserialized.to)
- ) {
- const preset = getPresetByKey(deserialized.preset, DEFAULT_PRESETS);
- if (preset) {
- const range = getPresetRange(preset, timezone);
- merged.period = {
- type: 'preset',
- preset: deserialized.preset,
- from: range.from,
- to: range.to,
- };
- }
- } else {
- merged.period = deserialized;
- }
- }
- }
-
- return merged;
- });
- },
- [timezone]
- );
-
- // ----------------------------------------
- // Return
- // ----------------------------------------
- return {
- // Data
- data: query.data,
-
- // Refetch
- refetch: query.refetch,
-
- // Loading states
- isLoading: query.isLoading,
- isFetching: query.isFetching,
-
- // Error state
- error: query.error,
-
- // Filter state
- filters,
- setFilters: hideFilters ? () => {} : handleSetFilters,
-
- // Time range
- startTime,
- endTime,
-
- // Pagination state
- pageSize,
- setPageSize,
- page: query.page,
- hasNextPage: query.hasNextPage,
- hasPrevPage: query.hasPrevPage,
-
- // Pagination actions
- goToNextPage: query.goToNextPage,
- goToPrevPage: query.goToPrevPage,
- resetPagination: query.resetPagination,
-
- // Computed values
- effectiveResources,
- };
-}
+import { useApp } from '@/providers/app.provider';
+import { useActivityLogs, type ActivityLogScope } from '@/resources/activity-logs';
+import { deserializeTimeRange } from '@datum-ui/components/data-table/utils/time-range-serialization';
+import {
+ type TimeRangeValue,
+ toApiTimeRange,
+ getBrowserTimezone,
+ getPresetByKey,
+ getPresetRange,
+ DEFAULT_PRESETS,
+} from '@datum-ui/components/time-range-picker';
+import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
+
+// ============================================
+// TYPES
+// ============================================
+
+interface FilterState {
+ q?: string;
+ actions?: string[];
+ resources?: string[];
+ period?: TimeRangeValue;
+}
+
+export interface UseActivityLogTableOptions {
+ /** The scope to query activity logs for */
+ scope: ActivityLogScope;
+ /** Default page size. @default 20 */
+ defaultPageSize?: number;
+ /**
+ * Default resource filter(s) to apply.
+ * When set, only these resources are shown.
+ */
+ defaultResource?: string | string[];
+ /**
+ * Initial action filter(s) for the filter UI.
+ * Sets a default action filter that users can change.
+ */
+ initialActions?: string | string[];
+ /** Whether filters are hidden (disables filter state management) */
+ hideFilters?: boolean;
+}
+
+export interface UseActivityLogTableReturn {
+ // Data
+ data: ReturnType['data'];
+
+ // Refetch
+ refetch: () => void;
+
+ // Loading states
+ isLoading: boolean;
+ isFetching: boolean;
+
+ // Error state
+ error: Error | null;
+
+ // Filter state
+ filters: FilterState;
+ setFilters: (filters: FilterState) => void;
+
+ // Time range
+ startTime: string;
+ endTime: string;
+
+ // Pagination state
+ pageSize: number;
+ setPageSize: (size: number) => void;
+ page: number;
+ hasNextPage: boolean;
+ hasPrevPage: boolean;
+
+ // Pagination actions
+ goToNextPage: () => void;
+ goToPrevPage: () => void;
+ resetPagination: () => void;
+
+ // Computed values
+ effectiveResources: string[] | undefined;
+}
+
+// ============================================
+// HOOK
+// ============================================
+
+/**
+ * Manages all state for the ActivityLogTable component.
+ *
+ * Extracts filter management, date range handling, pagination state,
+ * and the useActivityLogs query into a single reusable hook.
+ *
+ * @example
+ * ```tsx
+ * const table = useActivityLogTable({
+ * scope: { type: 'project', projectId },
+ * defaultPageSize: 20,
+ * });
+ *
+ * return (
+ *
+ * );
+ * ```
+ */
+/**
+ * Read initial filters from URL params
+ * This ensures the first API call uses the correct filters
+ */
+function getInitialFiltersFromUrl(timezone: string): FilterState {
+ if (typeof window === 'undefined') return {};
+
+ const urlParams = new URLSearchParams(window.location.search);
+ const initialFilters: FilterState = {};
+
+ // Parse 'q' (search query)
+ const q = urlParams.get('q');
+ if (q) {
+ initialFilters.q = q;
+ }
+
+ // Parse 'period' (time range)
+ const periodParam = urlParams.get('period');
+ if (periodParam) {
+ const deserialized = deserializeTimeRange(periodParam);
+ if (deserialized) {
+ // If it's a preset without timestamps, calculate them now
+ if (
+ deserialized.type === 'preset' &&
+ deserialized.preset &&
+ (!deserialized.from || !deserialized.to)
+ ) {
+ const preset = getPresetByKey(deserialized.preset, DEFAULT_PRESETS);
+ if (preset) {
+ const range = getPresetRange(preset, timezone);
+ initialFilters.period = {
+ type: 'preset',
+ preset: deserialized.preset,
+ from: range.from,
+ to: range.to,
+ };
+ }
+ } else {
+ initialFilters.period = deserialized;
+ }
+ }
+ }
+
+ // Parse 'actions' (array)
+ const actions = urlParams.get('actions');
+ if (actions) {
+ initialFilters.actions = actions.split(',').filter(Boolean);
+ }
+
+ // Parse 'resources' (array)
+ const resources = urlParams.get('resources');
+ if (resources) {
+ initialFilters.resources = resources.split(',').filter(Boolean);
+ }
+
+ return initialFilters;
+}
+
+export function useActivityLogTable(
+ options: UseActivityLogTableOptions
+): UseActivityLogTableReturn {
+ const {
+ scope,
+ defaultPageSize = 20,
+ defaultResource,
+ initialActions,
+ hideFilters = false,
+ } = options;
+
+ // ----------------------------------------
+ // Time range handling
+ // ----------------------------------------
+ const { userPreferences } = useApp();
+ const timezone = userPreferences?.timezone ?? getBrowserTimezone();
+
+ // ----------------------------------------
+ // Filter state - initialize from URL params, then initialActions
+ // ----------------------------------------
+ const [filters, setFilters] = useState(() => {
+ const urlFilters = getInitialFiltersFromUrl(timezone);
+
+ // If no URL params, use initialActions
+ if (!urlFilters.actions && initialActions) {
+ const actionsArray = Array.isArray(initialActions) ? initialActions : [initialActions];
+ return { ...urlFilters, actions: actionsArray };
+ }
+
+ return urlFilters;
+ });
+
+ // Normalize defaultResource to array
+ const effectiveResources = useMemo(() => {
+ if (defaultResource) {
+ return Array.isArray(defaultResource) ? defaultResource : [defaultResource];
+ }
+ return filters.resources;
+ }, [defaultResource, filters.resources]);
+
+ // Convert period filter to API format
+ const { startTime, endTime } = useMemo(() => {
+ // Pass null if no period - toApiTimeRange will use default preset
+ return toApiTimeRange(filters.period ?? null, timezone);
+ }, [filters.period, timezone]);
+
+ // ----------------------------------------
+ // Pagination state
+ // ----------------------------------------
+ const [pageSize, setPageSizeState] = useState(defaultPageSize);
+
+ // ----------------------------------------
+ // Query activity logs
+ // ----------------------------------------
+ const query = useActivityLogs(scope, {
+ filters: {
+ search: filters.q,
+ actions: filters.actions,
+ resources: effectiveResources,
+ },
+ startTime,
+ endTime,
+ pageSize,
+ });
+
+ // ----------------------------------------
+ // Reset pagination when filters change
+ // ----------------------------------------
+ const prevFiltersRef = useRef({
+ startTime,
+ endTime,
+ q: filters.q,
+ actions: filters.actions,
+ resources: filters.resources,
+ });
+
+ useEffect(() => {
+ const prev = prevFiltersRef.current;
+ const hasFilterChanged =
+ prev.startTime !== startTime ||
+ prev.endTime !== endTime ||
+ prev.q !== filters.q ||
+ JSON.stringify(prev.actions) !== JSON.stringify(filters.actions) ||
+ JSON.stringify(prev.resources) !== JSON.stringify(filters.resources);
+
+ if (hasFilterChanged) {
+ query.resetPagination();
+ prevFiltersRef.current = {
+ startTime,
+ endTime,
+ q: filters.q,
+ actions: filters.actions,
+ resources: filters.resources,
+ };
+ }
+ }, [startTime, endTime, filters.q, filters.actions, filters.resources, query]);
+
+ // Page size change handler (resets pagination)
+ const setPageSize = useCallback(
+ (size: number) => {
+ setPageSizeState(size);
+ query.resetPagination();
+ },
+ [query]
+ );
+
+ // ----------------------------------------
+ // Smart filter setter that properly handles period deserialization
+ // ----------------------------------------
+ const handleSetFilters = useCallback(
+ (newFilters: FilterState) => {
+ setFilters((prevFilters) => {
+ const merged = { ...prevFilters, ...newFilters };
+
+ // If period is a raw string from DataTableContext, deserialize it
+ if (merged.period && typeof merged.period === 'string') {
+ const deserialized = deserializeTimeRange(merged.period as unknown as string);
+ if (deserialized) {
+ // If it's a preset without timestamps, calculate them
+ if (
+ deserialized.type === 'preset' &&
+ deserialized.preset &&
+ (!deserialized.from || !deserialized.to)
+ ) {
+ const preset = getPresetByKey(deserialized.preset, DEFAULT_PRESETS);
+ if (preset) {
+ const range = getPresetRange(preset, timezone);
+ merged.period = {
+ type: 'preset',
+ preset: deserialized.preset,
+ from: range.from,
+ to: range.to,
+ };
+ }
+ } else {
+ merged.period = deserialized;
+ }
+ }
+ }
+
+ return merged;
+ });
+ },
+ [timezone]
+ );
+
+ // ----------------------------------------
+ // Return
+ // ----------------------------------------
+ return {
+ // Data
+ data: query.data,
+
+ // Refetch
+ refetch: query.refetch,
+
+ // Loading states
+ isLoading: query.isLoading,
+ isFetching: query.isFetching,
+
+ // Error state
+ error: query.error,
+
+ // Filter state
+ filters,
+ setFilters: hideFilters ? () => {} : handleSetFilters,
+
+ // Time range
+ startTime,
+ endTime,
+
+ // Pagination state
+ pageSize,
+ setPageSize,
+ page: query.page,
+ hasNextPage: query.hasNextPage,
+ hasPrevPage: query.hasPrevPage,
+
+ // Pagination actions
+ goToNextPage: query.goToNextPage,
+ goToPrevPage: query.goToPrevPage,
+ resetPagination: query.resetPagination,
+
+ // Computed values
+ effectiveResources,
+ };
+}
diff --git a/app/features/connectors/connector-download-card.tsx b/app/features/connectors/connector-download-card.tsx
index 578b57c4a..1116b70fe 100644
--- a/app/features/connectors/connector-download-card.tsx
+++ b/app/features/connectors/connector-download-card.tsx
@@ -1,93 +1,93 @@
-import { OsIcon } from '@/components/icon/os-icon';
-import { DATUM_DESKTOP_DOWNLOAD_URL } from '@/utils/config/query.config';
-import { Button, Card, CardContent, CloseIcon, LinkButton } from '@datum-ui/components';
-import { DownloadIcon } from 'lucide-react';
-import { useEffect, useState } from 'react';
-
-/** Detect OS from browser for download CTA (client-only). */
-function detectBrowserOs(): 'windows' | 'macos' | 'linux' | null {
- if (typeof navigator === 'undefined') return null;
- const ua = navigator.userAgent.toLowerCase();
- const platform = (
- navigator as { userAgentData?: { platform: string } }
- ).userAgentData?.platform?.toLowerCase();
- // Skip mobile platforms — no connector binary available
- if (platform === 'android' || ua.includes('android')) return null;
- if (/iphone|ipad|ipod/.test(ua)) return null;
- if (platform === 'macos' || ua.includes('mac')) return 'macos';
- if (platform === 'windows' || ua.includes('win')) return 'windows';
- if (platform === 'linux' || ua.includes('linux')) return 'linux';
- return null;
-}
-
-const OS_PATH: Record = {
- macos: 'mac-os',
- windows: 'windows',
- linux: 'linux',
-};
-
-const OS_LABELS: Record = {
- macos: 'macOS',
- windows: 'Windows',
- linux: 'Linux',
-};
-
-type ConnectorDownloadCardProps = {
- onDismiss?: () => void;
-};
-
-export function ConnectorDownloadCard({ onDismiss }: ConnectorDownloadCardProps) {
- const [os, setOs] = useState<'windows' | 'macos' | 'linux' | null>(null);
-
- useEffect(() => {
- setOs(detectBrowserOs());
- }, []);
-
- if (!os) return null;
-
- const osLabel = OS_LABELS[os] ?? os;
- const downloadUrl = `${DATUM_DESKTOP_DOWNLOAD_URL}/${OS_PATH[os] ?? os}`;
-
- return (
-
- {onDismiss && (
-
-
-
- )}
-
-
-
-
-
-
-
Start a connector
-
- Download Datum Desktop for {osLabel} to run a connector on this
- device.
-
-
}
- href={downloadUrl}
- target="_blank">
- Download for {osLabel}
-
-
-
-
-
- );
-}
+import { OsIcon } from '@/components/icon/os-icon';
+import { DATUM_DESKTOP_DOWNLOAD_URL } from '@/utils/config/query.config';
+import { Button, Card, CardContent, CloseIcon, LinkButton } from '@datum-ui/components';
+import { DownloadIcon } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+/** Detect OS from browser for download CTA (client-only). */
+function detectBrowserOs(): 'windows' | 'macos' | 'linux' | null {
+ if (typeof navigator === 'undefined') return null;
+ const ua = navigator.userAgent.toLowerCase();
+ const platform = (
+ navigator as { userAgentData?: { platform: string } }
+ ).userAgentData?.platform?.toLowerCase();
+ // Skip mobile platforms — no connector binary available
+ if (platform === 'android' || ua.includes('android')) return null;
+ if (/iphone|ipad|ipod/.test(ua)) return null;
+ if (platform === 'macos' || ua.includes('mac')) return 'macos';
+ if (platform === 'windows' || ua.includes('win')) return 'windows';
+ if (platform === 'linux' || ua.includes('linux')) return 'linux';
+ return null;
+}
+
+const OS_PATH: Record = {
+ macos: 'mac-os',
+ windows: 'windows',
+ linux: 'linux',
+};
+
+const OS_LABELS: Record = {
+ macos: 'macOS',
+ windows: 'Windows',
+ linux: 'Linux',
+};
+
+type ConnectorDownloadCardProps = {
+ onDismiss?: () => void;
+};
+
+export function ConnectorDownloadCard({ onDismiss }: ConnectorDownloadCardProps) {
+ const [os, setOs] = useState<'windows' | 'macos' | 'linux' | null>(null);
+
+ useEffect(() => {
+ setOs(detectBrowserOs());
+ }, []);
+
+ if (!os) return null;
+
+ const osLabel = OS_LABELS[os] ?? os;
+ const downloadUrl = `${DATUM_DESKTOP_DOWNLOAD_URL}/${OS_PATH[os] ?? os}`;
+
+ return (
+
+ {onDismiss && (
+
+
+
+ )}
+
+
+
+
+
+
+
Start a connector
+
+ Download Datum Desktop for {osLabel} to run a connector on this
+ device.
+
+
}
+ href={downloadUrl}
+ target="_blank">
+ Download for {osLabel}
+
+
+
+
+
+ );
+}
diff --git a/app/features/edge/dns-records/dns-record-ai-edge-cell.tsx b/app/features/edge/dns-records/dns-record-ai-edge-cell.tsx
index 2362939e1..668d293ca 100644
--- a/app/features/edge/dns-records/dns-record-ai-edge-cell.tsx
+++ b/app/features/edge/dns-records/dns-record-ai-edge-cell.tsx
@@ -1,119 +1,119 @@
-import { isEligibleForProtect } from './utils';
-import { IFlattenedDnsRecord } from '@/resources/dns-records';
-import { Button, Tooltip } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { EyeIcon, ShieldCheckIcon, ShieldOffIcon } from 'lucide-react';
-import { useState } from 'react';
-
-export interface RemoveEdgeCallbacks {
- /** Called when the user confirms in the dialog (before the mutation runs). Used to disable the button only after confirm. */
- onMutationStart?: () => void;
-}
-
-export interface DnsRecordAiEdgeCellProps {
- record: IFlattenedDnsRecord;
- zoneDomain: string;
-
- onProtect: (record: IFlattenedDnsRecord) => Promise;
- onRemove: (record: IFlattenedDnsRecord, callbacks?: RemoveEdgeCallbacks) => Promise;
- onViewProxy: (proxyId: string) => void;
-}
-
-/**
- * Renders the AI Edge column cell: Protect / Remove / View button or "DNS only".
- * Buttons are disabled (no loading spinner) while the action is in progress to avoid layout shift.
- */
-export function DnsRecordAiEdgeCell({
- record,
- zoneDomain: _zoneDomain,
- onProtect,
- onRemove,
- onViewProxy,
-}: DnsRecordAiEdgeCellProps) {
- const [isProtecting, setIsProtecting] = useState(false);
- const [isRemoving, setIsRemoving] = useState(false);
-
- const showProtect =
- isEligibleForProtect(record.type) &&
- record.status?.isProgrammed &&
- record.managedByGateway !== true &&
- record.hasProxyForThisRecord !== true;
- const showRemove =
- isEligibleForProtect(record.type) &&
- record.status?.isProgrammed &&
- record.managedByGateway !== true &&
- record.hasProxyForThisRecord === true &&
- record.linkedProxyId;
- const showView = record.managedByGateway === true && record.gatewaySourceName;
-
- const handleProtect = () => {
- setIsProtecting(true);
- onProtect(record).finally(() => setIsProtecting(false));
- };
-
- const handleRemove = () => {
- onRemove(record, {
- onMutationStart: () => setIsRemoving(true),
- }).finally(() => setIsRemoving(false));
- };
-
- if (showProtect) {
- const isIpOrigin = record.type === 'A' || record.type === 'AAAA';
- const protectButton = (
- }
- iconPosition="left">
- Protect with AI Edge
-
- );
- if (isIpOrigin) {
- return (
-
- {protectButton}
-
- );
- }
- return protectButton;
- }
-
- if (showRemove) {
- return (
- }
- iconPosition="left">
- Remove AI Edge
-
- );
- }
-
- if (showView) {
- return (
- onViewProxy(record.gatewaySourceName!)}
- icon={ }
- iconPosition="left">
- View AI Edge
-
- );
- }
-
- return DNS only ;
-}
+import { isEligibleForProtect } from './utils';
+import { IFlattenedDnsRecord } from '@/resources/dns-records';
+import { Button, Tooltip } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { EyeIcon, ShieldCheckIcon, ShieldOffIcon } from 'lucide-react';
+import { useState } from 'react';
+
+export interface RemoveEdgeCallbacks {
+ /** Called when the user confirms in the dialog (before the mutation runs). Used to disable the button only after confirm. */
+ onMutationStart?: () => void;
+}
+
+export interface DnsRecordAiEdgeCellProps {
+ record: IFlattenedDnsRecord;
+ zoneDomain: string;
+
+ onProtect: (record: IFlattenedDnsRecord) => Promise;
+ onRemove: (record: IFlattenedDnsRecord, callbacks?: RemoveEdgeCallbacks) => Promise;
+ onViewProxy: (proxyId: string) => void;
+}
+
+/**
+ * Renders the AI Edge column cell: Protect / Remove / View button or "DNS only".
+ * Buttons are disabled (no loading spinner) while the action is in progress to avoid layout shift.
+ */
+export function DnsRecordAiEdgeCell({
+ record,
+ zoneDomain: _zoneDomain,
+ onProtect,
+ onRemove,
+ onViewProxy,
+}: DnsRecordAiEdgeCellProps) {
+ const [isProtecting, setIsProtecting] = useState(false);
+ const [isRemoving, setIsRemoving] = useState(false);
+
+ const showProtect =
+ isEligibleForProtect(record.type) &&
+ record.status?.isProgrammed &&
+ record.managedByGateway !== true &&
+ record.hasProxyForThisRecord !== true;
+ const showRemove =
+ isEligibleForProtect(record.type) &&
+ record.status?.isProgrammed &&
+ record.managedByGateway !== true &&
+ record.hasProxyForThisRecord === true &&
+ record.linkedProxyId;
+ const showView = record.managedByGateway === true && record.gatewaySourceName;
+
+ const handleProtect = () => {
+ setIsProtecting(true);
+ onProtect(record).finally(() => setIsProtecting(false));
+ };
+
+ const handleRemove = () => {
+ onRemove(record, {
+ onMutationStart: () => setIsRemoving(true),
+ }).finally(() => setIsRemoving(false));
+ };
+
+ if (showProtect) {
+ const isIpOrigin = record.type === 'A' || record.type === 'AAAA';
+ const protectButton = (
+ }
+ iconPosition="left">
+ Protect with AI Edge
+
+ );
+ if (isIpOrigin) {
+ return (
+
+ {protectButton}
+
+ );
+ }
+ return protectButton;
+ }
+
+ if (showRemove) {
+ return (
+ }
+ iconPosition="left">
+ Remove AI Edge
+
+ );
+ }
+
+ if (showView) {
+ return (
+ onViewProxy(record.gatewaySourceName!)}
+ icon={ }
+ iconPosition="left">
+ View AI Edge
+
+ );
+ }
+
+ return DNS only ;
+}
diff --git a/app/features/edge/dns-records/dns-record-card.tsx b/app/features/edge/dns-records/dns-record-card.tsx
index 2da8abc9b..e10776618 100644
--- a/app/features/edge/dns-records/dns-record-card.tsx
+++ b/app/features/edge/dns-records/dns-record-card.tsx
@@ -1,36 +1,36 @@
-import { DnsRecordTable } from './dns-record-table';
-import type { DnsRecordCardProps } from './types';
-import { Card, CardContent, CardHeader, CardTitle } from '@datum-ui/components';
-import { useMemo } from 'react';
-
-/**
- * Card wrapper for DNS record table in compact mode
- * Used in overview pages
- */
-export const DnsRecordCard = ({
- records,
- projectId,
- maxRows = 5,
- title = 'DNS Records',
- actions,
-}: DnsRecordCardProps) => {
- // Slice data at card level for better control
- const displayData = useMemo(
- () => (maxRows ? records.slice(0, maxRows) : records),
- [records, maxRows]
- );
-
- return (
-
-
-
- {title}
- {actions}
-
-
-
-
-
-
- );
-};
+import { DnsRecordTable } from './dns-record-table';
+import type { DnsRecordCardProps } from './types';
+import { Card, CardContent, CardHeader, CardTitle } from '@datum-ui/components';
+import { useMemo } from 'react';
+
+/**
+ * Card wrapper for DNS record table in compact mode
+ * Used in overview pages
+ */
+export const DnsRecordCard = ({
+ records,
+ projectId,
+ maxRows = 5,
+ title = 'DNS Records',
+ actions,
+}: DnsRecordCardProps) => {
+ // Slice data at card level for better control
+ const displayData = useMemo(
+ () => (maxRows ? records.slice(0, maxRows) : records),
+ [records, maxRows]
+ );
+
+ return (
+
+
+
+ {title}
+ {actions}
+
+
+
+
+
+
+ );
+};
diff --git a/app/features/edge/dns-records/dns-record-modal-form.tsx b/app/features/edge/dns-records/dns-record-modal-form.tsx
index 816b52245..42aa6d6e0 100644
--- a/app/features/edge/dns-records/dns-record-modal-form.tsx
+++ b/app/features/edge/dns-records/dns-record-modal-form.tsx
@@ -1,88 +1,88 @@
-import { DnsRecordForm } from '../dns-zone/form/dns-record-form';
-import type { DnsRecordModalFormProps, DnsRecordModalFormRef } from './types';
-import { IFlattenedDnsRecord } from '@/resources/dns-records';
-import { CreateDnsRecordSchema } from '@/resources/dns-records';
-import { recordToFormDefaultValue } from '@/utils/helpers/dns-record.helper';
-import { Dialog } from '@datum-ui/components/dialog';
-import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
-
-export type { DnsRecordModalFormRef } from './types';
-
-export const DnsRecordModalForm = forwardRef(
- ({ projectId, dnsZoneId, dnsZoneName, onSuccess }, ref) => {
- const [isOpen, setIsOpen] = useState(false);
- const resolveRef = useRef<(value: boolean) => void>(null);
- const [mode, setMode] = useState<'create' | 'edit'>('create');
- const [initialData, setInitialData] = useState(null);
-
- useImperativeHandle(ref, () => ({
- show: (formMode: 'create' | 'edit', data?: IFlattenedDnsRecord) => {
- setMode(formMode);
- setInitialData(data || null);
- setIsOpen(true);
- return new Promise((resolve) => {
- resolveRef.current = resolve;
- });
- },
- }));
-
- const handleOpenChange = (open: boolean) => {
- if (!open) {
- resolveRef.current?.(false);
- }
- setIsOpen(open);
- };
-
- // Transform flattened data to schema format if needed
- const defaultValue: CreateDnsRecordSchema | undefined = initialData
- ? ({
- ...recordToFormDefaultValue(initialData),
- dnsZoneRef: dnsZoneName ? { name: dnsZoneName } : undefined,
- } as CreateDnsRecordSchema)
- : undefined;
-
- const handleSuccess = () => {
- resolveRef.current?.(true);
- onSuccess?.();
- setIsOpen(false);
- };
-
- const handleClose = () => {
- setIsOpen(false);
- };
-
- return (
-
-
-
-
-
-
-
-
- );
- }
-);
-
-DnsRecordModalForm.displayName = 'DnsRecordModalForm';
+import { DnsRecordForm } from '../dns-zone/form/dns-record-form';
+import type { DnsRecordModalFormProps, DnsRecordModalFormRef } from './types';
+import { IFlattenedDnsRecord } from '@/resources/dns-records';
+import { CreateDnsRecordSchema } from '@/resources/dns-records';
+import { recordToFormDefaultValue } from '@/utils/helpers/dns-record.helper';
+import { Dialog } from '@datum-ui/components/dialog';
+import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
+
+export type { DnsRecordModalFormRef } from './types';
+
+export const DnsRecordModalForm = forwardRef(
+ ({ projectId, dnsZoneId, dnsZoneName, onSuccess }, ref) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const resolveRef = useRef<(value: boolean) => void>(null);
+ const [mode, setMode] = useState<'create' | 'edit'>('create');
+ const [initialData, setInitialData] = useState(null);
+
+ useImperativeHandle(ref, () => ({
+ show: (formMode: 'create' | 'edit', data?: IFlattenedDnsRecord) => {
+ setMode(formMode);
+ setInitialData(data || null);
+ setIsOpen(true);
+ return new Promise((resolve) => {
+ resolveRef.current = resolve;
+ });
+ },
+ }));
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) {
+ resolveRef.current?.(false);
+ }
+ setIsOpen(open);
+ };
+
+ // Transform flattened data to schema format if needed
+ const defaultValue: CreateDnsRecordSchema | undefined = initialData
+ ? ({
+ ...recordToFormDefaultValue(initialData),
+ dnsZoneRef: dnsZoneName ? { name: dnsZoneName } : undefined,
+ } as CreateDnsRecordSchema)
+ : undefined;
+
+ const handleSuccess = () => {
+ resolveRef.current?.(true);
+ onSuccess?.();
+ setIsOpen(false);
+ };
+
+ const handleClose = () => {
+ setIsOpen(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+);
+
+DnsRecordModalForm.displayName = 'DnsRecordModalForm';
diff --git a/app/features/edge/dns-records/dns-record-table.tsx b/app/features/edge/dns-records/dns-record-table.tsx
index 79ccbb59b..4c778583c 100644
--- a/app/features/edge/dns-records/dns-record-table.tsx
+++ b/app/features/edge/dns-records/dns-record-table.tsx
@@ -1,242 +1,242 @@
-import { DnsRecordStatus } from './dns-record-status';
-import type { DnsRecordTableProps } from './types';
-import { DataTable } from '@/modules/datum-ui/components/data-table';
-import { DataTableRef } from '@/modules/datum-ui/components/data-table';
-import { IFlattenedDnsRecord } from '@/resources/dns-records';
-import { formatTTL } from '@/utils/helpers/dns-record.helper';
-import { Badge, Icon, Tooltip } from '@datum-ui/components';
-import { ColumnDef } from '@tanstack/react-table';
-import { InfoIcon, LockIcon } from 'lucide-react';
-import { forwardRef, useMemo } from 'react';
-
-/**
- * Unified DNS record table component
- * - mode="compact": Simple table without pagination/toolbar/actions (for overview pages)
- * - mode="full": Full DataTable with pagination, search, filters, actions (for standalone pages)
- *
- * Displays flattened DNS records with columns: Type, Name, Value, TTL
- * Each value in DNS records becomes a separate row
- */
-export const DnsRecordTable = forwardRef, DnsRecordTableProps>(
- (
- {
- data,
- mode = 'compact',
- className,
- emptyContent,
- tableContainerClassName,
- projectId,
- showStatus = true,
- getRowId,
- renderAiEdgeCell,
- ...props
- },
- ref
- ) => {
- const columns: ColumnDef[] = useMemo(
- () => [
- {
- header: 'Type',
- accessorKey: 'type',
- size: 120,
- filterFn: 'arrayOr', // Use OR logic for multi-select filter
- cell: ({ row }) => {
- const { type, _meta, lockReason } = row.original;
- const wasTransformed = _meta?.transformedFrom;
-
- return (
-
-
- {type}
-
-
- {wasTransformed && (
-
-
-
- )}
-
- {showStatus && (
-
- )}
-
- {lockReason && (
-
-
-
- )}
-
- );
- },
- meta: {
- sortPath: 'type',
- sortType: 'text',
- },
- },
- {
- header: 'Name',
- accessorKey: 'name',
- size: 150,
- cell: ({ row }) => {
- return {row.original.name} ;
- },
- meta: {
- sortPath: 'name',
- sortType: 'text',
- },
- },
- {
- header: 'Content',
- accessorKey: 'value',
- enableSorting: false,
- meta: {
- className: 'max-w-96 truncate',
- },
- cell: ({ row }) => {
- const { type, value } = row.original;
-
- // MX records: decode "preference|exchange" format
- if (type === 'MX' && value.includes('|')) {
- const [preference, exchange] = value.split('|');
- return (
-
-
- {exchange}
-
-
-
- {preference}
-
-
-
- );
- }
-
- // SOA records: parse JSON and format for display
- if (type === 'SOA') {
- try {
- const soa = JSON.parse(value);
- const formattedValue = `${soa.mname} ${soa.rname} ${soa.refresh || 0} ${soa.retry || 0} ${soa.expire || 0} ${soa.ttl || 0}`;
- return (
-
-
- {soa.mname} {soa.rname} {soa.refresh || 0} {soa.retry || 0} {soa.expire || 0}{' '}
- {soa.ttl || 0}
-
-
- );
- } catch {
- // Fallback if JSON parsing fails
- return (
-
- {value}
-
- );
- }
- }
-
- return (
-
- {value}
-
- );
- },
- },
- ...(mode === 'full' && renderAiEdgeCell
- ? [
- {
- id: 'aiEdge',
- header: () => AI Edge ,
- cell: ({ row }) => (
-
- {renderAiEdgeCell(row.original)}
-
- ),
-
- meta: {
- tooltip: "Protect your origin with Datum's AI Edge",
- },
- enableSorting: false,
- size: 180,
- } as ColumnDef,
- ]
- : []),
- {
- header: 'TTL',
- accessorKey: 'ttl',
- size: 100,
- cell: ({ row }) => {
- return {formatTTL(row.original.ttl)} ;
- },
- meta: {
- sortPath: 'ttl',
- sortType: 'number',
- },
- },
- ],
- [mode, renderAiEdgeCell, projectId, showStatus]
- );
-
- // Compact mode: Simple table without actions/pagination/toolbar
- if (mode === 'compact') {
- return (
-
- );
- }
-
- // Full mode: Spread all DataTable props
- return (
-
- );
- }
-);
-
-DnsRecordTable.displayName = 'DnsRecordTable';
+import { DnsRecordStatus } from './dns-record-status';
+import type { DnsRecordTableProps } from './types';
+import { DataTable } from '@/modules/datum-ui/components/data-table';
+import { DataTableRef } from '@/modules/datum-ui/components/data-table';
+import { IFlattenedDnsRecord } from '@/resources/dns-records';
+import { formatTTL } from '@/utils/helpers/dns-record.helper';
+import { Badge, Icon, Tooltip } from '@datum-ui/components';
+import { ColumnDef } from '@tanstack/react-table';
+import { InfoIcon, LockIcon } from 'lucide-react';
+import { forwardRef, useMemo } from 'react';
+
+/**
+ * Unified DNS record table component
+ * - mode="compact": Simple table without pagination/toolbar/actions (for overview pages)
+ * - mode="full": Full DataTable with pagination, search, filters, actions (for standalone pages)
+ *
+ * Displays flattened DNS records with columns: Type, Name, Value, TTL
+ * Each value in DNS records becomes a separate row
+ */
+export const DnsRecordTable = forwardRef, DnsRecordTableProps>(
+ (
+ {
+ data,
+ mode = 'compact',
+ className,
+ emptyContent,
+ tableContainerClassName,
+ projectId,
+ showStatus = true,
+ getRowId,
+ renderAiEdgeCell,
+ ...props
+ },
+ ref
+ ) => {
+ const columns: ColumnDef[] = useMemo(
+ () => [
+ {
+ header: 'Type',
+ accessorKey: 'type',
+ size: 120,
+ filterFn: 'arrayOr', // Use OR logic for multi-select filter
+ cell: ({ row }) => {
+ const { type, _meta, lockReason } = row.original;
+ const wasTransformed = _meta?.transformedFrom;
+
+ return (
+
+
+ {type}
+
+
+ {wasTransformed && (
+
+
+
+ )}
+
+ {showStatus && (
+
+ )}
+
+ {lockReason && (
+
+
+
+ )}
+
+ );
+ },
+ meta: {
+ sortPath: 'type',
+ sortType: 'text',
+ },
+ },
+ {
+ header: 'Name',
+ accessorKey: 'name',
+ size: 150,
+ cell: ({ row }) => {
+ return {row.original.name} ;
+ },
+ meta: {
+ sortPath: 'name',
+ sortType: 'text',
+ },
+ },
+ {
+ header: 'Content',
+ accessorKey: 'value',
+ enableSorting: false,
+ meta: {
+ className: 'max-w-96 truncate',
+ },
+ cell: ({ row }) => {
+ const { type, value } = row.original;
+
+ // MX records: decode "preference|exchange" format
+ if (type === 'MX' && value.includes('|')) {
+ const [preference, exchange] = value.split('|');
+ return (
+
+
+ {exchange}
+
+
+
+ {preference}
+
+
+
+ );
+ }
+
+ // SOA records: parse JSON and format for display
+ if (type === 'SOA') {
+ try {
+ const soa = JSON.parse(value);
+ const formattedValue = `${soa.mname} ${soa.rname} ${soa.refresh || 0} ${soa.retry || 0} ${soa.expire || 0} ${soa.ttl || 0}`;
+ return (
+
+
+ {soa.mname} {soa.rname} {soa.refresh || 0} {soa.retry || 0} {soa.expire || 0}{' '}
+ {soa.ttl || 0}
+
+
+ );
+ } catch {
+ // Fallback if JSON parsing fails
+ return (
+
+ {value}
+
+ );
+ }
+ }
+
+ return (
+
+ {value}
+
+ );
+ },
+ },
+ ...(mode === 'full' && renderAiEdgeCell
+ ? [
+ {
+ id: 'aiEdge',
+ header: () => AI Edge ,
+ cell: ({ row }) => (
+
+ {renderAiEdgeCell(row.original)}
+
+ ),
+
+ meta: {
+ tooltip: "Protect your origin with Datum's AI Edge",
+ },
+ enableSorting: false,
+ size: 180,
+ } as ColumnDef,
+ ]
+ : []),
+ {
+ header: 'TTL',
+ accessorKey: 'ttl',
+ size: 100,
+ cell: ({ row }) => {
+ return {formatTTL(row.original.ttl)} ;
+ },
+ meta: {
+ sortPath: 'ttl',
+ sortType: 'number',
+ },
+ },
+ ],
+ [mode, renderAiEdgeCell, projectId, showStatus]
+ );
+
+ // Compact mode: Simple table without actions/pagination/toolbar
+ if (mode === 'compact') {
+ return (
+
+ );
+ }
+
+ // Full mode: Spread all DataTable props
+ return (
+
+ );
+ }
+);
+
+DnsRecordTable.displayName = 'DnsRecordTable';
diff --git a/app/features/edge/dns-records/import-export/components/dropzone-state-content.tsx b/app/features/edge/dns-records/import-export/components/dropzone-state-content.tsx
index 5ffe43433..946d63f94 100644
--- a/app/features/edge/dns-records/import-export/components/dropzone-state-content.tsx
+++ b/app/features/edge/dns-records/import-export/components/dropzone-state-content.tsx
@@ -1,52 +1,52 @@
-import { SpinnerIcon } from '@datum-ui/components';
-import { DropzoneContent } from '@datum-ui/components/dropzone/dropzone';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { TriangleAlert } from 'lucide-react';
-
-// =============================================================================
-// Types
-// =============================================================================
-
-export type DropzoneState = 'idle' | 'loading' | 'error' | 'success';
-
-interface DropzoneStateContentProps {
- state: DropzoneState;
- errorMessage: string | null;
-}
-
-// =============================================================================
-// Component
-// =============================================================================
-
-/**
- * Dropzone state content for loading and error states
- */
-export const DropzoneStateContent = ({ state, errorMessage }: DropzoneStateContentProps) => {
- if (state === 'loading') {
- return (
- }
- description={
- Parsing zone file...
- }
- />
- );
- }
-
- if (state === 'error') {
- return (
-
- }
- description={
-
- {errorMessage || 'Failed to parse file'}
-
- }
- />
- );
- }
-
- return null;
-};
+import { SpinnerIcon } from '@datum-ui/components';
+import { DropzoneContent } from '@datum-ui/components/dropzone/dropzone';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { TriangleAlert } from 'lucide-react';
+
+// =============================================================================
+// Types
+// =============================================================================
+
+export type DropzoneState = 'idle' | 'loading' | 'error' | 'success';
+
+interface DropzoneStateContentProps {
+ state: DropzoneState;
+ errorMessage: string | null;
+}
+
+// =============================================================================
+// Component
+// =============================================================================
+
+/**
+ * Dropzone state content for loading and error states
+ */
+export const DropzoneStateContent = ({ state, errorMessage }: DropzoneStateContentProps) => {
+ if (state === 'loading') {
+ return (
+ }
+ description={
+ Parsing zone file...
+ }
+ />
+ );
+ }
+
+ if (state === 'error') {
+ return (
+
+ }
+ description={
+
+ {errorMessage || 'Failed to parse file'}
+
+ }
+ />
+ );
+ }
+
+ return null;
+};
diff --git a/app/features/edge/dns-records/import-export/components/import-result-table.tsx b/app/features/edge/dns-records/import-export/components/import-result-table.tsx
index 725fba93d..590b0a7d8 100644
--- a/app/features/edge/dns-records/import-export/components/import-result-table.tsx
+++ b/app/features/edge/dns-records/import-export/components/import-result-table.tsx
@@ -1,177 +1,177 @@
-import { DataTable } from '@/modules/datum-ui/components/data-table';
-import { formatTTL, type ImportDetail } from '@/utils/helpers/dns-record.helper';
-import { Badge, Tooltip } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { cn } from '@shadcn/lib/utils';
-import { ColumnDef } from '@tanstack/react-table';
-import { CheckCircle2, XCircle, AlertCircle, MinusCircle } from 'lucide-react';
-import { useMemo } from 'react';
-
-// =============================================================================
-// Status Configuration
-// =============================================================================
-
-/**
- * Get icon and styling for import action status
- */
-const getStatusConfig = (action: ImportDetail['action']) => {
- switch (action) {
- case 'created':
- return {
- icon: CheckCircle2,
- label: 'Created',
- className: 'text-green-600',
- };
- case 'updated':
- return {
- icon: AlertCircle,
- label: 'Updated',
- className: 'text-blue-600',
- };
- case 'skipped':
- return {
- icon: MinusCircle,
- label: 'Skipped',
- className: 'text-muted-foreground',
- };
- case 'failed':
- return {
- icon: XCircle,
- label: 'Failed',
- className: 'text-destructive',
- };
- }
-};
-
-// =============================================================================
-// Component
-// =============================================================================
-
-interface ImportResultTableProps {
- details: ImportDetail[];
-}
-
-/**
- * Import result table showing individual record results
- * Uses same DataTable component as DnsRecordTable for consistent styling
- */
-export const ImportResultTable = ({ details }: ImportResultTableProps) => {
- const columns: ColumnDef[] = useMemo(
- () => [
- {
- header: 'Type',
- accessorKey: 'recordType',
- size: 120,
- cell: ({ row }) => {
- return (
-
- {row.original.recordType}
-
- );
- },
- },
- {
- header: 'Name',
- accessorKey: 'name',
- size: 150,
- cell: ({ row }) => {
- return {row.original.name || '@'} ;
- },
- },
- {
- header: 'Content',
- accessorKey: 'value',
- enableSorting: false,
- meta: {
- className: 'max-w-96 break-all text-wrap whitespace-normal',
- },
- cell: ({ row }) => {
- const { recordType, value } = row.original;
-
- // MX records: decode "preference|exchange" format
- if (recordType === 'MX' && value.includes('|')) {
- const [preference, exchange] = value.split('|');
- return (
-
- {exchange}
-
-
- {preference}
-
-
-
- );
- }
-
- // SOA records: parse JSON and format for display
- if (recordType === 'SOA') {
- try {
- const soa = JSON.parse(value);
- return (
-
- {soa.mname} {soa.rname} {soa.refresh || 0} {soa.retry || 0} {soa.expire || 0}{' '}
- {soa.ttl || 0}
-
- );
- } catch {
- // Fallback if JSON parsing fails
- return {value} ;
- }
- }
-
- return {value} ;
- },
- },
- {
- header: 'TTL',
- accessorKey: 'ttl',
- size: 100,
- cell: ({ row }) => {
- return {formatTTL(row.original.ttl)} ;
- },
- },
- {
- header: 'Status',
- accessorKey: 'action',
- size: 180,
- cell: ({ row }) => {
- const { action, message } = row.original;
- const statusConfig = getStatusConfig(action);
- const StatusIcon = statusConfig.icon;
- return (
-
-
-
-
- {statusConfig.label}
-
-
- {/* Show message for failed and skipped records */}
- {message && action !== 'created' && (
-
{message}
- )}
-
- );
- },
- },
- ],
- []
- );
-
- return (
-
- );
-};
+import { DataTable } from '@/modules/datum-ui/components/data-table';
+import { formatTTL, type ImportDetail } from '@/utils/helpers/dns-record.helper';
+import { Badge, Tooltip } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { cn } from '@shadcn/lib/utils';
+import { ColumnDef } from '@tanstack/react-table';
+import { CheckCircle2, XCircle, AlertCircle, MinusCircle } from 'lucide-react';
+import { useMemo } from 'react';
+
+// =============================================================================
+// Status Configuration
+// =============================================================================
+
+/**
+ * Get icon and styling for import action status
+ */
+const getStatusConfig = (action: ImportDetail['action']) => {
+ switch (action) {
+ case 'created':
+ return {
+ icon: CheckCircle2,
+ label: 'Created',
+ className: 'text-green-600',
+ };
+ case 'updated':
+ return {
+ icon: AlertCircle,
+ label: 'Updated',
+ className: 'text-blue-600',
+ };
+ case 'skipped':
+ return {
+ icon: MinusCircle,
+ label: 'Skipped',
+ className: 'text-muted-foreground',
+ };
+ case 'failed':
+ return {
+ icon: XCircle,
+ label: 'Failed',
+ className: 'text-destructive',
+ };
+ }
+};
+
+// =============================================================================
+// Component
+// =============================================================================
+
+interface ImportResultTableProps {
+ details: ImportDetail[];
+}
+
+/**
+ * Import result table showing individual record results
+ * Uses same DataTable component as DnsRecordTable for consistent styling
+ */
+export const ImportResultTable = ({ details }: ImportResultTableProps) => {
+ const columns: ColumnDef[] = useMemo(
+ () => [
+ {
+ header: 'Type',
+ accessorKey: 'recordType',
+ size: 120,
+ cell: ({ row }) => {
+ return (
+
+ {row.original.recordType}
+
+ );
+ },
+ },
+ {
+ header: 'Name',
+ accessorKey: 'name',
+ size: 150,
+ cell: ({ row }) => {
+ return {row.original.name || '@'} ;
+ },
+ },
+ {
+ header: 'Content',
+ accessorKey: 'value',
+ enableSorting: false,
+ meta: {
+ className: 'max-w-96 break-all text-wrap whitespace-normal',
+ },
+ cell: ({ row }) => {
+ const { recordType, value } = row.original;
+
+ // MX records: decode "preference|exchange" format
+ if (recordType === 'MX' && value.includes('|')) {
+ const [preference, exchange] = value.split('|');
+ return (
+
+ {exchange}
+
+
+ {preference}
+
+
+
+ );
+ }
+
+ // SOA records: parse JSON and format for display
+ if (recordType === 'SOA') {
+ try {
+ const soa = JSON.parse(value);
+ return (
+
+ {soa.mname} {soa.rname} {soa.refresh || 0} {soa.retry || 0} {soa.expire || 0}{' '}
+ {soa.ttl || 0}
+
+ );
+ } catch {
+ // Fallback if JSON parsing fails
+ return {value} ;
+ }
+ }
+
+ return {value} ;
+ },
+ },
+ {
+ header: 'TTL',
+ accessorKey: 'ttl',
+ size: 100,
+ cell: ({ row }) => {
+ return {formatTTL(row.original.ttl)} ;
+ },
+ },
+ {
+ header: 'Status',
+ accessorKey: 'action',
+ size: 180,
+ cell: ({ row }) => {
+ const { action, message } = row.original;
+ const statusConfig = getStatusConfig(action);
+ const StatusIcon = statusConfig.icon;
+ return (
+
+
+
+
+ {statusConfig.label}
+
+
+ {/* Show message for failed and skipped records */}
+ {message && action !== 'created' && (
+
{message}
+ )}
+
+ );
+ },
+ },
+ ],
+ []
+ );
+
+ return (
+
+ );
+};
diff --git a/app/features/edge/dns-records/import-export/dns-record-import-action.tsx b/app/features/edge/dns-records/import-export/dns-record-import-action.tsx
index 8dbe88a90..82470680b 100644
--- a/app/features/edge/dns-records/import-export/dns-record-import-action.tsx
+++ b/app/features/edge/dns-records/import-export/dns-record-import-action.tsx
@@ -1,312 +1,312 @@
-import { DropzoneStateContent, ImportResultTable } from './components';
-import { useDnsRecordExport, useDnsRecordImport } from './hooks';
-import { DnsRecordTable } from '@/features/edge/dns-records';
-import { IFlattenedDnsRecord } from '@/resources/dns-records';
-import { getImportResultStatus } from '@/utils/helpers/dns-record.helper';
-import { openSupportMessage } from '@/utils/open-support-message';
-import { Alert, AlertDescription, AlertTitle, Button, Dialog } from '@datum-ui/components';
-import { Dropzone, DropzoneEmptyState } from '@datum-ui/components/dropzone/dropzone';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { ResponsiveDropdown } from '@datum-ui/components/responsive-dropdown';
-import { AlertCircle, DownloadIcon, FileTextIcon, Import, Info, PlusIcon } from 'lucide-react';
-import { useState } from 'react';
-
-// =============================================================================
-// Constants
-// =============================================================================
-
-const MAX_FILE_SIZE = 1024 * 1024; // 1MB
-
-const ACCEPTED_FILE_TYPES = {
- 'text/plain': ['.zone', '.db', '.txt'],
- 'application/octet-stream': ['.zone', '.db'],
-};
-
-// =============================================================================
-// Types
-// =============================================================================
-
-interface DnsRecordImportExportProps {
- origin?: string;
- existingRecords: IFlattenedDnsRecord[];
- projectId: string;
- dnsZoneId: string;
- onSuccess?: () => void;
-}
-
-// =============================================================================
-// Main Component
-// =============================================================================
-
-export const DnsRecordImportAction = ({
- origin,
- existingRecords,
- projectId,
- dnsZoneId,
- onSuccess,
-}: DnsRecordImportExportProps) => {
- const [popoverOpen, setPopoverOpen] = useState(false);
- const [selectedRecords, setSelectedRecords] = useState([]);
-
- // Import hook
- const {
- files,
- dropzoneState,
- errorMessage,
- handleDrop,
- handleDropError,
- dialogOpen,
- setDialogOpen,
- dialogView,
- flattenedRecords,
- unsupportedRecords,
- skippedApexRecords,
- closeDialog,
- isImporting,
- handleImport,
- importResult,
- } = useDnsRecordImport({
- projectId,
- dnsZoneId,
- onSuccess,
- });
-
- // Export hook
- const { isExporting, handleExport } = useDnsRecordExport({
- existingRecords,
- dnsZoneId,
- origin,
- });
-
- const handleDropWithClose = async (droppedFiles: File[]) => {
- await handleDrop(droppedFiles);
- setPopoverOpen(false);
- };
-
- const handleContactSupport = () => {
- openSupportMessage({ subject: 'DNS Record Import Support' });
- };
-
- return (
- <>
-
-
- Import & Export
-
- }>
-
- {/* Import Section */}
-
-
Import DNS Records
-
-
-
- }
- description={
-
- Select a file or drag it here (BIND
- format only)
-
- }
- />
-
-
-
-
- {/* Export Section */}
-
-
Export DNS Records
-
- }
- className="font-semibold">
- {isExporting ? 'Exporting...' : 'Download file'}
-
-
-
-
-
- {/* Import Dialog - Preview and Result Views */}
-
-
- {dialogView === 'preview' ? (
- <>
-
-
-
- `${row.type}-${row.name}-${row.value}`}
- onSelectionChange={(_selectedIds, selectedRows) => {
- setSelectedRecords(selectedRows);
- }}
- />
-
-
- {/* Alert for skipped apex SOA/NS records
- TODO: Allow advanced users to override this behavior in the future.
- @see https://github.com/datum-cloud/cloud-portal/issues/901 */}
- {skippedApexRecords && skippedApexRecords.totalCount > 0 && (
-
-
- Information
-
- {skippedApexRecords.soa.length > 0 && skippedApexRecords.ns.length > 0 ? (
- <>
- We found SOA and NS records at your zone apex which are managed
- automatically by Datum and won't be imported.
- >
- ) : skippedApexRecords.soa.length > 0 ? (
- <>
- We found an SOA record at your zone apex which is managed automatically by
- Datum and won't be imported.
- >
- ) : (
- <>
- We found {skippedApexRecords.ns.length} NS record
- {skippedApexRecords.ns.length > 1 ? 's' : ''} at your zone apex which{' '}
- {skippedApexRecords.ns.length > 1 ? 'are' : 'is'} managed automatically by
- Datum and won't be imported.
- >
- )}
-
-
- )}
-
- {unsupportedRecords && unsupportedRecords.totalRecords > 0 && (
-
-
- Error
-
- It's not possible to import {unsupportedRecords.totalRecords} other
- record
- {unsupportedRecords.totalRecords > 1 ? 's' : ''} we found as we don't
- support the following record type
- {unsupportedRecords.types.length > 1 ? 's' : ''}:{' '}
- {unsupportedRecords.types.length > 1
- ? `${unsupportedRecords.types.slice(0, -1).join(', ')}, and ${unsupportedRecords.types.slice(-1)}`
- : unsupportedRecords.types[0]}
- .{' '}
-
- Contact us
- {' '}
- to request support for{' '}
- {unsupportedRecords.types.length > 1 ? 'these' : 'this'} to be added to the
- product backlog.
-
-
- )}
-
-
-
- Cancel
-
- handleImport(selectedRecords)}
- icon={ }>
- {isImporting ? 'Importing...' : `Import ${selectedRecords.length} record(s)`}
-
-
- >
- ) : (
- <>
-
-
- {importResult && importResult.details.length > 0 ? (
- <>
- {getImportResultStatus(importResult.details) === 'warning' && (
-
-
- Some records failed to import. Please review the details below.
-
-
- )}
- {getImportResultStatus(importResult.details) === 'error' && (
-
-
- All records failed to import. Please review the error messages below.
-
-
- )}
-
- >
- ) : (
- No import details available.
- )}
-
-
-
- Done
-
-
- >
- )}
-
-
- >
- );
-};
+import { DropzoneStateContent, ImportResultTable } from './components';
+import { useDnsRecordExport, useDnsRecordImport } from './hooks';
+import { DnsRecordTable } from '@/features/edge/dns-records';
+import { IFlattenedDnsRecord } from '@/resources/dns-records';
+import { getImportResultStatus } from '@/utils/helpers/dns-record.helper';
+import { openSupportMessage } from '@/utils/open-support-message';
+import { Alert, AlertDescription, AlertTitle, Button, Dialog } from '@datum-ui/components';
+import { Dropzone, DropzoneEmptyState } from '@datum-ui/components/dropzone/dropzone';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { ResponsiveDropdown } from '@datum-ui/components/responsive-dropdown';
+import { AlertCircle, DownloadIcon, FileTextIcon, Import, Info, PlusIcon } from 'lucide-react';
+import { useState } from 'react';
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+const MAX_FILE_SIZE = 1024 * 1024; // 1MB
+
+const ACCEPTED_FILE_TYPES = {
+ 'text/plain': ['.zone', '.db', '.txt'],
+ 'application/octet-stream': ['.zone', '.db'],
+};
+
+// =============================================================================
+// Types
+// =============================================================================
+
+interface DnsRecordImportExportProps {
+ origin?: string;
+ existingRecords: IFlattenedDnsRecord[];
+ projectId: string;
+ dnsZoneId: string;
+ onSuccess?: () => void;
+}
+
+// =============================================================================
+// Main Component
+// =============================================================================
+
+export const DnsRecordImportAction = ({
+ origin,
+ existingRecords,
+ projectId,
+ dnsZoneId,
+ onSuccess,
+}: DnsRecordImportExportProps) => {
+ const [popoverOpen, setPopoverOpen] = useState(false);
+ const [selectedRecords, setSelectedRecords] = useState([]);
+
+ // Import hook
+ const {
+ files,
+ dropzoneState,
+ errorMessage,
+ handleDrop,
+ handleDropError,
+ dialogOpen,
+ setDialogOpen,
+ dialogView,
+ flattenedRecords,
+ unsupportedRecords,
+ skippedApexRecords,
+ closeDialog,
+ isImporting,
+ handleImport,
+ importResult,
+ } = useDnsRecordImport({
+ projectId,
+ dnsZoneId,
+ onSuccess,
+ });
+
+ // Export hook
+ const { isExporting, handleExport } = useDnsRecordExport({
+ existingRecords,
+ dnsZoneId,
+ origin,
+ });
+
+ const handleDropWithClose = async (droppedFiles: File[]) => {
+ await handleDrop(droppedFiles);
+ setPopoverOpen(false);
+ };
+
+ const handleContactSupport = () => {
+ openSupportMessage({ subject: 'DNS Record Import Support' });
+ };
+
+ return (
+ <>
+
+
+ Import & Export
+
+ }>
+
+ {/* Import Section */}
+
+
Import DNS Records
+
+
+
+ }
+ description={
+
+ Select a file or drag it here (BIND
+ format only)
+
+ }
+ />
+
+
+
+
+ {/* Export Section */}
+
+
Export DNS Records
+
+ }
+ className="font-semibold">
+ {isExporting ? 'Exporting...' : 'Download file'}
+
+
+
+
+
+ {/* Import Dialog - Preview and Result Views */}
+
+
+ {dialogView === 'preview' ? (
+ <>
+
+
+
+ `${row.type}-${row.name}-${row.value}`}
+ onSelectionChange={(_selectedIds, selectedRows) => {
+ setSelectedRecords(selectedRows);
+ }}
+ />
+
+
+ {/* Alert for skipped apex SOA/NS records
+ TODO: Allow advanced users to override this behavior in the future.
+ @see https://github.com/datum-cloud/cloud-portal/issues/901 */}
+ {skippedApexRecords && skippedApexRecords.totalCount > 0 && (
+
+
+ Information
+
+ {skippedApexRecords.soa.length > 0 && skippedApexRecords.ns.length > 0 ? (
+ <>
+ We found SOA and NS records at your zone apex which are managed
+ automatically by Datum and won't be imported.
+ >
+ ) : skippedApexRecords.soa.length > 0 ? (
+ <>
+ We found an SOA record at your zone apex which is managed automatically by
+ Datum and won't be imported.
+ >
+ ) : (
+ <>
+ We found {skippedApexRecords.ns.length} NS record
+ {skippedApexRecords.ns.length > 1 ? 's' : ''} at your zone apex which{' '}
+ {skippedApexRecords.ns.length > 1 ? 'are' : 'is'} managed automatically by
+ Datum and won't be imported.
+ >
+ )}
+
+
+ )}
+
+ {unsupportedRecords && unsupportedRecords.totalRecords > 0 && (
+
+
+ Error
+
+ It's not possible to import {unsupportedRecords.totalRecords} other
+ record
+ {unsupportedRecords.totalRecords > 1 ? 's' : ''} we found as we don't
+ support the following record type
+ {unsupportedRecords.types.length > 1 ? 's' : ''}:{' '}
+ {unsupportedRecords.types.length > 1
+ ? `${unsupportedRecords.types.slice(0, -1).join(', ')}, and ${unsupportedRecords.types.slice(-1)}`
+ : unsupportedRecords.types[0]}
+ .{' '}
+
+ Contact us
+ {' '}
+ to request support for{' '}
+ {unsupportedRecords.types.length > 1 ? 'these' : 'this'} to be added to the
+ product backlog.
+
+
+ )}
+
+
+
+ Cancel
+
+ handleImport(selectedRecords)}
+ icon={ }>
+ {isImporting ? 'Importing...' : `Import ${selectedRecords.length} record(s)`}
+
+
+ >
+ ) : (
+ <>
+
+
+ {importResult && importResult.details.length > 0 ? (
+ <>
+ {getImportResultStatus(importResult.details) === 'warning' && (
+
+
+ Some records failed to import. Please review the details below.
+
+
+ )}
+ {getImportResultStatus(importResult.details) === 'error' && (
+
+
+ All records failed to import. Please review the error messages below.
+
+
+ )}
+
+ >
+ ) : (
+ No import details available.
+ )}
+
+
+
+ Done
+
+
+ >
+ )}
+
+
+ >
+ );
+};
diff --git a/app/features/edge/dns-records/import-export/hooks/use-dns-record-export.ts b/app/features/edge/dns-records/import-export/hooks/use-dns-record-export.ts
index 5e3ab0048..14a60fac9 100644
--- a/app/features/edge/dns-records/import-export/hooks/use-dns-record-export.ts
+++ b/app/features/edge/dns-records/import-export/hooks/use-dns-record-export.ts
@@ -1,48 +1,48 @@
-import { IFlattenedDnsRecord } from '@/resources/dns-records';
-import { downloadFile } from '@/utils/common';
-import { transformRecordsToBindFormat } from '@/utils/helpers/dns-record.helper';
-import { toast } from '@datum-ui/components';
-import { useState } from 'react';
-
-interface UseDnsRecordExportProps {
- existingRecords: IFlattenedDnsRecord[];
- dnsZoneId: string;
- origin?: string;
-}
-
-export function useDnsRecordExport({
- existingRecords,
- dnsZoneId,
- origin,
-}: UseDnsRecordExportProps) {
- const [isExporting, setIsExporting] = useState(false);
-
- const handleExport = async () => {
- if (existingRecords.length === 0) {
- toast.info('No records to export');
- return;
- }
-
- setIsExporting(true);
- try {
- const content = transformRecordsToBindFormat(existingRecords, origin);
- const filename = `${dnsZoneId}.zone`;
- downloadFile(content, filename);
- toast.success('DNS Records', {
- description: `${existingRecords.length} records exported to ${filename}`,
- });
- } catch (error) {
- console.error(error);
- toast.error('Failed to export DNS records', {
- description: error instanceof Error ? error.message : 'Unknown error',
- });
- } finally {
- setIsExporting(false);
- }
- };
-
- return {
- isExporting,
- handleExport,
- };
-}
+import { IFlattenedDnsRecord } from '@/resources/dns-records';
+import { downloadFile } from '@/utils/common';
+import { transformRecordsToBindFormat } from '@/utils/helpers/dns-record.helper';
+import { toast } from '@datum-ui/components';
+import { useState } from 'react';
+
+interface UseDnsRecordExportProps {
+ existingRecords: IFlattenedDnsRecord[];
+ dnsZoneId: string;
+ origin?: string;
+}
+
+export function useDnsRecordExport({
+ existingRecords,
+ dnsZoneId,
+ origin,
+}: UseDnsRecordExportProps) {
+ const [isExporting, setIsExporting] = useState(false);
+
+ const handleExport = async () => {
+ if (existingRecords.length === 0) {
+ toast.info('No records to export');
+ return;
+ }
+
+ setIsExporting(true);
+ try {
+ const content = transformRecordsToBindFormat(existingRecords, origin);
+ const filename = `${dnsZoneId}.zone`;
+ downloadFile(content, filename);
+ toast.success('DNS Records', {
+ description: `${existingRecords.length} records exported to ${filename}`,
+ });
+ } catch (error) {
+ console.error(error);
+ toast.error('Failed to export DNS records', {
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ return {
+ isExporting,
+ handleExport,
+ };
+}
diff --git a/app/features/edge/dns-records/index.ts b/app/features/edge/dns-records/index.ts
index 2b7b99da6..fcd2b7596 100644
--- a/app/features/edge/dns-records/index.ts
+++ b/app/features/edge/dns-records/index.ts
@@ -1,11 +1,11 @@
-export { DnsRecordAiEdgeCell } from './dns-record-ai-edge-cell';
-export { DnsRecordTable } from './dns-record-table';
-export { DnsRecordCard } from './dns-record-card';
-export { isEligibleForProtect } from './utils';
-
-export type {
- DnsRecordCardProps,
- DnsRecordTableProps,
- DnsRecordInlineFormProps,
- DnsRecordModalFormProps,
-} from './types';
+export { DnsRecordAiEdgeCell } from './dns-record-ai-edge-cell';
+export { DnsRecordTable } from './dns-record-table';
+export { DnsRecordCard } from './dns-record-card';
+export { isEligibleForProtect } from './utils';
+
+export type {
+ DnsRecordCardProps,
+ DnsRecordTableProps,
+ DnsRecordInlineFormProps,
+ DnsRecordModalFormProps,
+} from './types';
diff --git a/app/features/edge/dns-records/types.ts b/app/features/edge/dns-records/types.ts
index 8bec29a7f..fa84090e0 100644
--- a/app/features/edge/dns-records/types.ts
+++ b/app/features/edge/dns-records/types.ts
@@ -1,106 +1,106 @@
-/**
- * Shared type definitions for DNS Records components
- */
-import { DataTableProps } from '@/modules/datum-ui/components/data-table';
-import { IFlattenedDnsRecord } from '@/resources/dns-records';
-import { EmptyContentProps } from '@datum-ui/components/empty-content';
-import type { ReactNode } from 'react';
-
-// =============================================================================
-// Table Component Types
-// =============================================================================
-
-/**
- * Base props shared by both compact and full table modes
- */
-export interface DnsRecordTableBaseProps {
- data: IFlattenedDnsRecord[];
- projectId: string;
- className?: string;
- tableContainerClassName?: string;
- emptyContent?: EmptyContentProps;
- showStatus?: boolean;
- enableMultiSelect?: boolean;
- getRowId?: (row: IFlattenedDnsRecord, index: number) => string;
- onSelectionChange?: (selectedIds: string[], selectedRows: IFlattenedDnsRecord[]) => void;
- /** When set (full mode), adds an AI Edge column before TTL with this cell renderer. */
- renderAiEdgeCell?: (record: IFlattenedDnsRecord) => ReactNode;
-}
-
-/**
- * Compact mode props
- * Simple table without actions, pagination, or toolbar (for overview pages)
- */
-export interface DnsRecordTableCompactProps extends DnsRecordTableBaseProps {
- mode: 'compact';
-}
-
-/**
- * Full mode props
- * Inherits ALL DataTable props for complete functionality (for standalone pages)
- */
-export interface DnsRecordTableFullProps
- extends
- DnsRecordTableBaseProps,
- Omit<
- DataTableProps,
- 'data' | 'columns' | 'className' | 'emptyContent' | 'mode'
- > {
- mode: 'full';
-}
-
-/**
- * Discriminated union: mode determines available props
- */
-export type DnsRecordTableProps = DnsRecordTableCompactProps | DnsRecordTableFullProps;
-
-// =============================================================================
-// Card Component Types
-// =============================================================================
-
-/**
- * Props for DNS record card wrapper component
- * Used in overview pages to display records in a card with optional row limit
- */
-export interface DnsRecordCardProps {
- records: IFlattenedDnsRecord[];
- projectId: string;
- maxRows?: number;
- title?: string;
- actions?: React.ReactNode;
-}
-
-// =============================================================================
-// Form Component Types
-// =============================================================================
-
-/**
- * Props for inline DNS record form (used in DataTable)
- */
-export interface DnsRecordInlineFormProps {
- mode: 'create' | 'edit';
- initialData: IFlattenedDnsRecord | null;
- projectId: string;
- dnsZoneId: string;
- dnsZoneName?: string;
- onClose: () => void;
- onSuccess?: () => void;
-}
-
-/**
- * Props for modal DNS record form
- */
-export interface DnsRecordModalFormProps {
- projectId: string;
- dnsZoneId: string;
- dnsZoneName?: string;
- onSuccess?: () => void;
-}
-
-/**
- * Ref interface for modal form (imperative handle)
- * Allows parent to programmatically show the modal
- */
-export interface DnsRecordModalFormRef {
- show: (mode: 'create' | 'edit', initialData?: IFlattenedDnsRecord) => Promise;
-}
+/**
+ * Shared type definitions for DNS Records components
+ */
+import { DataTableProps } from '@/modules/datum-ui/components/data-table';
+import { IFlattenedDnsRecord } from '@/resources/dns-records';
+import { EmptyContentProps } from '@datum-ui/components/empty-content';
+import type { ReactNode } from 'react';
+
+// =============================================================================
+// Table Component Types
+// =============================================================================
+
+/**
+ * Base props shared by both compact and full table modes
+ */
+export interface DnsRecordTableBaseProps {
+ data: IFlattenedDnsRecord[];
+ projectId: string;
+ className?: string;
+ tableContainerClassName?: string;
+ emptyContent?: EmptyContentProps;
+ showStatus?: boolean;
+ enableMultiSelect?: boolean;
+ getRowId?: (row: IFlattenedDnsRecord, index: number) => string;
+ onSelectionChange?: (selectedIds: string[], selectedRows: IFlattenedDnsRecord[]) => void;
+ /** When set (full mode), adds an AI Edge column before TTL with this cell renderer. */
+ renderAiEdgeCell?: (record: IFlattenedDnsRecord) => ReactNode;
+}
+
+/**
+ * Compact mode props
+ * Simple table without actions, pagination, or toolbar (for overview pages)
+ */
+export interface DnsRecordTableCompactProps extends DnsRecordTableBaseProps {
+ mode: 'compact';
+}
+
+/**
+ * Full mode props
+ * Inherits ALL DataTable props for complete functionality (for standalone pages)
+ */
+export interface DnsRecordTableFullProps
+ extends
+ DnsRecordTableBaseProps,
+ Omit<
+ DataTableProps,
+ 'data' | 'columns' | 'className' | 'emptyContent' | 'mode'
+ > {
+ mode: 'full';
+}
+
+/**
+ * Discriminated union: mode determines available props
+ */
+export type DnsRecordTableProps = DnsRecordTableCompactProps | DnsRecordTableFullProps;
+
+// =============================================================================
+// Card Component Types
+// =============================================================================
+
+/**
+ * Props for DNS record card wrapper component
+ * Used in overview pages to display records in a card with optional row limit
+ */
+export interface DnsRecordCardProps {
+ records: IFlattenedDnsRecord[];
+ projectId: string;
+ maxRows?: number;
+ title?: string;
+ actions?: React.ReactNode;
+}
+
+// =============================================================================
+// Form Component Types
+// =============================================================================
+
+/**
+ * Props for inline DNS record form (used in DataTable)
+ */
+export interface DnsRecordInlineFormProps {
+ mode: 'create' | 'edit';
+ initialData: IFlattenedDnsRecord | null;
+ projectId: string;
+ dnsZoneId: string;
+ dnsZoneName?: string;
+ onClose: () => void;
+ onSuccess?: () => void;
+}
+
+/**
+ * Props for modal DNS record form
+ */
+export interface DnsRecordModalFormProps {
+ projectId: string;
+ dnsZoneId: string;
+ dnsZoneName?: string;
+ onSuccess?: () => void;
+}
+
+/**
+ * Ref interface for modal form (imperative handle)
+ * Allows parent to programmatically show the modal
+ */
+export interface DnsRecordModalFormRef {
+ show: (mode: 'create' | 'edit', initialData?: IFlattenedDnsRecord) => Promise;
+}
diff --git a/app/features/edge/dns-zone/components/refresh-nameservers-button.tsx b/app/features/edge/dns-zone/components/refresh-nameservers-button.tsx
index fb92a0cf4..4d6eb1f0d 100644
--- a/app/features/edge/dns-zone/components/refresh-nameservers-button.tsx
+++ b/app/features/edge/dns-zone/components/refresh-nameservers-button.tsx
@@ -1,137 +1,137 @@
-import { useRefreshDomainRegistration } from '@/resources/domains';
-import { Button, ButtonProps, toast } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { cn } from '@shadcn/lib/utils';
-import { TimerIcon } from 'lucide-react';
-import { useEffect, useState } from 'react';
-
-interface ToastMessage {
- title: string;
- description?: string;
-}
-
-interface RefreshNameserversButtonProps extends Omit {
- domainName: string;
- projectId: string;
- label?: string;
- successMessage?: ToastMessage;
- errorMessage?: ToastMessage;
- lastRefreshAttempt?: string;
- containerClassName?: string;
-}
-
-const defaultSuccessMessage: ToastMessage = {
- title: 'Nameservers refreshed successfully',
- description: 'The nameservers have been refreshed successfully',
-};
-
-const defaultErrorMessage: ToastMessage = {
- title: 'Failed to refresh nameservers',
- description: 'Failed to refresh nameservers',
-};
-
-const COOLDOWN_SECONDS = 5 * 60; // 5 minutes
-
-const calculateRemainingSeconds = (lastRefreshAttempt?: string): number => {
- if (!lastRefreshAttempt) return 0;
-
- const lastAttemptTime = new Date(lastRefreshAttempt).getTime();
- if (isNaN(lastAttemptTime)) return 0;
-
- const now = Date.now();
- const elapsedSeconds = Math.floor((now - lastAttemptTime) / 1000);
- const remaining = COOLDOWN_SECONDS - elapsedSeconds;
-
- return remaining > 0 ? remaining : 0;
-};
-
-const formatTime = (seconds: number): string => {
- const minutes = Math.floor(seconds / 60);
- const secs = seconds % 60;
- return `${minutes}:${secs.toString().padStart(2, '0')}`;
-};
-
-export const RefreshNameserversButton = ({
- domainName,
- projectId,
- label = 'Refresh nameservers',
- successMessage = defaultSuccessMessage,
- errorMessage = defaultErrorMessage,
- icon,
- type = 'secondary',
- theme = 'outline',
- size = 'xs',
- disabled,
- lastRefreshAttempt,
- containerClassName,
- ...buttonProps
-}: RefreshNameserversButtonProps) => {
- const [remainingSeconds, setRemainingSeconds] = useState(() =>
- calculateRemainingSeconds(lastRefreshAttempt)
- );
-
- useEffect(() => {
- // Recalculate when lastRefreshAttempt changes
- setRemainingSeconds(calculateRemainingSeconds(lastRefreshAttempt));
- }, [lastRefreshAttempt]);
-
- useEffect(() => {
- if (remainingSeconds <= 0) return;
-
- const interval = setInterval(() => {
- const newRemaining = calculateRemainingSeconds(lastRefreshAttempt);
- setRemainingSeconds(newRemaining);
-
- if (newRemaining <= 0) {
- clearInterval(interval);
- }
- }, 1000);
-
- return () => clearInterval(interval);
- }, [lastRefreshAttempt, remainingSeconds]);
-
- const isOnCooldown = remainingSeconds > 0;
-
- const refreshMutation = useRefreshDomainRegistration(projectId, {
- onSuccess: () => {
- toast.success(successMessage.title, {
- description: successMessage.description,
- });
- },
- onError: (error) => {
- toast.error(errorMessage.title, {
- description: error.message || errorMessage.description,
- });
- },
- });
-
- const handleRefresh = () => {
- if (!domainName) return;
- refreshMutation.mutate(domainName);
- };
-
- return (
-
- {isOnCooldown && (
-
-
-
- {formatTime(remainingSeconds)} until refresh available
-
-
- )}
-
- {label}
-
-
- );
-};
+import { useRefreshDomainRegistration } from '@/resources/domains';
+import { Button, ButtonProps, toast } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { cn } from '@shadcn/lib/utils';
+import { TimerIcon } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+interface ToastMessage {
+ title: string;
+ description?: string;
+}
+
+interface RefreshNameserversButtonProps extends Omit {
+ domainName: string;
+ projectId: string;
+ label?: string;
+ successMessage?: ToastMessage;
+ errorMessage?: ToastMessage;
+ lastRefreshAttempt?: string;
+ containerClassName?: string;
+}
+
+const defaultSuccessMessage: ToastMessage = {
+ title: 'Nameservers refreshed successfully',
+ description: 'The nameservers have been refreshed successfully',
+};
+
+const defaultErrorMessage: ToastMessage = {
+ title: 'Failed to refresh nameservers',
+ description: 'Failed to refresh nameservers',
+};
+
+const COOLDOWN_SECONDS = 5 * 60; // 5 minutes
+
+const calculateRemainingSeconds = (lastRefreshAttempt?: string): number => {
+ if (!lastRefreshAttempt) return 0;
+
+ const lastAttemptTime = new Date(lastRefreshAttempt).getTime();
+ if (isNaN(lastAttemptTime)) return 0;
+
+ const now = Date.now();
+ const elapsedSeconds = Math.floor((now - lastAttemptTime) / 1000);
+ const remaining = COOLDOWN_SECONDS - elapsedSeconds;
+
+ return remaining > 0 ? remaining : 0;
+};
+
+const formatTime = (seconds: number): string => {
+ const minutes = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
+};
+
+export const RefreshNameserversButton = ({
+ domainName,
+ projectId,
+ label = 'Refresh nameservers',
+ successMessage = defaultSuccessMessage,
+ errorMessage = defaultErrorMessage,
+ icon,
+ type = 'secondary',
+ theme = 'outline',
+ size = 'xs',
+ disabled,
+ lastRefreshAttempt,
+ containerClassName,
+ ...buttonProps
+}: RefreshNameserversButtonProps) => {
+ const [remainingSeconds, setRemainingSeconds] = useState(() =>
+ calculateRemainingSeconds(lastRefreshAttempt)
+ );
+
+ useEffect(() => {
+ // Recalculate when lastRefreshAttempt changes
+ setRemainingSeconds(calculateRemainingSeconds(lastRefreshAttempt));
+ }, [lastRefreshAttempt]);
+
+ useEffect(() => {
+ if (remainingSeconds <= 0) return;
+
+ const interval = setInterval(() => {
+ const newRemaining = calculateRemainingSeconds(lastRefreshAttempt);
+ setRemainingSeconds(newRemaining);
+
+ if (newRemaining <= 0) {
+ clearInterval(interval);
+ }
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [lastRefreshAttempt, remainingSeconds]);
+
+ const isOnCooldown = remainingSeconds > 0;
+
+ const refreshMutation = useRefreshDomainRegistration(projectId, {
+ onSuccess: () => {
+ toast.success(successMessage.title, {
+ description: successMessage.description,
+ });
+ },
+ onError: (error) => {
+ toast.error(errorMessage.title, {
+ description: error.message || errorMessage.description,
+ });
+ },
+ });
+
+ const handleRefresh = () => {
+ if (!domainName) return;
+ refreshMutation.mutate(domainName);
+ };
+
+ return (
+
+ {isOnCooldown && (
+
+
+
+ {formatTime(remainingSeconds)} until refresh available
+
+
+ )}
+
+ {label}
+
+
+ );
+};
diff --git a/app/features/edge/dns-zone/discovery-preview.tsx b/app/features/edge/dns-zone/discovery-preview.tsx
index 2ca3a0d6a..5ac80b626 100644
--- a/app/features/edge/dns-zone/discovery-preview.tsx
+++ b/app/features/edge/dns-zone/discovery-preview.tsx
@@ -1,277 +1,277 @@
-import { DnsRecordTable } from '@/features/edge/dns-records';
-import { type IFlattenedDnsRecord, useBulkImportDnsRecords } from '@/resources/dns-records';
-import { useCreateDnsZoneDiscovery, useDnsZoneDiscovery } from '@/resources/dns-zone-discoveries';
-import { paths } from '@/utils/config/paths.config';
-import { transformFlattenedToRecordSets } from '@/utils/helpers/dns';
-import { flattenDnsRecordSets, type ImportResult } from '@/utils/helpers/dns-record.helper';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import {
- Button,
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
- SpinnerIcon,
- toast,
-} from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { PlusIcon } from 'lucide-react';
-import { AnimatePresence, motion } from 'motion/react';
-import { useEffect, useRef, useState } from 'react';
-import { useNavigate } from 'react-router';
-
-const MAX_POLL_ATTEMPTS = 5;
-
-export const DnsZoneDiscoveryPreview = ({
- projectId,
- dnsZoneId,
-}: {
- projectId: string;
- dnsZoneId: string;
-}) => {
- const navigate = useNavigate();
- const [dnsRecords, setDnsRecords] = useState([]);
- const [selectedRecords, setSelectedRecords] = useState([]);
- const [discoveryId, setDiscoveryId] = useState('');
-
- const loadingTimeoutRef = useRef(null);
- const pollCountRef = useRef(0);
- const [isLoading, setIsLoading] = useState(true);
- const [showEmpty, setShowEmpty] = useState(false);
- const [shouldPoll, setShouldPoll] = useState(false);
-
- // Create discovery on mount
- const createDiscovery = useCreateDnsZoneDiscovery(projectId, {
- onSuccess: (discovery) => {
- setDiscoveryId(discovery.name);
- setShouldPoll(true);
- },
- onError: (error) => {
- toast.error('DNS', {
- description: error.message || 'Failed to create DNS zone discovery',
- });
- setIsLoading(false);
- setShowEmpty(true);
- },
- });
-
- const hasTriggeredRef = useRef(false);
- useEffect(() => {
- if (projectId && dnsZoneId && !hasTriggeredRef.current) {
- hasTriggeredRef.current = true;
- createDiscovery.mutate(dnsZoneId);
- }
- }, [projectId, dnsZoneId]);
-
- // Poll discovery results
- const { data: discoveryData, error: discoveryError } = useDnsZoneDiscovery(
- projectId,
- discoveryId,
- {
- enabled: !!discoveryId && shouldPoll,
- refetchInterval: shouldPoll ? 2000 : false,
- }
- );
-
- // Bulk import mutation
- const bulkImportMutation = useBulkImportDnsRecords(projectId, dnsZoneId, {
- onSuccess: (result: ImportResult) => {
- const { summary } = result;
- const importedCount = summary.created + summary.updated;
- const failedCount = summary.failed;
-
- if (failedCount === 0 && importedCount > 0) {
- toast.success('DNS records', {
- description: `${importedCount} records imported successfully`,
- });
- } else if (importedCount > 0 && failedCount > 0) {
- toast.warning('DNS records', {
- description: `${importedCount} records imported, ${failedCount} failed`,
- });
- } else if (failedCount > 0) {
- toast.error('DNS records', {
- description: `${failedCount} records failed to import`,
- });
- } else {
- toast.success('DNS records', {
- description: 'Imported successfully.',
- });
- }
-
- navigateToZoneDetails();
- },
- onError: (error: Error) => {
- toast.error('DNS records', {
- description: error.message || 'An unexpected error occurred',
- });
- },
- });
-
- const navigateToZoneDetails = () => {
- navigate(
- getPathWithParams(paths.project.detail.dnsZones.detail.root, {
- projectId,
- dnsZoneId,
- })
- );
- };
-
- const cleanUp = () => {
- if (loadingTimeoutRef.current) {
- clearTimeout(loadingTimeoutRef.current);
- loadingTimeoutRef.current = null;
- }
- };
-
- // Handle polling count and discovery data processing
- useEffect(() => {
- if (!shouldPoll) return;
-
- pollCountRef.current += 1;
-
- if (pollCountRef.current > MAX_POLL_ATTEMPTS) {
- setShouldPoll(false);
- loadingTimeoutRef.current = setTimeout(() => {
- setIsLoading(false);
- if (dnsRecords.length === 0) {
- setShowEmpty(true);
- }
- }, 2500);
- return;
- }
-
- if (discoveryData?.recordSets && discoveryData.recordSets.length > 0) {
- const flattened = flattenDnsRecordSets(discoveryData.recordSets, dnsZoneId);
-
- setDnsRecords(flattened);
- setShouldPoll(false);
- setIsLoading(false);
- }
-
- return cleanUp;
- }, [discoveryData, shouldPoll, dnsZoneId]);
-
- useEffect(() => {
- if (discoveryError) {
- toast.error(discoveryError.message || 'An unexpected error occurred');
- }
- }, [discoveryError]);
-
- const handleBulkImport = () => {
- if (selectedRecords.length === 0) return;
-
- const recordSets = transformFlattenedToRecordSets(selectedRecords);
- bulkImportMutation.mutate({
- discoveryRecordSets: recordSets,
- importOptions: { skipDuplicates: true, mergeStrategy: 'append' },
- });
- };
-
- const handleSkip = () => {
- setShouldPoll(false);
- cleanUp();
- navigateToZoneDetails();
- };
-
- return (
-
-
- {isLoading ? (
-
-
-
- Discovering DNS records...
-
-
-
- Cancel DNS record discovery
-
-
-
- ) : dnsRecords.length > 0 ? (
-
-
- Add DNS records
-
- We found some records from your existing provider. These may be incomplete. Select
- the records you want to import.
-
-
-
- {
- return `${row.type}-${row.name}-${row.value}-${index + 1}`;
- }}
- onSelectionChange={(_selectedIds, rows) => {
- setSelectedRecords(rows);
- }}
- />
-
-
-
-
- Skip
-
- }>
- {bulkImportMutation.isPending
- ? 'Importing...'
- : `Add ${selectedRecords.length} record${selectedRecords.length !== 1 ? 's' : ''}`}
-
-
-
- ) : showEmpty ? (
-
-
-
- No DNS records found from your existing provider.
-
-
- Continue to DNS Zone
-
-
-
- ) : null}
-
-
- );
-};
+import { DnsRecordTable } from '@/features/edge/dns-records';
+import { type IFlattenedDnsRecord, useBulkImportDnsRecords } from '@/resources/dns-records';
+import { useCreateDnsZoneDiscovery, useDnsZoneDiscovery } from '@/resources/dns-zone-discoveries';
+import { paths } from '@/utils/config/paths.config';
+import { transformFlattenedToRecordSets } from '@/utils/helpers/dns';
+import { flattenDnsRecordSets, type ImportResult } from '@/utils/helpers/dns-record.helper';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import {
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+ SpinnerIcon,
+ toast,
+} from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { PlusIcon } from 'lucide-react';
+import { AnimatePresence, motion } from 'motion/react';
+import { useEffect, useRef, useState } from 'react';
+import { useNavigate } from 'react-router';
+
+const MAX_POLL_ATTEMPTS = 5;
+
+export const DnsZoneDiscoveryPreview = ({
+ projectId,
+ dnsZoneId,
+}: {
+ projectId: string;
+ dnsZoneId: string;
+}) => {
+ const navigate = useNavigate();
+ const [dnsRecords, setDnsRecords] = useState([]);
+ const [selectedRecords, setSelectedRecords] = useState([]);
+ const [discoveryId, setDiscoveryId] = useState('');
+
+ const loadingTimeoutRef = useRef(null);
+ const pollCountRef = useRef(0);
+ const [isLoading, setIsLoading] = useState(true);
+ const [showEmpty, setShowEmpty] = useState(false);
+ const [shouldPoll, setShouldPoll] = useState(false);
+
+ // Create discovery on mount
+ const createDiscovery = useCreateDnsZoneDiscovery(projectId, {
+ onSuccess: (discovery) => {
+ setDiscoveryId(discovery.name);
+ setShouldPoll(true);
+ },
+ onError: (error) => {
+ toast.error('DNS', {
+ description: error.message || 'Failed to create DNS zone discovery',
+ });
+ setIsLoading(false);
+ setShowEmpty(true);
+ },
+ });
+
+ const hasTriggeredRef = useRef(false);
+ useEffect(() => {
+ if (projectId && dnsZoneId && !hasTriggeredRef.current) {
+ hasTriggeredRef.current = true;
+ createDiscovery.mutate(dnsZoneId);
+ }
+ }, [projectId, dnsZoneId]);
+
+ // Poll discovery results
+ const { data: discoveryData, error: discoveryError } = useDnsZoneDiscovery(
+ projectId,
+ discoveryId,
+ {
+ enabled: !!discoveryId && shouldPoll,
+ refetchInterval: shouldPoll ? 2000 : false,
+ }
+ );
+
+ // Bulk import mutation
+ const bulkImportMutation = useBulkImportDnsRecords(projectId, dnsZoneId, {
+ onSuccess: (result: ImportResult) => {
+ const { summary } = result;
+ const importedCount = summary.created + summary.updated;
+ const failedCount = summary.failed;
+
+ if (failedCount === 0 && importedCount > 0) {
+ toast.success('DNS records', {
+ description: `${importedCount} records imported successfully`,
+ });
+ } else if (importedCount > 0 && failedCount > 0) {
+ toast.warning('DNS records', {
+ description: `${importedCount} records imported, ${failedCount} failed`,
+ });
+ } else if (failedCount > 0) {
+ toast.error('DNS records', {
+ description: `${failedCount} records failed to import`,
+ });
+ } else {
+ toast.success('DNS records', {
+ description: 'Imported successfully.',
+ });
+ }
+
+ navigateToZoneDetails();
+ },
+ onError: (error: Error) => {
+ toast.error('DNS records', {
+ description: error.message || 'An unexpected error occurred',
+ });
+ },
+ });
+
+ const navigateToZoneDetails = () => {
+ navigate(
+ getPathWithParams(paths.project.detail.dnsZones.detail.root, {
+ projectId,
+ dnsZoneId,
+ })
+ );
+ };
+
+ const cleanUp = () => {
+ if (loadingTimeoutRef.current) {
+ clearTimeout(loadingTimeoutRef.current);
+ loadingTimeoutRef.current = null;
+ }
+ };
+
+ // Handle polling count and discovery data processing
+ useEffect(() => {
+ if (!shouldPoll) return;
+
+ pollCountRef.current += 1;
+
+ if (pollCountRef.current > MAX_POLL_ATTEMPTS) {
+ setShouldPoll(false);
+ loadingTimeoutRef.current = setTimeout(() => {
+ setIsLoading(false);
+ if (dnsRecords.length === 0) {
+ setShowEmpty(true);
+ }
+ }, 2500);
+ return;
+ }
+
+ if (discoveryData?.recordSets && discoveryData.recordSets.length > 0) {
+ const flattened = flattenDnsRecordSets(discoveryData.recordSets, dnsZoneId);
+
+ setDnsRecords(flattened);
+ setShouldPoll(false);
+ setIsLoading(false);
+ }
+
+ return cleanUp;
+ }, [discoveryData, shouldPoll, dnsZoneId]);
+
+ useEffect(() => {
+ if (discoveryError) {
+ toast.error(discoveryError.message || 'An unexpected error occurred');
+ }
+ }, [discoveryError]);
+
+ const handleBulkImport = () => {
+ if (selectedRecords.length === 0) return;
+
+ const recordSets = transformFlattenedToRecordSets(selectedRecords);
+ bulkImportMutation.mutate({
+ discoveryRecordSets: recordSets,
+ importOptions: { skipDuplicates: true, mergeStrategy: 'append' },
+ });
+ };
+
+ const handleSkip = () => {
+ setShouldPoll(false);
+ cleanUp();
+ navigateToZoneDetails();
+ };
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ Discovering DNS records...
+
+
+
+ Cancel DNS record discovery
+
+
+
+ ) : dnsRecords.length > 0 ? (
+
+
+ Add DNS records
+
+ We found some records from your existing provider. These may be incomplete. Select
+ the records you want to import.
+
+
+
+ {
+ return `${row.type}-${row.name}-${row.value}-${index + 1}`;
+ }}
+ onSelectionChange={(_selectedIds, rows) => {
+ setSelectedRecords(rows);
+ }}
+ />
+
+
+
+
+ Skip
+
+ }>
+ {bulkImportMutation.isPending
+ ? 'Importing...'
+ : `Add ${selectedRecords.length} record${selectedRecords.length !== 1 ? 's' : ''}`}
+
+
+
+ ) : showEmpty ? (
+
+
+
+ No DNS records found from your existing provider.
+
+
+ Continue to DNS Zone
+
+
+
+ ) : null}
+
+
+ );
+};
diff --git a/app/features/edge/dns-zone/dns-zone-form-dialog.tsx b/app/features/edge/dns-zone/dns-zone-form-dialog.tsx
index af368c1bc..8663aa343 100644
--- a/app/features/edge/dns-zone/dns-zone-form-dialog.tsx
+++ b/app/features/edge/dns-zone/dns-zone-form-dialog.tsx
@@ -1,103 +1,103 @@
-import { AnalyticsAction, useAnalytics } from '@/modules/fathom';
-import {
- type CreateDnsZoneInput,
- createDnsZoneSchema,
- useCreateDnsZone,
-} from '@/resources/dns-zones';
-import { paths } from '@/utils/config/paths.config';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { toast } from '@datum-ui/components';
-import { Form } from '@datum-ui/components/form';
-import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
-import { useNavigate } from 'react-router';
-
-export interface DnsZoneFormDialogRef {
- show: (domainName?: string) => void;
- hide: () => void;
-}
-
-interface DnsZoneFormDialogProps {
- projectId: string;
-}
-
-export const DnsZoneFormDialog = forwardRef(
- ({ projectId }, ref) => {
- const [open, setOpen] = useState(false);
- const [defaultDomainName, setDefaultDomainName] = useState('');
- const navigate = useNavigate();
- const { trackAction } = useAnalytics();
-
- const createZone = useCreateDnsZone(projectId, {
- onSuccess: (dnsZone) => {
- trackAction(AnalyticsAction.AddDnsZone);
- setOpen(false);
- // Navigate to discovery page — it creates the discovery on mount
- navigate(
- getPathWithParams(paths.project.detail.dnsZones.detail.discovery, {
- projectId,
- dnsZoneId: dnsZone.name,
- })
- );
- },
- onError: (error) => {
- toast.error('DNS', {
- description: error.message || 'Failed to create DNS zone',
- });
- },
- });
-
- const show = useCallback((domainName?: string) => {
- setDefaultDomainName(domainName ?? '');
- setOpen(true);
- }, []);
-
- const hide = useCallback(() => {
- setOpen(false);
- }, []);
-
- useImperativeHandle(ref, () => ({ show, hide }), [show, hide]);
-
- const handleSubmit = useCallback(
- (data: CreateDnsZoneInput) => {
- createZone.mutate(data);
- },
- [createZone]
- );
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-);
-
-DnsZoneFormDialog.displayName = 'DnsZoneFormDialog';
+import { AnalyticsAction, useAnalytics } from '@/modules/fathom';
+import {
+ type CreateDnsZoneInput,
+ createDnsZoneSchema,
+ useCreateDnsZone,
+} from '@/resources/dns-zones';
+import { paths } from '@/utils/config/paths.config';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { toast } from '@datum-ui/components';
+import { Form } from '@datum-ui/components/form';
+import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
+import { useNavigate } from 'react-router';
+
+export interface DnsZoneFormDialogRef {
+ show: (domainName?: string) => void;
+ hide: () => void;
+}
+
+interface DnsZoneFormDialogProps {
+ projectId: string;
+}
+
+export const DnsZoneFormDialog = forwardRef(
+ ({ projectId }, ref) => {
+ const [open, setOpen] = useState(false);
+ const [defaultDomainName, setDefaultDomainName] = useState('');
+ const navigate = useNavigate();
+ const { trackAction } = useAnalytics();
+
+ const createZone = useCreateDnsZone(projectId, {
+ onSuccess: (dnsZone) => {
+ trackAction(AnalyticsAction.AddDnsZone);
+ setOpen(false);
+ // Navigate to discovery page — it creates the discovery on mount
+ navigate(
+ getPathWithParams(paths.project.detail.dnsZones.detail.discovery, {
+ projectId,
+ dnsZoneId: dnsZone.name,
+ })
+ );
+ },
+ onError: (error) => {
+ toast.error('DNS', {
+ description: error.message || 'Failed to create DNS zone',
+ });
+ },
+ });
+
+ const show = useCallback((domainName?: string) => {
+ setDefaultDomainName(domainName ?? '');
+ setOpen(true);
+ }, []);
+
+ const hide = useCallback(() => {
+ setOpen(false);
+ }, []);
+
+ useImperativeHandle(ref, () => ({ show, hide }), [show, hide]);
+
+ const handleSubmit = useCallback(
+ (data: CreateDnsZoneInput) => {
+ createZone.mutate(data);
+ },
+ [createZone]
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+);
+
+DnsZoneFormDialog.displayName = 'DnsZoneFormDialog';
diff --git a/app/features/edge/dns-zone/form/dns-record-form.tsx b/app/features/edge/dns-zone/form/dns-record-form.tsx
index 2a9785959..4234c09bd 100644
--- a/app/features/edge/dns-zone/form/dns-record-form.tsx
+++ b/app/features/edge/dns-zone/form/dns-record-form.tsx
@@ -1,261 +1,261 @@
-import {
- ARecordField,
- AAAARecordField,
- ALIASRecordField,
- CAARecordField,
- CNAMERecordField,
- HTTPSRecordField,
- MXRecordField,
- NSRecordField,
- PTRRecordField,
- SOARecordField,
- SRVRecordField,
- SVCBRecordField,
- TLSARecordField,
- TXTRecordField,
-} from './types';
-import { SelectBox } from '@/components/select-box/select-box';
-import {
- CreateDnsRecordSchema,
- createDnsRecordSchema,
- DNSRecordType,
- TTL_OPTIONS,
- useCreateDnsRecord,
- useUpdateDnsRecord,
-} from '@/resources/dns-records';
-import { formatDnsError, getDnsRecordTypeSelectOptions } from '@/utils/helpers/dns-record.helper';
-import { type FieldMetadata } from '@conform-to/react';
-import { Button, toast } from '@datum-ui/components';
-import { Form } from '@datum-ui/components/form';
-import { LoaderOverlay } from '@datum-ui/components/loader-overlay';
-import { cn } from '@shadcn/lib/utils';
-import { Form as RouterForm } from 'react-router';
-
-interface DnsRecordFormProps {
- style?: 'inline' | 'modal';
- mode: 'create' | 'edit';
- defaultValue?: CreateDnsRecordSchema;
- projectId: string;
- dnsZoneId: string;
- dnsZoneName?: string;
- recordSetName?: string;
- recordName?: string;
- oldValue?: string; // The original value being edited (for updating specific values in arrays)
- oldTTL?: number | null; // The original TTL (for identifying which record to update)
- onClose: () => void;
- onSuccess?: () => void;
- isPending?: boolean;
- testMode?: boolean; // Enable test mode: disables type selector and hides submit button
-}
-
-export function DnsRecordForm({
- style = 'inline',
- mode,
- defaultValue,
- projectId,
- dnsZoneId,
- dnsZoneName,
- recordSetName,
- recordName,
- oldValue,
- oldTTL,
- onClose,
- onSuccess,
- isPending = false,
- testMode = false,
-}: DnsRecordFormProps) {
- const createMutation = useCreateDnsRecord(projectId, dnsZoneId);
- const updateMutation = useUpdateDnsRecord(projectId, dnsZoneId, recordSetName ?? '');
-
- // Initialize form with default values based on record type
- const getInitialValues = (): Partial => {
- if (defaultValue) return defaultValue;
-
- return {
- recordType: 'A',
- name: '',
- ttl: null, // Auto by default
- a: { content: '' },
- // Initialize all complex types with single object (not arrays)
- mx: { exchange: '', preference: 10 },
- srv: { target: '', port: 443, priority: 10, weight: 5 },
- caa: { flag: 0, tag: 'issue', value: '' },
- tlsa: { usage: 3, selector: 1, matchingType: 1, certData: '' },
- https: { priority: 1, target: '', params: {} },
- svcb: { priority: 1, target: '', params: {} },
- dnsZoneRef: dnsZoneName ? { name: dnsZoneName } : undefined,
- } as Partial;
- };
-
- const handleSubmit = async (data: CreateDnsRecordSchema) => {
- try {
- if (mode === 'create') {
- await createMutation.mutateAsync(data);
- } else {
- await updateMutation.mutateAsync({
- ...data,
- recordName,
- oldValue,
- oldTTL,
- });
- }
- onSuccess?.();
- onClose();
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : String(error);
- toast.error('DNS Record', {
- description: formatDnsError(message) || `Failed to ${mode} DNS record`,
- });
- }
- };
-
- const loading = isPending || createMutation.isPending || updateMutation.isPending;
-
- return (
-
- {({ fields }) => (
- <>
- {loading && style === 'inline' && (
-
- )}
-
-
- {/* Record Type */}
-
- {({ control, meta }) => (
- control.change(value.value)}
- options={getDnsRecordTypeSelectOptions()}
- disabled={testMode}
- itemPreview={(option) => {option.label} }
- />
- )}
-
-
- {/* Name */}
-
-
-
-
- {/* TTL */}
-
- {({ control, meta }) => (
-
- control.change(value.value === 'auto' ? '' : String(value.value))
- }
- options={TTL_OPTIONS.map((option) => ({
- value: option.value === null ? 'auto' : String(option.value),
- label: option.label,
- }))}
- />
- )}
-
-
- {/* Type-Specific Fields */}
-
-
-
- {/* Form Actions - Hidden in test mode */}
- {!testMode && (
-
- {style === 'modal' && (
-
- Cancel
-
- )}
-
- {loading && style === 'modal'
- ? mode === 'create'
- ? 'Adding'
- : 'Saving'
- : mode === 'create'
- ? 'Add'
- : 'Save'}
-
-
- )}
- >
- )}
-
- );
-}
-
-/** Renders the appropriate type-specific fields based on the current recordType value */
-function RecordTypeFields({ recordTypeField }: { recordTypeField: FieldMetadata }) {
- const recordType = (recordTypeField.value || 'A') as DNSRecordType;
-
- switch (recordType) {
- case 'A':
- return ;
- case 'AAAA':
- return ;
- case 'CNAME':
- return ;
- case 'ALIAS':
- return ;
- case 'TXT':
- return ;
- case 'MX':
- return ;
- case 'SRV':
- return ;
- case 'CAA':
- return ;
- case 'NS':
- return ;
- case 'SOA':
- return ;
- case 'PTR':
- return ;
- case 'TLSA':
- return ;
- case 'HTTPS':
- return ;
- case 'SVCB':
- return ;
- default:
- return null;
- }
-}
+import {
+ ARecordField,
+ AAAARecordField,
+ ALIASRecordField,
+ CAARecordField,
+ CNAMERecordField,
+ HTTPSRecordField,
+ MXRecordField,
+ NSRecordField,
+ PTRRecordField,
+ SOARecordField,
+ SRVRecordField,
+ SVCBRecordField,
+ TLSARecordField,
+ TXTRecordField,
+} from './types';
+import { SelectBox } from '@/components/select-box/select-box';
+import {
+ CreateDnsRecordSchema,
+ createDnsRecordSchema,
+ DNSRecordType,
+ TTL_OPTIONS,
+ useCreateDnsRecord,
+ useUpdateDnsRecord,
+} from '@/resources/dns-records';
+import { formatDnsError, getDnsRecordTypeSelectOptions } from '@/utils/helpers/dns-record.helper';
+import { type FieldMetadata } from '@conform-to/react';
+import { Button, toast } from '@datum-ui/components';
+import { Form } from '@datum-ui/components/form';
+import { LoaderOverlay } from '@datum-ui/components/loader-overlay';
+import { cn } from '@shadcn/lib/utils';
+import { Form as RouterForm } from 'react-router';
+
+interface DnsRecordFormProps {
+ style?: 'inline' | 'modal';
+ mode: 'create' | 'edit';
+ defaultValue?: CreateDnsRecordSchema;
+ projectId: string;
+ dnsZoneId: string;
+ dnsZoneName?: string;
+ recordSetName?: string;
+ recordName?: string;
+ oldValue?: string; // The original value being edited (for updating specific values in arrays)
+ oldTTL?: number | null; // The original TTL (for identifying which record to update)
+ onClose: () => void;
+ onSuccess?: () => void;
+ isPending?: boolean;
+ testMode?: boolean; // Enable test mode: disables type selector and hides submit button
+}
+
+export function DnsRecordForm({
+ style = 'inline',
+ mode,
+ defaultValue,
+ projectId,
+ dnsZoneId,
+ dnsZoneName,
+ recordSetName,
+ recordName,
+ oldValue,
+ oldTTL,
+ onClose,
+ onSuccess,
+ isPending = false,
+ testMode = false,
+}: DnsRecordFormProps) {
+ const createMutation = useCreateDnsRecord(projectId, dnsZoneId);
+ const updateMutation = useUpdateDnsRecord(projectId, dnsZoneId, recordSetName ?? '');
+
+ // Initialize form with default values based on record type
+ const getInitialValues = (): Partial => {
+ if (defaultValue) return defaultValue;
+
+ return {
+ recordType: 'A',
+ name: '',
+ ttl: null, // Auto by default
+ a: { content: '' },
+ // Initialize all complex types with single object (not arrays)
+ mx: { exchange: '', preference: 10 },
+ srv: { target: '', port: 443, priority: 10, weight: 5 },
+ caa: { flag: 0, tag: 'issue', value: '' },
+ tlsa: { usage: 3, selector: 1, matchingType: 1, certData: '' },
+ https: { priority: 1, target: '', params: {} },
+ svcb: { priority: 1, target: '', params: {} },
+ dnsZoneRef: dnsZoneName ? { name: dnsZoneName } : undefined,
+ } as Partial;
+ };
+
+ const handleSubmit = async (data: CreateDnsRecordSchema) => {
+ try {
+ if (mode === 'create') {
+ await createMutation.mutateAsync(data);
+ } else {
+ await updateMutation.mutateAsync({
+ ...data,
+ recordName,
+ oldValue,
+ oldTTL,
+ });
+ }
+ onSuccess?.();
+ onClose();
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ toast.error('DNS Record', {
+ description: formatDnsError(message) || `Failed to ${mode} DNS record`,
+ });
+ }
+ };
+
+ const loading = isPending || createMutation.isPending || updateMutation.isPending;
+
+ return (
+
+ {({ fields }) => (
+ <>
+ {loading && style === 'inline' && (
+
+ )}
+
+
+ {/* Record Type */}
+
+ {({ control, meta }) => (
+ control.change(value.value)}
+ options={getDnsRecordTypeSelectOptions()}
+ disabled={testMode}
+ itemPreview={(option) => {option.label} }
+ />
+ )}
+
+
+ {/* Name */}
+
+
+
+
+ {/* TTL */}
+
+ {({ control, meta }) => (
+
+ control.change(value.value === 'auto' ? '' : String(value.value))
+ }
+ options={TTL_OPTIONS.map((option) => ({
+ value: option.value === null ? 'auto' : String(option.value),
+ label: option.label,
+ }))}
+ />
+ )}
+
+
+ {/* Type-Specific Fields */}
+
+
+
+ {/* Form Actions - Hidden in test mode */}
+ {!testMode && (
+
+ {style === 'modal' && (
+
+ Cancel
+
+ )}
+
+ {loading && style === 'modal'
+ ? mode === 'create'
+ ? 'Adding'
+ : 'Saving'
+ : mode === 'create'
+ ? 'Add'
+ : 'Save'}
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+/** Renders the appropriate type-specific fields based on the current recordType value */
+function RecordTypeFields({ recordTypeField }: { recordTypeField: FieldMetadata }) {
+ const recordType = (recordTypeField.value || 'A') as DNSRecordType;
+
+ switch (recordType) {
+ case 'A':
+ return ;
+ case 'AAAA':
+ return ;
+ case 'CNAME':
+ return ;
+ case 'ALIAS':
+ return ;
+ case 'TXT':
+ return ;
+ case 'MX':
+ return ;
+ case 'SRV':
+ return ;
+ case 'CAA':
+ return ;
+ case 'NS':
+ return ;
+ case 'SOA':
+ return ;
+ case 'PTR':
+ return ;
+ case 'TLSA':
+ return ;
+ case 'HTTPS':
+ return ;
+ case 'SVCB':
+ return ;
+ default:
+ return null;
+ }
+}
diff --git a/app/features/edge/dns-zone/form/types/a-record-field.tsx b/app/features/edge/dns-zone/form/types/a-record-field.tsx
index b5fe8c6a1..71c9e06ee 100644
--- a/app/features/edge/dns-zone/form/types/a-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/a-record-field.tsx
@@ -1,7 +1,7 @@
-import { Form } from '@datum-ui/components/form';
-
-export const ARecordField = () => (
-
-
-
-);
+import { Form } from '@datum-ui/components/form';
+
+export const ARecordField = () => (
+
+
+
+);
diff --git a/app/features/edge/dns-zone/form/types/aaaa-record-field.tsx b/app/features/edge/dns-zone/form/types/aaaa-record-field.tsx
index 17fda670c..aaed5531d 100644
--- a/app/features/edge/dns-zone/form/types/aaaa-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/aaaa-record-field.tsx
@@ -1,7 +1,7 @@
-import { Form } from '@datum-ui/components/form';
-
-export const AAAARecordField = () => (
-
-
-
-);
+import { Form } from '@datum-ui/components/form';
+
+export const AAAARecordField = () => (
+
+
+
+);
diff --git a/app/features/edge/dns-zone/form/types/alias-record-field.tsx b/app/features/edge/dns-zone/form/types/alias-record-field.tsx
index 8e29a6c6e..7fc3ba154 100644
--- a/app/features/edge/dns-zone/form/types/alias-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/alias-record-field.tsx
@@ -1,7 +1,7 @@
-import { Form } from '@datum-ui/components/form';
-
-export const ALIASRecordField = () => (
-
-
-
-);
+import { Form } from '@datum-ui/components/form';
+
+export const ALIASRecordField = () => (
+
+
+
+);
diff --git a/app/features/edge/dns-zone/form/types/caa-record-field.tsx b/app/features/edge/dns-zone/form/types/caa-record-field.tsx
index fb52a19d4..cd4c65bbb 100644
--- a/app/features/edge/dns-zone/form/types/caa-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/caa-record-field.tsx
@@ -1,40 +1,40 @@
-import { Form } from '@datum-ui/components/form';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@shadcn/ui/select';
-
-const CAA_TAGS = [
- { value: 'issue', label: 'issue - Authorization to issue certificates' },
- { value: 'issuewild', label: 'issuewild - Authorization to issue wildcard certificates' },
- { value: 'iodef', label: 'iodef - URL for incident reporting' },
-];
-
-export const CAARecordField = () => (
- <>
-
-
-
-
-
- {({ control, meta }) => (
- control.change(value)}>
-
-
-
-
- {CAA_TAGS.map((tag) => (
-
- {tag.label}
-
- ))}
-
-
- )}
-
-
-
-
-
- >
-);
+import { Form } from '@datum-ui/components/form';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@shadcn/ui/select';
+
+const CAA_TAGS = [
+ { value: 'issue', label: 'issue - Authorization to issue certificates' },
+ { value: 'issuewild', label: 'issuewild - Authorization to issue wildcard certificates' },
+ { value: 'iodef', label: 'iodef - URL for incident reporting' },
+];
+
+export const CAARecordField = () => (
+ <>
+
+
+
+
+
+ {({ control, meta }) => (
+ control.change(value)}>
+
+
+
+
+ {CAA_TAGS.map((tag) => (
+
+ {tag.label}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ >
+);
diff --git a/app/features/edge/dns-zone/form/types/cname-record-field.tsx b/app/features/edge/dns-zone/form/types/cname-record-field.tsx
index a7191826f..36949b960 100644
--- a/app/features/edge/dns-zone/form/types/cname-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/cname-record-field.tsx
@@ -1,7 +1,7 @@
-import { Form } from '@datum-ui/components/form';
-
-export const CNAMERecordField = () => (
-
-
-
-);
+import { Form } from '@datum-ui/components/form';
+
+export const CNAMERecordField = () => (
+
+
+
+);
diff --git a/app/features/edge/dns-zone/form/types/https-record-field.tsx b/app/features/edge/dns-zone/form/types/https-record-field.tsx
index 328d8c4c5..5f56835e2 100644
--- a/app/features/edge/dns-zone/form/types/https-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/https-record-field.tsx
@@ -1,17 +1,17 @@
-import { Form } from '@datum-ui/components/form';
-
-export const HTTPSRecordField = () => (
- <>
-
-
-
-
-
-
-
-
-
-
-
- >
-);
+import { Form } from '@datum-ui/components/form';
+
+export const HTTPSRecordField = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
diff --git a/app/features/edge/dns-zone/form/types/mx-record-field.tsx b/app/features/edge/dns-zone/form/types/mx-record-field.tsx
index 7a1478d9a..9cb3a5955 100644
--- a/app/features/edge/dns-zone/form/types/mx-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/mx-record-field.tsx
@@ -1,13 +1,13 @@
-import { Form } from '@datum-ui/components/form';
-
-export const MXRecordField = () => (
- <>
-
-
-
-
-
-
-
- >
-);
+import { Form } from '@datum-ui/components/form';
+
+export const MXRecordField = () => (
+ <>
+
+
+
+
+
+
+
+ >
+);
diff --git a/app/features/edge/dns-zone/form/types/ns-record-field.tsx b/app/features/edge/dns-zone/form/types/ns-record-field.tsx
index 1f2caabc0..533b716f7 100644
--- a/app/features/edge/dns-zone/form/types/ns-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/ns-record-field.tsx
@@ -1,7 +1,7 @@
-import { Form } from '@datum-ui/components/form';
-
-export const NSRecordField = () => (
-
-
-
-);
+import { Form } from '@datum-ui/components/form';
+
+export const NSRecordField = () => (
+
+
+
+);
diff --git a/app/features/edge/dns-zone/form/types/ptr-record-field.tsx b/app/features/edge/dns-zone/form/types/ptr-record-field.tsx
index 67a799209..f2a463a13 100644
--- a/app/features/edge/dns-zone/form/types/ptr-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/ptr-record-field.tsx
@@ -1,7 +1,7 @@
-import { Form } from '@datum-ui/components/form';
-
-export const PTRRecordField = () => (
-
-
-
-);
+import { Form } from '@datum-ui/components/form';
+
+export const PTRRecordField = () => (
+
+
+
+);
diff --git a/app/features/edge/dns-zone/form/types/soa-record-field.tsx b/app/features/edge/dns-zone/form/types/soa-record-field.tsx
index ad9188bd6..c7f8b4afc 100644
--- a/app/features/edge/dns-zone/form/types/soa-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/soa-record-field.tsx
@@ -1,33 +1,33 @@
-import { Form } from '@datum-ui/components/form';
-
-export const SOARecordField = () => (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
-);
+import { Form } from '@datum-ui/components/form';
+
+export const SOARecordField = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
diff --git a/app/features/edge/dns-zone/form/types/srv-record-field.tsx b/app/features/edge/dns-zone/form/types/srv-record-field.tsx
index 17b11699c..6a128b512 100644
--- a/app/features/edge/dns-zone/form/types/srv-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/srv-record-field.tsx
@@ -1,21 +1,21 @@
-import { Form } from '@datum-ui/components/form';
-
-export const SRVRecordField = () => (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
-);
+import { Form } from '@datum-ui/components/form';
+
+export const SRVRecordField = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
diff --git a/app/features/edge/dns-zone/form/types/svcb-record-field.tsx b/app/features/edge/dns-zone/form/types/svcb-record-field.tsx
index cf9a6e742..51aaad8c6 100644
--- a/app/features/edge/dns-zone/form/types/svcb-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/svcb-record-field.tsx
@@ -1,17 +1,17 @@
-import { Form } from '@datum-ui/components/form';
-
-export const SVCBRecordField = () => (
- <>
-
-
-
-
-
-
-
-
-
-
-
- >
-);
+import { Form } from '@datum-ui/components/form';
+
+export const SVCBRecordField = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
diff --git a/app/features/edge/dns-zone/form/types/tlsa-record-field.tsx b/app/features/edge/dns-zone/form/types/tlsa-record-field.tsx
index 6efd3eba3..ac15d6c78 100644
--- a/app/features/edge/dns-zone/form/types/tlsa-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/tlsa-record-field.tsx
@@ -1,29 +1,29 @@
-import { Form } from '@datum-ui/components/form';
-
-export const TLSARecordField = () => (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
-);
+import { Form } from '@datum-ui/components/form';
+
+export const TLSARecordField = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
diff --git a/app/features/edge/dns-zone/form/types/txt-record-field.tsx b/app/features/edge/dns-zone/form/types/txt-record-field.tsx
index e3ac91906..dd4f93e5a 100644
--- a/app/features/edge/dns-zone/form/types/txt-record-field.tsx
+++ b/app/features/edge/dns-zone/form/types/txt-record-field.tsx
@@ -1,7 +1,7 @@
-import { Form } from '@datum-ui/components/form';
-
-export const TXTRecordField = () => (
-
-
-
-);
+import { Form } from '@datum-ui/components/form';
+
+export const TXTRecordField = () => (
+
+
+
+);
diff --git a/app/features/edge/dns-zone/overview/coming-soon-card.tsx b/app/features/edge/dns-zone/overview/coming-soon-card.tsx
index a85f9ea09..da5d25e32 100644
--- a/app/features/edge/dns-zone/overview/coming-soon-card.tsx
+++ b/app/features/edge/dns-zone/overview/coming-soon-card.tsx
@@ -1,31 +1,31 @@
-import { ComingSoonFeatureCard } from '@/components/coming-soon/coming-soon-feature-card';
-import { Col, Row } from '@datum-ui/components';
-
-export const ComingSoonCard = () => {
- const FEATURES = [
- {
- title: 'DNSSEC',
- description:
- 'DNSSEC uses a cryptographic signature of published DNS records to protect your domain against forged DNS answers.',
- },
- {
- title: 'Multi-signer DNSSEC',
- description:
- 'Multi-signer DNSSEC allows Cloudflare and your other authoritative DNS providers to serve the same zone and have DNSSEC enabled at the same time.',
- },
- {
- title: 'Multi-provider DNS',
- description:
- 'Multi-provider DNS allows domains using a full DNS setup to be active on Datum while using another authoritative DNS provider, in addition to Datum. Also allows the domain to serve any apex NS records added to its DNS configuration at Datum.',
- },
- ];
- return (
-
- {FEATURES.map((feature, index) => (
-
-
-
- ))}
-
- );
-};
+import { ComingSoonFeatureCard } from '@/components/coming-soon/coming-soon-feature-card';
+import { Col, Row } from '@datum-ui/components';
+
+export const ComingSoonCard = () => {
+ const FEATURES = [
+ {
+ title: 'DNSSEC',
+ description:
+ 'DNSSEC uses a cryptographic signature of published DNS records to protect your domain against forged DNS answers.',
+ },
+ {
+ title: 'Multi-signer DNSSEC',
+ description:
+ 'Multi-signer DNSSEC allows Cloudflare and your other authoritative DNS providers to serve the same zone and have DNSSEC enabled at the same time.',
+ },
+ {
+ title: 'Multi-provider DNS',
+ description:
+ 'Multi-provider DNS allows domains using a full DNS setup to be active on Datum while using another authoritative DNS provider, in addition to Datum. Also allows the domain to serve any apex NS records added to its DNS configuration at Datum.',
+ },
+ ];
+ return (
+
+ {FEATURES.map((feature, index) => (
+
+
+
+ ))}
+
+ );
+};
diff --git a/app/features/edge/dns-zone/overview/description-form-card.tsx b/app/features/edge/dns-zone/overview/description-form-card.tsx
index ea4df3258..06e5456d3 100644
--- a/app/features/edge/dns-zone/overview/description-form-card.tsx
+++ b/app/features/edge/dns-zone/overview/description-form-card.tsx
@@ -1,93 +1,93 @@
-import type { DnsZone } from '@/resources/dns-zones';
-import { createDnsZoneSchema, useUpdateDnsZone } from '@/resources/dns-zones';
-import {
- Button,
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- toast,
-} from '@datum-ui/components';
-import { Form } from '@datum-ui/components/form';
-
-export const DescriptionFormCard = ({
- projectId,
- defaultValue,
-}: {
- projectId: string;
- defaultValue: DnsZone;
-}) => {
- const updateDnsZoneMutation = useUpdateDnsZone(projectId, defaultValue?.name ?? '', {
- onSuccess: () => {
- toast.success('DNS Zone', {
- description: 'You have successfully updated the DNS Zone description.',
- });
- },
- onError: (error: Error) => {
- toast.error('Error', {
- description: error.message ?? 'An error occurred while updating the DNS Zone description',
- });
- },
- });
-
- return (
-
-
-
- This description is for your own reference and won't be shared externally
-
-
- {
- updateDnsZoneMutation.mutate({
- description: data.description ?? '',
- resourceVersion: defaultValue.resourceVersion,
- });
- }}
- className="mt-6 flex flex-col gap-10 space-y-0">
- {({ form, isSubmitting }) => (
- <>
-
-
-
-
-
-
-
-
-
-
- {
- form.update({
- value: {
- description: defaultValue?.description ?? '',
- },
- });
- }}>
- Cancel
-
-
- Save
-
-
- >
- )}
-
-
- );
-};
+import type { DnsZone } from '@/resources/dns-zones';
+import { createDnsZoneSchema, useUpdateDnsZone } from '@/resources/dns-zones';
+import {
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ toast,
+} from '@datum-ui/components';
+import { Form } from '@datum-ui/components/form';
+
+export const DescriptionFormCard = ({
+ projectId,
+ defaultValue,
+}: {
+ projectId: string;
+ defaultValue: DnsZone;
+}) => {
+ const updateDnsZoneMutation = useUpdateDnsZone(projectId, defaultValue?.name ?? '', {
+ onSuccess: () => {
+ toast.success('DNS Zone', {
+ description: 'You have successfully updated the DNS Zone description.',
+ });
+ },
+ onError: (error: Error) => {
+ toast.error('Error', {
+ description: error.message ?? 'An error occurred while updating the DNS Zone description',
+ });
+ },
+ });
+
+ return (
+
+
+
+ This description is for your own reference and won't be shared externally
+
+
+ {
+ updateDnsZoneMutation.mutate({
+ description: data.description ?? '',
+ resourceVersion: defaultValue.resourceVersion,
+ });
+ }}
+ className="mt-6 flex flex-col gap-10 space-y-0">
+ {({ form, isSubmitting }) => (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {
+ form.update({
+ value: {
+ description: defaultValue?.description ?? '',
+ },
+ });
+ }}>
+ Cancel
+
+
+ Save
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/app/features/edge/dns-zone/overview/task-nameserver-card.tsx b/app/features/edge/dns-zone/overview/task-nameserver-card.tsx
index 3d0113bc3..e7d37820f 100644
--- a/app/features/edge/dns-zone/overview/task-nameserver-card.tsx
+++ b/app/features/edge/dns-zone/overview/task-nameserver-card.tsx
@@ -1,82 +1,82 @@
-import { BadgeCopy } from '@/components/badge/badge-copy';
-import { RefreshNameserversButton } from '@/features/edge/dns-zone/components/refresh-nameservers-button';
-import type { DnsZone } from '@/resources/dns-zones';
-import type { Domain } from '@/resources/domains';
-import { Card, CardContent, CardHeader, CardTitle } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { RefreshCcwIcon } from 'lucide-react';
-import { useMemo } from 'react';
-
-export const TaskNameserverCard = ({
- dnsZone,
- projectId,
- domain,
-}: {
- dnsZone: DnsZone;
- projectId: string;
- domain: Domain;
-}) => {
- const dnsHost = useMemo(() => {
- return dnsZone?.status?.domainRef?.status?.nameservers?.[0].ips?.[0]?.registrantName;
- }, [dnsZone]);
-
- return (
-
-
- Point Your Nameservers at Datum
-
-
-
- {dnsHost ? (
- <>
- This DNS zone is currently hosted by {dnsHost}, however for optimum performance we
- recommend delegating your nameservers so that it is hosted with Datum. To do this, log
- in to your current host and change the nameservers to these:
- >
- ) : (
- <>
- This DNS zone is currently not associated with any detected host. For optimum
- performance we recommend delegating your nameservers to Datum. To do this, log in to
- your domain's current DNS provider and change the nameservers to these:
- >
- )}
-
-
-
- {(dnsZone?.status?.nameservers as string[] | undefined)?.map(
- (nameserver: string, index: number) => (
-
- )
- )}
-
-
- {domain?.name && (
-
- }
- size="small"
- lastRefreshAttempt={domain?.desiredRegistrationRefreshAttempt}
- domainName={domain?.name ?? ''}
- projectId={projectId ?? ''}
- className="text-xs font-semibold"
- containerClassName="flex-row-reverse justify-end"
- />
-
- )}
-
-
-
-
- );
-};
+import { BadgeCopy } from '@/components/badge/badge-copy';
+import { RefreshNameserversButton } from '@/features/edge/dns-zone/components/refresh-nameservers-button';
+import type { DnsZone } from '@/resources/dns-zones';
+import type { Domain } from '@/resources/domains';
+import { Card, CardContent, CardHeader, CardTitle } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { RefreshCcwIcon } from 'lucide-react';
+import { useMemo } from 'react';
+
+export const TaskNameserverCard = ({
+ dnsZone,
+ projectId,
+ domain,
+}: {
+ dnsZone: DnsZone;
+ projectId: string;
+ domain: Domain;
+}) => {
+ const dnsHost = useMemo(() => {
+ return dnsZone?.status?.domainRef?.status?.nameservers?.[0].ips?.[0]?.registrantName;
+ }, [dnsZone]);
+
+ return (
+
+
+ Point Your Nameservers at Datum
+
+
+
+ {dnsHost ? (
+ <>
+ This DNS zone is currently hosted by {dnsHost}, however for optimum performance we
+ recommend delegating your nameservers so that it is hosted with Datum. To do this, log
+ in to your current host and change the nameservers to these:
+ >
+ ) : (
+ <>
+ This DNS zone is currently not associated with any detected host. For optimum
+ performance we recommend delegating your nameservers to Datum. To do this, log in to
+ your domain's current DNS provider and change the nameservers to these:
+ >
+ )}
+
+
+
+ {(dnsZone?.status?.nameservers as string[] | undefined)?.map(
+ (nameserver: string, index: number) => (
+
+ )
+ )}
+
+
+ {domain?.name && (
+
+ }
+ size="small"
+ lastRefreshAttempt={domain?.desiredRegistrationRefreshAttempt}
+ domainName={domain?.name ?? ''}
+ projectId={projectId ?? ''}
+ className="text-xs font-semibold"
+ containerClassName="flex-row-reverse justify-end"
+ />
+
+ )}
+
+
+
+
+ );
+};
diff --git a/app/features/edge/dns-zone/overview/task-record-card.tsx b/app/features/edge/dns-zone/overview/task-record-card.tsx
index a8a8963b8..60414a2b0 100644
--- a/app/features/edge/dns-zone/overview/task-record-card.tsx
+++ b/app/features/edge/dns-zone/overview/task-record-card.tsx
@@ -1,65 +1,65 @@
-import type { DnsZone } from '@/resources/dns-zones';
-import { paths } from '@/utils/config/paths.config';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { Card, CardContent, CardHeader, CardTitle, LinkButton } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { CheckIcon } from 'lucide-react';
-import { useMemo } from 'react';
-import { Link } from 'react-router';
-
-export const TaskRecordCard = ({ projectId, dnsZone }: { projectId: string; dnsZone: DnsZone }) => {
- const dnsRecordItems = useMemo(
- () => [
- <>
- Add an A, AAAA, or CNAME record for www so that{' '}
- www.{dnsZone?.domainName} will resolve.
- >,
- <>
- Add an A, AAAA, or CNAME record for your root so that{' '}
- {dnsZone?.domainName} will resolve.
- >,
- <>
- Add an MX record for your root domain so that mail can reach{' '}
- @{dnsZone?.domainName} addresses.
- >,
- ],
- [dnsZone?.domainName]
- );
- return (
-
-
- Add Key DNS Records
-
-
-
- {dnsRecordItems.map((item, index) => (
-
-
- {item}
-
- ))}
-
-
-
- Edit DNS records
-
-
-
-
-
- );
-};
+import type { DnsZone } from '@/resources/dns-zones';
+import { paths } from '@/utils/config/paths.config';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { Card, CardContent, CardHeader, CardTitle, LinkButton } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { CheckIcon } from 'lucide-react';
+import { useMemo } from 'react';
+import { Link } from 'react-router';
+
+export const TaskRecordCard = ({ projectId, dnsZone }: { projectId: string; dnsZone: DnsZone }) => {
+ const dnsRecordItems = useMemo(
+ () => [
+ <>
+ Add an A, AAAA, or CNAME record for www so that{' '}
+ www.{dnsZone?.domainName} will resolve.
+ >,
+ <>
+ Add an A, AAAA, or CNAME record for your root so that{' '}
+ {dnsZone?.domainName} will resolve.
+ >,
+ <>
+ Add an MX record for your root domain so that mail can reach{' '}
+ @{dnsZone?.domainName} addresses.
+ >,
+ ],
+ [dnsZone?.domainName]
+ );
+ return (
+
+
+ Add Key DNS Records
+
+
+
+ {dnsRecordItems.map((item, index) => (
+
+
+ {item}
+
+ ))}
+
+
+
+ Edit DNS records
+
+
+
+
+
+ );
+};
diff --git a/app/features/edge/domain/bulk-add/bulk-add-domains-action.tsx b/app/features/edge/domain/bulk-add/bulk-add-domains-action.tsx
index 1280c88d3..f60abe09f 100644
--- a/app/features/edge/domain/bulk-add/bulk-add-domains-action.tsx
+++ b/app/features/edge/domain/bulk-add/bulk-add-domains-action.tsx
@@ -1,224 +1,224 @@
-import { useApp } from '@/providers/app.provider';
-import { bulkDomainsSchema, domainKeys, parseDomains, useCreateDomain } from '@/resources/domains';
-import { readFileAsText } from '@/utils/common';
-import { paths } from '@/utils/config/paths.config';
-import { parseDomainsFromFile } from '@/utils/helpers/parse.helper';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { Button, Dialog, toast, useTaskQueue, createProjectMetadata } from '@datum-ui/components';
-import { FileInputButton } from '@datum-ui/components/file-input-button/file-input-button';
-import { Form } from '@datum-ui/components/form';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { ResponsiveDropdown } from '@datum-ui/components/responsive-dropdown';
-import { useQueryClient } from '@tanstack/react-query';
-import { ArrowRightIcon, GlobeIcon, ListChecksIcon } from 'lucide-react';
-import { useState } from 'react';
-import { useNavigate } from 'react-router';
-
-interface BulkAddDomainsActionProps {
- projectId: string;
- popoverOpen?: boolean;
- onPopoverOpenChange?: (open: boolean) => void;
-}
-
-export const BulkAddDomainsAction = ({
- projectId,
- popoverOpen: controlledPopoverOpen,
- onPopoverOpenChange,
-}: BulkAddDomainsActionProps) => {
- const [internalPopoverOpen, setInternalPopoverOpen] = useState(false);
-
- // Use controlled state if provided, otherwise use internal state
- const popoverOpen = controlledPopoverOpen ?? internalPopoverOpen;
- const setPopoverOpen = onPopoverOpenChange ?? setInternalPopoverOpen;
-
- const { enqueue, showSummary } = useTaskQueue();
- const navigate = useNavigate();
- const queryClient = useQueryClient();
- const { mutateAsync: createDomain } = useCreateDomain(projectId);
- const { project, organization } = useApp();
-
- const submitDomains = (domains: string[]) => {
- setPopoverOpen(false);
-
- // Build metadata for scope-aware task panel display
- const metadata =
- project && organization
- ? createProjectMetadata(
- { id: project.name, name: project.displayName || project.name },
- { id: organization.name, name: organization.displayName || organization.name }
- )
- : undefined;
-
- const taskTitle = `Add ${domains.length} ${domains.length === 1 ? 'domain' : 'domains'}`;
-
- enqueue({
- title: taskTitle,
- icon: ,
- items: domains,
- metadata,
- itemConcurrency: 3,
- processItem: async (domain) => {
- await createDomain({ domainName: domain });
- },
- onComplete: () => queryClient.invalidateQueries({ queryKey: domainKeys.list(projectId) }),
- completionActions: (_result, { failed, items }) => [
- ...(failed > 0
- ? [
- {
- children: 'Summary',
- type: 'quaternary' as const,
- theme: 'outline' as const,
- size: 'xs' as const,
- onClick: () =>
- showSummary(
- taskTitle,
- items.map((item) => ({
- id: item.id,
- label: item.id,
- status: item.status === 'succeeded' ? 'success' : 'failed',
- message: item.message,
- }))
- ),
- },
- ]
- : []),
- {
- children: 'View Domains',
- type: 'primary',
- theme: 'outline',
- size: 'xs',
- onClick: () =>
- navigate(
- getPathWithParams(paths.project.detail.domains.root, { projectId: projectId! })
- ),
- },
- ],
- });
- };
-
- const handleFileSelect = async (files: File[]) => {
- const file = files[0];
- if (!file) return;
-
- try {
- const content = await readFileAsText(file);
- const domainsRaw = parseDomainsFromFile(content);
- const result = bulkDomainsSchema.safeParse({ domains: domainsRaw.join('\n') });
-
- if (!result.success) {
- toast.error('Domains', {
- description: result.error.issues[0]?.message || 'Invalid domains in file',
- });
- return;
- }
-
- submitDomains(result.data.domains);
- } catch {
- toast.error('Domains', { description: 'Failed to read file' });
- }
- };
-
- const handleFormSubmit = (data: { domains: string[] }) => {
- submitDomains(data.domains);
- };
-
- // Use Dialog when controlled (for empty content), Popover when uncontrolled (for toolbar)
- const isControlled = controlledPopoverOpen !== undefined;
-
- const content = (
- <>
- {!isControlled && (
-
-
Bulk Add Domains
-
- Paste a list of domains below, separated by new lines or commas.
-
-
- )}
-
-
- {({ fields }) => {
- const domains = fields.domains?.value;
- const domainsCount = domains ? parseDomains(domains as string).length : 0;
- return (
- <>
-
-
-
- }
- iconPosition="right"
- type="secondary"
- theme="solid"
- size="small"
- htmlType="submit"
- disabled={domainsCount === 0}>
- {domainsCount > 0
- ? `Add ${domainsCount === 1 ? 'domain' : 'domains'} (${domainsCount})`
- : 'Add domains'}
-
- >
- );
- }}
-
-
-
-
Import from file
- toast.error('Domains', { description: error.message })}>
- Choose file
-
-
- >
- );
-
- if (isControlled) {
- return (
-
-
- setPopoverOpen(false)}
- />
- {content}
-
-
- );
- }
-
- return (
-
-
- Bulk add domains
-
- }>
- {content}
-
- );
-};
+import { useApp } from '@/providers/app.provider';
+import { bulkDomainsSchema, domainKeys, parseDomains, useCreateDomain } from '@/resources/domains';
+import { readFileAsText } from '@/utils/common';
+import { paths } from '@/utils/config/paths.config';
+import { parseDomainsFromFile } from '@/utils/helpers/parse.helper';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { Button, Dialog, toast, useTaskQueue, createProjectMetadata } from '@datum-ui/components';
+import { FileInputButton } from '@datum-ui/components/file-input-button/file-input-button';
+import { Form } from '@datum-ui/components/form';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { ResponsiveDropdown } from '@datum-ui/components/responsive-dropdown';
+import { useQueryClient } from '@tanstack/react-query';
+import { ArrowRightIcon, GlobeIcon, ListChecksIcon } from 'lucide-react';
+import { useState } from 'react';
+import { useNavigate } from 'react-router';
+
+interface BulkAddDomainsActionProps {
+ projectId: string;
+ popoverOpen?: boolean;
+ onPopoverOpenChange?: (open: boolean) => void;
+}
+
+export const BulkAddDomainsAction = ({
+ projectId,
+ popoverOpen: controlledPopoverOpen,
+ onPopoverOpenChange,
+}: BulkAddDomainsActionProps) => {
+ const [internalPopoverOpen, setInternalPopoverOpen] = useState(false);
+
+ // Use controlled state if provided, otherwise use internal state
+ const popoverOpen = controlledPopoverOpen ?? internalPopoverOpen;
+ const setPopoverOpen = onPopoverOpenChange ?? setInternalPopoverOpen;
+
+ const { enqueue, showSummary } = useTaskQueue();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { mutateAsync: createDomain } = useCreateDomain(projectId);
+ const { project, organization } = useApp();
+
+ const submitDomains = (domains: string[]) => {
+ setPopoverOpen(false);
+
+ // Build metadata for scope-aware task panel display
+ const metadata =
+ project && organization
+ ? createProjectMetadata(
+ { id: project.name, name: project.displayName || project.name },
+ { id: organization.name, name: organization.displayName || organization.name }
+ )
+ : undefined;
+
+ const taskTitle = `Add ${domains.length} ${domains.length === 1 ? 'domain' : 'domains'}`;
+
+ enqueue({
+ title: taskTitle,
+ icon: ,
+ items: domains,
+ metadata,
+ itemConcurrency: 3,
+ processItem: async (domain) => {
+ await createDomain({ domainName: domain });
+ },
+ onComplete: () => queryClient.invalidateQueries({ queryKey: domainKeys.list(projectId) }),
+ completionActions: (_result, { failed, items }) => [
+ ...(failed > 0
+ ? [
+ {
+ children: 'Summary',
+ type: 'quaternary' as const,
+ theme: 'outline' as const,
+ size: 'xs' as const,
+ onClick: () =>
+ showSummary(
+ taskTitle,
+ items.map((item) => ({
+ id: item.id,
+ label: item.id,
+ status: item.status === 'succeeded' ? 'success' : 'failed',
+ message: item.message,
+ }))
+ ),
+ },
+ ]
+ : []),
+ {
+ children: 'View Domains',
+ type: 'primary',
+ theme: 'outline',
+ size: 'xs',
+ onClick: () =>
+ navigate(
+ getPathWithParams(paths.project.detail.domains.root, { projectId: projectId! })
+ ),
+ },
+ ],
+ });
+ };
+
+ const handleFileSelect = async (files: File[]) => {
+ const file = files[0];
+ if (!file) return;
+
+ try {
+ const content = await readFileAsText(file);
+ const domainsRaw = parseDomainsFromFile(content);
+ const result = bulkDomainsSchema.safeParse({ domains: domainsRaw.join('\n') });
+
+ if (!result.success) {
+ toast.error('Domains', {
+ description: result.error.issues[0]?.message || 'Invalid domains in file',
+ });
+ return;
+ }
+
+ submitDomains(result.data.domains);
+ } catch {
+ toast.error('Domains', { description: 'Failed to read file' });
+ }
+ };
+
+ const handleFormSubmit = (data: { domains: string[] }) => {
+ submitDomains(data.domains);
+ };
+
+ // Use Dialog when controlled (for empty content), Popover when uncontrolled (for toolbar)
+ const isControlled = controlledPopoverOpen !== undefined;
+
+ const content = (
+ <>
+ {!isControlled && (
+
+
Bulk Add Domains
+
+ Paste a list of domains below, separated by new lines or commas.
+
+
+ )}
+
+
+ {({ fields }) => {
+ const domains = fields.domains?.value;
+ const domainsCount = domains ? parseDomains(domains as string).length : 0;
+ return (
+ <>
+
+
+
+ }
+ iconPosition="right"
+ type="secondary"
+ theme="solid"
+ size="small"
+ htmlType="submit"
+ disabled={domainsCount === 0}>
+ {domainsCount > 0
+ ? `Add ${domainsCount === 1 ? 'domain' : 'domains'} (${domainsCount})`
+ : 'Add domains'}
+
+ >
+ );
+ }}
+
+
+
+
Import from file
+ toast.error('Domains', { description: error.message })}>
+ Choose file
+
+
+ >
+ );
+
+ if (isControlled) {
+ return (
+
+
+ setPopoverOpen(false)}
+ />
+ {content}
+
+
+ );
+ }
+
+ return (
+
+
+ Bulk add domains
+
+ }>
+ {content}
+
+ );
+};
diff --git a/app/features/edge/domain/domain-form-dialog.tsx b/app/features/edge/domain/domain-form-dialog.tsx
index 867c59946..1765ef2f7 100644
--- a/app/features/edge/domain/domain-form-dialog.tsx
+++ b/app/features/edge/domain/domain-form-dialog.tsx
@@ -1,83 +1,83 @@
-import { AnalyticsAction, useAnalytics } from '@/modules/fathom';
-import { type DomainSchema, domainSchema, useCreateDomain, type Domain } from '@/resources/domains';
-import { toast } from '@datum-ui/components';
-import { Form } from '@datum-ui/components/form';
-import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
-
-export interface DomainFormDialogRef {
- show: (initialValues?: Partial) => void;
- hide: () => void;
-}
-
-interface DomainFormDialogProps {
- projectId: string;
- onSuccess?: (domain: Domain) => void;
- onError?: (error: Error) => void;
-}
-
-export const DomainFormDialog = forwardRef(
- ({ projectId, onSuccess, onError }, ref) => {
- const [open, setOpen] = useState(false);
- const [defaultValues, setDefaultValues] = useState>();
-
- const createDomainMutation = useCreateDomain(projectId);
- const { trackAction } = useAnalytics();
-
- const show = useCallback((initialValues?: Partial) => {
- setDefaultValues(initialValues);
- setOpen(true);
- }, []);
-
- const hide = useCallback(() => {
- setOpen(false);
- }, []);
-
- useImperativeHandle(ref, () => ({ show, hide }), [show, hide]);
-
- const handleSubmit = async (formData: DomainSchema) => {
- try {
- const domain = await createDomainMutation.mutateAsync({ domainName: formData.domain });
- trackAction(AnalyticsAction.AddDomain);
- setOpen(false);
-
- if (onSuccess && domain.name) {
- onSuccess?.(domain);
- }
- } catch (error) {
- toast.error('Domain', {
- description: (error as Error).message || 'Failed to add domain',
- });
- onError?.(error as Error);
- }
- };
-
- return (
-
-
-
-
-
- );
- }
-);
-
-DomainFormDialog.displayName = 'DomainFormDialog';
+import { AnalyticsAction, useAnalytics } from '@/modules/fathom';
+import { type DomainSchema, domainSchema, useCreateDomain, type Domain } from '@/resources/domains';
+import { toast } from '@datum-ui/components';
+import { Form } from '@datum-ui/components/form';
+import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
+
+export interface DomainFormDialogRef {
+ show: (initialValues?: Partial) => void;
+ hide: () => void;
+}
+
+interface DomainFormDialogProps {
+ projectId: string;
+ onSuccess?: (domain: Domain) => void;
+ onError?: (error: Error) => void;
+}
+
+export const DomainFormDialog = forwardRef(
+ ({ projectId, onSuccess, onError }, ref) => {
+ const [open, setOpen] = useState(false);
+ const [defaultValues, setDefaultValues] = useState>();
+
+ const createDomainMutation = useCreateDomain(projectId);
+ const { trackAction } = useAnalytics();
+
+ const show = useCallback((initialValues?: Partial) => {
+ setDefaultValues(initialValues);
+ setOpen(true);
+ }, []);
+
+ const hide = useCallback(() => {
+ setOpen(false);
+ }, []);
+
+ useImperativeHandle(ref, () => ({ show, hide }), [show, hide]);
+
+ const handleSubmit = async (formData: DomainSchema) => {
+ try {
+ const domain = await createDomainMutation.mutateAsync({ domainName: formData.domain });
+ trackAction(AnalyticsAction.AddDomain);
+ setOpen(false);
+
+ if (onSuccess && domain.name) {
+ onSuccess?.(domain);
+ }
+ } catch (error) {
+ toast.error('Domain', {
+ description: (error as Error).message || 'Failed to add domain',
+ });
+ onError?.(error as Error);
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
+);
+
+DomainFormDialog.displayName = 'DomainFormDialog';
diff --git a/app/features/edge/domain/expiration.tsx b/app/features/edge/domain/expiration.tsx
index e4f6dbd35..82196ad74 100644
--- a/app/features/edge/domain/expiration.tsx
+++ b/app/features/edge/domain/expiration.tsx
@@ -1,29 +1,29 @@
-import { DateTime } from '@/components/date-time';
-import { Badge } from '@datum-ui/components';
-import { addMonths, isBefore } from 'date-fns';
-
-export interface DomainExpirationProps {
- expiresAt?: string;
- showBadge?: boolean;
-}
-
-export const DomainExpiration = ({ expiresAt, showBadge = true }: DomainExpirationProps) => {
- if (!expiresAt) return <>->;
-
- const expiresDate = new Date(expiresAt);
- const soonThreshold = addMonths(new Date(), 1);
- const isExpiringSoon = isBefore(expiresDate, soonThreshold);
-
- return (
-
-
- {showBadge && isExpiringSoon && (
-
- Expiring soon
-
- )}
-
- );
-};
+import { DateTime } from '@/components/date-time';
+import { Badge } from '@datum-ui/components';
+import { addMonths, isBefore } from 'date-fns';
+
+export interface DomainExpirationProps {
+ expiresAt?: string;
+ showBadge?: boolean;
+}
+
+export const DomainExpiration = ({ expiresAt, showBadge = true }: DomainExpirationProps) => {
+ if (!expiresAt) return <>->;
+
+ const expiresDate = new Date(expiresAt);
+ const soonThreshold = addMonths(new Date(), 1);
+ const isExpiringSoon = isBefore(expiresDate, soonThreshold);
+
+ return (
+
+
+ {showBadge && isExpiringSoon && (
+
+ Expiring soon
+
+ )}
+
+ );
+};
diff --git a/app/features/edge/domain/overview/general-card.tsx b/app/features/edge/domain/overview/general-card.tsx
index 059867552..0d333b609 100644
--- a/app/features/edge/domain/overview/general-card.tsx
+++ b/app/features/edge/domain/overview/general-card.tsx
@@ -1,127 +1,127 @@
-import { BadgeCopy } from '@/components/badge/badge-copy';
-import { DateTime } from '@/components/date-time';
-import { List, ListItem } from '@/components/list/list';
-import { NameserverChips } from '@/components/nameserver-chips';
-import { DomainExpiration } from '@/features/edge/domain/expiration';
-import { DomainStatus } from '@/features/edge/domain/status';
-import { AnalyticsAction, useAnalytics } from '@/modules/fathom';
-import type { DnsZone } from '@/resources/dns-zones';
-import type { Domain } from '@/resources/domains';
-import { paths } from '@/utils/config/paths.config';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { Card, CardContent, LinkButton, Badge, Tooltip } from '@datum-ui/components';
-import { useMemo } from 'react';
-import { Link } from 'react-router';
-
-export const DomainGeneralCard = ({
- domain,
- dnsZone,
- projectId,
-}: {
- domain: Domain;
- dnsZone?: DnsZone;
- projectId?: string;
-}) => {
- const { trackAction } = useAnalytics();
-
- const listItems: ListItem[] = useMemo(() => {
- if (!domain) return [];
-
- const registrationFetching = !!domain.status && !domain.status?.registration;
- const nameserversFetching = !!domain.status && !domain.status?.nameservers?.length;
-
- return [
- {
- label: 'Resource Name',
- content: ,
- },
- {
- label: 'Registrar',
- content: registrationFetching ? (
-
- Looking up...
-
- ) : domain.status?.registration?.registrar?.name ? (
-
- {domain.status?.registration?.registrar?.name}
-
- ) : domain.status?.registration ? (
-
-
- Private
-
-
- ) : (
- '-'
- ),
- },
- {
- label: 'DNS Host',
- content: nameserversFetching ? (
-
- Looking up...
-
- ) : (
-
- ),
- },
- {
- label: 'Status',
- content: ,
- },
- {
- label: 'Expiration Date',
- content: ,
- },
- {
- label: 'Created At',
- content: ,
- },
- {
- label: 'DNS Zone',
- content: dnsZone ? (
-
- {domain.domainName}
-
- ) : (
- trackAction(AnalyticsAction.TransferDnsToDatum)}
- href={getPathWithParams(
- paths.project.detail.dnsZones.root,
- {
- projectId,
- },
- new URLSearchParams({
- action: 'create',
- domainName: domain.domainName ?? '',
- })
- )}>
- Transfer to Datum
-
- ),
- },
- ];
- }, [domain, dnsZone, trackAction, projectId]);
-
- return (
-
-
-
-
-
- );
-};
+import { BadgeCopy } from '@/components/badge/badge-copy';
+import { DateTime } from '@/components/date-time';
+import { List, ListItem } from '@/components/list/list';
+import { NameserverChips } from '@/components/nameserver-chips';
+import { DomainExpiration } from '@/features/edge/domain/expiration';
+import { DomainStatus } from '@/features/edge/domain/status';
+import { AnalyticsAction, useAnalytics } from '@/modules/fathom';
+import type { DnsZone } from '@/resources/dns-zones';
+import type { Domain } from '@/resources/domains';
+import { paths } from '@/utils/config/paths.config';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { Card, CardContent, LinkButton, Badge, Tooltip } from '@datum-ui/components';
+import { useMemo } from 'react';
+import { Link } from 'react-router';
+
+export const DomainGeneralCard = ({
+ domain,
+ dnsZone,
+ projectId,
+}: {
+ domain: Domain;
+ dnsZone?: DnsZone;
+ projectId?: string;
+}) => {
+ const { trackAction } = useAnalytics();
+
+ const listItems: ListItem[] = useMemo(() => {
+ if (!domain) return [];
+
+ const registrationFetching = !!domain.status && !domain.status?.registration;
+ const nameserversFetching = !!domain.status && !domain.status?.nameservers?.length;
+
+ return [
+ {
+ label: 'Resource Name',
+ content: ,
+ },
+ {
+ label: 'Registrar',
+ content: registrationFetching ? (
+
+ Looking up...
+
+ ) : domain.status?.registration?.registrar?.name ? (
+
+ {domain.status?.registration?.registrar?.name}
+
+ ) : domain.status?.registration ? (
+
+
+ Private
+
+
+ ) : (
+ '-'
+ ),
+ },
+ {
+ label: 'DNS Host',
+ content: nameserversFetching ? (
+
+ Looking up...
+
+ ) : (
+
+ ),
+ },
+ {
+ label: 'Status',
+ content: ,
+ },
+ {
+ label: 'Expiration Date',
+ content: ,
+ },
+ {
+ label: 'Created At',
+ content: ,
+ },
+ {
+ label: 'DNS Zone',
+ content: dnsZone ? (
+
+ {domain.domainName}
+
+ ) : (
+ trackAction(AnalyticsAction.TransferDnsToDatum)}
+ href={getPathWithParams(
+ paths.project.detail.dnsZones.root,
+ {
+ projectId,
+ },
+ new URLSearchParams({
+ action: 'create',
+ domainName: domain.domainName ?? '',
+ })
+ )}>
+ Transfer to Datum
+
+ ),
+ },
+ ];
+ }, [domain, dnsZone, trackAction, projectId]);
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/app/features/edge/domain/overview/quick-setup-card.tsx b/app/features/edge/domain/overview/quick-setup-card.tsx
index 6b02c208d..1e968e589 100644
--- a/app/features/edge/domain/overview/quick-setup-card.tsx
+++ b/app/features/edge/domain/overview/quick-setup-card.tsx
@@ -1,115 +1,115 @@
-import { CreateDNSSetupResponse } from '@/modules/cloudvalid';
-import { AnalyticsAction, useAnalytics } from '@/modules/fathom';
-import type { Domain } from '@/resources/domains';
-import { paths } from '@/utils/config/paths.config';
-import { getPathWithParams } from '@/utils/helpers/path.helper';
-import { Button, toast } from '@datum-ui/components';
-import { Card, CardContent } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { useMutation } from '@tanstack/react-query';
-import { CheckIcon, CloudLightningIcon } from 'lucide-react';
-
-const CLOUD_VALIDATION_DNS_PATH = '/api/cloudvalid/dns' as const;
-
-type DnsSetupInput = {
- domain: string;
- dnsName: string;
- dnsContent: string;
- redirectUri: string;
-};
-
-type DnsSetupResponse = {
- success: boolean;
- error?: string;
- data?: CreateDNSSetupResponse;
-};
-
-export const QuickSetupCard = ({ projectId, domain }: { projectId: string; domain: Domain }) => {
- const { trackAction } = useAnalytics();
-
- const dnsSetupMutation = useMutation({
- mutationFn: async (input) => {
- const response = await fetch(CLOUD_VALIDATION_DNS_PATH, {
- method: 'POST',
- body: JSON.stringify({
- domain: input.domain,
- dnsName: input.dnsName,
- dnsContent: input.dnsContent,
- redirectUri: input.redirectUri,
- }),
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- const data = await response.json();
-
- if (!response.ok || !data.success) {
- throw new Error(data.error || 'Failed to submit DNS setup');
- }
-
- return data;
- },
- onSuccess: (data) => {
- trackAction(AnalyticsAction.VerifyDomain);
- if (data.data?.public_url) {
- window.open(data.data.public_url, '_blank');
- }
- },
- onError: (error) => {
- toast.error('Failed to submit DNS setup', { description: error.message });
- },
- });
-
- const dnsRecord = domain.status?.verification?.dnsRecord;
-
- const setupItems = [
- 'Automatically adds the required TXT record to your DNS',
- 'Configures verification settings without manual intervention',
- 'Completes domain validation in minutes instead of hours',
- 'No manual steps required',
- ];
-
- const handleQuickSetup = () => {
- dnsSetupMutation.mutate({
- domain: domain.domainName ?? '',
- dnsName: dnsRecord?.name ?? '',
- dnsContent: dnsRecord?.content ?? '',
- redirectUri: `${window.location.origin}${getPathWithParams(paths.project.detail.domains.detail.overview, { projectId, domainId: domain.name })}?cloudvalid=success`,
- });
- };
-
- return (
-
-
-
-
- Automatic Verification
-
-
-
- Skip the manual DNS setup process and let Datum automatically configure your DNS records
- and validate your domain for you.
-
-
- {setupItems.map((item, index) => (
-
-
- {item}
-
- ))}
-
-
-
- Verify your domain
-
-
-
- );
-};
+import { CreateDNSSetupResponse } from '@/modules/cloudvalid';
+import { AnalyticsAction, useAnalytics } from '@/modules/fathom';
+import type { Domain } from '@/resources/domains';
+import { paths } from '@/utils/config/paths.config';
+import { getPathWithParams } from '@/utils/helpers/path.helper';
+import { Button, toast } from '@datum-ui/components';
+import { Card, CardContent } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { useMutation } from '@tanstack/react-query';
+import { CheckIcon, CloudLightningIcon } from 'lucide-react';
+
+const CLOUD_VALIDATION_DNS_PATH = '/api/cloudvalid/dns' as const;
+
+type DnsSetupInput = {
+ domain: string;
+ dnsName: string;
+ dnsContent: string;
+ redirectUri: string;
+};
+
+type DnsSetupResponse = {
+ success: boolean;
+ error?: string;
+ data?: CreateDNSSetupResponse;
+};
+
+export const QuickSetupCard = ({ projectId, domain }: { projectId: string; domain: Domain }) => {
+ const { trackAction } = useAnalytics();
+
+ const dnsSetupMutation = useMutation({
+ mutationFn: async (input) => {
+ const response = await fetch(CLOUD_VALIDATION_DNS_PATH, {
+ method: 'POST',
+ body: JSON.stringify({
+ domain: input.domain,
+ dnsName: input.dnsName,
+ dnsContent: input.dnsContent,
+ redirectUri: input.redirectUri,
+ }),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok || !data.success) {
+ throw new Error(data.error || 'Failed to submit DNS setup');
+ }
+
+ return data;
+ },
+ onSuccess: (data) => {
+ trackAction(AnalyticsAction.VerifyDomain);
+ if (data.data?.public_url) {
+ window.open(data.data.public_url, '_blank');
+ }
+ },
+ onError: (error) => {
+ toast.error('Failed to submit DNS setup', { description: error.message });
+ },
+ });
+
+ const dnsRecord = domain.status?.verification?.dnsRecord;
+
+ const setupItems = [
+ 'Automatically adds the required TXT record to your DNS',
+ 'Configures verification settings without manual intervention',
+ 'Completes domain validation in minutes instead of hours',
+ 'No manual steps required',
+ ];
+
+ const handleQuickSetup = () => {
+ dnsSetupMutation.mutate({
+ domain: domain.domainName ?? '',
+ dnsName: dnsRecord?.name ?? '',
+ dnsContent: dnsRecord?.content ?? '',
+ redirectUri: `${window.location.origin}${getPathWithParams(paths.project.detail.domains.detail.overview, { projectId, domainId: domain.name })}?cloudvalid=success`,
+ });
+ };
+
+ return (
+
+
+
+
+ Automatic Verification
+
+
+
+ Skip the manual DNS setup process and let Datum automatically configure your DNS records
+ and validate your domain for you.
+
+
+ {setupItems.map((item, index) => (
+
+
+ {item}
+
+ ))}
+
+
+
+ Verify your domain
+
+
+
+ );
+};
diff --git a/app/features/edge/domain/overview/verification-card.tsx b/app/features/edge/domain/overview/verification-card.tsx
index 25a7815df..4a8875888 100644
--- a/app/features/edge/domain/overview/verification-card.tsx
+++ b/app/features/edge/domain/overview/verification-card.tsx
@@ -1,116 +1,116 @@
-import { DateTime } from '@/components/date-time';
-import type { Domain } from '@/resources/domains';
-import { Card, CardContent, Tooltip, toast } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { useCopyToClipboard } from '@datum-ui/hooks';
-import { BookOpenIcon, CopyIcon } from 'lucide-react';
-import { useState } from 'react';
-
-/** Verification badge — text truncates, copy button always visible, click anywhere to copy */
-function VerificationBadge({ value }: { value: string }) {
- const [_, copy] = useCopyToClipboard();
- const [copied, setCopied] = useState(false);
-
- const copyToClipboard = () => {
- if (!value) return;
-
- copy(value).then((success) => {
- if (!success) return;
- toast.success('Copied to clipboard');
- setCopied(true);
- setTimeout(() => {
- setCopied(false);
- }, 2000);
- });
- };
-
- return (
-
- {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- copyToClipboard();
- }
- }}
- className="flex max-w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md border border-transparent bg-[var(--color-badge-muted)] px-1.5 py-[5px] text-[var(--color-badge-muted-foreground)] transition-colors dark:border-[var(--color-badge-muted)]/20 dark:bg-[var(--color-badge-muted)]/20">
- {value}
-
-
-
-
-
- );
-}
-
-export const DomainVerificationCard = ({ domain }: { domain: Domain }) => {
- const dnsRecord = domain.status?.verification?.dnsRecord;
- const httpToken = domain.status?.verification?.httpToken;
-
- if (!dnsRecord && !httpToken) {
- return null;
- }
-
- return (
-
-
-
-
- Manual Verification
-
-
- To verify domain ownership, use one of the methods below. Once verified, you may remove
- the record from your DNS system. Next verification{' '}
- {domain.status?.verification?.nextVerificationAttempt && (
-
- )}
-
-
-
-
Add a TXT DNS Record
-
- {dnsRecord?.name && (
-
- Name
-
-
- )}
- {dnsRecord?.content && (
-
- Value
-
-
- )}
-
-
-
-
Create a HTTP Token File
-
- {httpToken?.url && (
-
- URL
-
-
- )}
- {httpToken?.body && (
-
- Body
-
-
- )}
-
-
-
-
-
- );
-};
+import { DateTime } from '@/components/date-time';
+import type { Domain } from '@/resources/domains';
+import { Card, CardContent, Tooltip, toast } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { useCopyToClipboard } from '@datum-ui/hooks';
+import { BookOpenIcon, CopyIcon } from 'lucide-react';
+import { useState } from 'react';
+
+/** Verification badge — text truncates, copy button always visible, click anywhere to copy */
+function VerificationBadge({ value }: { value: string }) {
+ const [_, copy] = useCopyToClipboard();
+ const [copied, setCopied] = useState(false);
+
+ const copyToClipboard = () => {
+ if (!value) return;
+
+ copy(value).then((success) => {
+ if (!success) return;
+ toast.success('Copied to clipboard');
+ setCopied(true);
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ });
+ };
+
+ return (
+
+ {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ copyToClipboard();
+ }
+ }}
+ className="flex max-w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md border border-transparent bg-[var(--color-badge-muted)] px-1.5 py-[5px] text-[var(--color-badge-muted-foreground)] transition-colors dark:border-[var(--color-badge-muted)]/20 dark:bg-[var(--color-badge-muted)]/20">
+ {value}
+
+
+
+
+
+ );
+}
+
+export const DomainVerificationCard = ({ domain }: { domain: Domain }) => {
+ const dnsRecord = domain.status?.verification?.dnsRecord;
+ const httpToken = domain.status?.verification?.httpToken;
+
+ if (!dnsRecord && !httpToken) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ Manual Verification
+
+
+ To verify domain ownership, use one of the methods below. Once verified, you may remove
+ the record from your DNS system. Next verification{' '}
+ {domain.status?.verification?.nextVerificationAttempt && (
+
+ )}
+
+
+
+
Add a TXT DNS Record
+
+ {dnsRecord?.name && (
+
+ Name
+
+
+ )}
+ {dnsRecord?.content && (
+
+ Value
+
+
+ )}
+
+
+
+
Create a HTTP Token File
+
+ {httpToken?.url && (
+
+ URL
+
+
+ )}
+ {httpToken?.body && (
+
+ Body
+
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/app/features/edge/domain/select-domain.tsx b/app/features/edge/domain/select-domain.tsx
index 7af18cfec..2a1f97a2a 100644
--- a/app/features/edge/domain/select-domain.tsx
+++ b/app/features/edge/domain/select-domain.tsx
@@ -1,289 +1,289 @@
-import { DomainFormDialog, type DomainFormDialogRef } from './domain-form-dialog';
-import { BadgeStatus } from '@/components/badge/badge-status';
-import { ControlPlaneStatus } from '@/resources/base';
-import {
- DOMAIN_VERIFICATION_STATUS,
- domainKeys,
- useDomains,
- type Domain,
-} from '@/resources/domains';
-import { transformControlPlaneStatus } from '@/utils/helpers/control-plane.helper';
-import { useInputControl } from '@conform-to/react';
-import { Button } from '@datum-ui/components';
-import type { AutocompleteOption, AutocompleteProps } from '@datum-ui/components/form';
-import { useFieldContext } from '@datum-ui/components/form';
-import { Autocomplete } from '@datum-ui/components/form/primitives';
-import { cn } from '@shadcn/lib/utils';
-import { useQueryClient } from '@tanstack/react-query';
-import { AlertTriangleIcon, CheckIcon, PlusIcon } from 'lucide-react';
-import { useCallback, useMemo, useRef, useState } from 'react';
-
-// ============================================================================
-// Domain Option Type
-// ============================================================================
-
-interface DomainOption extends AutocompleteOption {
- domainStatus: ControlPlaneStatus;
-}
-
-// ============================================================================
-// Domain Option Renderer
-// ============================================================================
-
-function DomainOptionContent({
- option,
- isSelected,
-}: {
- option: DomainOption;
- isSelected: boolean;
-}) {
- const statusConfig = DOMAIN_VERIFICATION_STATUS[option.domainStatus];
-
- return (
-
-
- {option.label}
- {statusConfig && (
-
- )}
-
- {isSelected &&
}
-
- );
-}
-
-// ============================================================================
-// SelectDomain (Standalone)
-// ============================================================================
-
-type SelectDomainProps = {
- /** Project ID for fetching domains */
- projectId: string;
- /** Currently selected domain value */
- value?: string;
- /** Called when selection changes */
- onValueChange?: (value: string) => void;
- /** Domain values to exclude from the options (e.g., already selected in other fields) */
- excludeValues?: string[];
- /** Allow custom hostnames not in the domain list */
- creatable?: boolean;
- /** Disable the component */
- disabled?: boolean;
- /** Placeholder text */
- placeholder?: string;
- /** Additional CSS classes for the root wrapper */
- className?: string;
- /** Additional CSS classes for the trigger button */
- triggerClassName?: string;
- /** Hide the verification warning (for compact/embedded usage) */
- compact?: boolean;
- /** Show the "Add a Domain" footer even in compact mode */
- showAddDomain?: boolean;
-} & Pick;
-
-export function SelectDomain({
- projectId,
- value,
- onValueChange,
- excludeValues,
- creatable,
- disabled,
- placeholder = 'Select a domain...',
- className,
- triggerClassName,
- compact,
- showAddDomain,
- loading: externalLoading,
- emptyContent = 'No domains found',
- ...rest
-}: SelectDomainProps) {
- const queryClient = useQueryClient();
- const { data: domains = [], isLoading } = useDomains(projectId);
- const domainFormRef = useRef(null);
-
- const domainOptions: DomainOption[] = useMemo(() => {
- const unique = new Map(domains.map((d) => [d.domainName, d]));
- const options = Array.from(unique.values(), (d) => ({
- value: d.domainName,
- label: d.domainName,
- domainStatus: transformControlPlaneStatus(d.status).status,
- }));
-
- // In creatable mode, the Autocomplete trigger handles unknown values natively.
- // In non-creatable mode, inject the current value so it remains visible and selectable.
- if (!creatable && value && !options.some((o) => o.value === value)) {
- options.unshift({
- value,
- label: value,
- domainStatus: ControlPlaneStatus.Pending,
- });
- }
-
- return options;
- }, [domains, value, creatable]);
-
- const filteredOptions = useMemo(() => {
- if (!excludeValues?.length) return domainOptions;
- return domainOptions.filter(
- (option) => option.value === value || !excludeValues.includes(option.value)
- );
- }, [domainOptions, excludeValues, value]);
-
- // In creatable mode, we take over filtering from cmdk so we can keep
- // parent domains visible when the user types a subdomain.
- const [searchTerm, setSearchTerm] = useState('');
-
- const searchFilteredOptions = useMemo(() => {
- if (!creatable) return filteredOptions;
- const needle = searchTerm.trim().toLowerCase();
- if (!needle) return filteredOptions;
- return filteredOptions.filter((option) => {
- // Normal substring match
- if (option.label.toLowerCase().includes(needle)) return true;
- // Keep parent domains visible: typing "app.hiyahya.dev" keeps "hiyahya.dev"
- if (needle.endsWith(`.${option.value.toLowerCase()}`)) return true;
- return false;
- });
- }, [creatable, searchTerm, filteredOptions]);
-
- const selectedOption = useMemo(
- () => domainOptions.find((o) => o.value === value),
- [domainOptions, value]
- );
-
- // In creatable mode, preserve the full typed subdomain instead of truncating to parent domain
- const handleValueChange = useCallback(
- (selectedValue: string) => {
- if (creatable && searchTerm && selectedValue) {
- const typedValue = searchTerm.trim().toLowerCase();
- const selectedDomain = selectedValue.toLowerCase();
-
- // If the typed value is a subdomain of the selected domain,
- // use the full typed value (e.g., "api.staging.example.com" instead of "example.com")
- if (typedValue !== selectedDomain && typedValue.endsWith(`.${selectedDomain}`)) {
- onValueChange?.(typedValue);
- return;
- }
- }
-
- onValueChange?.(selectedValue);
- },
- [creatable, searchTerm, onValueChange]
- );
-
- const handleDomainCreated = useCallback(
- async (domain: Domain) => {
- // Refetch the domains list so the new domain appears in options
- await queryClient.invalidateQueries({ queryKey: domainKeys.list(projectId) });
- onValueChange?.(domain.domainName);
- },
- [queryClient, projectId, onValueChange]
- );
-
- const isUnverified = selectedOption && selectedOption.domainStatus !== ControlPlaneStatus.Success;
-
- const renderCreatableLabel = useCallback(
- (val: string) => {
- // Check if the typed value is a subdomain of a registered domain
- const parentDomain = domainOptions.find(
- (d) => val.endsWith(`.${d.value}`) && val !== d.value
- );
-
- return (
-
- Use “{val}”
- {parentDomain && (
-
- subdomain of {parentDomain.value}
-
- )}
-
- );
- },
- [domainOptions]
- );
-
- return (
- <>
-
- options={searchFilteredOptions}
- value={value}
- onValueChange={handleValueChange}
- onSearchChange={creatable ? setSearchTerm : undefined}
- loading={externalLoading ?? isLoading}
- disabled={disabled}
- placeholder={placeholder}
- emptyContent={emptyContent}
- className={className}
- triggerClassName={triggerClassName}
- creatable={creatable}
- creatableLabel={creatable ? renderCreatableLabel : undefined}
- renderOption={(option, isSelected) => (
-
- )}
- footer={
- creatable || (compact && !showAddDomain) ? undefined : (
- domainFormRef.current?.show()}
- icon={ }
- iconPosition="left">
- Add a Domain
-
- )
- }
- {...rest}
- />
- {!compact && isUnverified && (
-
-
-
- {selectedOption.domainStatus === ControlPlaneStatus.Pending
- ? 'This domain is being verified — your proxy may not activate until verification is complete.'
- : "This domain is not verified — your proxy won't activate until the domain is verified."}
-
-
- )}
- {(!compact || showAddDomain) && !creatable && (
-
- )}
- >
- );
-}
-
-SelectDomain.displayName = 'SelectDomain';
-
-// ============================================================================
-// FormSelectDomain (Form-aware wrapper)
-// ============================================================================
-
-type FormSelectDomainProps = Omit;
-
-export function FormSelectDomain({ disabled, triggerClassName, ...props }: FormSelectDomainProps) {
- const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext();
- const control = useInputControl(fieldMeta as any);
-
- const isDisabled = disabled ?? fieldDisabled;
- const hasErrors = errors && errors.length > 0;
- const selectValue = Array.isArray(control.value) ? control.value[0] : control.value;
-
- return (
-
- );
-}
-
-FormSelectDomain.displayName = 'FormSelectDomain';
+import { DomainFormDialog, type DomainFormDialogRef } from './domain-form-dialog';
+import { BadgeStatus } from '@/components/badge/badge-status';
+import { ControlPlaneStatus } from '@/resources/base';
+import {
+ DOMAIN_VERIFICATION_STATUS,
+ domainKeys,
+ useDomains,
+ type Domain,
+} from '@/resources/domains';
+import { transformControlPlaneStatus } from '@/utils/helpers/control-plane.helper';
+import { useInputControl } from '@conform-to/react';
+import { Button } from '@datum-ui/components';
+import type { AutocompleteOption, AutocompleteProps } from '@datum-ui/components/form';
+import { useFieldContext } from '@datum-ui/components/form';
+import { Autocomplete } from '@datum-ui/components/form/primitives';
+import { cn } from '@shadcn/lib/utils';
+import { useQueryClient } from '@tanstack/react-query';
+import { AlertTriangleIcon, CheckIcon, PlusIcon } from 'lucide-react';
+import { useCallback, useMemo, useRef, useState } from 'react';
+
+// ============================================================================
+// Domain Option Type
+// ============================================================================
+
+interface DomainOption extends AutocompleteOption {
+ domainStatus: ControlPlaneStatus;
+}
+
+// ============================================================================
+// Domain Option Renderer
+// ============================================================================
+
+function DomainOptionContent({
+ option,
+ isSelected,
+}: {
+ option: DomainOption;
+ isSelected: boolean;
+}) {
+ const statusConfig = DOMAIN_VERIFICATION_STATUS[option.domainStatus];
+
+ return (
+
+
+ {option.label}
+ {statusConfig && (
+
+ )}
+
+ {isSelected &&
}
+
+ );
+}
+
+// ============================================================================
+// SelectDomain (Standalone)
+// ============================================================================
+
+type SelectDomainProps = {
+ /** Project ID for fetching domains */
+ projectId: string;
+ /** Currently selected domain value */
+ value?: string;
+ /** Called when selection changes */
+ onValueChange?: (value: string) => void;
+ /** Domain values to exclude from the options (e.g., already selected in other fields) */
+ excludeValues?: string[];
+ /** Allow custom hostnames not in the domain list */
+ creatable?: boolean;
+ /** Disable the component */
+ disabled?: boolean;
+ /** Placeholder text */
+ placeholder?: string;
+ /** Additional CSS classes for the root wrapper */
+ className?: string;
+ /** Additional CSS classes for the trigger button */
+ triggerClassName?: string;
+ /** Hide the verification warning (for compact/embedded usage) */
+ compact?: boolean;
+ /** Show the "Add a Domain" footer even in compact mode */
+ showAddDomain?: boolean;
+} & Pick;
+
+export function SelectDomain({
+ projectId,
+ value,
+ onValueChange,
+ excludeValues,
+ creatable,
+ disabled,
+ placeholder = 'Select a domain...',
+ className,
+ triggerClassName,
+ compact,
+ showAddDomain,
+ loading: externalLoading,
+ emptyContent = 'No domains found',
+ ...rest
+}: SelectDomainProps) {
+ const queryClient = useQueryClient();
+ const { data: domains = [], isLoading } = useDomains(projectId);
+ const domainFormRef = useRef(null);
+
+ const domainOptions: DomainOption[] = useMemo(() => {
+ const unique = new Map(domains.map((d) => [d.domainName, d]));
+ const options = Array.from(unique.values(), (d) => ({
+ value: d.domainName,
+ label: d.domainName,
+ domainStatus: transformControlPlaneStatus(d.status).status,
+ }));
+
+ // In creatable mode, the Autocomplete trigger handles unknown values natively.
+ // In non-creatable mode, inject the current value so it remains visible and selectable.
+ if (!creatable && value && !options.some((o) => o.value === value)) {
+ options.unshift({
+ value,
+ label: value,
+ domainStatus: ControlPlaneStatus.Pending,
+ });
+ }
+
+ return options;
+ }, [domains, value, creatable]);
+
+ const filteredOptions = useMemo(() => {
+ if (!excludeValues?.length) return domainOptions;
+ return domainOptions.filter(
+ (option) => option.value === value || !excludeValues.includes(option.value)
+ );
+ }, [domainOptions, excludeValues, value]);
+
+ // In creatable mode, we take over filtering from cmdk so we can keep
+ // parent domains visible when the user types a subdomain.
+ const [searchTerm, setSearchTerm] = useState('');
+
+ const searchFilteredOptions = useMemo(() => {
+ if (!creatable) return filteredOptions;
+ const needle = searchTerm.trim().toLowerCase();
+ if (!needle) return filteredOptions;
+ return filteredOptions.filter((option) => {
+ // Normal substring match
+ if (option.label.toLowerCase().includes(needle)) return true;
+ // Keep parent domains visible: typing "app.hiyahya.dev" keeps "hiyahya.dev"
+ if (needle.endsWith(`.${option.value.toLowerCase()}`)) return true;
+ return false;
+ });
+ }, [creatable, searchTerm, filteredOptions]);
+
+ const selectedOption = useMemo(
+ () => domainOptions.find((o) => o.value === value),
+ [domainOptions, value]
+ );
+
+ // In creatable mode, preserve the full typed subdomain instead of truncating to parent domain
+ const handleValueChange = useCallback(
+ (selectedValue: string) => {
+ if (creatable && searchTerm && selectedValue) {
+ const typedValue = searchTerm.trim().toLowerCase();
+ const selectedDomain = selectedValue.toLowerCase();
+
+ // If the typed value is a subdomain of the selected domain,
+ // use the full typed value (e.g., "api.staging.example.com" instead of "example.com")
+ if (typedValue !== selectedDomain && typedValue.endsWith(`.${selectedDomain}`)) {
+ onValueChange?.(typedValue);
+ return;
+ }
+ }
+
+ onValueChange?.(selectedValue);
+ },
+ [creatable, searchTerm, onValueChange]
+ );
+
+ const handleDomainCreated = useCallback(
+ async (domain: Domain) => {
+ // Refetch the domains list so the new domain appears in options
+ await queryClient.invalidateQueries({ queryKey: domainKeys.list(projectId) });
+ onValueChange?.(domain.domainName);
+ },
+ [queryClient, projectId, onValueChange]
+ );
+
+ const isUnverified = selectedOption && selectedOption.domainStatus !== ControlPlaneStatus.Success;
+
+ const renderCreatableLabel = useCallback(
+ (val: string) => {
+ // Check if the typed value is a subdomain of a registered domain
+ const parentDomain = domainOptions.find(
+ (d) => val.endsWith(`.${d.value}`) && val !== d.value
+ );
+
+ return (
+
+ Use “{val}”
+ {parentDomain && (
+
+ subdomain of {parentDomain.value}
+
+ )}
+
+ );
+ },
+ [domainOptions]
+ );
+
+ return (
+ <>
+
+ options={searchFilteredOptions}
+ value={value}
+ onValueChange={handleValueChange}
+ onSearchChange={creatable ? setSearchTerm : undefined}
+ loading={externalLoading ?? isLoading}
+ disabled={disabled}
+ placeholder={placeholder}
+ emptyContent={emptyContent}
+ className={className}
+ triggerClassName={triggerClassName}
+ creatable={creatable}
+ creatableLabel={creatable ? renderCreatableLabel : undefined}
+ renderOption={(option, isSelected) => (
+
+ )}
+ footer={
+ creatable || (compact && !showAddDomain) ? undefined : (
+ domainFormRef.current?.show()}
+ icon={ }
+ iconPosition="left">
+ Add a Domain
+
+ )
+ }
+ {...rest}
+ />
+ {!compact && isUnverified && (
+
+
+
+ {selectedOption.domainStatus === ControlPlaneStatus.Pending
+ ? 'This domain is being verified — your proxy may not activate until verification is complete.'
+ : "This domain is not verified — your proxy won't activate until the domain is verified."}
+
+
+ )}
+ {(!compact || showAddDomain) && !creatable && (
+
+ )}
+ >
+ );
+}
+
+SelectDomain.displayName = 'SelectDomain';
+
+// ============================================================================
+// FormSelectDomain (Form-aware wrapper)
+// ============================================================================
+
+type FormSelectDomainProps = Omit;
+
+export function FormSelectDomain({ disabled, triggerClassName, ...props }: FormSelectDomainProps) {
+ const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext();
+ const control = useInputControl(fieldMeta as any);
+
+ const isDisabled = disabled ?? fieldDisabled;
+ const hasErrors = errors && errors.length > 0;
+ const selectValue = Array.isArray(control.value) ? control.value[0] : control.value;
+
+ return (
+
+ );
+}
+
+FormSelectDomain.displayName = 'FormSelectDomain';
diff --git a/app/features/edge/domain/status.tsx b/app/features/edge/domain/status.tsx
index 9d100a245..f534784e6 100644
--- a/app/features/edge/domain/status.tsx
+++ b/app/features/edge/domain/status.tsx
@@ -1,102 +1,102 @@
-import { BadgeStatus } from '@/components/badge/badge-status';
-import { ControlPlaneStatus } from '@/resources/base';
-import { DOMAIN_VERIFICATION_STATUS, type Domain } from '@/resources/domains';
-import { transformControlPlaneStatus } from '@/utils/helpers/control-plane.helper';
-import { cn } from '@shadcn/lib/utils';
-import { HoverCard, HoverCardContent, HoverCardTrigger } from '@shadcn/ui/hover-card';
-import { useMemo } from 'react';
-
-type Condition = {
- type: string;
- status: 'True' | 'False' | 'Unknown';
- lastTransitionTime: Date;
- reason: string;
- message: string;
- observedGeneration?: bigint;
-};
-
-const getConditionTitle = (condition: Condition): string => {
- switch (condition.type) {
- case 'Verified':
- return 'Domain Verification';
- case 'VerifiedDNS':
- return 'DNS Verification';
- case 'VerifiedHTTP':
- return 'HTTP Verification';
- default:
- return condition.type;
- }
-};
-
-export const DomainStatus = ({ domainStatus }: { domainStatus: Domain['status'] }) => {
- const currentStatus = useMemo(() => {
- return transformControlPlaneStatus(domainStatus);
- }, [domainStatus]);
-
- const conditions = useMemo(() => {
- return (domainStatus?.conditions || []) as unknown as Condition[];
- }, [domainStatus?.conditions]);
-
- const priorityConditions = useMemo(() => {
- // Show Verified, VerifiedDNS, and VerifiedHTTP conditions that have errors
- return conditions.filter(
- (condition) =>
- ['Verified', 'VerifiedDNS', 'VerifiedHTTP'].includes(condition.type) &&
- condition.status !== 'True'
- );
- }, [conditions]);
-
- const statusLabel = currentStatus
- ? DOMAIN_VERIFICATION_STATUS[currentStatus.status]?.label
- : undefined;
-
- if (!currentStatus) {
- return null;
- }
-
- return (
-
-
-
-
- 0 && 'border-amber-200 bg-amber-50')}>
- {priorityConditions.length > 0 ? (
-
-
Pending Validation Checks:
-
- {priorityConditions.map((condition) => (
-
- {getConditionTitle(condition)}:
-
- {condition.type === 'Verified'
- ? 'Update your DNS provider with the provided record, or use the HTTP token method.'
- : condition.message}
-
-
- ))}
-
-
-
- These items are checked every few minutes. If you've already made changes, they
- should be resolve shortly;
-
-
- ) : (
-
- Domain verification is in progress. This may take a few minutes.
-
- )}
-
-
- );
-};
+import { BadgeStatus } from '@/components/badge/badge-status';
+import { ControlPlaneStatus } from '@/resources/base';
+import { DOMAIN_VERIFICATION_STATUS, type Domain } from '@/resources/domains';
+import { transformControlPlaneStatus } from '@/utils/helpers/control-plane.helper';
+import { cn } from '@shadcn/lib/utils';
+import { HoverCard, HoverCardContent, HoverCardTrigger } from '@shadcn/ui/hover-card';
+import { useMemo } from 'react';
+
+type Condition = {
+ type: string;
+ status: 'True' | 'False' | 'Unknown';
+ lastTransitionTime: Date;
+ reason: string;
+ message: string;
+ observedGeneration?: bigint;
+};
+
+const getConditionTitle = (condition: Condition): string => {
+ switch (condition.type) {
+ case 'Verified':
+ return 'Domain Verification';
+ case 'VerifiedDNS':
+ return 'DNS Verification';
+ case 'VerifiedHTTP':
+ return 'HTTP Verification';
+ default:
+ return condition.type;
+ }
+};
+
+export const DomainStatus = ({ domainStatus }: { domainStatus: Domain['status'] }) => {
+ const currentStatus = useMemo(() => {
+ return transformControlPlaneStatus(domainStatus);
+ }, [domainStatus]);
+
+ const conditions = useMemo(() => {
+ return (domainStatus?.conditions || []) as unknown as Condition[];
+ }, [domainStatus?.conditions]);
+
+ const priorityConditions = useMemo(() => {
+ // Show Verified, VerifiedDNS, and VerifiedHTTP conditions that have errors
+ return conditions.filter(
+ (condition) =>
+ ['Verified', 'VerifiedDNS', 'VerifiedHTTP'].includes(condition.type) &&
+ condition.status !== 'True'
+ );
+ }, [conditions]);
+
+ const statusLabel = currentStatus
+ ? DOMAIN_VERIFICATION_STATUS[currentStatus.status]?.label
+ : undefined;
+
+ if (!currentStatus) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ 0 && 'border-amber-200 bg-amber-50')}>
+ {priorityConditions.length > 0 ? (
+
+
Pending Validation Checks:
+
+ {priorityConditions.map((condition) => (
+
+ {getConditionTitle(condition)}:
+
+ {condition.type === 'Verified'
+ ? 'Update your DNS provider with the provided record, or use the HTTP token method.'
+ : condition.message}
+
+
+ ))}
+
+
+
+ These items are checked every few minutes. If you've already made changes, they
+ should be resolve shortly;
+
+
+ ) : (
+
+ Domain verification is in progress. This may take a few minutes.
+
+ )}
+
+
+ );
+};
diff --git a/app/features/edge/nameservers/nameserver-card.tsx b/app/features/edge/nameservers/nameserver-card.tsx
index 52d812f74..bb46b89e5 100644
--- a/app/features/edge/nameservers/nameserver-card.tsx
+++ b/app/features/edge/nameservers/nameserver-card.tsx
@@ -1,44 +1,44 @@
-import { NameserverTable } from './nameserver-table';
-import { IDnsNameserver, IDnsRegistration } from '@/resources/domains';
-import { Card, CardContent, CardHeader, CardTitle } from '@datum-ui/components';
-import { useMemo } from 'react';
-
-export interface NameserverCardProps {
- nameservers: IDnsNameserver[];
- registration?: IDnsRegistration;
- maxRows?: number;
- title?: string;
- actions?: React.ReactNode;
-}
-
-/**
- * Card wrapper for nameserver table in compact mode
- * Used in overview pages
- */
-export const NameserverCard = ({
- nameservers,
- registration,
- maxRows = 5,
- title = 'Nameservers',
- actions,
-}: NameserverCardProps) => {
- // Slice data at card level for better control
- const displayData = useMemo(
- () => (maxRows ? nameservers.slice(0, maxRows) : nameservers),
- [nameservers, maxRows]
- );
-
- return (
-
-
-
- {title}
- {actions}
-
-
-
-
-
-
- );
-};
+import { NameserverTable } from './nameserver-table';
+import { IDnsNameserver, IDnsRegistration } from '@/resources/domains';
+import { Card, CardContent, CardHeader, CardTitle } from '@datum-ui/components';
+import { useMemo } from 'react';
+
+export interface NameserverCardProps {
+ nameservers: IDnsNameserver[];
+ registration?: IDnsRegistration;
+ maxRows?: number;
+ title?: string;
+ actions?: React.ReactNode;
+}
+
+/**
+ * Card wrapper for nameserver table in compact mode
+ * Used in overview pages
+ */
+export const NameserverCard = ({
+ nameservers,
+ registration,
+ maxRows = 5,
+ title = 'Nameservers',
+ actions,
+}: NameserverCardProps) => {
+ // Slice data at card level for better control
+ const displayData = useMemo(
+ () => (maxRows ? nameservers.slice(0, maxRows) : nameservers),
+ [nameservers, maxRows]
+ );
+
+ return (
+
+
+
+ {title}
+ {actions}
+
+
+
+
+
+
+ );
+};
diff --git a/app/features/edge/nameservers/nameserver-table.tsx b/app/features/edge/nameservers/nameserver-table.tsx
index ed89ff345..8e7b273fe 100644
--- a/app/features/edge/nameservers/nameserver-table.tsx
+++ b/app/features/edge/nameservers/nameserver-table.tsx
@@ -1,97 +1,97 @@
-import { NameserverChips } from '@/components/nameserver-chips';
-import { DataTable, DataTableRowActionsProps } from '@/modules/datum-ui/components/data-table';
-import { DataTableTitleProps } from '@/modules/datum-ui/components/data-table';
-import { IDnsNameserver, IDnsRegistration } from '@/resources/domains';
-import { Badge } from '@datum-ui/components';
-import { EmptyContentProps } from '@datum-ui/components/empty-content';
-import { ColumnDef } from '@tanstack/react-table';
-import { useMemo } from 'react';
-
-export interface NameserverTableProps {
- data: IDnsNameserver[];
- registration?: IDnsRegistration;
- tableTitle?: DataTableTitleProps;
- emptyContent?: EmptyContentProps;
- className?: string;
- rowActions?: DataTableRowActionsProps[];
-}
-
-export const NameserverTable = ({
- data,
- registration,
- tableTitle,
- emptyContent,
- className,
- rowActions,
-}: NameserverTableProps) => {
- const columns: ColumnDef[] = useMemo(
- () => [
- {
- header: 'Type',
- accessorKey: 'type',
- enableSorting: false,
- cell: () => {
- return (
-
- NS
-
- );
- },
- },
- {
- header: 'Value',
- accessorKey: 'hostname',
- cell: ({ row }) => {
- return {row.original.hostname} ;
- },
- meta: {
- sortPath: 'hostname',
- sortType: 'text',
- },
- },
- {
- id: 'nameservers',
- header: 'DNS Host',
- accessorKey: 'nameservers',
- cell: ({ row }) => {
- return ;
- },
- meta: {
- sortPath: 'status.nameservers',
- sortType: 'array',
- sortArrayBy: 'ips.registrantName',
- },
- },
- {
- id: 'registrar',
- header: 'Registrar',
- accessorKey: 'registrar',
- enableSorting: false,
- cell: () => {
- return (
-
- {registration?.registrar?.name ?? '-'}
-
- );
- },
- },
- ],
- [registration]
- );
-
- return (
-
- );
-};
+import { NameserverChips } from '@/components/nameserver-chips';
+import { DataTable, DataTableRowActionsProps } from '@/modules/datum-ui/components/data-table';
+import { DataTableTitleProps } from '@/modules/datum-ui/components/data-table';
+import { IDnsNameserver, IDnsRegistration } from '@/resources/domains';
+import { Badge } from '@datum-ui/components';
+import { EmptyContentProps } from '@datum-ui/components/empty-content';
+import { ColumnDef } from '@tanstack/react-table';
+import { useMemo } from 'react';
+
+export interface NameserverTableProps {
+ data: IDnsNameserver[];
+ registration?: IDnsRegistration;
+ tableTitle?: DataTableTitleProps;
+ emptyContent?: EmptyContentProps;
+ className?: string;
+ rowActions?: DataTableRowActionsProps[];
+}
+
+export const NameserverTable = ({
+ data,
+ registration,
+ tableTitle,
+ emptyContent,
+ className,
+ rowActions,
+}: NameserverTableProps) => {
+ const columns: ColumnDef[] = useMemo(
+ () => [
+ {
+ header: 'Type',
+ accessorKey: 'type',
+ enableSorting: false,
+ cell: () => {
+ return (
+
+ NS
+
+ );
+ },
+ },
+ {
+ header: 'Value',
+ accessorKey: 'hostname',
+ cell: ({ row }) => {
+ return {row.original.hostname} ;
+ },
+ meta: {
+ sortPath: 'hostname',
+ sortType: 'text',
+ },
+ },
+ {
+ id: 'nameservers',
+ header: 'DNS Host',
+ accessorKey: 'nameservers',
+ cell: ({ row }) => {
+ return ;
+ },
+ meta: {
+ sortPath: 'status.nameservers',
+ sortType: 'array',
+ sortArrayBy: 'ips.registrantName',
+ },
+ },
+ {
+ id: 'registrar',
+ header: 'Registrar',
+ accessorKey: 'registrar',
+ enableSorting: false,
+ cell: () => {
+ return (
+
+ {registration?.registrar?.name ?? '-'}
+
+ );
+ },
+ },
+ ],
+ [registration]
+ );
+
+ return (
+
+ );
+};
diff --git a/app/features/edge/proxy/form/hostnames-field.tsx b/app/features/edge/proxy/form/hostnames-field.tsx
index bfaf20579..d4f4353d8 100644
--- a/app/features/edge/proxy/form/hostnames-field.tsx
+++ b/app/features/edge/proxy/form/hostnames-field.tsx
@@ -1,63 +1,63 @@
-import { SubdomainHostnameField } from '@/features/edge/proxy/form/subdomain-hostname-field';
-import { Button } from '@datum-ui/components';
-import { Form, useFormContext } from '@datum-ui/components/form';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { PlusIcon } from 'lucide-react';
-import { forwardRef } from 'react';
-
-interface ProxyHostnamesFieldProps {
- projectId: string;
- /** Proxy display name for smart suggestions */
- proxyDisplayName?: string;
-}
-
-export const ProxyHostnamesField = forwardRef(
- ({ projectId, proxyDisplayName }, ref) => {
- const { fields: formFields } = useFormContext();
-
- const hostnameFieldList = (formFields.hostnames as any)?.getFieldList?.() ?? [];
- const selectedHostnames: string[] = hostnameFieldList.map((f: any) => f.value).filter(Boolean);
-
- const getExcludeValues = (currentIndex: number) =>
- selectedHostnames.filter((_v, i) => i !== currentIndex);
-
- return (
-
-
Hostnames
-
-
- {({ fields, append, remove }) => (
- <>
- {fields.length > 0 && (
-
- {fields.map((field, index) => (
-
- remove(index)}
- />
-
- ))}
-
- )}
- append()}>
-
- Add hostname
-
- >
- )}
-
-
- );
- }
-);
-
-ProxyHostnamesField.displayName = 'ProxyHostnamesField';
+import { SubdomainHostnameField } from '@/features/edge/proxy/form/subdomain-hostname-field';
+import { Button } from '@datum-ui/components';
+import { Form, useFormContext } from '@datum-ui/components/form';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { PlusIcon } from 'lucide-react';
+import { forwardRef } from 'react';
+
+interface ProxyHostnamesFieldProps {
+ projectId: string;
+ /** Proxy display name for smart suggestions */
+ proxyDisplayName?: string;
+}
+
+export const ProxyHostnamesField = forwardRef(
+ ({ projectId, proxyDisplayName }, ref) => {
+ const { fields: formFields } = useFormContext();
+
+ const hostnameFieldList = (formFields.hostnames as any)?.getFieldList?.() ?? [];
+ const selectedHostnames: string[] = hostnameFieldList.map((f: any) => f.value).filter(Boolean);
+
+ const getExcludeValues = (currentIndex: number) =>
+ selectedHostnames.filter((_v, i) => i !== currentIndex);
+
+ return (
+
+
Hostnames
+
+
+ {({ fields, append, remove }) => (
+ <>
+ {fields.length > 0 && (
+
+ {fields.map((field, index) => (
+
+ remove(index)}
+ />
+
+ ))}
+
+ )}
+ append()}>
+
+ Add hostname
+
+ >
+ )}
+
+
+ );
+ }
+);
+
+ProxyHostnamesField.displayName = 'ProxyHostnamesField';
diff --git a/app/features/edge/proxy/form/protocol-endpoint-input.tsx b/app/features/edge/proxy/form/protocol-endpoint-input.tsx
index d26e79e41..cbf34e23d 100644
--- a/app/features/edge/proxy/form/protocol-endpoint-input.tsx
+++ b/app/features/edge/proxy/form/protocol-endpoint-input.tsx
@@ -1,78 +1,78 @@
-import { isIPAddress } from '@/utils/helpers/validation.helper';
-import { useInputControl } from '@conform-to/react';
-import { Form } from '@datum-ui/components/form';
-import { InputWithAddons } from '@datum-ui/components/input-with-addons';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@shadcn/ui/select';
-import { useEffect, useMemo } from 'react';
-
-/**
- * Custom component that combines protocol selector with endpoint input.
- * Used in proxy forms to allow users to select HTTP/HTTPS protocol and enter an endpoint hostname.
- *
- * When the entered origin is an IP address, the protocol is forced to HTTPS.
- */
-interface ProtocolEndpointInputProps {
- autoFocus?: boolean;
- onIPChange?: (isIP: boolean) => void;
-}
-
-export const ProtocolEndpointInput = ({ autoFocus, onIPChange }: ProtocolEndpointInputProps) => {
- const { fields } = Form.useFormContext();
- const protocolField = fields.protocol as any;
- const endpointField = fields.endpointHost as any;
-
- const protocolControl = useInputControl(protocolField);
- const endpointControl = useInputControl(endpointField);
-
- const protocolValue =
- (Array.isArray(protocolControl.value) ? protocolControl.value[0] : protocolControl.value) ||
- 'https';
- const endpointValue =
- (Array.isArray(endpointControl.value) ? endpointControl.value[0] : endpointControl.value) || '';
-
- const isIP = useMemo(() => {
- if (!endpointValue) return false;
- const hostname = endpointValue.split(':')[0];
- return isIPAddress(hostname);
- }, [endpointValue]);
-
- useEffect(() => {
- if (isIP && protocolValue !== 'https') {
- protocolControl.change('https');
- }
- }, [isIP, protocolValue, protocolControl]);
-
- useEffect(() => {
- onIPChange?.(isIP);
- }, [isIP, onIPChange]);
-
- return (
-
-
-
-
-
- http
- https
-
-
- }
- value={endpointValue}
- onChange={(e) => endpointControl.change(e.target.value)}
- onBlur={endpointControl.blur}
- name={endpointField.name}
- id={endpointField.id}
- autoFocus={autoFocus}
- placeholder="e.g. api.example.com or 203.0.113.1:8080"
- className="text-xs!"
- />
- );
-};
+import { isIPAddress } from '@/utils/helpers/validation.helper';
+import { useInputControl } from '@conform-to/react';
+import { Form } from '@datum-ui/components/form';
+import { InputWithAddons } from '@datum-ui/components/input-with-addons';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@shadcn/ui/select';
+import { useEffect, useMemo } from 'react';
+
+/**
+ * Custom component that combines protocol selector with endpoint input.
+ * Used in proxy forms to allow users to select HTTP/HTTPS protocol and enter an endpoint hostname.
+ *
+ * When the entered origin is an IP address, the protocol is forced to HTTPS.
+ */
+interface ProtocolEndpointInputProps {
+ autoFocus?: boolean;
+ onIPChange?: (isIP: boolean) => void;
+}
+
+export const ProtocolEndpointInput = ({ autoFocus, onIPChange }: ProtocolEndpointInputProps) => {
+ const { fields } = Form.useFormContext();
+ const protocolField = fields.protocol as any;
+ const endpointField = fields.endpointHost as any;
+
+ const protocolControl = useInputControl(protocolField);
+ const endpointControl = useInputControl(endpointField);
+
+ const protocolValue =
+ (Array.isArray(protocolControl.value) ? protocolControl.value[0] : protocolControl.value) ||
+ 'https';
+ const endpointValue =
+ (Array.isArray(endpointControl.value) ? endpointControl.value[0] : endpointControl.value) || '';
+
+ const isIP = useMemo(() => {
+ if (!endpointValue) return false;
+ const hostname = endpointValue.split(':')[0];
+ return isIPAddress(hostname);
+ }, [endpointValue]);
+
+ useEffect(() => {
+ if (isIP && protocolValue !== 'https') {
+ protocolControl.change('https');
+ }
+ }, [isIP, protocolValue, protocolControl]);
+
+ useEffect(() => {
+ onIPChange?.(isIP);
+ }, [isIP, onIPChange]);
+
+ return (
+
+
+
+
+
+ http
+ https
+
+
+ }
+ value={endpointValue}
+ onChange={(e) => endpointControl.change(e.target.value)}
+ onBlur={endpointControl.blur}
+ name={endpointField.name}
+ id={endpointField.id}
+ autoFocus={autoFocus}
+ placeholder="e.g. api.example.com or 203.0.113.1:8080"
+ className="text-xs!"
+ />
+ );
+};
diff --git a/app/features/edge/proxy/form/subdomain-hostname-field.tsx b/app/features/edge/proxy/form/subdomain-hostname-field.tsx
index 59d6cf3bd..945b474c7 100644
--- a/app/features/edge/proxy/form/subdomain-hostname-field.tsx
+++ b/app/features/edge/proxy/form/subdomain-hostname-field.tsx
@@ -1,359 +1,359 @@
-import { useConfirmationDialog } from '@/components/confirmation-dialog/confirmation-dialog.provider';
-import { SelectDomain } from '@/features/edge/domain/select-domain';
-import { ControlPlaneStatus } from '@/resources/base';
-import { useDomains } from '@/resources/domains';
-import { transformControlPlaneStatus } from '@/utils/helpers/control-plane.helper';
-import { useInputControl } from '@conform-to/react';
-import { useFieldContext } from '@datum-ui/components/form';
-import { Skeleton } from '@datum-ui/components/skeleton';
-import { cn } from '@shadcn/lib/utils';
-import { AlertTriangleIcon, GlobeIcon, XIcon } from 'lucide-react';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-
-interface SubdomainHostnameFieldProps {
- projectId: string;
- /** Proxy display name used for smart suggestions */
- proxyDisplayName?: string;
- /** Hostname values to exclude (already selected in other rows) */
- excludeValues?: string[];
- /** Called when the user clicks the remove button */
- onRemove?: () => void;
-}
-
-function slugify(name: string): string {
- return name
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-+|-+$/g, '');
-}
-
-/**
- * Decompose a full hostname into prefix + parent domain.
- * Returns the longest matching registered domain.
- */
-function decomposeHostname(
- hostname: string,
- domainNames: string[]
-): { prefix: string; domain: string } | null {
- if (!hostname) return null;
- const lower = hostname.toLowerCase();
- const sorted = [...domainNames].sort((a, b) => b.length - a.length);
-
- for (const domain of sorted) {
- const domainLower = domain.toLowerCase();
- if (lower === domainLower) {
- return { prefix: '', domain };
- }
- if (lower.endsWith(`.${domainLower}`)) {
- const prefix = lower.slice(0, -(domainLower.length + 1));
- return { prefix, domain };
- }
- }
- return null;
-}
-
-export function SubdomainHostnameField({
- projectId,
- proxyDisplayName,
- excludeValues,
- onRemove,
-}: SubdomainHostnameFieldProps) {
- const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext();
- const control = useInputControl(fieldMeta as any);
- const currentValue = Array.isArray(control.value) ? control.value[0] : control.value;
-
- const { confirm } = useConfirmationDialog();
- const { data: domains = [], isLoading: domainsLoading } = useDomains(projectId);
- const domainNames = useMemo(() => domains.map((d) => d.domainName), [domains]);
-
- const [isCustomMode, setIsCustomMode] = useState(false);
- const [prefix, setPrefix] = useState('');
- const [selectedDomain, setSelectedDomain] = useState('');
- const [customHostname, setCustomHostname] = useState('');
- /** Last (form value, domain list) we derived local UI from — ref avoids an init flag in effect deps / extra render cycle. */
- const lastFormSyncKeyRef = useRef(null);
-
- useEffect(() => {
- if (domainNames.length === 0) {
- lastFormSyncKeyRef.current = null;
- return;
- }
-
- const val = currentValue ?? '';
- const syncKey = `${val}\0${domainNames.join('\0')}`;
- if (lastFormSyncKeyRef.current === syncKey) return;
- lastFormSyncKeyRef.current = syncKey;
-
- if (!val) {
- setPrefix('');
- setCustomHostname('');
- setIsCustomMode(false);
- setSelectedDomain(domainNames.length === 1 ? domainNames[0] : '');
- return;
- }
-
- const decomposed = decomposeHostname(val, domainNames);
- if (decomposed) {
- setPrefix(decomposed.prefix);
- setSelectedDomain(decomposed.domain);
- setIsCustomMode(false);
- } else {
- setCustomHostname(val);
- setIsCustomMode(true);
- }
- }, [currentValue, domainNames]);
-
- const syncToForm = useCallback(
- (newPrefix: string, newDomain: string) => {
- if (!newDomain) {
- control.change('');
- return;
- }
- const trimmedPrefix = newPrefix.trim().replace(/\.$/, '');
- const hostname = trimmedPrefix ? `${trimmedPrefix}.${newDomain}` : newDomain;
- control.change(hostname);
- },
- [control]
- );
-
- const handlePrefixChange = useCallback(
- (e: React.ChangeEvent) => {
- const newPrefix = e.target.value;
- setPrefix(newPrefix);
- if (!selectedDomain) return;
- syncToForm(newPrefix, selectedDomain);
- },
- [selectedDomain, syncToForm]
- );
-
- const handleDomainChange = useCallback(
- (newDomain: string) => {
- setSelectedDomain(newDomain);
- syncToForm(prefix, newDomain);
- },
- [prefix, syncToForm]
- );
-
- const handleCustomChange = useCallback(
- (e: React.ChangeEvent) => {
- const val = e.target.value;
- setCustomHostname(val);
- control.change(val);
- },
- [control]
- );
-
- const switchToCustom = useCallback(() => {
- setIsCustomMode(true);
- if (prefix && selectedDomain) {
- setCustomHostname(`${prefix}.${selectedDomain}`);
- } else {
- setCustomHostname(String(currentValue ?? ''));
- }
- }, [prefix, selectedDomain, currentValue]);
-
- const switchToSplit = useCallback(async () => {
- if (!customHostname) {
- setIsCustomMode(false);
- return;
- }
- const decomposed = decomposeHostname(customHostname, domainNames);
- if (decomposed) {
- setIsCustomMode(false);
- setPrefix(decomposed.prefix);
- setSelectedDomain(decomposed.domain);
- return;
- }
- const accepted = await confirm({
- title: 'Hostname will change',
- description: `Clear “${customHostname}” and use a verified domain instead?`,
- variant: 'default',
- submitText: 'Continue',
- cancelText: 'Cancel',
- });
- if (!accepted) return;
- setIsCustomMode(false);
- setPrefix('');
- setSelectedDomain('');
- }, [customHostname, domainNames, confirm]);
-
- const suggestions = useMemo(() => {
- if (!proxyDisplayName || domainNames.length === 0) return [];
- const slug = slugify(proxyDisplayName);
- if (!slug) return [];
-
- const allExcluded = new Set(excludeValues ?? []);
- if (currentValue) allExcluded.add(currentValue);
-
- return domainNames
- .map((domain) => `${slug}.${domain}`)
- .filter((s) => !allExcluded.has(s))
- .slice(0, 10);
- }, [proxyDisplayName, domainNames, excludeValues, currentValue]);
-
- const handleSuggestionClick = useCallback(
- (suggestion: string) => {
- const decomposed = decomposeHostname(suggestion, domainNames);
- if (decomposed) {
- setPrefix(decomposed.prefix);
- setSelectedDomain(decomposed.domain);
- setIsCustomMode(false);
- syncToForm(decomposed.prefix, decomposed.domain);
- }
- },
- [domainNames, syncToForm]
- );
-
- const hasErrors = errors && errors.length > 0;
- const showSuggestions = !isCustomMode && !currentValue && suggestions.length > 0;
-
- const selectedDomainStatus = useMemo(() => {
- if (!selectedDomain) return null;
- const domain = domains.find((d) => d.domainName === selectedDomain);
- if (!domain) return null;
- return transformControlPlaneStatus(domain.status).status;
- }, [selectedDomain, domains]);
-
- const isUnverified = selectedDomainStatus && selectedDomainStatus !== ControlPlaneStatus.Success;
-
- /** Same composition as syncToForm — shown below fields on small screens where the dot separator is hidden. */
- const splitHostnamePreview = useMemo(() => {
- if (!selectedDomain) return null;
- const trimmed = prefix.trim().replace(/\.$/, '');
- return trimmed ? `${trimmed}.${selectedDomain}` : selectedDomain;
- }, [prefix, selectedDomain]);
-
- if (isCustomMode) {
- return (
-
-
-
- {onRemove && (
-
-
-
- )}
-
- {domainNames.length > 0 && (
-
void switchToSplit()}
- className="text-muted-foreground hover:text-foreground flex items-center gap-1 self-start text-[11px] underline transition-colors">
-
- Use a verified domain
-
- )}
-
- );
- }
-
- return (
-
- {domainsLoading && (
-
-
-
-
-
- )}
- {showSuggestions && (
-
- {suggestions.map((s) => (
- handleSuggestionClick(s)}
- className="bg-accent hover:bg-accent/80 text-accent-foreground rounded-md px-2 py-0.5 text-[11px] transition-colors">
- {s}
-
- ))}
-
- )}
-
-
-
- .
-
-
-
- {onRemove && (
-
-
-
- )}
-
-
-
-
- {splitHostnamePreview}
-
-
- {isUnverified && (
-
-
-
- {selectedDomainStatus === ControlPlaneStatus.Pending
- ? 'This domain is being verified — your proxy may not activate until verification is complete.'
- : "This domain is not verified — your proxy won't activate until the domain is verified."}
-
-
- )}
-
-
- Type a custom hostname
-
-
- );
-}
-
-SubdomainHostnameField.displayName = 'SubdomainHostnameField';
+import { useConfirmationDialog } from '@/components/confirmation-dialog/confirmation-dialog.provider';
+import { SelectDomain } from '@/features/edge/domain/select-domain';
+import { ControlPlaneStatus } from '@/resources/base';
+import { useDomains } from '@/resources/domains';
+import { transformControlPlaneStatus } from '@/utils/helpers/control-plane.helper';
+import { useInputControl } from '@conform-to/react';
+import { useFieldContext } from '@datum-ui/components/form';
+import { Skeleton } from '@datum-ui/components/skeleton';
+import { cn } from '@shadcn/lib/utils';
+import { AlertTriangleIcon, GlobeIcon, XIcon } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+interface SubdomainHostnameFieldProps {
+ projectId: string;
+ /** Proxy display name used for smart suggestions */
+ proxyDisplayName?: string;
+ /** Hostname values to exclude (already selected in other rows) */
+ excludeValues?: string[];
+ /** Called when the user clicks the remove button */
+ onRemove?: () => void;
+}
+
+function slugify(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
+/**
+ * Decompose a full hostname into prefix + parent domain.
+ * Returns the longest matching registered domain.
+ */
+function decomposeHostname(
+ hostname: string,
+ domainNames: string[]
+): { prefix: string; domain: string } | null {
+ if (!hostname) return null;
+ const lower = hostname.toLowerCase();
+ const sorted = [...domainNames].sort((a, b) => b.length - a.length);
+
+ for (const domain of sorted) {
+ const domainLower = domain.toLowerCase();
+ if (lower === domainLower) {
+ return { prefix: '', domain };
+ }
+ if (lower.endsWith(`.${domainLower}`)) {
+ const prefix = lower.slice(0, -(domainLower.length + 1));
+ return { prefix, domain };
+ }
+ }
+ return null;
+}
+
+export function SubdomainHostnameField({
+ projectId,
+ proxyDisplayName,
+ excludeValues,
+ onRemove,
+}: SubdomainHostnameFieldProps) {
+ const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext();
+ const control = useInputControl(fieldMeta as any);
+ const currentValue = Array.isArray(control.value) ? control.value[0] : control.value;
+
+ const { confirm } = useConfirmationDialog();
+ const { data: domains = [], isLoading: domainsLoading } = useDomains(projectId);
+ const domainNames = useMemo(() => domains.map((d) => d.domainName), [domains]);
+
+ const [isCustomMode, setIsCustomMode] = useState(false);
+ const [prefix, setPrefix] = useState('');
+ const [selectedDomain, setSelectedDomain] = useState('');
+ const [customHostname, setCustomHostname] = useState('');
+ /** Last (form value, domain list) we derived local UI from — ref avoids an init flag in effect deps / extra render cycle. */
+ const lastFormSyncKeyRef = useRef(null);
+
+ useEffect(() => {
+ if (domainNames.length === 0) {
+ lastFormSyncKeyRef.current = null;
+ return;
+ }
+
+ const val = currentValue ?? '';
+ const syncKey = `${val}\0${domainNames.join('\0')}`;
+ if (lastFormSyncKeyRef.current === syncKey) return;
+ lastFormSyncKeyRef.current = syncKey;
+
+ if (!val) {
+ setPrefix('');
+ setCustomHostname('');
+ setIsCustomMode(false);
+ setSelectedDomain(domainNames.length === 1 ? domainNames[0] : '');
+ return;
+ }
+
+ const decomposed = decomposeHostname(val, domainNames);
+ if (decomposed) {
+ setPrefix(decomposed.prefix);
+ setSelectedDomain(decomposed.domain);
+ setIsCustomMode(false);
+ } else {
+ setCustomHostname(val);
+ setIsCustomMode(true);
+ }
+ }, [currentValue, domainNames]);
+
+ const syncToForm = useCallback(
+ (newPrefix: string, newDomain: string) => {
+ if (!newDomain) {
+ control.change('');
+ return;
+ }
+ const trimmedPrefix = newPrefix.trim().replace(/\.$/, '');
+ const hostname = trimmedPrefix ? `${trimmedPrefix}.${newDomain}` : newDomain;
+ control.change(hostname);
+ },
+ [control]
+ );
+
+ const handlePrefixChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const newPrefix = e.target.value;
+ setPrefix(newPrefix);
+ if (!selectedDomain) return;
+ syncToForm(newPrefix, selectedDomain);
+ },
+ [selectedDomain, syncToForm]
+ );
+
+ const handleDomainChange = useCallback(
+ (newDomain: string) => {
+ setSelectedDomain(newDomain);
+ syncToForm(prefix, newDomain);
+ },
+ [prefix, syncToForm]
+ );
+
+ const handleCustomChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const val = e.target.value;
+ setCustomHostname(val);
+ control.change(val);
+ },
+ [control]
+ );
+
+ const switchToCustom = useCallback(() => {
+ setIsCustomMode(true);
+ if (prefix && selectedDomain) {
+ setCustomHostname(`${prefix}.${selectedDomain}`);
+ } else {
+ setCustomHostname(String(currentValue ?? ''));
+ }
+ }, [prefix, selectedDomain, currentValue]);
+
+ const switchToSplit = useCallback(async () => {
+ if (!customHostname) {
+ setIsCustomMode(false);
+ return;
+ }
+ const decomposed = decomposeHostname(customHostname, domainNames);
+ if (decomposed) {
+ setIsCustomMode(false);
+ setPrefix(decomposed.prefix);
+ setSelectedDomain(decomposed.domain);
+ return;
+ }
+ const accepted = await confirm({
+ title: 'Hostname will change',
+ description: `Clear “${customHostname}” and use a verified domain instead?`,
+ variant: 'default',
+ submitText: 'Continue',
+ cancelText: 'Cancel',
+ });
+ if (!accepted) return;
+ setIsCustomMode(false);
+ setPrefix('');
+ setSelectedDomain('');
+ }, [customHostname, domainNames, confirm]);
+
+ const suggestions = useMemo(() => {
+ if (!proxyDisplayName || domainNames.length === 0) return [];
+ const slug = slugify(proxyDisplayName);
+ if (!slug) return [];
+
+ const allExcluded = new Set(excludeValues ?? []);
+ if (currentValue) allExcluded.add(currentValue);
+
+ return domainNames
+ .map((domain) => `${slug}.${domain}`)
+ .filter((s) => !allExcluded.has(s))
+ .slice(0, 10);
+ }, [proxyDisplayName, domainNames, excludeValues, currentValue]);
+
+ const handleSuggestionClick = useCallback(
+ (suggestion: string) => {
+ const decomposed = decomposeHostname(suggestion, domainNames);
+ if (decomposed) {
+ setPrefix(decomposed.prefix);
+ setSelectedDomain(decomposed.domain);
+ setIsCustomMode(false);
+ syncToForm(decomposed.prefix, decomposed.domain);
+ }
+ },
+ [domainNames, syncToForm]
+ );
+
+ const hasErrors = errors && errors.length > 0;
+ const showSuggestions = !isCustomMode && !currentValue && suggestions.length > 0;
+
+ const selectedDomainStatus = useMemo(() => {
+ if (!selectedDomain) return null;
+ const domain = domains.find((d) => d.domainName === selectedDomain);
+ if (!domain) return null;
+ return transformControlPlaneStatus(domain.status).status;
+ }, [selectedDomain, domains]);
+
+ const isUnverified = selectedDomainStatus && selectedDomainStatus !== ControlPlaneStatus.Success;
+
+ /** Same composition as syncToForm — shown below fields on small screens where the dot separator is hidden. */
+ const splitHostnamePreview = useMemo(() => {
+ if (!selectedDomain) return null;
+ const trimmed = prefix.trim().replace(/\.$/, '');
+ return trimmed ? `${trimmed}.${selectedDomain}` : selectedDomain;
+ }, [prefix, selectedDomain]);
+
+ if (isCustomMode) {
+ return (
+
+
+
+ {onRemove && (
+
+
+
+ )}
+
+ {domainNames.length > 0 && (
+
void switchToSplit()}
+ className="text-muted-foreground hover:text-foreground flex items-center gap-1 self-start text-[11px] underline transition-colors">
+
+ Use a verified domain
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {domainsLoading && (
+
+
+
+
+
+ )}
+ {showSuggestions && (
+
+ {suggestions.map((s) => (
+ handleSuggestionClick(s)}
+ className="bg-accent hover:bg-accent/80 text-accent-foreground rounded-md px-2 py-0.5 text-[11px] transition-colors">
+ {s}
+
+ ))}
+
+ )}
+
+
+
+ .
+
+
+
+ {onRemove && (
+
+
+
+ )}
+
+
+
+
+ {splitHostnamePreview}
+
+
+ {isUnverified && (
+
+
+
+ {selectedDomainStatus === ControlPlaneStatus.Pending
+ ? 'This domain is being verified — your proxy may not activate until verification is complete.'
+ : "This domain is not verified — your proxy won't activate until the domain is verified."}
+
+
+ )}
+
+
+ Type a custom hostname
+
+
+ );
+}
+
+SubdomainHostnameField.displayName = 'SubdomainHostnameField';
diff --git a/app/features/edge/proxy/form/tls-field.tsx b/app/features/edge/proxy/form/tls-field.tsx
index 134c9a371..5a51f5c43 100644
--- a/app/features/edge/proxy/form/tls-field.tsx
+++ b/app/features/edge/proxy/form/tls-field.tsx
@@ -1,21 +1,21 @@
-import { Form } from '@datum-ui/components/form';
-
-interface ProxyTlsFieldProps {
- required?: boolean;
-}
-
-export const ProxyTlsField = ({ required = false }: ProxyTlsFieldProps) => {
- return (
-
-
-
- );
-};
+import { Form } from '@datum-ui/components/form';
+
+interface ProxyTlsFieldProps {
+ required?: boolean;
+}
+
+export const ProxyTlsField = ({ required = false }: ProxyTlsFieldProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/app/features/edge/proxy/metrics/connector-sparkline.tsx b/app/features/edge/proxy/metrics/connector-sparkline.tsx
index dff58434b..2f3be1590 100644
--- a/app/features/edge/proxy/metrics/connector-sparkline.tsx
+++ b/app/features/edge/proxy/metrics/connector-sparkline.tsx
@@ -1,136 +1,136 @@
-import { buildRateQuery } from '@/modules/metrics';
-import { usePrometheusAPIQuery } from '@/modules/metrics/hooks';
-import { transformForRecharts, type FormattedMetricData } from '@/modules/prometheus';
-import { SpinnerIcon } from '@datum-ui/components';
-import { ChartContainer, ChartTooltip, type ChartConfig } from '@shadcn/ui/chart';
-import { useMemo } from 'react';
-import { Area, AreaChart, ResponsiveContainer } from 'recharts';
-
-interface ConnectorSparklineProps {
- projectId: string;
- /** Proxy names (gateway_name) that use this connector; request rate is summed across these */
- proxyNames: string[];
- /** Optional stable id for chart elements (e.g. connector name); avoids long or invalid SVG ids */
- connectorId?: string;
-}
-
-const chartConfig: ChartConfig = {
- requests: {
- label: 'Requests',
- color: 'var(--primary)',
- },
-};
-
-export function ConnectorSparkline({
- projectId,
- proxyNames,
- connectorId = 'connector',
-}: ConnectorSparklineProps) {
- const endTime = new Date();
- const startTime = new Date(endTime.getTime() - 60 * 60 * 1000);
-
- const proxyNamesKey = useMemo(() => [...proxyNames].sort().join(','), [proxyNames]);
-
- const query = useMemo(() => {
- if (!proxyNamesKey) return '';
- const names = proxyNamesKey.split(',');
- return buildRateQuery({
- metric: 'envoy_vhost_vcluster_upstream_rq',
- timeWindow: '1m',
- baseLabels: {
- resourcemanager_datumapis_com_project_name: projectId,
- gateway_namespace: 'default',
- },
- filters: [{ label: 'gateway_name', value: names }],
- groupBy: [],
- });
- }, [projectId, proxyNamesKey]);
-
- const { data, isLoading, error } = usePrometheusAPIQuery(
- ['connector-sparkline', projectId, proxyNamesKey],
- {
- type: 'chart',
- query,
- timeRange: { start: startTime, end: endTime },
- step: '1m',
- },
- {
- enabled: !!projectId && proxyNames.length > 0 && !!query,
- refetchInterval: 30000,
- }
- );
-
- const chartData = useMemo(() => {
- if (!data || error) return [];
- return transformForRecharts(data);
- }, [data, error]);
-
- const seriesName = data?.series[0]?.name || 'requests';
- const dataKey = seriesName;
- const gradientId = `connector-sparkline-${connectorId}`.replace(/[^a-z0-9-]/gi, '-');
-
- if (proxyNames.length === 0) {
- return (
-
- No data
-
- );
- }
-
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- if (!data || error || chartData.length === 0) {
- return (
-
- No data
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
- {
- if (active && payload?.length) {
- const value = payload[0].value as number;
- return (
-
-
{value.toFixed(2)} req/s
-
- );
- }
- return null;
- }}
- />
-
-
-
-
-
- );
-}
+import { buildRateQuery } from '@/modules/metrics';
+import { usePrometheusAPIQuery } from '@/modules/metrics/hooks';
+import { transformForRecharts, type FormattedMetricData } from '@/modules/prometheus';
+import { SpinnerIcon } from '@datum-ui/components';
+import { ChartContainer, ChartTooltip, type ChartConfig } from '@shadcn/ui/chart';
+import { useMemo } from 'react';
+import { Area, AreaChart, ResponsiveContainer } from 'recharts';
+
+interface ConnectorSparklineProps {
+ projectId: string;
+ /** Proxy names (gateway_name) that use this connector; request rate is summed across these */
+ proxyNames: string[];
+ /** Optional stable id for chart elements (e.g. connector name); avoids long or invalid SVG ids */
+ connectorId?: string;
+}
+
+const chartConfig: ChartConfig = {
+ requests: {
+ label: 'Requests',
+ color: 'var(--primary)',
+ },
+};
+
+export function ConnectorSparkline({
+ projectId,
+ proxyNames,
+ connectorId = 'connector',
+}: ConnectorSparklineProps) {
+ const endTime = new Date();
+ const startTime = new Date(endTime.getTime() - 60 * 60 * 1000);
+
+ const proxyNamesKey = useMemo(() => [...proxyNames].sort().join(','), [proxyNames]);
+
+ const query = useMemo(() => {
+ if (!proxyNamesKey) return '';
+ const names = proxyNamesKey.split(',');
+ return buildRateQuery({
+ metric: 'envoy_vhost_vcluster_upstream_rq',
+ timeWindow: '1m',
+ baseLabels: {
+ resourcemanager_datumapis_com_project_name: projectId,
+ gateway_namespace: 'default',
+ },
+ filters: [{ label: 'gateway_name', value: names }],
+ groupBy: [],
+ });
+ }, [projectId, proxyNamesKey]);
+
+ const { data, isLoading, error } = usePrometheusAPIQuery(
+ ['connector-sparkline', projectId, proxyNamesKey],
+ {
+ type: 'chart',
+ query,
+ timeRange: { start: startTime, end: endTime },
+ step: '1m',
+ },
+ {
+ enabled: !!projectId && proxyNames.length > 0 && !!query,
+ refetchInterval: 30000,
+ }
+ );
+
+ const chartData = useMemo(() => {
+ if (!data || error) return [];
+ return transformForRecharts(data);
+ }, [data, error]);
+
+ const seriesName = data?.series[0]?.name || 'requests';
+ const dataKey = seriesName;
+ const gradientId = `connector-sparkline-${connectorId}`.replace(/[^a-z0-9-]/gi, '-');
+
+ if (proxyNames.length === 0) {
+ return (
+
+ No data
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!data || error || chartData.length === 0) {
+ return (
+
+ No data
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {
+ if (active && payload?.length) {
+ const value = payload[0].value as number;
+ return (
+
+
{value.toFixed(2)} req/s
+
+ );
+ }
+ return null;
+ }}
+ />
+
+
+
+
+
+ );
+}
diff --git a/app/features/edge/proxy/metrics/edge-requests.tsx b/app/features/edge/proxy/metrics/edge-requests.tsx
index e35b13bff..58f021c59 100644
--- a/app/features/edge/proxy/metrics/edge-requests.tsx
+++ b/app/features/edge/proxy/metrics/edge-requests.tsx
@@ -1,150 +1,150 @@
-import { DateTime } from '@/components/date-time';
-import {
- MetricChart,
- MetricChartTooltipContent,
- MetricsToolbar,
- buildHistogramQuantileQuery,
- buildPrometheusLabelSelector,
- createRegionFilter,
-} from '@/modules/metrics';
-import { formatValue } from '@/modules/prometheus';
-import type { ChartSeries } from '@/modules/prometheus';
-import { useState } from 'react';
-
-const RESPONSE_CODE_COLORS: Record = {
- '2XX': 'var(--color-chart-2)',
- '3XX': 'var(--color-chart-4)',
- '4XX': 'var(--color-chart-1)',
- '5XX': 'var(--color-chart-3)',
-};
-
-export const HttpProxyEdgeRequests = ({
- projectId,
- proxyId,
-}: {
- projectId: string;
- proxyId: string;
-}) => {
- const [series, setSeries] = useState([]);
-
- return (
-
-
Requests per second
-
-
-
-
- {series.length > 0 && (
-
- {series.map((s) => (
-
-
- {s.name}
-
- ))}
-
- )}
-
-
-
{
- const regionFilter = createRegionFilter(get('regions'));
- const selector = buildPrometheusLabelSelector({
- baseLabels: {
- resourcemanager_datumapis_com_project_name: projectId,
- gateway_name: proxyId,
- gateway_namespace: 'default',
- },
- customLabels: { label_topology_kubernetes_io_region: '!=""' },
- filters: [regionFilter],
- });
- const step = filters.step ?? '15m';
- // Group individual response codes into classes (2XX, 3XX, 4XX, 5XX).
- // Use a sub-query (sum_over_time of 1m increases) to avoid increase()
- // extrapolation inflating counts when data only partially covers the step window.
- return (
- `sum by (envoy_response_code_class) (` +
- `sum_over_time(` +
- `label_replace(` +
- `increase(envoy_vhost_vcluster_upstream_rq${selector}[1m]),` +
- `"envoy_response_code_class","$\{1}XX","envoy_response_code","([0-9]).*"` +
- `)[${step}:1m]))`
- );
- }}
- chartType="area"
- showLegend={false}
- colorOverrides={RESPONSE_CODE_COLORS}
- height={220}
- xAxisFormatter={(value) => {
- const mins = Math.round((Date.now() - value) / 60000);
- return mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
- }}
- yAxisFormatter={(value) => String(Math.round(value))}
- yAxisOptions={{ width: 55 }}
- onSeriesChange={setSeries}
- className="text-foreground shadow-none"
- />
-
- P95 Upstream Latency
-
- buildHistogramQuantileQuery({
- quantile: 0.95,
- metric: 'envoy_vhost_vcluster_upstream_rq_time_bucket',
- timeWindow: filters.step ?? '15m',
- baseLabels: {
- resourcemanager_datumapis_com_project_name: projectId,
- gateway_name: proxyId,
- gateway_namespace: 'default',
- },
- customLabels: { label_topology_kubernetes_io_region: '!=""' },
- groupBy: ['le'],
- })
- }
- chartType="area"
- showLegend={false}
- colorOverrides={{ Series: 'var(--primary)' }}
- valueFormat="milliseconds-auto"
- height={220}
- xAxisFormatter={(value) => {
- const mins = Math.round((Date.now() - value) / 60000);
- return mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
- }}
- yAxisFormatter={(value) => formatValue(value, 'milliseconds-auto')}
- yAxisOptions={{ width: 55 }}
- tooltipContent={({ active, payload, label, ...props }) => {
- if (!active || !payload?.length) return null;
- const filteredPayload = payload.filter((p) => (p.value as number) > 0);
- if (!filteredPayload.length) return null;
- return (
- }
- formatter={(value, _name, item) => (
-
-
-
- {formatValue(value as number, 'milliseconds-auto')}
-
-
- )}
- {...props}
- />
- );
- }}
- className="text-foreground shadow-none"
- />
-
- );
-};
+import { DateTime } from '@/components/date-time';
+import {
+ MetricChart,
+ MetricChartTooltipContent,
+ MetricsToolbar,
+ buildHistogramQuantileQuery,
+ buildPrometheusLabelSelector,
+ createRegionFilter,
+} from '@/modules/metrics';
+import { formatValue } from '@/modules/prometheus';
+import type { ChartSeries } from '@/modules/prometheus';
+import { useState } from 'react';
+
+const RESPONSE_CODE_COLORS: Record = {
+ '2XX': 'var(--color-chart-2)',
+ '3XX': 'var(--color-chart-4)',
+ '4XX': 'var(--color-chart-1)',
+ '5XX': 'var(--color-chart-3)',
+};
+
+export const HttpProxyEdgeRequests = ({
+ projectId,
+ proxyId,
+}: {
+ projectId: string;
+ proxyId: string;
+}) => {
+ const [series, setSeries] = useState([]);
+
+ return (
+
+
Requests per second
+
+
+
+
+ {series.length > 0 && (
+
+ {series.map((s) => (
+
+
+ {s.name}
+
+ ))}
+
+ )}
+
+
+
{
+ const regionFilter = createRegionFilter(get('regions'));
+ const selector = buildPrometheusLabelSelector({
+ baseLabels: {
+ resourcemanager_datumapis_com_project_name: projectId,
+ gateway_name: proxyId,
+ gateway_namespace: 'default',
+ },
+ customLabels: { label_topology_kubernetes_io_region: '!=""' },
+ filters: [regionFilter],
+ });
+ const step = filters.step ?? '15m';
+ // Group individual response codes into classes (2XX, 3XX, 4XX, 5XX).
+ // Use a sub-query (sum_over_time of 1m increases) to avoid increase()
+ // extrapolation inflating counts when data only partially covers the step window.
+ return (
+ `sum by (envoy_response_code_class) (` +
+ `sum_over_time(` +
+ `label_replace(` +
+ `increase(envoy_vhost_vcluster_upstream_rq${selector}[1m]),` +
+ `"envoy_response_code_class","$\{1}XX","envoy_response_code","([0-9]).*"` +
+ `)[${step}:1m]))`
+ );
+ }}
+ chartType="area"
+ showLegend={false}
+ colorOverrides={RESPONSE_CODE_COLORS}
+ height={220}
+ xAxisFormatter={(value) => {
+ const mins = Math.round((Date.now() - value) / 60000);
+ return mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
+ }}
+ yAxisFormatter={(value) => String(Math.round(value))}
+ yAxisOptions={{ width: 55 }}
+ onSeriesChange={setSeries}
+ className="text-foreground shadow-none"
+ />
+
+ P95 Upstream Latency
+
+ buildHistogramQuantileQuery({
+ quantile: 0.95,
+ metric: 'envoy_vhost_vcluster_upstream_rq_time_bucket',
+ timeWindow: filters.step ?? '15m',
+ baseLabels: {
+ resourcemanager_datumapis_com_project_name: projectId,
+ gateway_name: proxyId,
+ gateway_namespace: 'default',
+ },
+ customLabels: { label_topology_kubernetes_io_region: '!=""' },
+ groupBy: ['le'],
+ })
+ }
+ chartType="area"
+ showLegend={false}
+ colorOverrides={{ Series: 'var(--primary)' }}
+ valueFormat="milliseconds-auto"
+ height={220}
+ xAxisFormatter={(value) => {
+ const mins = Math.round((Date.now() - value) / 60000);
+ return mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
+ }}
+ yAxisFormatter={(value) => formatValue(value, 'milliseconds-auto')}
+ yAxisOptions={{ width: 55 }}
+ tooltipContent={({ active, payload, label, ...props }) => {
+ if (!active || !payload?.length) return null;
+ const filteredPayload = payload.filter((p) => (p.value as number) > 0);
+ if (!filteredPayload.length) return null;
+ return (
+ }
+ formatter={(value, _name, item) => (
+
+
+
+ {formatValue(value as number, 'milliseconds-auto')}
+
+
+ )}
+ {...props}
+ />
+ );
+ }}
+ className="text-foreground shadow-none"
+ />
+
+ );
+};
diff --git a/app/features/edge/proxy/metrics/proxy-sparkline.tsx b/app/features/edge/proxy/metrics/proxy-sparkline.tsx
index 342cd2d71..8c4f6999a 100644
--- a/app/features/edge/proxy/metrics/proxy-sparkline.tsx
+++ b/app/features/edge/proxy/metrics/proxy-sparkline.tsx
@@ -1,128 +1,128 @@
-import { buildRateQuery } from '@/modules/metrics';
-import { usePrometheusAPIQuery } from '@/modules/metrics/hooks';
-import { transformForRecharts, type FormattedMetricData } from '@/modules/prometheus';
-import { SpinnerIcon } from '@datum-ui/components';
-import { ChartContainer, ChartTooltip, type ChartConfig } from '@shadcn/ui/chart';
-import { useMemo } from 'react';
-import { Area, AreaChart, ResponsiveContainer } from 'recharts';
-
-interface ProxySparklineProps {
- projectId: string;
- proxyId: string;
-}
-
-const chartConfig: ChartConfig = {
- requests: {
- label: 'Requests',
- color: 'var(--primary)',
- },
-};
-
-export function ProxySparkline({ projectId, proxyId }: ProxySparklineProps) {
- // Fetch metrics for the last hour
- const endTime = useMemo(() => new Date(), []);
- const startTime = useMemo(() => {
- const date = new Date();
- date.setHours(date.getHours() - 1);
- return date;
- }, []);
-
- // Build query for requests rate
- const query = useMemo(() => {
- return buildRateQuery({
- metric: 'envoy_vhost_vcluster_upstream_rq',
- timeWindow: '1m', // 1 minute resolution for last hour
- baseLabels: {
- resourcemanager_datumapis_com_project_name: projectId,
- gateway_name: proxyId,
- gateway_namespace: 'default',
- },
- // Sum all regions together for a single line
- groupBy: [],
- });
- }, [projectId, proxyId]);
-
- const { data, isLoading, error } = usePrometheusAPIQuery(
- ['proxy-sparkline', projectId, proxyId],
- {
- type: 'chart',
- query,
- timeRange: {
- start: startTime,
- end: endTime,
- },
- step: '1m', // 1 minute step for last hour
- },
- {
- enabled: !!projectId && !!proxyId,
- refetchInterval: 30000, // Refresh every 30 seconds
- }
- );
-
- const chartData = useMemo(() => {
- if (!data || error) {
- return [];
- }
- return transformForRecharts(data);
- }, [data, error]);
-
- const seriesName = data?.series[0]?.name || 'requests';
- const dataKey = seriesName;
-
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- if (!data || error || chartData.length === 0) {
- return (
-
- No data
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
- {
- if (active && payload && payload.length) {
- const value = payload[0].value as number;
- return (
-
-
{value.toFixed(2)} req/s
-
- );
- }
- return null;
- }}
- />
-
-
-
-
-
- );
-}
+import { buildRateQuery } from '@/modules/metrics';
+import { usePrometheusAPIQuery } from '@/modules/metrics/hooks';
+import { transformForRecharts, type FormattedMetricData } from '@/modules/prometheus';
+import { SpinnerIcon } from '@datum-ui/components';
+import { ChartContainer, ChartTooltip, type ChartConfig } from '@shadcn/ui/chart';
+import { useMemo } from 'react';
+import { Area, AreaChart, ResponsiveContainer } from 'recharts';
+
+interface ProxySparklineProps {
+ projectId: string;
+ proxyId: string;
+}
+
+const chartConfig: ChartConfig = {
+ requests: {
+ label: 'Requests',
+ color: 'var(--primary)',
+ },
+};
+
+export function ProxySparkline({ projectId, proxyId }: ProxySparklineProps) {
+ // Fetch metrics for the last hour
+ const endTime = useMemo(() => new Date(), []);
+ const startTime = useMemo(() => {
+ const date = new Date();
+ date.setHours(date.getHours() - 1);
+ return date;
+ }, []);
+
+ // Build query for requests rate
+ const query = useMemo(() => {
+ return buildRateQuery({
+ metric: 'envoy_vhost_vcluster_upstream_rq',
+ timeWindow: '1m', // 1 minute resolution for last hour
+ baseLabels: {
+ resourcemanager_datumapis_com_project_name: projectId,
+ gateway_name: proxyId,
+ gateway_namespace: 'default',
+ },
+ // Sum all regions together for a single line
+ groupBy: [],
+ });
+ }, [projectId, proxyId]);
+
+ const { data, isLoading, error } = usePrometheusAPIQuery(
+ ['proxy-sparkline', projectId, proxyId],
+ {
+ type: 'chart',
+ query,
+ timeRange: {
+ start: startTime,
+ end: endTime,
+ },
+ step: '1m', // 1 minute step for last hour
+ },
+ {
+ enabled: !!projectId && !!proxyId,
+ refetchInterval: 30000, // Refresh every 30 seconds
+ }
+ );
+
+ const chartData = useMemo(() => {
+ if (!data || error) {
+ return [];
+ }
+ return transformForRecharts(data);
+ }, [data, error]);
+
+ const seriesName = data?.series[0]?.name || 'requests';
+ const dataKey = seriesName;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!data || error || chartData.length === 0) {
+ return (
+
+ No data
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {
+ if (active && payload && payload.length) {
+ const value = payload[0].value as number;
+ return (
+
+
{value.toFixed(2)} req/s
+
+ );
+ }
+ return null;
+ }}
+ />
+
+
+
+
+
+ );
+}
diff --git a/app/features/edge/proxy/metrics/upstream-table.tsx b/app/features/edge/proxy/metrics/upstream-table.tsx
index 63c09d43d..dc0953297 100644
--- a/app/features/edge/proxy/metrics/upstream-table.tsx
+++ b/app/features/edge/proxy/metrics/upstream-table.tsx
@@ -1,74 +1,74 @@
-import { ChartDataPoint, ChartSeries } from '@/modules/prometheus';
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@shadcn/ui/table';
-import { useMemo } from 'react';
-
-export const HttpProxyUpstreamTable = ({ series }: { series: ChartSeries[] }) => {
- // Calculate statistics for each series
- const seriesStats = useMemo(() => {
- if (!series.length) return [];
-
- return series.map((val) => {
- const seriesValues = val.data
- .map((point: ChartDataPoint) => point.value)
- .filter((value: number) => typeof value === 'number' && !isNaN(value));
-
- if (seriesValues.length === 0) {
- return {
- region: val.name,
- last: 0,
- mean: 0,
- max: 0,
- };
- }
-
- const last = seriesValues[seriesValues.length - 1] || 0;
- const mean =
- seriesValues.reduce((sum: number, val: number) => sum + val, 0) / seriesValues.length;
- const max = Math.max(...seriesValues);
-
- return {
- region: (
- <>
-
- >
- ),
- last: Number(last.toFixed(6)),
- mean: Number(mean.toFixed(6)),
- max: Number(max.toFixed(6)),
- };
- });
- }, [series]);
-
- return (
-
-
-
-
- Region
- Last
- Mean
- Max
-
-
-
- {seriesStats.map((stat, index) => (
-
- {stat.region}
- {stat.last} req/s
- {stat.mean} req/s
- {stat.max} req/s
-
- ))}
-
-
-
- );
-};
+import { ChartDataPoint, ChartSeries } from '@/modules/prometheus';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@shadcn/ui/table';
+import { useMemo } from 'react';
+
+export const HttpProxyUpstreamTable = ({ series }: { series: ChartSeries[] }) => {
+ // Calculate statistics for each series
+ const seriesStats = useMemo(() => {
+ if (!series.length) return [];
+
+ return series.map((val) => {
+ const seriesValues = val.data
+ .map((point: ChartDataPoint) => point.value)
+ .filter((value: number) => typeof value === 'number' && !isNaN(value));
+
+ if (seriesValues.length === 0) {
+ return {
+ region: val.name,
+ last: 0,
+ mean: 0,
+ max: 0,
+ };
+ }
+
+ const last = seriesValues[seriesValues.length - 1] || 0;
+ const mean =
+ seriesValues.reduce((sum: number, val: number) => sum + val, 0) / seriesValues.length;
+ const max = Math.max(...seriesValues);
+
+ return {
+ region: (
+ <>
+
+ >
+ ),
+ last: Number(last.toFixed(6)),
+ mean: Number(mean.toFixed(6)),
+ max: Number(max.toFixed(6)),
+ };
+ });
+ }, [series]);
+
+ return (
+
+
+
+
+ Region
+ Last
+ Mean
+ Max
+
+
+
+ {seriesStats.map((stat, index) => (
+
+ {stat.region}
+ {stat.last} req/s
+ {stat.mean} req/s
+ {stat.max} req/s
+
+ ))}
+
+
+
+ );
+};
diff --git a/app/features/edge/proxy/metrics/waf-events.tsx b/app/features/edge/proxy/metrics/waf-events.tsx
index 94cf6b8c7..1c62ee609 100644
--- a/app/features/edge/proxy/metrics/waf-events.tsx
+++ b/app/features/edge/proxy/metrics/waf-events.tsx
@@ -1,111 +1,111 @@
-import { DateTime } from '@/components/date-time';
-import {
- MetricChart,
- MetricChartTooltipContent,
- buildPrometheusLabelSelector,
- createRegionFilter,
-} from '@/modules/metrics';
-import type { ChartSeries } from '@/modules/prometheus';
-import { useState } from 'react';
-
-const OUTCOME_LABELS: Record = {
- allowed: 'Allowed',
- blocked: 'Blocked',
- dropped: 'Dropped',
-};
-
-const OUTCOME_COLORS: Record = {
- allowed: 'var(--color-chart-2)',
- blocked: 'var(--color-chart-1)',
- dropped: 'var(--color-chart-4)',
-};
-
-export const HttpProxyWafEvents = ({
- projectId,
- proxyId,
-}: {
- projectId: string;
- proxyId: string;
-}) => {
- const [series, setSeries] = useState([]);
-
- return (
-
-
-
Traffic Protection Events
- {series.length > 0 && (
-
- {series.map((s) => (
-
-
- {OUTCOME_LABELS[s.name] ?? s.name}
-
- ))}
-
- )}
-
-
-
{
- const regionFilter = createRegionFilter(get('regions'));
- const selector = buildPrometheusLabelSelector({
- baseLabels: {
- resourcemanager_datumapis_com_project_name: projectId,
- gateway_name: proxyId,
- },
- customLabels: { label_topology_kubernetes_io_region: '!=""' },
- filters: [regionFilter],
- });
- const step = filters.step ?? '15m';
- return (
- `sum by (coraza_outcome) (` +
- `sum_over_time(` +
- `increase(coraza_envoy_filter_request_events_total${selector}[1m])` +
- `[${step}:1m]))`
- );
- }}
- chartType="area"
- showLegend={false}
- colorOverrides={OUTCOME_COLORS}
- height={220}
- xAxisFormatter={(value) => {
- const mins = Math.round((Date.now() - value) / 60000);
- return mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
- }}
- yAxisFormatter={(value) => String(Math.round(value))}
- yAxisOptions={{ width: 55 }}
- onSeriesChange={setSeries}
- tooltipContent={({ active, payload, label, ...props }) => {
- if (!active || !payload?.length) return null;
- const filteredPayload = payload.filter((p) => (p.value as number) > 0);
- if (!filteredPayload.length) return null;
- return (
- }
- formatter={(value, name, item) => (
-
-
-
-
{OUTCOME_LABELS[name as string] ?? name}
-
-
{Math.round(value as number)}
-
- )}
- {...props}
- />
- );
- }}
- className="text-foreground shadow-none"
- />
-
- );
-};
+import { DateTime } from '@/components/date-time';
+import {
+ MetricChart,
+ MetricChartTooltipContent,
+ buildPrometheusLabelSelector,
+ createRegionFilter,
+} from '@/modules/metrics';
+import type { ChartSeries } from '@/modules/prometheus';
+import { useState } from 'react';
+
+const OUTCOME_LABELS: Record = {
+ allowed: 'Allowed',
+ blocked: 'Blocked',
+ dropped: 'Dropped',
+};
+
+const OUTCOME_COLORS: Record = {
+ allowed: 'var(--color-chart-2)',
+ blocked: 'var(--color-chart-1)',
+ dropped: 'var(--color-chart-4)',
+};
+
+export const HttpProxyWafEvents = ({
+ projectId,
+ proxyId,
+}: {
+ projectId: string;
+ proxyId: string;
+}) => {
+ const [series, setSeries] = useState([]);
+
+ return (
+
+
+
Traffic Protection Events
+ {series.length > 0 && (
+
+ {series.map((s) => (
+
+
+ {OUTCOME_LABELS[s.name] ?? s.name}
+
+ ))}
+
+ )}
+
+
+
{
+ const regionFilter = createRegionFilter(get('regions'));
+ const selector = buildPrometheusLabelSelector({
+ baseLabels: {
+ resourcemanager_datumapis_com_project_name: projectId,
+ gateway_name: proxyId,
+ },
+ customLabels: { label_topology_kubernetes_io_region: '!=""' },
+ filters: [regionFilter],
+ });
+ const step = filters.step ?? '15m';
+ return (
+ `sum by (coraza_outcome) (` +
+ `sum_over_time(` +
+ `increase(coraza_envoy_filter_request_events_total${selector}[1m])` +
+ `[${step}:1m]))`
+ );
+ }}
+ chartType="area"
+ showLegend={false}
+ colorOverrides={OUTCOME_COLORS}
+ height={220}
+ xAxisFormatter={(value) => {
+ const mins = Math.round((Date.now() - value) / 60000);
+ return mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
+ }}
+ yAxisFormatter={(value) => String(Math.round(value))}
+ yAxisOptions={{ width: 55 }}
+ onSeriesChange={setSeries}
+ tooltipContent={({ active, payload, label, ...props }) => {
+ if (!active || !payload?.length) return null;
+ const filteredPayload = payload.filter((p) => (p.value as number) > 0);
+ if (!filteredPayload.length) return null;
+ return (
+ }
+ formatter={(value, name, item) => (
+
+
+
+
{OUTCOME_LABELS[name as string] ?? name}
+
+
{Math.round(value as number)}
+
+ )}
+ {...props}
+ />
+ );
+ }}
+ className="text-foreground shadow-none"
+ />
+
+ );
+};
diff --git a/app/features/edge/proxy/overview/active-pops-card.tsx b/app/features/edge/proxy/overview/active-pops-card.tsx
index af7ffc3c0..ae97397dc 100644
--- a/app/features/edge/proxy/overview/active-pops-card.tsx
+++ b/app/features/edge/proxy/overview/active-pops-card.tsx
@@ -1,129 +1,129 @@
-import { getRegionCoordinates } from './region-coordinates';
-import { ChunkErrorBoundary } from '@/components/chunk-error-boundary/chunk-error-boundary';
-import { usePrometheusLabels } from '@/modules/metrics';
-import { buildPrometheusLabelSelector } from '@/modules/metrics/utils/query-builders';
-import { ControlPlaneStatus } from '@/resources/base';
-import { useHttpProxy } from '@/resources/http-proxies';
-import { transformControlPlaneStatus } from '@/utils/helpers/control-plane.helper';
-import { lazyWithRetry } from '@/utils/helpers/lazy-with-retry';
-import { Button, SpinnerIcon } from '@datum-ui/components';
-import { Card, CardContent, Skeleton } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { MapPinIcon } from 'lucide-react';
-import { Suspense, useMemo } from 'react';
-
-const ActivePopsMap = lazyWithRetry(
- () => import('./active-pops-map').then((m) => ({ default: m.ActivePopsMap })),
- 'active-pops-map'
-);
-
-const REGION_LABEL = 'label_topology_kubernetes_io_region';
-const PROXY_METRIC = 'envoy_vhost_vcluster_upstream_rq';
-
-export const ActivePopsCard = ({ projectId, proxyId }: { projectId: string; proxyId: string }) => {
- const matchSelector = useMemo(() => {
- const selector = buildPrometheusLabelSelector({
- baseLabels: {
- resourcemanager_datumapis_com_project_name: projectId,
- gateway_name: proxyId,
- gateway_namespace: 'default',
- },
- customLabels: {
- [REGION_LABEL]: '!=""',
- },
- });
- return `${PROXY_METRIC}${selector}`;
- }, [projectId, proxyId]);
-
- const {
- options: regionOptionsFromApi,
- isLoading,
- error,
- } = usePrometheusLabels({
- label: REGION_LABEL,
- match: matchSelector,
- enabled: !!projectId && !!proxyId,
- filter: (v) => !!v?.trim(),
- sort: (a, b) => a.label.localeCompare(b.label),
- });
-
- const regionOptions = regionOptionsFromApi;
-
- const regionsWithCoords = useMemo(() => {
- return regionOptions
- .map((opt) => ({
- ...opt,
- coords: getRegionCoordinates(opt.value),
- }))
- .filter((r): r is typeof r & { coords: [number, number] } => r.coords !== null);
- }, [regionOptions]);
-
- // Check if proxy is still being created (Pending status)
- const { data: proxy } = useHttpProxy(projectId, proxyId, {
- enabled: !!projectId && !!proxyId,
- });
-
- const isProxyPending = useMemo(() => {
- if (!proxy?.status) return true;
- const transformedStatus = transformControlPlaneStatus(proxy.status);
- return transformedStatus.status === ControlPlaneStatus.Pending;
- }, [proxy?.status]);
-
- // Show skeleton when proxy is pending and no POPs data yet
- const showSkeleton = isProxyPending && !isLoading && regionOptions.length === 0 && !error;
-
- return (
-
-
-
-
- Active POPs
-
-
- Points of presence where this proxy is currently active, based on recent traffic metrics.
-
- {isLoading && (
-
-
-
-
Loading active POPs...
-
-
- )}
- {showSkeleton && }
- {!isLoading && !showSkeleton && !error && (
-
-
-
Unable to load map.
-
window.location.reload()}>
- Reload page
-
-
-
- }>
-
- }>
-
-
-
- )}
- {!isLoading && !showSkeleton && error && (
-
-
- Unable to load active regions.
-
-
- )}
-
-
- );
-};
+import { getRegionCoordinates } from './region-coordinates';
+import { ChunkErrorBoundary } from '@/components/chunk-error-boundary/chunk-error-boundary';
+import { usePrometheusLabels } from '@/modules/metrics';
+import { buildPrometheusLabelSelector } from '@/modules/metrics/utils/query-builders';
+import { ControlPlaneStatus } from '@/resources/base';
+import { useHttpProxy } from '@/resources/http-proxies';
+import { transformControlPlaneStatus } from '@/utils/helpers/control-plane.helper';
+import { lazyWithRetry } from '@/utils/helpers/lazy-with-retry';
+import { Button, SpinnerIcon } from '@datum-ui/components';
+import { Card, CardContent, Skeleton } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { MapPinIcon } from 'lucide-react';
+import { Suspense, useMemo } from 'react';
+
+const ActivePopsMap = lazyWithRetry(
+ () => import('./active-pops-map').then((m) => ({ default: m.ActivePopsMap })),
+ 'active-pops-map'
+);
+
+const REGION_LABEL = 'label_topology_kubernetes_io_region';
+const PROXY_METRIC = 'envoy_vhost_vcluster_upstream_rq';
+
+export const ActivePopsCard = ({ projectId, proxyId }: { projectId: string; proxyId: string }) => {
+ const matchSelector = useMemo(() => {
+ const selector = buildPrometheusLabelSelector({
+ baseLabels: {
+ resourcemanager_datumapis_com_project_name: projectId,
+ gateway_name: proxyId,
+ gateway_namespace: 'default',
+ },
+ customLabels: {
+ [REGION_LABEL]: '!=""',
+ },
+ });
+ return `${PROXY_METRIC}${selector}`;
+ }, [projectId, proxyId]);
+
+ const {
+ options: regionOptionsFromApi,
+ isLoading,
+ error,
+ } = usePrometheusLabels({
+ label: REGION_LABEL,
+ match: matchSelector,
+ enabled: !!projectId && !!proxyId,
+ filter: (v) => !!v?.trim(),
+ sort: (a, b) => a.label.localeCompare(b.label),
+ });
+
+ const regionOptions = regionOptionsFromApi;
+
+ const regionsWithCoords = useMemo(() => {
+ return regionOptions
+ .map((opt) => ({
+ ...opt,
+ coords: getRegionCoordinates(opt.value),
+ }))
+ .filter((r): r is typeof r & { coords: [number, number] } => r.coords !== null);
+ }, [regionOptions]);
+
+ // Check if proxy is still being created (Pending status)
+ const { data: proxy } = useHttpProxy(projectId, proxyId, {
+ enabled: !!projectId && !!proxyId,
+ });
+
+ const isProxyPending = useMemo(() => {
+ if (!proxy?.status) return true;
+ const transformedStatus = transformControlPlaneStatus(proxy.status);
+ return transformedStatus.status === ControlPlaneStatus.Pending;
+ }, [proxy?.status]);
+
+ // Show skeleton when proxy is pending and no POPs data yet
+ const showSkeleton = isProxyPending && !isLoading && regionOptions.length === 0 && !error;
+
+ return (
+
+
+
+
+ Active POPs
+
+
+ Points of presence where this proxy is currently active, based on recent traffic metrics.
+
+ {isLoading && (
+
+
+
+
Loading active POPs...
+
+
+ )}
+ {showSkeleton && }
+ {!isLoading && !showSkeleton && !error && (
+
+
+
Unable to load map.
+
window.location.reload()}>
+ Reload page
+
+
+
+ }>
+
+ }>
+
+
+
+ )}
+ {!isLoading && !showSkeleton && error && (
+
+
+ Unable to load active regions.
+
+
+ )}
+
+
+ );
+};
diff --git a/app/features/edge/proxy/overview/config-card.tsx b/app/features/edge/proxy/overview/config-card.tsx
index 72dd82ebb..dd598d141 100644
--- a/app/features/edge/proxy/overview/config-card.tsx
+++ b/app/features/edge/proxy/overview/config-card.tsx
@@ -1,280 +1,280 @@
-import { OsIcon, getOsLabel } from '@/components/icon/os-icon';
-import { List, ListItem } from '@/components/list/list';
-import { StatusPulseDot } from '@/components/status-pulse-dot';
-import {
- ProxyBasicAuthDialog,
- type ProxyBasicAuthDialogRef,
-} from '@/features/edge/proxy/proxy-basic-auth-dialog';
-import {
- ProxyDisplayNameDialog,
- type ProxyDisplayNameDialogRef,
-} from '@/features/edge/proxy/proxy-display-name-dialog';
-import { ProxyWafDialog, type ProxyWafDialogRef } from '@/features/edge/proxy/proxy-waf-dialog';
-import { ControlPlaneStatus } from '@/resources/base';
-import { useConnector, useConnectorWatch } from '@/resources/connectors';
-import {
- type HttpProxy,
- formatWafProtectionDisplay,
- useUpdateHttpProxy,
-} from '@/resources/http-proxies';
-import { DATUM_DESKTOP_DOWNLOAD_URL } from '@/utils/config/query.config';
-import { transformControlPlaneStatus } from '@/utils/helpers/control-plane.helper';
-import { Badge, Card, CardContent, toast, Tooltip, Skeleton } from '@datum-ui/components';
-import { Icon } from '@datum-ui/components/icons/icon-wrapper';
-import { Switch } from '@shadcn/ui/switch';
-import { CircleHelp, PencilIcon, SettingsIcon } from 'lucide-react';
-import { useMemo, useRef } from 'react';
-
-export const HttpProxyConfigCard = ({
- proxy,
- projectId,
-}: {
- proxy: HttpProxy;
- projectId?: string;
-}) => {
- const displayNameDialogRef = useRef(null);
- const wafDialogRef = useRef(null);
- const basicAuthDialogRef = useRef(null);
- const updateMutation = useUpdateHttpProxy(projectId ?? '', proxy.name);
- const { data: connector, isLoading: isConnectorLoading } = useConnector(
- projectId ?? '',
- proxy.connector?.name
- );
-
- useConnectorWatch(projectId ?? '', proxy.connector?.name);
-
- const listItems: ListItem[] = useMemo(() => {
- if (!proxy) return [];
-
- return [
- {
- label: 'Name',
- content: !proxy.chosenName ? (
-
- ) : (
-
- {proxy.chosenName || proxy.name}
- {projectId && (
- displayNameDialogRef.current?.show(proxy)}>
-
-
- )}
-
- ),
- },
-
- {
- label: (
-
- Protection
-
-
-
-
- ),
- content:
- proxy.trafficProtectionMode !== 'Disabled' ||
- proxy.paranoiaLevels?.blocking !== undefined ||
- proxy.paranoiaLevels?.detection !== undefined ? (
-
-
- {formatWafProtectionDisplay(proxy)}
-
- {projectId && (
- wafDialogRef.current?.show(proxy)}>
-
-
- )}
-
- ) : (
-
-
- Disabled
-
- {projectId && (
- wafDialogRef.current?.show(proxy)}>
-
-
- )}
-
- ),
- },
- {
- label: (
-
- Force HTTPS
-
-
-
-
- ),
- content:
- proxy.enableHttpRedirect === undefined ? (
-
- ) : (
- {
- if (!checked && proxy.basicAuthEnabled) {
- toast.warning('AI Edge', {
- description:
- 'Basic Authentication is enabled. Disabling Force HTTPS will transmit credentials in plaintext.',
- });
- }
- updateMutation.mutate(
- {
- enableHttpRedirect: checked,
- },
- {
- onSuccess: () => {
- toast.success('AI Edge', {
- description: `Force HTTPS ${checked ? 'enabled' : 'disabled'}`,
- });
- },
- onError: (error) => {
- toast.error('AI Edge', {
- description: (error as Error).message || 'Failed to update Force HTTPS',
- });
- },
- }
- );
- }}
- />
- ),
- },
- {
- label: (
-
- Basic Authentication
-
-
-
-
- ),
- content:
- proxy.basicAuthEnabled === undefined ? (
-
- ) : (
-
-
- {proxy.basicAuthEnabled
- ? proxy.basicAuthUserCount
- ? `${proxy.basicAuthUserCount} user${proxy.basicAuthUserCount !== 1 ? 's' : ''}`
- : 'Enabled'
- : 'Disabled'}
-
- {projectId && (
- basicAuthDialogRef.current?.show(proxy)}>
-
-
- )}
-
- ),
- },
- ];
- }, [proxy, projectId, updateMutation]);
-
- const connectorBlock = useMemo(() => {
- if (!proxy.connector) return null;
- if (isConnectorLoading || !connector) return ;
-
- const connectorStatus = transformControlPlaneStatus(connector.status);
- const isActive = connectorStatus?.status === ControlPlaneStatus.Success;
- const showDevice = connector.deviceName || connector.deviceOs;
-
- return (
-
-
-
-
-
-
- {showDevice && (
-
-
-
-
- {connector.deviceOs && (
-
- )}
- {connector.deviceName ?? getOsLabel(connector.deviceOs)}
-
- {connector.name}
-
-
-
- )}
-
-
- Connector created via{' '}
-
- Datum Desktop
-
-
-
- );
- }, [proxy.connector, isConnectorLoading, connector]);
-
- return (
-
-
-
-
- Configuration
-
-
- {connectorBlock && {connectorBlock}
}
-
- {projectId && (
- <>
-
-
-
- >
- )}
-
- );
-};
+import { OsIcon, getOsLabel } from '@/components/icon/os-icon';
+import { List, ListItem } from '@/components/list/list';
+import { StatusPulseDot } from '@/components/status-pulse-dot';
+import {
+ ProxyBasicAuthDialog,
+ type ProxyBasicAuthDialogRef,
+} from '@/features/edge/proxy/proxy-basic-auth-dialog';
+import {
+ ProxyDisplayNameDialog,
+ type ProxyDisplayNameDialogRef,
+} from '@/features/edge/proxy/proxy-display-name-dialog';
+import { ProxyWafDialog, type ProxyWafDialogRef } from '@/features/edge/proxy/proxy-waf-dialog';
+import { ControlPlaneStatus } from '@/resources/base';
+import { useConnector, useConnectorWatch } from '@/resources/connectors';
+import {
+ type HttpProxy,
+ formatWafProtectionDisplay,
+ useUpdateHttpProxy,
+} from '@/resources/http-proxies';
+import { DATUM_DESKTOP_DOWNLOAD_URL } from '@/utils/config/query.config';
+import { transformControlPlaneStatus } from '@/utils/helpers/control-plane.helper';
+import { Badge, Card, CardContent, toast, Tooltip, Skeleton } from '@datum-ui/components';
+import { Icon } from '@datum-ui/components/icons/icon-wrapper';
+import { Switch } from '@shadcn/ui/switch';
+import { CircleHelp, PencilIcon, SettingsIcon } from 'lucide-react';
+import { useMemo, useRef } from 'react';
+
+export const HttpProxyConfigCard = ({
+ proxy,
+ projectId,
+}: {
+ proxy: HttpProxy;
+ projectId?: string;
+}) => {
+ const displayNameDialogRef = useRef(null);
+ const wafDialogRef = useRef(null);
+ const basicAuthDialogRef = useRef(null);
+ const updateMutation = useUpdateHttpProxy(projectId ?? '', proxy.name);
+ const { data: connector, isLoading: isConnectorLoading } = useConnector(
+ projectId ?? '',
+ proxy.connector?.name
+ );
+
+ useConnectorWatch(projectId ?? '', proxy.connector?.name);
+
+ const listItems: ListItem[] = useMemo(() => {
+ if (!proxy) return [];
+
+ return [
+ {
+ label: 'Name',
+ content: !proxy.chosenName ? (
+
+ ) : (
+
+ {proxy.chosenName || proxy.name}
+ {projectId && (
+ displayNameDialogRef.current?.show(proxy)}>
+
+
+ )}
+
+ ),
+ },
+
+ {
+ label: (
+
+ Protection
+
+
+
+
+ ),
+ content:
+ proxy.trafficProtectionMode !== 'Disabled' ||
+ proxy.paranoiaLevels?.blocking !== undefined ||
+ proxy.paranoiaLevels?.detection !== undefined ? (
+
+
+ {formatWafProtectionDisplay(proxy)}
+
+ {projectId && (
+ wafDialogRef.current?.show(proxy)}>
+
+
+ )}
+
+ ) : (
+
+
+ Disabled
+
+ {projectId && (
+ wafDialogRef.current?.show(proxy)}>
+
+
+ )}
+
+ ),
+ },
+ {
+ label: (
+
+ Force HTTPS
+
+
+
+
+ ),
+ content:
+ proxy.enableHttpRedirect === undefined ? (
+
+ ) : (
+ {
+ if (!checked && proxy.basicAuthEnabled) {
+ toast.warning('AI Edge', {
+ description:
+ 'Basic Authentication is enabled. Disabling Force HTTPS will transmit credentials in plaintext.',
+ });
+ }
+ updateMutation.mutate(
+ {
+ enableHttpRedirect: checked,
+ },
+ {
+ onSuccess: () => {
+ toast.success('AI Edge', {
+ description: `Force HTTPS ${checked ? 'enabled' : 'disabled'}`,
+ });
+ },
+ onError: (error) => {
+ toast.error('AI Edge', {
+ description: (error as Error).message || 'Failed to update Force HTTPS',
+ });
+ },
+ }
+ );
+ }}
+ />
+ ),
+ },
+ {
+ label: (
+
+ Basic Authentication
+
+
+
+
+ ),
+ content:
+ proxy.basicAuthEnabled === undefined ? (
+
+ ) : (
+
+
+ {proxy.basicAuthEnabled
+ ? proxy.basicAuthUserCount
+ ? `${proxy.basicAuthUserCount} user${proxy.basicAuthUserCount !== 1 ? 's' : ''}`
+ : 'Enabled'
+ : 'Disabled'}
+
+ {projectId && (
+ basicAuthDialogRef.current?.show(proxy)}>
+
+
+ )}
+
+ ),
+ },
+ ];
+ }, [proxy, projectId, updateMutation]);
+
+ const connectorBlock = useMemo(() => {
+ if (!proxy.connector) return null;
+ if (isConnectorLoading || !connector) return ;
+
+ const connectorStatus = transformControlPlaneStatus(connector.status);
+ const isActive = connectorStatus?.status === ControlPlaneStatus.Success;
+ const showDevice = connector.deviceName || connector.deviceOs;
+
+ return (
+
+
+
+
+
+
+ {showDevice && (
+
+
+