From 6d910519bc150525fa432645db79f418ab8991a2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 14 Aug 2023 12:15:57 +0200 Subject: [PATCH 1/3] Use external shlink-web-component and remove local one --- config/test/setupTests.ts | 10 +- package-lock.json | 253 +--- package.json | 25 +- shlink-web-client.d.ts | 10 - shlink-web-component/src/Main.scss | 37 - shlink-web-component/src/Main.tsx | 79 -- .../src/ShlinkWebComponent.tsx | 67 - .../src/api-contract/ShlinkApiClient.ts | 63 - .../src/api-contract/errors.ts | 54 - .../src/api-contract/index.ts | 3 - .../src/api-contract/types.ts | 182 --- .../src/api-contract/utils.ts | 25 - .../src/common/AsideMenu.scss | 63 - shlink-web-component/src/common/AsideMenu.tsx | 71 - .../src/common/ShlinkApiError.tsx | 18 - shlink-web-component/src/container/index.ts | 42 - .../src/container/provideServices.ts | 23 - shlink-web-component/src/container/store.ts | 65 - .../src/domains/DomainRow.tsx | 62 - .../src/domains/DomainSelector.scss | 19 - .../src/domains/DomainSelector.tsx | 73 - .../src/domains/ManageDomains.tsx | 71 - .../src/domains/data/index.ts | 7 - .../src/domains/helpers/DomainDropdown.tsx | 46 - .../src/domains/helpers/DomainStatusIcon.tsx | 60 - .../helpers/EditDomainRedirectsModal.tsx | 78 -- .../src/domains/reducers/domainRedirects.ts | 20 - .../src/domains/reducers/domainsList.ts | 108 -- .../src/domains/services/provideServices.ts | 34 - shlink-web-component/src/index.scss | 2 - shlink-web-component/src/index.ts | 18 - .../src/mercure/helpers/Topics.ts | 7 - .../src/mercure/helpers/boundToMercureHub.tsx | 46 - .../src/mercure/helpers/index.ts | 31 - .../src/mercure/reducers/mercureInfo.ts | 43 - .../src/mercure/services/provideServices.ts | 12 - .../src/overview/Overview.tsx | 112 -- .../src/overview/helpers/HighlightCard.scss | 21 - .../src/overview/helpers/HighlightCard.tsx | 30 - .../overview/helpers/VisitsHighlightCard.tsx | 26 - .../src/overview/services/provideServices.ts | 11 - .../src/short-urls/CreateShortUrl.tsx | 65 - .../src/short-urls/EditShortUrl.tsx | 95 -- .../src/short-urls/Paginator.tsx | 53 - .../src/short-urls/ShortUrlForm.scss | 9 - .../src/short-urls/ShortUrlForm.tsx | 274 ---- .../src/short-urls/ShortUrlsFilteringBar.scss | 4 - .../src/short-urls/ShortUrlsFilteringBar.tsx | 121 -- .../src/short-urls/ShortUrlsList.tsx | 120 -- .../src/short-urls/ShortUrlsTable.scss | 7 - .../src/short-urls/ShortUrlsTable.tsx | 90 -- .../UseExistingIfFoundInfoIcon.scss | 7 - .../short-urls/UseExistingIfFoundInfoIcon.tsx | 50 - .../src/short-urls/data/index.ts | 43 - .../helpers/CreateShortUrlResult.scss | 4 - .../helpers/CreateShortUrlResult.tsx | 64 - .../helpers/DeleteShortUrlModal.tsx | 75 - .../short-urls/helpers/ExportShortUrlsBtn.tsx | 58 - .../src/short-urls/helpers/QrCodeModal.scss | 4 - .../src/short-urls/helpers/QrCodeModal.tsx | 95 -- .../short-urls/helpers/ShortUrlDetailLink.tsx | 29 - .../helpers/ShortUrlFormCheckboxGroup.tsx | 20 - .../src/short-urls/helpers/ShortUrlStatus.tsx | 86 -- .../helpers/ShortUrlVisitsCount.scss | 16 - .../helpers/ShortUrlVisitsCount.tsx | 74 - .../helpers/ShortUrlsFilterDropdown.tsx | 46 - .../src/short-urls/helpers/ShortUrlsRow.scss | 46 - .../src/short-urls/helpers/ShortUrlsRow.tsx | 89 -- .../short-urls/helpers/ShortUrlsRowMenu.tsx | 52 - .../src/short-urls/helpers/Tags.tsx | 29 - .../src/short-urls/helpers/hooks.ts | 77 -- .../src/short-urls/helpers/index.ts | 57 - .../qr-codes/QrErrorCorrectionDropdown.tsx | 28 - .../helpers/qr-codes/QrFormatDropdown.tsx | 16 - .../short-urls/reducers/shortUrlCreation.ts | 65 - .../short-urls/reducers/shortUrlDeletion.ts | 58 - .../src/short-urls/reducers/shortUrlDetail.ts | 50 - .../short-urls/reducers/shortUrlEdition.ts | 49 - .../src/short-urls/reducers/shortUrlsList.ts | 112 -- .../short-urls/services/provideServices.ts | 94 -- shlink-web-component/src/tags/TagsList.tsx | 96 -- shlink-web-component/src/tags/TagsTable.scss | 10 - shlink-web-component/src/tags/TagsTable.tsx | 70 - .../src/tags/TagsTableRow.tsx | 56 - .../src/tags/data/TagsListChildrenProps.ts | 16 - shlink-web-component/src/tags/data/index.ts | 15 - .../tags/helpers/DeleteTagConfirmModal.tsx | 41 - .../src/tags/helpers/EditTagModal.scss | 11 - .../src/tags/helpers/EditTagModal.tsx | 81 -- .../src/tags/helpers/Tag.scss | 24 - shlink-web-component/src/tags/helpers/Tag.tsx | 26 - .../src/tags/helpers/TagBullet.scss | 10 - .../src/tags/helpers/TagBullet.tsx | 14 - .../src/tags/helpers/TagsSelector.tsx | 101 -- .../src/tags/helpers/index.ts | 3 - .../src/tags/react-tag-autocomplete.scss | 140 -- .../src/tags/reducers/tagDelete.ts | 43 - .../src/tags/reducers/tagEdit.ts | 66 - .../src/tags/reducers/tagsList.ts | 149 -- .../src/tags/services/provideServices.ts | 60 - .../src/utils/StickyCardPaginator.scss | 7 - .../utils/components/CopyToClipboardIcon.scss | 4 - .../utils/components/CopyToClipboardIcon.tsx | 16 - .../src/utils/components/ExportBtn.tsx | 17 - .../src/utils/components/IconInput.scss | 26 - .../src/utils/components/IconInput.tsx | 29 - .../src/utils/components/InfoTooltip.tsx | 24 - .../utils/components/PaginationDropdown.tsx | 25 - .../src/utils/components/SimplePaginator.scss | 3 - .../src/utils/components/SimplePaginator.tsx | 50 - .../src/utils/dates/DateInput.scss | 111 -- .../src/utils/dates/DateInput.tsx | 42 - .../utils/dates/DateIntervalDropdownItems.tsx | 26 - .../src/utils/dates/DateIntervalSelector.tsx | 11 - .../src/utils/dates/DateRangeRow.tsx | 37 - .../src/utils/dates/DateRangeSelector.tsx | 67 - .../src/utils/dates/DateTimeInput.tsx | 15 - shlink-web-component/src/utils/dates/Time.tsx | 18 - .../src/utils/dates/helpers/date.ts | 42 - .../src/utils/dates/helpers/dateIntervals.ts | 106 -- shlink-web-component/src/utils/features.ts | 38 - .../src/utils/helpers/charts.ts | 16 - .../src/utils/helpers/files.ts | 17 - .../src/utils/helpers/hooks.ts | 76 - .../src/utils/helpers/index.ts | 32 - .../src/utils/helpers/json.ts | 7 - .../src/utils/helpers/numbers.ts | 7 - .../src/utils/helpers/pagination.ts | 39 - .../src/utils/helpers/qrCodes.ts | 23 - .../src/utils/helpers/version.ts | 21 - .../src/utils/mixins/fit-with-margin.scss | 8 - .../src/utils/mixins/sticky-cell.scss | 30 - .../src/utils/mixins/vertical-align.scss | 5 - shlink-web-component/src/utils/redux.ts | 13 - .../src/utils/routesPrefix.ts | 7 - .../src/utils/services/ColorGenerator.ts | 59 - .../src/utils/services/ImageDownloader.ts | 13 - .../src/utils/services/LocalStorage.ts | 14 - .../src/utils/services/ReportExporter.ts | 30 - .../src/utils/services/TagColorsStorage.ts | 5 - .../src/utils/services/provideServices.ts | 21 - shlink-web-component/src/utils/settings.ts | 83 -- .../src/utils/table/TableOrderIcon.tsx | 19 - shlink-web-component/src/utils/types/index.ts | 3 - .../src/visits/DomainVisits.tsx | 41 - .../src/visits/NonOrphanVisits.tsx | 37 - .../src/visits/OrphanVisits.tsx | 40 - .../src/visits/ShortUrlVisits.tsx | 60 - .../src/visits/ShortUrlVisitsHeader.scss | 3 - .../src/visits/ShortUrlVisitsHeader.tsx | 45 - shlink-web-component/src/visits/TagVisits.tsx | 41 - .../src/visits/TagVisitsHeader.tsx | 23 - .../src/visits/VisitsHeader.tsx | 38 - .../src/visits/VisitsStats.tsx | 333 ----- .../src/visits/VisitsTable.scss | 39 - .../src/visits/VisitsTable.tsx | 215 --- .../src/visits/charts/ChartCard.scss | 4 - .../src/visits/charts/ChartCard.tsx | 16 - .../src/visits/charts/DoughnutChart.tsx | 76 - .../src/visits/charts/DoughnutChartCard.tsx | 15 - .../visits/charts/DoughnutChartLegend.scss | 29 - .../src/visits/charts/DoughnutChartLegend.tsx | 28 - .../src/visits/charts/HorizontalBarChart.tsx | 134 -- .../src/visits/charts/LineChartCard.scss | 9 - .../src/visits/charts/LineChartCard.tsx | 274 ---- .../visits/charts/SortableBarChartCard.tsx | 141 -- .../src/visits/helpers/MapModal.scss | 45 - .../src/visits/helpers/MapModal.tsx | 56 - .../src/visits/helpers/OpenMapModalBtn.scss | 4 - .../src/visits/helpers/OpenMapModalBtn.tsx | 55 - .../visits/helpers/VisitsFilterDropdown.tsx | 48 - .../src/visits/helpers/hooks.ts | 67 - .../src/visits/reducers/common.ts | 144 -- .../src/visits/reducers/domainVisits.ts | 59 - .../src/visits/reducers/nonOrphanVisits.ts | 41 - .../src/visits/reducers/orphanVisits.ts | 53 - .../src/visits/reducers/shortUrlVisits.ts | 62 - .../src/visits/reducers/tagVisits.ts | 53 - .../src/visits/reducers/types/index.ts | 25 - .../src/visits/reducers/visitCreation.ts | 12 - .../src/visits/reducers/visitsOverview.ts | 99 -- .../src/visits/services/VisitsParser.ts | 107 -- .../src/visits/services/provideServices.ts | 93 -- .../src/visits/types/helpers.ts | 37 - .../src/visits/types/index.ts | 89 -- .../src/visits/utils/index.ts | 41 - shlink-web-component/test/Main.test.tsx | 72 - .../test/ShlinkWebComponent.test.tsx | 54 - .../test/__helpers__/TestModalWrapper.tsx | 14 - .../test/__helpers__/setUpTest.ts | 20 - .../test/__mocks__/Window.mock.ts | 18 - .../test/common/AsideMenu.test.tsx | 21 - .../test/common/ShlinkApiError.test.tsx | 31 - .../test/domains/DomainRow.test.tsx | 78 -- .../test/domains/DomainSelector.test.tsx | 66 - .../test/domains/ManageDomains.test.tsx | 67 - .../domains/helpers/DomainDropdown.test.tsx | 67 - .../domains/helpers/DomainStatusIcon.test.tsx | 31 - .../helpers/EditDomainRedirectsModal.test.tsx | 86 -- .../DomainStatusIcon.test.tsx.snap | 89 -- .../domains/reducers/domainRedirects.test.ts | 27 - .../test/domains/reducers/domainsList.test.ts | 147 -- .../test/mercure/helpers/index.test.tsx | 60 - .../test/mercure/reducers/mercureInfo.test.ts | 67 - .../test/overview/Overview.test.tsx | 106 -- .../overview/helpers/HighlightCard.test.tsx | 50 - .../helpers/VisitsHighlightCard.test.tsx | 70 - .../test/short-urls/CreateShortUrl.test.tsx | 30 - .../test/short-urls/EditShortUrl.test.tsx | 60 - .../test/short-urls/Paginator.test.tsx | 59 - .../test/short-urls/ShortUrlForm.test.tsx | 134 -- .../short-urls/ShortUrlsFilteringBar.test.tsx | 155 --- .../test/short-urls/ShortUrlsList.test.tsx | 121 -- .../test/short-urls/ShortUrlsTable.test.tsx | 63 - .../UseExistingIfFoundInfoIcon.test.tsx | 13 - .../helpers/CreateShortUrlResult.test.tsx | 42 - .../helpers/DeleteShortUrlModal.test.tsx | 89 -- .../helpers/ExportShortUrlsBtn.test.tsx | 64 - .../short-urls/helpers/QrCodeModal.test.tsx | 77 -- .../helpers/ShortUrlDetailLink.test.tsx | 65 - .../ShortUrlFormCheckboxGroup.test.tsx | 17 - .../helpers/ShortUrlStatus.test.tsx | 48 - .../helpers/ShortUrlVisitsCount.test.tsx | 56 - .../helpers/ShortUrlsFilterDropdown.test.tsx | 21 - .../short-urls/helpers/ShortUrlsRow.test.tsx | 163 --- .../helpers/ShortUrlsRowMenu.test.tsx | 33 - .../test/short-urls/helpers/Tags.test.tsx | 22 - .../__snapshots__/ShortUrlsRow.test.tsx.snap | 145 -- .../test/short-urls/helpers/index.test.ts | 48 - .../QrErrorCorrectionDropdown.test.tsx | 48 - .../qr-codes/QrFormatDropdown.test.tsx | 38 - .../reducers/shortUrlCreation.test.ts | 62 - .../reducers/shortUrlDeletion.test.ts | 75 - .../reducers/shortUrlDetail.test.ts | 89 -- .../reducers/shortUrlEdition.test.ts | 57 - .../short-urls/reducers/shortUrlsList.test.ts | 201 --- .../test/tags/TagsList.test.tsx | 100 -- .../test/tags/TagsTable.test.tsx | 95 -- .../test/tags/TagsTableRow.test.tsx | 72 - .../helpers/DeleteTagConfirmModal.test.tsx | 66 - .../test/tags/helpers/EditTagModal.test.tsx | 83 -- .../test/tags/helpers/Tag.test.tsx | 98 -- .../test/tags/helpers/TagsSelector.test.tsx | 136 -- .../test/tags/reducers/tagDelete.test.ts | 58 - .../test/tags/reducers/tagEdit.test.ts | 71 - .../test/tags/reducers/tagsList.test.ts | 233 ---- .../components/CopyToClipboardIcon.test.tsx | 24 - .../test/utils/components/ExportBtn.test.tsx | 36 - .../test/utils/components/IconInput.test.tsx | 24 - .../utils/components/InfoTooltip.test.tsx | 51 - .../components/PaginationDropdown.test.tsx | 33 - .../utils/components/SimplePaginator.test.tsx | 47 - .../CopyToClipboardIcon.test.tsx.snap | 21 - .../__snapshots__/ExportBtn.test.tsx.snap | 19 - .../__snapshots__/IconInput.test.tsx.snap | 85 -- .../test/utils/dates/DateInput.test.tsx | 45 - .../dates/DateIntervalDropdownItems.test.tsx | 56 - .../test/utils/dates/DateRangeRow.test.tsx | 32 - .../utils/dates/DateRangeSelector.test.tsx | 68 - .../test/utils/dates/Time.test.tsx | 23 - .../test/utils/dates/helpers/date.test.ts | 49 - .../utils/dates/helpers/dateIntervals.test.ts | 139 -- .../test/utils/helpers/index.test.ts | 65 - .../test/utils/helpers/numbers.test.ts | 20 - .../test/utils/helpers/qrCodes.test.ts | 36 - .../utils/services/ColorGenerator.test.ts | 62 - .../utils/services/ImageDownloader.test.ts | 21 - .../utils/services/ReportExporter.test.ts | 70 - .../services/__mocks__/ColorGenerator.mock.ts | 8 - .../test/utils/table/TableOrderIcon.test.tsx | 37 - .../TableOrderIcon.test.tsx.snap | 37 - .../test/visits/DomainVisits.test.tsx | 51 - .../test/visits/NonOrphanVisits.test.tsx | 46 - .../test/visits/OrphanVisits.test.tsx | 45 - .../test/visits/ShortUrlVisits.test.tsx | 50 - .../test/visits/ShortUrlVisitsHeader.test.tsx | 51 - .../test/visits/TagVisits.test.tsx | 55 - .../test/visits/TagVisitsHeader.test.tsx | 27 - .../test/visits/VisitsHeader.test.tsx | 21 - .../test/visits/VisitsStats.test.tsx | 106 -- .../test/visits/VisitsTable.test.tsx | 142 -- .../test/visits/charts/ChartCard.test.tsx | 22 - .../test/visits/charts/DoughnutChart.test.tsx | 24 - .../charts/DoughnutChartLegend.test.tsx | 36 - .../visits/charts/HorizontalBarChart.test.tsx | 18 - .../test/visits/charts/LineChartCard.test.tsx | 68 - .../charts/SortableBarChartCard.test.tsx | 77 -- .../__snapshots__/DoughnutChart.test.tsx.snap | 25 - .../HorizontalBarChart.test.tsx.snap | 521 ------- .../__snapshots__/LineChartCard.test.tsx.snap | 461 ------ .../SortableBarChartCard.test.tsx.snap | 1231 ----------------- .../test/visits/helpers/MapModal.test.tsx | 28 - .../visits/helpers/OpenMapModalBtn.test.tsx | 55 - .../helpers/VisitsFilterDropdown.test.tsx | 69 - .../__snapshots__/MapModal.test.tsx.snap | 184 --- .../OpenMapModalBtn.test.tsx.snap | 353 ----- .../test/visits/reducers/domainVisits.test.ts | 216 --- .../visits/reducers/nonOrphanVisits.test.ts | 183 --- .../test/visits/reducers/orphanVisits.test.ts | 183 --- .../visits/reducers/shortUrlVisits.test.ts | 231 ---- .../test/visits/reducers/tagVisits.test.ts | 208 --- .../visits/reducers/visitCreation.test.ts | 16 - .../visits/reducers/visitsOverview.test.ts | 164 --- .../test/visits/services/VisitsParser.test.ts | 280 ---- .../test/visits/types/helpers.test.ts | 99 -- src/index.scss | 2 +- test/api/services/ShlinkApiClient.test.ts | 15 +- test/settings/ShortUrlsListSettings.test.tsx | 9 +- tsconfig.json | 9 +- vite.config.ts | 18 +- 310 files changed, 94 insertions(+), 20244 deletions(-) delete mode 100644 shlink-web-component/src/Main.scss delete mode 100644 shlink-web-component/src/Main.tsx delete mode 100644 shlink-web-component/src/ShlinkWebComponent.tsx delete mode 100644 shlink-web-component/src/api-contract/ShlinkApiClient.ts delete mode 100644 shlink-web-component/src/api-contract/errors.ts delete mode 100644 shlink-web-component/src/api-contract/index.ts delete mode 100644 shlink-web-component/src/api-contract/types.ts delete mode 100644 shlink-web-component/src/api-contract/utils.ts delete mode 100644 shlink-web-component/src/common/AsideMenu.scss delete mode 100644 shlink-web-component/src/common/AsideMenu.tsx delete mode 100644 shlink-web-component/src/common/ShlinkApiError.tsx delete mode 100644 shlink-web-component/src/container/index.ts delete mode 100644 shlink-web-component/src/container/provideServices.ts delete mode 100644 shlink-web-component/src/container/store.ts delete mode 100644 shlink-web-component/src/domains/DomainRow.tsx delete mode 100644 shlink-web-component/src/domains/DomainSelector.scss delete mode 100644 shlink-web-component/src/domains/DomainSelector.tsx delete mode 100644 shlink-web-component/src/domains/ManageDomains.tsx delete mode 100644 shlink-web-component/src/domains/data/index.ts delete mode 100644 shlink-web-component/src/domains/helpers/DomainDropdown.tsx delete mode 100644 shlink-web-component/src/domains/helpers/DomainStatusIcon.tsx delete mode 100644 shlink-web-component/src/domains/helpers/EditDomainRedirectsModal.tsx delete mode 100644 shlink-web-component/src/domains/reducers/domainRedirects.ts delete mode 100644 shlink-web-component/src/domains/reducers/domainsList.ts delete mode 100644 shlink-web-component/src/domains/services/provideServices.ts delete mode 100644 shlink-web-component/src/index.scss delete mode 100644 shlink-web-component/src/index.ts delete mode 100644 shlink-web-component/src/mercure/helpers/Topics.ts delete mode 100644 shlink-web-component/src/mercure/helpers/boundToMercureHub.tsx delete mode 100644 shlink-web-component/src/mercure/helpers/index.ts delete mode 100644 shlink-web-component/src/mercure/reducers/mercureInfo.ts delete mode 100644 shlink-web-component/src/mercure/services/provideServices.ts delete mode 100644 shlink-web-component/src/overview/Overview.tsx delete mode 100644 shlink-web-component/src/overview/helpers/HighlightCard.scss delete mode 100644 shlink-web-component/src/overview/helpers/HighlightCard.tsx delete mode 100644 shlink-web-component/src/overview/helpers/VisitsHighlightCard.tsx delete mode 100644 shlink-web-component/src/overview/services/provideServices.ts delete mode 100644 shlink-web-component/src/short-urls/CreateShortUrl.tsx delete mode 100644 shlink-web-component/src/short-urls/EditShortUrl.tsx delete mode 100644 shlink-web-component/src/short-urls/Paginator.tsx delete mode 100644 shlink-web-component/src/short-urls/ShortUrlForm.scss delete mode 100644 shlink-web-component/src/short-urls/ShortUrlForm.tsx delete mode 100644 shlink-web-component/src/short-urls/ShortUrlsFilteringBar.scss delete mode 100644 shlink-web-component/src/short-urls/ShortUrlsFilteringBar.tsx delete mode 100644 shlink-web-component/src/short-urls/ShortUrlsList.tsx delete mode 100644 shlink-web-component/src/short-urls/ShortUrlsTable.scss delete mode 100644 shlink-web-component/src/short-urls/ShortUrlsTable.tsx delete mode 100644 shlink-web-component/src/short-urls/UseExistingIfFoundInfoIcon.scss delete mode 100644 shlink-web-component/src/short-urls/UseExistingIfFoundInfoIcon.tsx delete mode 100644 shlink-web-component/src/short-urls/data/index.ts delete mode 100644 shlink-web-component/src/short-urls/helpers/CreateShortUrlResult.scss delete mode 100644 shlink-web-component/src/short-urls/helpers/CreateShortUrlResult.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/DeleteShortUrlModal.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/ExportShortUrlsBtn.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/QrCodeModal.scss delete mode 100644 shlink-web-component/src/short-urls/helpers/QrCodeModal.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/ShortUrlDetailLink.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/ShortUrlStatus.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.scss delete mode 100644 shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/ShortUrlsRow.scss delete mode 100644 shlink-web-component/src/short-urls/helpers/ShortUrlsRow.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/ShortUrlsRowMenu.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/Tags.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/hooks.ts delete mode 100644 shlink-web-component/src/short-urls/helpers/index.ts delete mode 100644 shlink-web-component/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx delete mode 100644 shlink-web-component/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx delete mode 100644 shlink-web-component/src/short-urls/reducers/shortUrlCreation.ts delete mode 100644 shlink-web-component/src/short-urls/reducers/shortUrlDeletion.ts delete mode 100644 shlink-web-component/src/short-urls/reducers/shortUrlDetail.ts delete mode 100644 shlink-web-component/src/short-urls/reducers/shortUrlEdition.ts delete mode 100644 shlink-web-component/src/short-urls/reducers/shortUrlsList.ts delete mode 100644 shlink-web-component/src/short-urls/services/provideServices.ts delete mode 100644 shlink-web-component/src/tags/TagsList.tsx delete mode 100644 shlink-web-component/src/tags/TagsTable.scss delete mode 100644 shlink-web-component/src/tags/TagsTable.tsx delete mode 100644 shlink-web-component/src/tags/TagsTableRow.tsx delete mode 100644 shlink-web-component/src/tags/data/TagsListChildrenProps.ts delete mode 100644 shlink-web-component/src/tags/data/index.ts delete mode 100644 shlink-web-component/src/tags/helpers/DeleteTagConfirmModal.tsx delete mode 100644 shlink-web-component/src/tags/helpers/EditTagModal.scss delete mode 100644 shlink-web-component/src/tags/helpers/EditTagModal.tsx delete mode 100644 shlink-web-component/src/tags/helpers/Tag.scss delete mode 100644 shlink-web-component/src/tags/helpers/Tag.tsx delete mode 100644 shlink-web-component/src/tags/helpers/TagBullet.scss delete mode 100644 shlink-web-component/src/tags/helpers/TagBullet.tsx delete mode 100644 shlink-web-component/src/tags/helpers/TagsSelector.tsx delete mode 100644 shlink-web-component/src/tags/helpers/index.ts delete mode 100644 shlink-web-component/src/tags/react-tag-autocomplete.scss delete mode 100644 shlink-web-component/src/tags/reducers/tagDelete.ts delete mode 100644 shlink-web-component/src/tags/reducers/tagEdit.ts delete mode 100644 shlink-web-component/src/tags/reducers/tagsList.ts delete mode 100644 shlink-web-component/src/tags/services/provideServices.ts delete mode 100644 shlink-web-component/src/utils/StickyCardPaginator.scss delete mode 100644 shlink-web-component/src/utils/components/CopyToClipboardIcon.scss delete mode 100644 shlink-web-component/src/utils/components/CopyToClipboardIcon.tsx delete mode 100644 shlink-web-component/src/utils/components/ExportBtn.tsx delete mode 100644 shlink-web-component/src/utils/components/IconInput.scss delete mode 100644 shlink-web-component/src/utils/components/IconInput.tsx delete mode 100644 shlink-web-component/src/utils/components/InfoTooltip.tsx delete mode 100644 shlink-web-component/src/utils/components/PaginationDropdown.tsx delete mode 100644 shlink-web-component/src/utils/components/SimplePaginator.scss delete mode 100644 shlink-web-component/src/utils/components/SimplePaginator.tsx delete mode 100644 shlink-web-component/src/utils/dates/DateInput.scss delete mode 100644 shlink-web-component/src/utils/dates/DateInput.tsx delete mode 100644 shlink-web-component/src/utils/dates/DateIntervalDropdownItems.tsx delete mode 100644 shlink-web-component/src/utils/dates/DateIntervalSelector.tsx delete mode 100644 shlink-web-component/src/utils/dates/DateRangeRow.tsx delete mode 100644 shlink-web-component/src/utils/dates/DateRangeSelector.tsx delete mode 100644 shlink-web-component/src/utils/dates/DateTimeInput.tsx delete mode 100644 shlink-web-component/src/utils/dates/Time.tsx delete mode 100644 shlink-web-component/src/utils/dates/helpers/date.ts delete mode 100644 shlink-web-component/src/utils/dates/helpers/dateIntervals.ts delete mode 100644 shlink-web-component/src/utils/features.ts delete mode 100644 shlink-web-component/src/utils/helpers/charts.ts delete mode 100644 shlink-web-component/src/utils/helpers/files.ts delete mode 100644 shlink-web-component/src/utils/helpers/hooks.ts delete mode 100644 shlink-web-component/src/utils/helpers/index.ts delete mode 100644 shlink-web-component/src/utils/helpers/json.ts delete mode 100644 shlink-web-component/src/utils/helpers/numbers.ts delete mode 100644 shlink-web-component/src/utils/helpers/pagination.ts delete mode 100644 shlink-web-component/src/utils/helpers/qrCodes.ts delete mode 100644 shlink-web-component/src/utils/helpers/version.ts delete mode 100644 shlink-web-component/src/utils/mixins/fit-with-margin.scss delete mode 100644 shlink-web-component/src/utils/mixins/sticky-cell.scss delete mode 100644 shlink-web-component/src/utils/mixins/vertical-align.scss delete mode 100644 shlink-web-component/src/utils/redux.ts delete mode 100644 shlink-web-component/src/utils/routesPrefix.ts delete mode 100644 shlink-web-component/src/utils/services/ColorGenerator.ts delete mode 100644 shlink-web-component/src/utils/services/ImageDownloader.ts delete mode 100644 shlink-web-component/src/utils/services/LocalStorage.ts delete mode 100644 shlink-web-component/src/utils/services/ReportExporter.ts delete mode 100644 shlink-web-component/src/utils/services/TagColorsStorage.ts delete mode 100644 shlink-web-component/src/utils/services/provideServices.ts delete mode 100644 shlink-web-component/src/utils/settings.ts delete mode 100644 shlink-web-component/src/utils/table/TableOrderIcon.tsx delete mode 100644 shlink-web-component/src/utils/types/index.ts delete mode 100644 shlink-web-component/src/visits/DomainVisits.tsx delete mode 100644 shlink-web-component/src/visits/NonOrphanVisits.tsx delete mode 100644 shlink-web-component/src/visits/OrphanVisits.tsx delete mode 100644 shlink-web-component/src/visits/ShortUrlVisits.tsx delete mode 100644 shlink-web-component/src/visits/ShortUrlVisitsHeader.scss delete mode 100644 shlink-web-component/src/visits/ShortUrlVisitsHeader.tsx delete mode 100644 shlink-web-component/src/visits/TagVisits.tsx delete mode 100644 shlink-web-component/src/visits/TagVisitsHeader.tsx delete mode 100644 shlink-web-component/src/visits/VisitsHeader.tsx delete mode 100644 shlink-web-component/src/visits/VisitsStats.tsx delete mode 100644 shlink-web-component/src/visits/VisitsTable.scss delete mode 100644 shlink-web-component/src/visits/VisitsTable.tsx delete mode 100644 shlink-web-component/src/visits/charts/ChartCard.scss delete mode 100644 shlink-web-component/src/visits/charts/ChartCard.tsx delete mode 100644 shlink-web-component/src/visits/charts/DoughnutChart.tsx delete mode 100644 shlink-web-component/src/visits/charts/DoughnutChartCard.tsx delete mode 100644 shlink-web-component/src/visits/charts/DoughnutChartLegend.scss delete mode 100644 shlink-web-component/src/visits/charts/DoughnutChartLegend.tsx delete mode 100644 shlink-web-component/src/visits/charts/HorizontalBarChart.tsx delete mode 100644 shlink-web-component/src/visits/charts/LineChartCard.scss delete mode 100644 shlink-web-component/src/visits/charts/LineChartCard.tsx delete mode 100644 shlink-web-component/src/visits/charts/SortableBarChartCard.tsx delete mode 100644 shlink-web-component/src/visits/helpers/MapModal.scss delete mode 100644 shlink-web-component/src/visits/helpers/MapModal.tsx delete mode 100644 shlink-web-component/src/visits/helpers/OpenMapModalBtn.scss delete mode 100644 shlink-web-component/src/visits/helpers/OpenMapModalBtn.tsx delete mode 100644 shlink-web-component/src/visits/helpers/VisitsFilterDropdown.tsx delete mode 100644 shlink-web-component/src/visits/helpers/hooks.ts delete mode 100644 shlink-web-component/src/visits/reducers/common.ts delete mode 100644 shlink-web-component/src/visits/reducers/domainVisits.ts delete mode 100644 shlink-web-component/src/visits/reducers/nonOrphanVisits.ts delete mode 100644 shlink-web-component/src/visits/reducers/orphanVisits.ts delete mode 100644 shlink-web-component/src/visits/reducers/shortUrlVisits.ts delete mode 100644 shlink-web-component/src/visits/reducers/tagVisits.ts delete mode 100644 shlink-web-component/src/visits/reducers/types/index.ts delete mode 100644 shlink-web-component/src/visits/reducers/visitCreation.ts delete mode 100644 shlink-web-component/src/visits/reducers/visitsOverview.ts delete mode 100644 shlink-web-component/src/visits/services/VisitsParser.ts delete mode 100644 shlink-web-component/src/visits/services/provideServices.ts delete mode 100644 shlink-web-component/src/visits/types/helpers.ts delete mode 100644 shlink-web-component/src/visits/types/index.ts delete mode 100644 shlink-web-component/src/visits/utils/index.ts delete mode 100644 shlink-web-component/test/Main.test.tsx delete mode 100644 shlink-web-component/test/ShlinkWebComponent.test.tsx delete mode 100644 shlink-web-component/test/__helpers__/TestModalWrapper.tsx delete mode 100644 shlink-web-component/test/__helpers__/setUpTest.ts delete mode 100644 shlink-web-component/test/__mocks__/Window.mock.ts delete mode 100644 shlink-web-component/test/common/AsideMenu.test.tsx delete mode 100644 shlink-web-component/test/common/ShlinkApiError.test.tsx delete mode 100644 shlink-web-component/test/domains/DomainRow.test.tsx delete mode 100644 shlink-web-component/test/domains/DomainSelector.test.tsx delete mode 100644 shlink-web-component/test/domains/ManageDomains.test.tsx delete mode 100644 shlink-web-component/test/domains/helpers/DomainDropdown.test.tsx delete mode 100644 shlink-web-component/test/domains/helpers/DomainStatusIcon.test.tsx delete mode 100644 shlink-web-component/test/domains/helpers/EditDomainRedirectsModal.test.tsx delete mode 100644 shlink-web-component/test/domains/helpers/__snapshots__/DomainStatusIcon.test.tsx.snap delete mode 100644 shlink-web-component/test/domains/reducers/domainRedirects.test.ts delete mode 100644 shlink-web-component/test/domains/reducers/domainsList.test.ts delete mode 100644 shlink-web-component/test/mercure/helpers/index.test.tsx delete mode 100644 shlink-web-component/test/mercure/reducers/mercureInfo.test.ts delete mode 100644 shlink-web-component/test/overview/Overview.test.tsx delete mode 100644 shlink-web-component/test/overview/helpers/HighlightCard.test.tsx delete mode 100644 shlink-web-component/test/overview/helpers/VisitsHighlightCard.test.tsx delete mode 100644 shlink-web-component/test/short-urls/CreateShortUrl.test.tsx delete mode 100644 shlink-web-component/test/short-urls/EditShortUrl.test.tsx delete mode 100644 shlink-web-component/test/short-urls/Paginator.test.tsx delete mode 100644 shlink-web-component/test/short-urls/ShortUrlForm.test.tsx delete mode 100644 shlink-web-component/test/short-urls/ShortUrlsFilteringBar.test.tsx delete mode 100644 shlink-web-component/test/short-urls/ShortUrlsList.test.tsx delete mode 100644 shlink-web-component/test/short-urls/ShortUrlsTable.test.tsx delete mode 100644 shlink-web-component/test/short-urls/UseExistingIfFoundInfoIcon.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/CreateShortUrlResult.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/DeleteShortUrlModal.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/QrCodeModal.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/ShortUrlDetailLink.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/ShortUrlStatus.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/ShortUrlVisitsCount.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/ShortUrlsRow.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/Tags.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap delete mode 100644 shlink-web-component/test/short-urls/helpers/index.test.ts delete mode 100644 shlink-web-component/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx delete mode 100644 shlink-web-component/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx delete mode 100644 shlink-web-component/test/short-urls/reducers/shortUrlCreation.test.ts delete mode 100644 shlink-web-component/test/short-urls/reducers/shortUrlDeletion.test.ts delete mode 100644 shlink-web-component/test/short-urls/reducers/shortUrlDetail.test.ts delete mode 100644 shlink-web-component/test/short-urls/reducers/shortUrlEdition.test.ts delete mode 100644 shlink-web-component/test/short-urls/reducers/shortUrlsList.test.ts delete mode 100644 shlink-web-component/test/tags/TagsList.test.tsx delete mode 100644 shlink-web-component/test/tags/TagsTable.test.tsx delete mode 100644 shlink-web-component/test/tags/TagsTableRow.test.tsx delete mode 100644 shlink-web-component/test/tags/helpers/DeleteTagConfirmModal.test.tsx delete mode 100644 shlink-web-component/test/tags/helpers/EditTagModal.test.tsx delete mode 100644 shlink-web-component/test/tags/helpers/Tag.test.tsx delete mode 100644 shlink-web-component/test/tags/helpers/TagsSelector.test.tsx delete mode 100644 shlink-web-component/test/tags/reducers/tagDelete.test.ts delete mode 100644 shlink-web-component/test/tags/reducers/tagEdit.test.ts delete mode 100644 shlink-web-component/test/tags/reducers/tagsList.test.ts delete mode 100644 shlink-web-component/test/utils/components/CopyToClipboardIcon.test.tsx delete mode 100644 shlink-web-component/test/utils/components/ExportBtn.test.tsx delete mode 100644 shlink-web-component/test/utils/components/IconInput.test.tsx delete mode 100644 shlink-web-component/test/utils/components/InfoTooltip.test.tsx delete mode 100644 shlink-web-component/test/utils/components/PaginationDropdown.test.tsx delete mode 100644 shlink-web-component/test/utils/components/SimplePaginator.test.tsx delete mode 100644 shlink-web-component/test/utils/components/__snapshots__/CopyToClipboardIcon.test.tsx.snap delete mode 100644 shlink-web-component/test/utils/components/__snapshots__/ExportBtn.test.tsx.snap delete mode 100644 shlink-web-component/test/utils/components/__snapshots__/IconInput.test.tsx.snap delete mode 100644 shlink-web-component/test/utils/dates/DateInput.test.tsx delete mode 100644 shlink-web-component/test/utils/dates/DateIntervalDropdownItems.test.tsx delete mode 100644 shlink-web-component/test/utils/dates/DateRangeRow.test.tsx delete mode 100644 shlink-web-component/test/utils/dates/DateRangeSelector.test.tsx delete mode 100644 shlink-web-component/test/utils/dates/Time.test.tsx delete mode 100644 shlink-web-component/test/utils/dates/helpers/date.test.ts delete mode 100644 shlink-web-component/test/utils/dates/helpers/dateIntervals.test.ts delete mode 100644 shlink-web-component/test/utils/helpers/index.test.ts delete mode 100644 shlink-web-component/test/utils/helpers/numbers.test.ts delete mode 100644 shlink-web-component/test/utils/helpers/qrCodes.test.ts delete mode 100644 shlink-web-component/test/utils/services/ColorGenerator.test.ts delete mode 100644 shlink-web-component/test/utils/services/ImageDownloader.test.ts delete mode 100644 shlink-web-component/test/utils/services/ReportExporter.test.ts delete mode 100644 shlink-web-component/test/utils/services/__mocks__/ColorGenerator.mock.ts delete mode 100644 shlink-web-component/test/utils/table/TableOrderIcon.test.tsx delete mode 100644 shlink-web-component/test/utils/table/__snapshots__/TableOrderIcon.test.tsx.snap delete mode 100644 shlink-web-component/test/visits/DomainVisits.test.tsx delete mode 100644 shlink-web-component/test/visits/NonOrphanVisits.test.tsx delete mode 100644 shlink-web-component/test/visits/OrphanVisits.test.tsx delete mode 100644 shlink-web-component/test/visits/ShortUrlVisits.test.tsx delete mode 100644 shlink-web-component/test/visits/ShortUrlVisitsHeader.test.tsx delete mode 100644 shlink-web-component/test/visits/TagVisits.test.tsx delete mode 100644 shlink-web-component/test/visits/TagVisitsHeader.test.tsx delete mode 100644 shlink-web-component/test/visits/VisitsHeader.test.tsx delete mode 100644 shlink-web-component/test/visits/VisitsStats.test.tsx delete mode 100644 shlink-web-component/test/visits/VisitsTable.test.tsx delete mode 100644 shlink-web-component/test/visits/charts/ChartCard.test.tsx delete mode 100644 shlink-web-component/test/visits/charts/DoughnutChart.test.tsx delete mode 100644 shlink-web-component/test/visits/charts/DoughnutChartLegend.test.tsx delete mode 100644 shlink-web-component/test/visits/charts/HorizontalBarChart.test.tsx delete mode 100644 shlink-web-component/test/visits/charts/LineChartCard.test.tsx delete mode 100644 shlink-web-component/test/visits/charts/SortableBarChartCard.test.tsx delete mode 100644 shlink-web-component/test/visits/charts/__snapshots__/DoughnutChart.test.tsx.snap delete mode 100644 shlink-web-component/test/visits/charts/__snapshots__/HorizontalBarChart.test.tsx.snap delete mode 100644 shlink-web-component/test/visits/charts/__snapshots__/LineChartCard.test.tsx.snap delete mode 100644 shlink-web-component/test/visits/charts/__snapshots__/SortableBarChartCard.test.tsx.snap delete mode 100644 shlink-web-component/test/visits/helpers/MapModal.test.tsx delete mode 100644 shlink-web-component/test/visits/helpers/OpenMapModalBtn.test.tsx delete mode 100644 shlink-web-component/test/visits/helpers/VisitsFilterDropdown.test.tsx delete mode 100644 shlink-web-component/test/visits/helpers/__snapshots__/MapModal.test.tsx.snap delete mode 100644 shlink-web-component/test/visits/helpers/__snapshots__/OpenMapModalBtn.test.tsx.snap delete mode 100644 shlink-web-component/test/visits/reducers/domainVisits.test.ts delete mode 100644 shlink-web-component/test/visits/reducers/nonOrphanVisits.test.ts delete mode 100644 shlink-web-component/test/visits/reducers/orphanVisits.test.ts delete mode 100644 shlink-web-component/test/visits/reducers/shortUrlVisits.test.ts delete mode 100644 shlink-web-component/test/visits/reducers/tagVisits.test.ts delete mode 100644 shlink-web-component/test/visits/reducers/visitCreation.test.ts delete mode 100644 shlink-web-component/test/visits/reducers/visitsOverview.test.ts delete mode 100644 shlink-web-component/test/visits/services/VisitsParser.test.ts delete mode 100644 shlink-web-component/test/visits/types/helpers.test.ts diff --git a/config/test/setupTests.ts b/config/test/setupTests.ts index f8539125..821a215e 100644 --- a/config/test/setupTests.ts +++ b/config/test/setupTests.ts @@ -1,9 +1,6 @@ -import 'vitest-canvas-mock'; -import 'chart.js/auto'; import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'; import matchers from '@testing-library/jest-dom/matchers'; import { cleanup } from '@testing-library/react'; -import ResizeObserver from 'resize-observer-polyfill'; import { afterEach, expect } from 'vitest'; // Workaround for TypeScript error: https://github.com/testing-library/jest-dom/issues/439#issuecomment-1536524120 @@ -14,15 +11,10 @@ declare module 'vitest' { // Extends Vitest's expect method with methods from react-testing-library expect.extend(matchers); +// Clear all mocks and cleanup DOM after every test afterEach(() => { - // Clears all mocks after every test vi.clearAllMocks(); - // Run a cleanup after each test case (e.g. clearing jsdom) cleanup(); }); -(global as any).ResizeObserver = ResizeObserver; (global as any).scrollTo = () => {}; -(global as any).prompt = () => {}; -(global as any).matchMedia = (media: string) => ({ matches: false, media }); -(global as any).HTMLElement.prototype.scrollIntoView = () => {}; diff --git a/package-lock.json b/package-lock.json index 199e96bd..0279f0d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,30 +16,21 @@ "@json2csv/plainjs": "^7.0.1", "@reduxjs/toolkit": "^1.9.5", "@shlinkio/shlink-frontend-kit": "^0.2.0", + "@shlinkio/shlink-web-component": "^0.1.0", "bootstrap": "5.2.3", "bottlejs": "^2.0.1", - "bowser": "^2.11.0", "chart.js": "^4.3.3", "classnames": "^2.3.2", "compare-versions": "^6.1.0", "csvtojson": "^2.0.10", "date-fns": "^2.30.0", - "event-source-polyfill": "^1.0.31", - "history": "^5.3.0", "leaflet": "^1.9.4", "ramda": "^0.27.2", "react": "^18.2.0", - "react-chartjs-2": "^5.2.0", - "react-colorful": "^5.6.1", - "react-copy-to-clipboard": "^5.1.0", - "react-datepicker": "^4.16.0", "react-dom": "^18.2.0", "react-external-link": "^2.2.0", - "react-leaflet": "^4.2.1", "react-redux": "^8.1.2", "react-router-dom": "^6.14.2", - "react-swipeable": "^7.0.1", - "react-tag-autocomplete": "^7.0.0", "reactstrap": "^9.2.0", "redux-localstorage-simple": "^2.5.1", "uuid": "^9.0.0", @@ -57,29 +48,23 @@ "@testing-library/user-event": "^14.4.3", "@total-typescript/shoehorn": "^0.1.1", "@types/leaflet": "^1.9.3", - "@types/qs": "^6.9.7", "@types/ramda": "^0.27.66", "@types/react": "^18.2.19", - "@types/react-color": "^3.0.6", - "@types/react-copy-to-clipboard": "^5.0.4", - "@types/react-datepicker": "^4.15.0", "@types/react-dom": "^18.2.7", - "@types/react-tag-autocomplete": "^6.3.0", "@types/uuid": "^9.0.2", "@vitejs/plugin-react": "^4.0.4", "@vitest/coverage-v8": "^0.34.1", "adm-zip": "^0.5.10", "chalk": "^5.3.0", "eslint": "^8.46.0", + "history": "^5.3.0", "jsdom": "^22.1.0", - "resize-observer-polyfill": "^1.5.1", "sass": "^1.64.2", "stylelint": "^15.10.2", "typescript": "^5.1.6", "vite": "^4.4.9", "vite-plugin-pwa": "^0.16.4", - "vitest": "^0.34.1", - "vitest-canvas-mock": "^0.3.2" + "vitest": "^0.34.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -3127,6 +3112,45 @@ "reactstrap": "^9.2.0" } }, + "node_modules/@shlinkio/shlink-web-component": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.1.0.tgz", + "integrity": "sha512-3YsvRKZrMKtSlBO0YRi+4MsHGR0Akn8Tl5Akd6yYa6v8NeLjpDDcy7sM+eI/YzXJ9bAHJUost3eJvWNc4cdezw==", + "dependencies": { + "@json2csv/plainjs": "^7.0.1", + "bottlejs": "^2.0.1", + "bowser": "^2.11.0", + "chart.js": "^4.3.3", + "classnames": "^2.3.2", + "compare-versions": "^6.1.0", + "date-fns": "^2.30.0", + "event-source-polyfill": "^1.0.31", + "leaflet": "^1.9.4", + "ramda": "^0.27.2", + "react-chartjs-2": "^5.2.0", + "react-colorful": "^5.6.1", + "react-copy-to-clipboard": "^5.1.0", + "react-datepicker": "^4.16.0", + "react-external-link": "^2.2.0", + "react-leaflet": "^4.2.1", + "react-swipeable": "^7.0.1", + "react-tag-autocomplete": "^7.0.0" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "^6.4.2", + "@fortawesome/free-brands-svg-icons": "^6.4.2", + "@fortawesome/free-regular-svg-icons": "^6.4.2", + "@fortawesome/free-solid-svg-icons": "^6.4.2", + "@fortawesome/react-fontawesome": "^0.2.0", + "@reduxjs/toolkit": "^1.9.5", + "@shlinkio/shlink-frontend-kit": "^0.2.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-redux": "^8.1.2", + "react-router-dom": "^6.14.2", + "reactstrap": "^9.2.0" + } + }, "node_modules/@shlinkio/stylelint-config-css-coding-standard": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@shlinkio/stylelint-config-css-coding-standard/-/stylelint-config-css-coding-standard-1.1.1.tgz", @@ -3531,11 +3555,6 @@ "version": "15.7.3", "license": "MIT" }, - "node_modules/@types/qs": { - "version": "6.9.7", - "dev": true, - "license": "MIT" - }, "node_modules/@types/ramda": { "version": "0.27.66", "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.66.tgz", @@ -3555,35 +3574,6 @@ "csstype": "^3.0.2" } }, - "node_modules/@types/react-color": { - "version": "3.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*", - "@types/reactcss": "*" - } - }, - "node_modules/@types/react-copy-to-clipboard": { - "version": "5.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/react-datepicker": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-4.15.0.tgz", - "integrity": "sha512-kr10s8ex4+MmCJmzdhA9kfmoMQBmsW5uDYDlH+8f/PgStrp7rRaz23Y/cvTiMgvESVq8ujDh4SOo6jlSwEw13g==", - "dev": true, - "dependencies": { - "@popperjs/core": "^2.9.2", - "@types/react": "*", - "date-fns": "^2.0.1", - "react-popper": "^2.2.5" - } - }, "node_modules/@types/react-dom": { "version": "18.2.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", @@ -3593,22 +3583,6 @@ "@types/react": "*" } }, - "node_modules/@types/react-tag-autocomplete": { - "version": "6.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/reactcss": { - "version": "1.2.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -4917,11 +4891,6 @@ "node": ">=4" } }, - "node_modules/cssfontparser": { - "version": "1.2.1", - "dev": true, - "license": "MIT" - }, "node_modules/cssstyle": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", @@ -6422,6 +6391,7 @@ }, "node_modules/history": { "version": "5.3.0", + "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.7.6" @@ -7075,15 +7045,6 @@ "node": ">=8" } }, - "node_modules/jest-canvas-mock": { - "version": "2.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "cssfontparser": "^1.2.1", - "moo-color": "^1.0.2" - } - }, "node_modules/jest-diff": { "version": "29.3.1", "dev": true, @@ -8065,14 +8026,6 @@ "ufo": "^1.1.2" } }, - "node_modules/moo-color": { - "version": "1.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "^1.1.4" - } - }, "node_modules/ms": { "version": "2.0.0", "dev": true, @@ -9146,11 +9099,6 @@ "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" }, - "node_modules/resize-observer-polyfill": { - "version": "1.5.1", - "dev": true, - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.1", "dev": true, @@ -10601,18 +10549,6 @@ } } }, - "node_modules/vitest-canvas-mock": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/vitest-canvas-mock/-/vitest-canvas-mock-0.3.2.tgz", - "integrity": "sha512-lds7MKxvFFPDCGLXsQI2ym1fxvC93DaS0Bb6sdjvylFyL6NYrAAcPb6xZGF2sMOt5fSLHddqAQaujqpbc3p0Zg==", - "dev": true, - "dependencies": { - "jest-canvas-mock": "~2.4.0" - }, - "peerDependencies": { - "vitest": "*" - } - }, "node_modules/vitest/node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -13333,6 +13269,31 @@ "uuid": "^9.0.0" } }, + "@shlinkio/shlink-web-component": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.1.0.tgz", + "integrity": "sha512-3YsvRKZrMKtSlBO0YRi+4MsHGR0Akn8Tl5Akd6yYa6v8NeLjpDDcy7sM+eI/YzXJ9bAHJUost3eJvWNc4cdezw==", + "requires": { + "@json2csv/plainjs": "^7.0.1", + "bottlejs": "^2.0.1", + "bowser": "^2.11.0", + "chart.js": "^4.3.3", + "classnames": "^2.3.2", + "compare-versions": "^6.1.0", + "date-fns": "^2.30.0", + "event-source-polyfill": "^1.0.31", + "leaflet": "^1.9.4", + "ramda": "^0.27.2", + "react-chartjs-2": "^5.2.0", + "react-colorful": "^5.6.1", + "react-copy-to-clipboard": "^5.1.0", + "react-datepicker": "^4.16.0", + "react-external-link": "^2.2.0", + "react-leaflet": "^4.2.1", + "react-swipeable": "^7.0.1", + "react-tag-autocomplete": "^7.0.0" + } + }, "@shlinkio/stylelint-config-css-coding-standard": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@shlinkio/stylelint-config-css-coding-standard/-/stylelint-config-css-coding-standard-1.1.1.tgz", @@ -13638,10 +13599,6 @@ "@types/prop-types": { "version": "15.7.3" }, - "@types/qs": { - "version": "6.9.7", - "dev": true - }, "@types/ramda": { "version": "0.27.66", "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.66.tgz", @@ -13661,33 +13618,6 @@ "csstype": "^3.0.2" } }, - "@types/react-color": { - "version": "3.0.6", - "dev": true, - "requires": { - "@types/react": "*", - "@types/reactcss": "*" - } - }, - "@types/react-copy-to-clipboard": { - "version": "5.0.4", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/react-datepicker": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-4.15.0.tgz", - "integrity": "sha512-kr10s8ex4+MmCJmzdhA9kfmoMQBmsW5uDYDlH+8f/PgStrp7rRaz23Y/cvTiMgvESVq8ujDh4SOo6jlSwEw13g==", - "dev": true, - "requires": { - "@popperjs/core": "^2.9.2", - "@types/react": "*", - "date-fns": "^2.0.1", - "react-popper": "^2.2.5" - } - }, "@types/react-dom": { "version": "18.2.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", @@ -13697,20 +13627,6 @@ "@types/react": "*" } }, - "@types/react-tag-autocomplete": { - "version": "6.3.0", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/reactcss": { - "version": "1.2.3", - "dev": true, - "requires": { - "@types/react": "*" - } - }, "@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -14571,10 +14487,6 @@ "version": "3.0.0", "dev": true }, - "cssfontparser": { - "version": "1.2.1", - "dev": true - }, "cssstyle": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", @@ -15599,6 +15511,7 @@ }, "history": { "version": "5.3.0", + "dev": true, "requires": { "@babel/runtime": "^7.7.6" } @@ -16014,14 +15927,6 @@ } } }, - "jest-canvas-mock": { - "version": "2.4.0", - "dev": true, - "requires": { - "cssfontparser": "^1.2.1", - "moo-color": "^1.0.2" - } - }, "jest-diff": { "version": "29.3.1", "dev": true, @@ -16697,13 +16602,6 @@ "ufo": "^1.1.2" } }, - "moo-color": { - "version": "1.0.3", - "dev": true, - "requires": { - "color-name": "^1.1.4" - } - }, "ms": { "version": "2.0.0", "dev": true, @@ -17386,10 +17284,6 @@ "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" }, - "resize-observer-polyfill": { - "version": "1.5.1", - "dev": true - }, "resolve": { "version": "1.22.1", "dev": true, @@ -18358,15 +18252,6 @@ } } }, - "vitest-canvas-mock": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/vitest-canvas-mock/-/vitest-canvas-mock-0.3.2.tgz", - "integrity": "sha512-lds7MKxvFFPDCGLXsQI2ym1fxvC93DaS0Bb6sdjvylFyL6NYrAAcPb6xZGF2sMOt5fSLHddqAQaujqpbc3p0Zg==", - "dev": true, - "requires": { - "jest-canvas-mock": "~2.4.0" - } - }, "w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/package.json b/package.json index 15f5cc67..32608760 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "license": "MIT", "scripts": { "lint": "npm run lint:css && npm run lint:js", - "lint:css": "stylelint src/*.scss src/**/*.scss shlink-web-component/*.scss shlink-web-component/**/*.scss", - "lint:js": "eslint --ext .js,.ts,.tsx src shlink-web-component test", + "lint:css": "stylelint src/*.scss src/**/*.scss", + "lint:js": "eslint --ext .js,.ts,.tsx src test", "lint:fix": "npm run lint:css:fix && npm run lint:js:fix", "lint:css:fix": "npm run lint:css -- --fix", "lint:js:fix": "npm run lint:js -- --fix", @@ -32,30 +32,21 @@ "@json2csv/plainjs": "^7.0.1", "@reduxjs/toolkit": "^1.9.5", "@shlinkio/shlink-frontend-kit": "^0.2.0", + "@shlinkio/shlink-web-component": "^0.1.0", "bootstrap": "5.2.3", "bottlejs": "^2.0.1", - "bowser": "^2.11.0", "chart.js": "^4.3.3", "classnames": "^2.3.2", "compare-versions": "^6.1.0", "csvtojson": "^2.0.10", "date-fns": "^2.30.0", - "event-source-polyfill": "^1.0.31", - "history": "^5.3.0", "leaflet": "^1.9.4", "ramda": "^0.27.2", "react": "^18.2.0", - "react-chartjs-2": "^5.2.0", - "react-colorful": "^5.6.1", - "react-copy-to-clipboard": "^5.1.0", - "react-datepicker": "^4.16.0", "react-dom": "^18.2.0", "react-external-link": "^2.2.0", - "react-leaflet": "^4.2.1", "react-redux": "^8.1.2", "react-router-dom": "^6.14.2", - "react-swipeable": "^7.0.1", - "react-tag-autocomplete": "^7.0.0", "reactstrap": "^9.2.0", "redux-localstorage-simple": "^2.5.1", "uuid": "^9.0.0", @@ -73,29 +64,23 @@ "@testing-library/user-event": "^14.4.3", "@total-typescript/shoehorn": "^0.1.1", "@types/leaflet": "^1.9.3", - "@types/qs": "^6.9.7", "@types/ramda": "^0.27.66", "@types/react": "^18.2.19", - "@types/react-color": "^3.0.6", - "@types/react-copy-to-clipboard": "^5.0.4", - "@types/react-datepicker": "^4.15.0", "@types/react-dom": "^18.2.7", - "@types/react-tag-autocomplete": "^6.3.0", "@types/uuid": "^9.0.2", "@vitejs/plugin-react": "^4.0.4", "@vitest/coverage-v8": "^0.34.1", "adm-zip": "^0.5.10", "chalk": "^5.3.0", "eslint": "^8.46.0", + "history": "^5.3.0", "jsdom": "^22.1.0", - "resize-observer-polyfill": "^1.5.1", "sass": "^1.64.2", "stylelint": "^15.10.2", "typescript": "^5.1.6", "vite": "^4.4.9", "vite-plugin-pwa": "^0.16.4", - "vitest": "^0.34.1", - "vitest-canvas-mock": "^0.3.2" + "vitest": "^0.34.1" }, "browserslist": [ ">0.2%", diff --git a/shlink-web-client.d.ts b/shlink-web-client.d.ts index bb455b3c..6d8ad4a0 100644 --- a/shlink-web-client.d.ts +++ b/shlink-web-client.d.ts @@ -1,13 +1,3 @@ -// eslint-disable-next-line max-classes-per-file -declare module 'event-source-polyfill' { - declare class EventSourcePolyfill { - public onmessage?: ({ data }: { data: string }) => void; - public onerror?: ({ status }: { status: number }) => void; - public close: () => void; - public constructor(hubUrl: URL, options?: any); - } -} - declare module '@json2csv/plainjs' { export class Parser { parse: (data: T[]) => string; diff --git a/shlink-web-component/src/Main.scss b/shlink-web-component/src/Main.scss deleted file mode 100644 index 3472e14e..00000000 --- a/shlink-web-component/src/Main.scss +++ /dev/null @@ -1,37 +0,0 @@ -@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; - -.shlink-layout__swipeable { - height: 100%; -} - -.shlink-layout__swipeable-inner { - height: 100%; -} - -.shlink-layout__burger-icon { - display: none; - transition: color 300ms; - position: fixed; - top: 18px; - z-index: 1035; - font-size: 1.5rem; - cursor: pointer; - color: rgb(255 255 255 / .5); - - @media (max-width: $smMax) { - display: inline-block; - } -} - -.shlink-layout__burger-icon--active { - color: white; -} - -.shlink-layout__container.shlink-layout__container { - padding: 20px 0 0; - min-height: 100%; - - @media (min-width: $mdMin) { - padding: 30px 0 0 $asideMenuWidth; - } -} diff --git a/shlink-web-component/src/Main.tsx b/shlink-web-component/src/Main.tsx deleted file mode 100644 index ca02c054..00000000 --- a/shlink-web-component/src/Main.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useToggle } from '@shlinkio/shlink-frontend-kit'; -import classNames from 'classnames'; -import type { FC, ReactNode } from 'react'; -import { Fragment, useEffect, useMemo } from 'react'; -import { BrowserRouter, Navigate, Route, Routes, useInRouterContext, useLocation } from 'react-router-dom'; -import { AsideMenu } from './common/AsideMenu'; -import { useFeature } from './utils/features'; -import { useSwipeable } from './utils/helpers/hooks'; -import { useRoutesPrefix } from './utils/routesPrefix'; -import './Main.scss'; - -export type MainProps = { - createNotFound?: (nonPrefixedHomePath: string) => ReactNode; -}; - -export const Main = ( - TagsList: FC, - ShortUrlsList: FC, - CreateShortUrl: FC, - ShortUrlVisits: FC, - TagVisits: FC, - DomainVisits: FC, - OrphanVisits: FC, - NonOrphanVisits: FC, - Overview: FC, - EditShortUrl: FC, - ManageDomains: FC, -): FC => ({ createNotFound }) => { - const location = useLocation(); - const routesPrefix = useRoutesPrefix(); - const inRouterContext = useInRouterContext(); - const [Wrapper, props] = useMemo(() => ( - inRouterContext - ? [Fragment, {}] - : [BrowserRouter, { basename: routesPrefix }] - ), [inRouterContext]); - - const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle(); - useEffect(() => hideSidebar(), [location]); - - const addDomainVisitsRoute = useFeature('domainVisits'); - const burgerClasses = classNames('shlink-layout__burger-icon', { 'shlink-layout__burger-icon--active': sidebarVisible }); - const swipeableProps = useSwipeable(showSidebar, hideSidebar); - - // FIXME Check if this works when not currently wrapped in a router - - return ( - - - -
-
- -
hideSidebar()}> -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {addDomainVisitsRoute && } />} - } /> - } /> - } /> - } /> - {createNotFound && } - -
-
-
-
-
- ); -}; diff --git a/shlink-web-component/src/ShlinkWebComponent.tsx b/shlink-web-component/src/ShlinkWebComponent.tsx deleted file mode 100644 index 0dc1c47d..00000000 --- a/shlink-web-component/src/ShlinkWebComponent.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import type { Store } from '@reduxjs/toolkit'; -import type Bottle from 'bottlejs'; -import type { FC, ReactNode } from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { Provider as ReduxStoreProvider } from 'react-redux'; -import type { ShlinkApiClient } from './api-contract'; -import { FeaturesProvider, useFeatures } from './utils/features'; -import type { SemVer } from './utils/helpers/version'; -import { RoutesPrefixProvider } from './utils/routesPrefix'; -import type { TagColorsStorage } from './utils/services/TagColorsStorage'; -import type { Settings } from './utils/settings'; -import { SettingsProvider } from './utils/settings'; - -type ShlinkWebComponentProps = { - serverVersion: SemVer; // FIXME Consider making this optional and trying to resolve it if not set - apiClient: ShlinkApiClient; - tagColorsStorage?: TagColorsStorage; - routesPrefix?: string; - settings?: Settings; - createNotFound?: (nonPrefixedHomePath: string) => ReactNode; -}; - -// FIXME This allows to track the reference to be resolved by the container, but it's hacky and relies on not more than -// one ShlinkWebComponent rendered at the same time. -// Works for now, but should be addressed. -let apiClientRef: ShlinkApiClient; - -export const createShlinkWebComponent = ( - bottle: Bottle, -): FC => ( - { serverVersion, apiClient, settings, routesPrefix = '', createNotFound, tagColorsStorage }, -) => { - const features = useFeatures(serverVersion); - const mainContent = useRef(); - const [theStore, setStore] = useState(); - - useEffect(() => { - apiClientRef = apiClient; - bottle.value('apiClientFactory', () => apiClientRef); - - if (tagColorsStorage) { - bottle.value('TagColorsStorage', tagColorsStorage); - } - - // It's important to not try to resolve services before the API client has been registered, as many other services - // depend on it - const { container } = bottle; - const { Main, store, loadMercureInfo } = container; - mainContent.current =
; - setStore(store); - - // Load mercure info - store.dispatch(loadMercureInfo(settings)); - }, [apiClient, tagColorsStorage]); - - return !theStore ? <> : ( - - - - - {mainContent.current} - - - - - ); -}; diff --git a/shlink-web-component/src/api-contract/ShlinkApiClient.ts b/shlink-web-component/src/api-contract/ShlinkApiClient.ts deleted file mode 100644 index 9ec85594..00000000 --- a/shlink-web-component/src/api-contract/ShlinkApiClient.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { - ShlinkCreateShortUrlData, - ShlinkDomainRedirects, - ShlinkDomainsResponse, - ShlinkEditDomainRedirects, - ShlinkEditShortUrlData, - ShlinkHealth, - ShlinkMercureInfo, - ShlinkShortUrl, - ShlinkShortUrlsListParams, - ShlinkShortUrlsResponse, - ShlinkTags, - ShlinkVisits, - ShlinkVisitsOverview, - ShlinkVisitsParams, -} from './types'; - -export type ShlinkApiClient = { - readonly baseUrl: string; - readonly apiKey: string; - - listShortUrls(params?: ShlinkShortUrlsListParams): Promise; - - createShortUrl(options: ShlinkCreateShortUrlData): Promise; - - getShortUrlVisits(shortCode: string, query?: ShlinkVisitsParams): Promise; - - getTagVisits(tag: string, query?: Omit): Promise; - - getDomainVisits(domain: string, query?: Omit): Promise; - - getOrphanVisits(query?: Omit): Promise; - - getNonOrphanVisits(query?: Omit): Promise; - - getVisitsOverview(): Promise; - - getShortUrl(shortCode: string, domain?: string | null): Promise; - - deleteShortUrl(shortCode: string, domain?: string | null): Promise; - - updateShortUrl( - shortCode: string, - domain: string | null | undefined, - body: ShlinkEditShortUrlData, - ): Promise; - - listTags(): Promise; - - tagsStats(): Promise; - - deleteTags(tags: string[]): Promise<{ tags: string[] }>; - - editTag(oldName: string, newName: string): Promise<{ oldName: string; newName: string }>; - - health(authority?: string): Promise; - - mercureInfo(): Promise; - - listDomains(): Promise; - - editDomainRedirects(domainRedirects: ShlinkEditDomainRedirects): Promise; -}; diff --git a/shlink-web-component/src/api-contract/errors.ts b/shlink-web-component/src/api-contract/errors.ts deleted file mode 100644 index 97b04cec..00000000 --- a/shlink-web-component/src/api-contract/errors.ts +++ /dev/null @@ -1,54 +0,0 @@ -export enum ErrorTypeV2 { - INVALID_ARGUMENT = 'INVALID_ARGUMENT', - INVALID_SHORT_URL_DELETION = 'INVALID_SHORT_URL_DELETION', - DOMAIN_NOT_FOUND = 'DOMAIN_NOT_FOUND', - FORBIDDEN_OPERATION = 'FORBIDDEN_OPERATION', - INVALID_URL = 'INVALID_URL', - INVALID_SLUG = 'INVALID_SLUG', - INVALID_SHORTCODE = 'INVALID_SHORTCODE', - TAG_CONFLICT = 'TAG_CONFLICT', - TAG_NOT_FOUND = 'TAG_NOT_FOUND', - MERCURE_NOT_CONFIGURED = 'MERCURE_NOT_CONFIGURED', - INVALID_AUTHORIZATION = 'INVALID_AUTHORIZATION', - INVALID_API_KEY = 'INVALID_API_KEY', - NOT_FOUND = 'NOT_FOUND', -} - -export enum ErrorTypeV3 { - INVALID_ARGUMENT = 'https://shlink.io/api/error/invalid-data', - INVALID_SHORT_URL_DELETION = 'https://shlink.io/api/error/invalid-short-url-deletion', - DOMAIN_NOT_FOUND = 'https://shlink.io/api/error/domain-not-found', - FORBIDDEN_OPERATION = 'https://shlink.io/api/error/forbidden-tag-operation', - INVALID_URL = 'https://shlink.io/api/error/invalid-url', - INVALID_SLUG = 'https://shlink.io/api/error/non-unique-slug', - INVALID_SHORTCODE = 'https://shlink.io/api/error/short-url-not-found', - TAG_CONFLICT = 'https://shlink.io/api/error/tag-conflict', - TAG_NOT_FOUND = 'https://shlink.io/api/error/tag-not-found', - MERCURE_NOT_CONFIGURED = 'https://shlink.io/api/error/mercure-not-configured', - INVALID_AUTHORIZATION = 'https://shlink.io/api/error/missing-authentication', - INVALID_API_KEY = 'https://shlink.io/api/error/invalid-api-key', - NOT_FOUND = 'https://shlink.io/api/error/not-found', -} - -export interface ProblemDetailsError { - type: string; - detail: string; - title: string; - status: number; - [extraProps: string]: any; -} - -export interface InvalidArgumentError extends ProblemDetailsError { - type: ErrorTypeV2.INVALID_ARGUMENT | ErrorTypeV3.INVALID_ARGUMENT; - invalidElements: string[]; -} - -export interface InvalidShortUrlDeletion extends ProblemDetailsError { - type: 'INVALID_SHORTCODE_DELETION' | ErrorTypeV2.INVALID_SHORT_URL_DELETION | ErrorTypeV3.INVALID_SHORT_URL_DELETION; - threshold: number; -} - -export interface RegularNotFound extends ProblemDetailsError { - type: ErrorTypeV2.NOT_FOUND | ErrorTypeV3.NOT_FOUND; - status: 404; -} diff --git a/shlink-web-component/src/api-contract/index.ts b/shlink-web-component/src/api-contract/index.ts deleted file mode 100644 index 850275d8..00000000 --- a/shlink-web-component/src/api-contract/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './errors'; -export * from './ShlinkApiClient'; -export * from './types'; diff --git a/shlink-web-component/src/api-contract/types.ts b/shlink-web-component/src/api-contract/types.ts deleted file mode 100644 index 5fb2f692..00000000 --- a/shlink-web-component/src/api-contract/types.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { Order } from '@shlinkio/shlink-frontend-kit'; -import type { Nullable, OptionalString } from '../utils/helpers'; -import type { Visit } from '../visits/types'; - -export interface ShlinkDeviceLongUrls { - android?: OptionalString; - ios?: OptionalString; - desktop?: OptionalString; -} - -export interface ShlinkShortUrlMeta { - validSince?: string; - validUntil?: string; - maxVisits?: number; -} - -export interface ShlinkShortUrl { - shortCode: string; - shortUrl: string; - longUrl: string; - deviceLongUrls?: Required, // Optional only before Shlink 3.5.0 - dateCreated: string; - /** @deprecated */ - visitsCount: number; // Deprecated since Shlink 3.4.0 - visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.4.0 - meta: Required>; - tags: string[]; - domain: string | null; - title?: string | null; - crawlable?: boolean; - forwardQuery?: boolean; -} - -export interface ShlinkEditShortUrlData { - longUrl?: string; - title?: string | null; - tags?: string[]; - deviceLongUrls?: ShlinkDeviceLongUrls; - crawlable?: boolean; - forwardQuery?: boolean; - validSince?: string | null; - validUntil?: string | null; - maxVisits?: number | null; - - /** @deprecated */ - validateUrl?: boolean; -} - -export interface ShlinkCreateShortUrlData extends Omit { - longUrl: string; - customSlug?: string; - shortCodeLength?: number; - domain?: string; - findIfExists?: boolean; - deviceLongUrls?: { - android?: string; - ios?: string; - desktop?: string; - } -} - -export interface ShlinkShortUrlsResponse { - data: ShlinkShortUrl[]; - pagination: ShlinkPaginator; -} - -export interface ShlinkMercureInfo { - token: string; - mercureHubUrl: string; -} - -export interface ShlinkHealth { - status: 'pass' | 'fail'; - version: string; -} - -export interface ShlinkTagsStats { - tag: string; - shortUrlsCount: number; - visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0 - - /** @deprecated */ - visitsCount: number; -} - -export interface ShlinkTags { - tags: string[]; - stats: ShlinkTagsStats[]; -} - -export interface ShlinkTagsResponse { - data: string[]; - /** @deprecated Present only when withStats=true is provided, which is deprecated */ - stats: ShlinkTagsStats[]; -} - -export interface ShlinkTagsStatsResponse { - data: ShlinkTagsStats[]; -} - -export interface ShlinkPaginator { - currentPage: number; - pagesCount: number; - totalItems: number; -} - -export interface ShlinkVisitsSummary { - total: number; - nonBots: number; - bots: number; -} - -export interface ShlinkVisits { - data: Visit[]; - pagination: ShlinkPaginator; -} - -export interface ShlinkVisitsOverview { - nonOrphanVisits?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0 - orphanVisits?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0 - - /** @deprecated */ - visitsCount: number; - /** @deprecated */ - orphanVisitsCount: number; -} - -export interface ShlinkVisitsParams { - domain?: string | null; - page?: number; - itemsPerPage?: number; - startDate?: string; - endDate?: string; - excludeBots?: boolean; -} - -export interface ShlinkDomainRedirects { - baseUrlRedirect: string | null; - regular404Redirect: string | null; - invalidShortUrlRedirect: string | null; -} - -export interface ShlinkEditDomainRedirects extends Partial { - domain: string; -} - -export interface ShlinkDomain { - domain: string; - isDefault: boolean; - redirects: ShlinkDomainRedirects; -} - -export interface ShlinkDomainsResponse { - data: ShlinkDomain[]; - defaultRedirects: ShlinkDomainRedirects; -} - -export type TagsFilteringMode = 'all' | 'any'; - -type ShlinkShortUrlsOrderableFields = 'dateCreated' | 'shortCode' | 'longUrl' | 'title' | 'visits' | 'nonBotVisits'; - -export type ShlinkShortUrlsOrder = Order; - -export interface ShlinkShortUrlsListParams { - page?: string; - itemsPerPage?: number; - searchTerm?: string; - tags?: string[]; - tagsMode?: TagsFilteringMode; - orderBy?: ShlinkShortUrlsOrder; - startDate?: string; - endDate?: string; - excludeMaxVisitsReached?: boolean; - excludePastValidUntil?: boolean; -} - -export interface ShlinkShortUrlsListNormalizedParams extends - Omit { - orderBy?: string; - excludeMaxVisitsReached?: 'true'; - excludePastValidUntil?: 'true'; -} diff --git a/shlink-web-component/src/api-contract/utils.ts b/shlink-web-component/src/api-contract/utils.ts deleted file mode 100644 index 0d24c81d..00000000 --- a/shlink-web-component/src/api-contract/utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { - InvalidArgumentError, - InvalidShortUrlDeletion, - ProblemDetailsError, - RegularNotFound } from './errors'; -import { - ErrorTypeV2, - ErrorTypeV3, -} from './errors'; - -export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError => - error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT; - -export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion => - error?.type === 'INVALID_SHORTCODE_DELETION' - || error?.type === ErrorTypeV2.INVALID_SHORT_URL_DELETION - || error?.type === ErrorTypeV3.INVALID_SHORT_URL_DELETION; - -export const isRegularNotFound = (error?: ProblemDetailsError): error is RegularNotFound => - (error?.type === ErrorTypeV2.NOT_FOUND || error?.type === ErrorTypeV3.NOT_FOUND) && error?.status === 404; - -const isProblemDetails = (e: unknown): e is ProblemDetailsError => - !!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e); - -export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : undefined); diff --git a/shlink-web-component/src/common/AsideMenu.scss b/shlink-web-component/src/common/AsideMenu.scss deleted file mode 100644 index 765bc86f..00000000 --- a/shlink-web-component/src/common/AsideMenu.scss +++ /dev/null @@ -1,63 +0,0 @@ -@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; -@import '../utils/mixins/vertical-align'; - -.aside-menu { - width: $asideMenuWidth; - background-color: var(--primary-color); - box-shadow: rgb(0 0 0 / .05) 0 8px 15px; - position: fixed !important; - padding-top: 13px; - padding-bottom: 10px; - top: $headerHeight; - bottom: 0; - left: 0; - display: block; - z-index: 1010; - overflow-x: hidden; - overflow-y: auto; - - @media (min-width: $mdMin) { - padding: 30px 15px 15px; - } - - @media (max-width: $smMax) { - transition: left 300ms; - top: $headerHeight - 3px; - box-shadow: -10px 0 50px 11px rgb(0 0 0 / .55); - } -} - -.aside-menu--hidden { - @media (max-width: $smMax) { - left: -($asideMenuWidth + 35px); - } -} - -.aside-menu__nav { - height: 100%; -} - -.aside-menu__item { - padding: 10px 20px; - margin: 0 -15px; - text-decoration: none !important; - cursor: pointer; - - @media (max-width: $smMax) { - margin: 0; - } -} - -.aside-menu__item:hover { - background-color: var(--secondary-color); -} - -.aside-menu__item--selected, -.aside-menu__item--selected:hover { - color: #ffffff; - background-color: var(--brand-color); -} - -.aside-menu__item-text { - margin-left: 8px; -} diff --git a/shlink-web-component/src/common/AsideMenu.tsx b/shlink-web-component/src/common/AsideMenu.tsx deleted file mode 100644 index bdbc3f14..00000000 --- a/shlink-web-component/src/common/AsideMenu.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { - faGlobe as domainsIcon, - faHome as overviewIcon, - faLink as createIcon, - faList as listIcon, - faTags as tagsIcon, -} from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import classNames from 'classnames'; -import type { FC } from 'react'; -import type { NavLinkProps } from 'react-router-dom'; -import { NavLink, useLocation } from 'react-router-dom'; -import './AsideMenu.scss'; - -export interface AsideMenuProps { - routePrefix: string; - showOnMobile?: boolean; -} - -interface AsideMenuItemProps extends NavLinkProps { - to: string; - className?: string; -} - -const AsideMenuItem: FC = ({ children, to, className, ...rest }) => ( - classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })} - to={to} - {...rest} - > - {children} - -); - -export const AsideMenu: FC = ({ routePrefix, showOnMobile = false }) => { - const { pathname } = useLocation(); - const asideClass = classNames('aside-menu', { - 'aside-menu--hidden': !showOnMobile, - }); - const buildPath = (suffix: string) => `${routePrefix}${suffix}`; - - return ( - - ); -}; diff --git a/shlink-web-component/src/common/ShlinkApiError.tsx b/shlink-web-component/src/common/ShlinkApiError.tsx deleted file mode 100644 index 18a1f95d..00000000 --- a/shlink-web-component/src/common/ShlinkApiError.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { ProblemDetailsError } from '../api-contract'; -import { isInvalidArgumentError } from '../api-contract/utils'; - -export interface ShlinkApiErrorProps { - errorData?: ProblemDetailsError; - fallbackMessage?: string; -} - -export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => ( - <> - {errorData?.detail ?? fallbackMessage} - {isInvalidArgumentError(errorData) && ( -

- Invalid elements: [{errorData.invalidElements.join(', ')}] -

- )} - -); diff --git a/shlink-web-component/src/container/index.ts b/shlink-web-component/src/container/index.ts deleted file mode 100644 index f5f6f9c4..00000000 --- a/shlink-web-component/src/container/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { IContainer } from 'bottlejs'; -import Bottle from 'bottlejs'; -import { pick } from 'ramda'; -import { connect as reduxConnect } from 'react-redux'; -import { provideServices as provideDomainsServices } from '../domains/services/provideServices'; -import { provideServices as provideMercureServices } from '../mercure/services/provideServices'; -import { provideServices as provideOverviewServices } from '../overview/services/provideServices'; -import { provideServices as provideShortUrlsServices } from '../short-urls/services/provideServices'; -import { provideServices as provideTagsServices } from '../tags/services/provideServices'; -import { provideServices as provideUtilsServices } from '../utils/services/provideServices'; -import { provideServices as provideVisitsServices } from '../visits/services/provideServices'; -import { provideServices as provideWebComponentServices } from './provideServices'; - -type LazyActionMap = Record; - -export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any; - -export const bottle = new Bottle(); - -export const { container } = bottle; - -const lazyService = (cont: IContainer, serviceName: string) => - (...args: any[]) => (cont[serviceName] as T)(...args) as K; -const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({ - ...map, - // Wrap actual action service in a function so that it is lazily created the first time it is called - [actionName]: lazyService(container, actionName), -}); -const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) => - reduxConnect( - propsFromState ? pick(propsFromState) : null, - actionServiceNames.reduce(mapActionService, {}), - ); - -provideWebComponentServices(bottle); -provideShortUrlsServices(bottle, connect); -provideTagsServices(bottle, connect); -provideVisitsServices(bottle, connect); -provideMercureServices(bottle); -provideDomainsServices(bottle, connect); -provideOverviewServices(bottle, connect); -provideUtilsServices(bottle); diff --git a/shlink-web-component/src/container/provideServices.ts b/shlink-web-component/src/container/provideServices.ts deleted file mode 100644 index 11bbd36f..00000000 --- a/shlink-web-component/src/container/provideServices.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type Bottle from 'bottlejs'; -import { Main } from '../Main'; -import { setUpStore } from './store'; - -export const provideServices = (bottle: Bottle) => { - bottle.serviceFactory( - 'Main', - Main, - 'TagsList', - 'ShortUrlsList', - 'CreateShortUrl', - 'ShortUrlVisits', - 'TagVisits', - 'DomainVisits', - 'OrphanVisits', - 'NonOrphanVisits', - 'Overview', - 'EditShortUrl', - 'ManageDomains', - ); - - bottle.factory('store', setUpStore); -}; diff --git a/shlink-web-component/src/container/store.ts b/shlink-web-component/src/container/store.ts deleted file mode 100644 index db777abe..00000000 --- a/shlink-web-component/src/container/store.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { combineReducers, configureStore } from '@reduxjs/toolkit'; -import type { IContainer } from 'bottlejs'; -import type { DomainsList } from '../domains/reducers/domainsList'; -import type { MercureInfo } from '../mercure/reducers/mercureInfo'; -import type { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation'; -import type { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion'; -import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail'; -import type { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition'; -import type { ShortUrlsList } from '../short-urls/reducers/shortUrlsList'; -import type { TagDeletion } from '../tags/reducers/tagDelete'; -import type { TagEdition } from '../tags/reducers/tagEdit'; -import type { TagsList } from '../tags/reducers/tagsList'; -import type { DomainVisits } from '../visits/reducers/domainVisits'; -import type { ShortUrlVisits } from '../visits/reducers/shortUrlVisits'; -import type { TagVisits } from '../visits/reducers/tagVisits'; -import type { VisitsInfo } from '../visits/reducers/types'; -import type { VisitsOverview } from '../visits/reducers/visitsOverview'; - -const isProduction = process.env.NODE_ENV === 'production'; - -export const setUpStore = (container: IContainer) => configureStore({ - devTools: !isProduction, - reducer: combineReducers({ - mercureInfo: container.mercureInfoReducer, - shortUrlsList: container.shortUrlsListReducer, - shortUrlCreation: container.shortUrlCreationReducer, - shortUrlDeletion: container.shortUrlDeletionReducer, - shortUrlEdition: container.shortUrlEditionReducer, - shortUrlDetail: container.shortUrlDetailReducer, - shortUrlVisits: container.shortUrlVisitsReducer, - tagVisits: container.tagVisitsReducer, - domainVisits: container.domainVisitsReducer, - orphanVisits: container.orphanVisitsReducer, - nonOrphanVisits: container.nonOrphanVisitsReducer, - tagsList: container.tagsListReducer, - tagDelete: container.tagDeleteReducer, - tagEdit: container.tagEditReducer, - domainsList: container.domainsListReducer, - visitsOverview: container.visitsOverviewReducer, - }), - middleware: (defaultMiddlewaresIncludingReduxThunk) => defaultMiddlewaresIncludingReduxThunk({ - // State is too big for these - immutableCheck: false, - serializableCheck: false, - }), -}); - -export type RootState = { - shortUrlsList: ShortUrlsList; - shortUrlCreation: ShortUrlCreation; - shortUrlDeletion: ShortUrlDeletion; - shortUrlEdition: ShortUrlEdition; - shortUrlVisits: ShortUrlVisits; - tagVisits: TagVisits; - domainVisits: DomainVisits; - orphanVisits: VisitsInfo; - nonOrphanVisits: VisitsInfo; - shortUrlDetail: ShortUrlDetail; - tagsList: TagsList; - tagDelete: TagDeletion; - tagEdit: TagEdition; - mercureInfo: MercureInfo; - domainsList: DomainsList; - visitsOverview: VisitsOverview; -}; diff --git a/shlink-web-component/src/domains/DomainRow.tsx b/shlink-web-component/src/domains/DomainRow.tsx deleted file mode 100644 index 8ed162bc..00000000 --- a/shlink-web-component/src/domains/DomainRow.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import type { FC } from 'react'; -import { useEffect } from 'react'; -import { UncontrolledTooltip } from 'reactstrap'; -import type { ShlinkDomainRedirects } from '../api-contract'; -import type { Domain } from './data'; -import { DomainDropdown } from './helpers/DomainDropdown'; -import { DomainStatusIcon } from './helpers/DomainStatusIcon'; -import type { EditDomainRedirects } from './reducers/domainRedirects'; - -interface DomainRowProps { - domain: Domain; - defaultRedirects?: ShlinkDomainRedirects; - editDomainRedirects: (redirects: EditDomainRedirects) => Promise; - checkDomainHealth: (domain: string) => void; -} - -const Nr: FC<{ fallback?: string | null }> = ({ fallback }) => ( - - {!fallback && No redirect} - {fallback && <>{fallback} (as fallback)} - -); -const DefaultDomain: FC = () => ( - <> - - Default domain - -); - -export const DomainRow: FC = ( - { domain, editDomainRedirects, checkDomainHealth, defaultRedirects }, -) => { - const { domain: authority, isDefault, redirects, status } = domain; - - useEffect(() => { - checkDomainHealth(domain.domain); - }, []); - - return ( - - {isDefault && } - {authority} - - {redirects?.baseUrlRedirect ?? } - - - {redirects?.regular404Redirect ?? } - - - {redirects?.invalidShortUrlRedirect ?? } - - - - - - - - - ); -}; diff --git a/shlink-web-component/src/domains/DomainSelector.scss b/shlink-web-component/src/domains/DomainSelector.scss deleted file mode 100644 index e6bd0cfc..00000000 --- a/shlink-web-component/src/domains/DomainSelector.scss +++ /dev/null @@ -1,19 +0,0 @@ -@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; -@import '../utils/mixins/vertical-align'; - -.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn, -.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:hover, -.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active { - color: $textPlaceholder !important; -} - -.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active, -.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover, -.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active { - color: var(--input-text-color) !important; -} - -.domains-dropdown__back-btn.domains-dropdown__back-btn, -.domains-dropdown__back-btn.domains-dropdown__back-btn:hover { - border-color: var(--border-color); -} diff --git a/shlink-web-component/src/domains/DomainSelector.tsx b/shlink-web-component/src/domains/DomainSelector.tsx deleted file mode 100644 index e89be930..00000000 --- a/shlink-web-component/src/domains/DomainSelector.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { faUndo } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { DropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit'; -import { isEmpty, pipe } from 'ramda'; -import { useEffect } from 'react'; -import type { InputProps } from 'reactstrap'; -import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip } from 'reactstrap'; -import type { DomainsList } from './reducers/domainsList'; -import './DomainSelector.scss'; - -export interface DomainSelectorProps extends Omit { - value?: string; - onChange: (domain: string) => void; -} - -interface DomainSelectorConnectProps extends DomainSelectorProps { - listDomains: Function; - domainsList: DomainsList; -} - -export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => { - const [inputDisplayed,, showInput, hideInput] = useToggle(); - const { domains } = domainsList; - const valueIsEmpty = isEmpty(value); - const unselectDomain = () => onChange(''); - - useEffect(() => { - listDomains(); - }, []); - - return inputDisplayed ? ( - - onChange(e.target.value)} - /> - - - Existing domains - - - ) : ( - - {domains.map(({ domain, isDefault }) => ( - onChange(domain)} - > - {domain} - {isDefault && default} - - ))} - - - New domain - - - ); -}; diff --git a/shlink-web-component/src/domains/ManageDomains.tsx b/shlink-web-component/src/domains/ManageDomains.tsx deleted file mode 100644 index 0dfa88bf..00000000 --- a/shlink-web-component/src/domains/ManageDomains.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Message, Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit'; -import type { FC } from 'react'; -import { useEffect } from 'react'; -import { ShlinkApiError } from '../common/ShlinkApiError'; -import { DomainRow } from './DomainRow'; -import type { EditDomainRedirects } from './reducers/domainRedirects'; -import type { DomainsList } from './reducers/domainsList'; - -interface ManageDomainsProps { - listDomains: Function; - filterDomains: (searchTerm: string) => void; - editDomainRedirects: (redirects: EditDomainRedirects) => Promise; - checkDomainHealth: (domain: string) => void; - domainsList: DomainsList; -} - -const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '']; - -export const ManageDomains: FC = ( - { listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth }, -) => { - const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList; - const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects; - - useEffect(() => { - listDomains(); - }, []); - - if (loading) { - return ; - } - - const renderContent = () => { - if (error) { - return ( - - - - ); - } - - return ( - - - - {headers.map((column, index) => )} - - - {domains.length < 1 && } - {domains.map((domain) => ( - - ))} - -
{column}
No results found
-
- ); - }; - - return ( - <> - - {renderContent()} - - ); -}; diff --git a/shlink-web-component/src/domains/data/index.ts b/shlink-web-component/src/domains/data/index.ts deleted file mode 100644 index 6cc50587..00000000 --- a/shlink-web-component/src/domains/data/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ShlinkDomain } from '../../api-contract'; - -export type DomainStatus = 'validating' | 'valid' | 'invalid'; - -export interface Domain extends ShlinkDomain { - status: DomainStatus; -} diff --git a/shlink-web-component/src/domains/helpers/DomainDropdown.tsx b/shlink-web-component/src/domains/helpers/DomainDropdown.tsx deleted file mode 100644 index 73aeb47c..00000000 --- a/shlink-web-component/src/domains/helpers/DomainDropdown.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit'; -import type { FC } from 'react'; -import { Link } from 'react-router-dom'; -import { DropdownItem } from 'reactstrap'; -import { useFeature } from '../../utils/features'; -import { useRoutesPrefix } from '../../utils/routesPrefix'; -import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; -import type { Domain } from '../data'; -import type { EditDomainRedirects } from '../reducers/domainRedirects'; -import { EditDomainRedirectsModal } from './EditDomainRedirectsModal'; - -interface DomainDropdownProps { - domain: Domain; - editDomainRedirects: (redirects: EditDomainRedirects) => Promise; -} - -export const DomainDropdown: FC = ({ domain, editDomainRedirects }) => { - const [isModalOpen, toggleModal] = useToggle(); - const withVisits = useFeature('domainVisits'); - const routesPrefix = useRoutesPrefix(); - - return ( - - {withVisits && ( - - Visit stats - - )} - - Edit redirects - - - - - ); -}; diff --git a/shlink-web-component/src/domains/helpers/DomainStatusIcon.tsx b/shlink-web-component/src/domains/helpers/DomainStatusIcon.tsx deleted file mode 100644 index 003c1733..00000000 --- a/shlink-web-component/src/domains/helpers/DomainStatusIcon.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { - faCheck as checkIcon, - faCircleNotch as loadingStatusIcon, - faTimes as invalidIcon, -} from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useElementRef } from '@shlinkio/shlink-frontend-kit'; -import type { FC } from 'react'; -import { useEffect, useState } from 'react'; -import { ExternalLink } from 'react-external-link'; -import { UncontrolledTooltip } from 'reactstrap'; -import type { MediaMatcher } from '../../utils/types'; -import type { DomainStatus } from '../data'; - -interface DomainStatusIconProps { - status: DomainStatus; - matchMedia?: MediaMatcher; -} - -export const DomainStatusIcon: FC = ({ status, matchMedia = window.matchMedia }) => { - const ref = useElementRef(); - const matchesMobile = () => matchMedia('(max-width: 991px)').matches; - const [isMobile, setIsMobile] = useState(matchesMobile()); - - useEffect(() => { - const listener = () => setIsMobile(matchesMobile()); - - window.addEventListener('resize', listener); - - return () => window.removeEventListener('resize', listener); - }, []); - - if (status === 'validating') { - return ; - } - - return ( - <> - - {status === 'valid' - ? - : } - - - {status === 'valid' ? 'Congratulations! This domain is properly configured.' : ( - - Oops! There is some missing configuration, and short URLs shared with this domain will not work. -
- Check the documentation in order to - find out what is missing. -
- )} -
- - ); -}; diff --git a/shlink-web-component/src/domains/helpers/EditDomainRedirectsModal.tsx b/shlink-web-component/src/domains/helpers/EditDomainRedirectsModal.tsx deleted file mode 100644 index bb21dee7..00000000 --- a/shlink-web-component/src/domains/helpers/EditDomainRedirectsModal.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { InputFormGroupProps } from '@shlinkio/shlink-frontend-kit'; -import { InputFormGroup } from '@shlinkio/shlink-frontend-kit'; -import type { FC } from 'react'; -import { useState } from 'react'; -import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; -import type { ShlinkDomain } from '../../api-contract'; -import { InfoTooltip } from '../../utils/components/InfoTooltip'; -import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/helpers'; -import type { EditDomainRedirects } from '../reducers/domainRedirects'; - -interface EditDomainRedirectsModalProps { - domain: ShlinkDomain; - isOpen: boolean; - toggle: () => void; - editDomainRedirects: (redirects: EditDomainRedirects) => Promise; -} - -const FormGroup: FC = ({ isLast, ...rest }) => ( - -); - -export const EditDomainRedirectsModal: FC = ( - { isOpen, toggle, domain, editDomainRedirects }, -) => { - const [baseUrlRedirect, setBaseUrlRedirect] = useState(domain.redirects?.baseUrlRedirect ?? ''); - const [regular404Redirect, setRegular404Redirect] = useState(domain.redirects?.regular404Redirect ?? ''); - const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState( - domain.redirects?.invalidShortUrlRedirect ?? '', - ); - const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects({ - domain: domain.domain, - redirects: { - baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect), - regular404Redirect: nonEmptyValueOrNull(regular404Redirect), - invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect), - }, - }).then(toggle)); - - return ( - -
- Edit redirects for {domain.domain} - - - - Visitors accessing the base url, as in https://{domain.domain}/, will be redirected to this URL. - - Base URL - - - - Visitors accessing a url not matching a short URL pattern, as in https://{domain.domain}/???/[...], - will be redirected to this URL. - - Regular 404 - - - - Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be - redirected to this URL. - - Invalid short URL - - - - - - -
-
- ); -}; diff --git a/shlink-web-component/src/domains/reducers/domainRedirects.ts b/shlink-web-component/src/domains/reducers/domainRedirects.ts deleted file mode 100644 index e8ae3cf2..00000000 --- a/shlink-web-component/src/domains/reducers/domainRedirects.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ShlinkApiClient, ShlinkDomainRedirects } from '../../api-contract'; -import { createAsyncThunk } from '../../utils/redux'; - -const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS'; - -export interface EditDomainRedirects { - domain: string; - redirects: ShlinkDomainRedirects; -} - -export const editDomainRedirects = ( - apiClientFactory: () => ShlinkApiClient, -) => createAsyncThunk( - EDIT_DOMAIN_REDIRECTS, - async ({ domain, redirects: providedRedirects }: EditDomainRedirects): Promise => { - const apiClient = apiClientFactory(); - const redirects = await apiClient.editDomainRedirects({ domain, ...providedRedirects }); - return { domain, redirects }; - }, -); diff --git a/shlink-web-component/src/domains/reducers/domainsList.ts b/shlink-web-component/src/domains/reducers/domainsList.ts deleted file mode 100644 index 4a6f3311..00000000 --- a/shlink-web-component/src/domains/reducers/domainsList.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit'; -import { createAction, createSlice } from '@reduxjs/toolkit'; -import type { ProblemDetailsError, ShlinkApiClient, ShlinkDomainRedirects } from '../../api-contract'; -import { parseApiError } from '../../api-contract/utils'; -import { createAsyncThunk } from '../../utils/redux'; -import type { Domain, DomainStatus } from '../data'; -import type { EditDomainRedirects } from './domainRedirects'; - -const REDUCER_PREFIX = 'shlink/domainsList'; - -export interface DomainsList { - domains: Domain[]; - filteredDomains: Domain[]; - defaultRedirects?: ShlinkDomainRedirects; - loading: boolean; - error: boolean; - errorData?: ProblemDetailsError; -} - -interface ListDomains { - domains: Domain[]; - defaultRedirects?: ShlinkDomainRedirects; -} - -interface ValidateDomain { - domain: string; - status: DomainStatus; -} - -const initialState: DomainsList = { - domains: [], - filteredDomains: [], - loading: false, - error: false, -}; - -export const replaceRedirectsOnDomain = ({ domain, redirects }: EditDomainRedirects) => - (d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects }); - -export const replaceStatusOnDomain = (domain: string, status: DomainStatus) => - (d: Domain): Domain => (d.domain !== domain ? d : { ...d, status }); - -export const domainsListReducerCreator = ( - apiClientFactory: () => ShlinkApiClient, - editDomainRedirects: AsyncThunk, -) => { - const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (): Promise => { - const { data, defaultRedirects } = await apiClientFactory().listDomains(); - - return { - domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), - defaultRedirects, - }; - }); - - const checkDomainHealth = createAsyncThunk( - `${REDUCER_PREFIX}/checkDomainHealth`, - async (domain: string): Promise => { - try { - const { status } = await apiClientFactory().health(domain); - return { domain, status: status === 'pass' ? 'valid' : 'invalid' }; - } catch (e) { - return { domain, status: 'invalid' }; - } - }, - ); - - const filterDomains = createAction(`${REDUCER_PREFIX}/filterDomains`); - - const { reducer } = createSlice>({ - name: REDUCER_PREFIX, - initialState, - reducers: {}, - extraReducers: (builder) => { - builder.addCase(listDomains.pending, () => ({ ...initialState, loading: true })); - builder.addCase(listDomains.rejected, (_, { error }) => ( - { ...initialState, error: true, errorData: parseApiError(error) } - )); - builder.addCase(listDomains.fulfilled, (_, { payload }) => ( - { ...initialState, ...payload, filteredDomains: payload.domains } - )); - - builder.addCase(checkDomainHealth.fulfilled, ({ domains, filteredDomains, ...rest }, { payload }) => ({ - ...rest, - domains: domains.map(replaceStatusOnDomain(payload.domain, payload.status)), - filteredDomains: filteredDomains.map(replaceStatusOnDomain(payload.domain, payload.status)), - })); - - builder.addCase(filterDomains, (state, { payload }) => ({ - ...state, - filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(payload.toLowerCase())), - })); - - builder.addCase(editDomainRedirects.fulfilled, (state, { payload }) => ({ - ...state, - domains: state.domains.map(replaceRedirectsOnDomain(payload)), - filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(payload)), - })); - }, - }); - - return { - reducer, - listDomains, - checkDomainHealth, - filterDomains, - }; -}; diff --git a/shlink-web-component/src/domains/services/provideServices.ts b/shlink-web-component/src/domains/services/provideServices.ts deleted file mode 100644 index 3731a81e..00000000 --- a/shlink-web-component/src/domains/services/provideServices.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type Bottle from 'bottlejs'; -import { prop } from 'ramda'; -import type { ConnectDecorator } from '../../container'; -import { DomainSelector } from '../DomainSelector'; -import { ManageDomains } from '../ManageDomains'; -import { editDomainRedirects } from '../reducers/domainRedirects'; -import { domainsListReducerCreator } from '../reducers/domainsList'; - -export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { - // Components - bottle.serviceFactory('DomainSelector', () => DomainSelector); - bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains'])); - - bottle.serviceFactory('ManageDomains', () => ManageDomains); - bottle.decorator('ManageDomains', connect( - ['domainsList'], - ['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'], - )); - - // Reducer - bottle.serviceFactory( - 'domainsListReducerCreator', - domainsListReducerCreator, - 'apiClientFactory', - 'editDomainRedirects', - ); - bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator'); - - // Actions - bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator'); - bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator'); - bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'apiClientFactory'); - bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator'); -}; diff --git a/shlink-web-component/src/index.scss b/shlink-web-component/src/index.scss deleted file mode 100644 index 07792cf4..00000000 --- a/shlink-web-component/src/index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import './tags/react-tag-autocomplete'; -@import './utils/StickyCardPaginator.scss'; diff --git a/shlink-web-component/src/index.ts b/shlink-web-component/src/index.ts deleted file mode 100644 index 7f586a6c..00000000 --- a/shlink-web-component/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { bottle } from './container'; -import { createShlinkWebComponent } from './ShlinkWebComponent'; - -export const ShlinkWebComponent = createShlinkWebComponent(bottle); - -export type ShlinkWebComponentType = typeof ShlinkWebComponent; - -export type { - RealTimeUpdatesSettings, - ShortUrlCreationSettings, - ShortUrlsListSettings, - UiSettings, - VisitsSettings, - TagsSettings, - Settings, -} from './utils/settings'; - -export type { TagColorsStorage } from './utils/services/TagColorsStorage'; diff --git a/shlink-web-component/src/mercure/helpers/Topics.ts b/shlink-web-component/src/mercure/helpers/Topics.ts deleted file mode 100644 index 663cc371..00000000 --- a/shlink-web-component/src/mercure/helpers/Topics.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class Topics { - public static readonly visits = 'https://shlink.io/new-visit'; - - public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit'; - - public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`; -} diff --git a/shlink-web-component/src/mercure/helpers/boundToMercureHub.tsx b/shlink-web-component/src/mercure/helpers/boundToMercureHub.tsx deleted file mode 100644 index 230552b1..00000000 --- a/shlink-web-component/src/mercure/helpers/boundToMercureHub.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { pipe } from 'ramda'; -import type { FC } from 'react'; -import { useEffect } from 'react'; -import { useParams } from 'react-router-dom'; -import type { CreateVisit } from '../../visits/types'; -import type { MercureInfo } from '../reducers/mercureInfo'; -import { bindToMercureTopic } from './index'; - -export interface MercureBoundProps { - createNewVisits: (createdVisits: CreateVisit[]) => void; - loadMercureInfo: () => void; - mercureInfo: MercureInfo; -} - -export function boundToMercureHub( - WrappedComponent: FC, - getTopicsForProps: (props: T, routeParams: any) => string[], -) { - const pendingUpdates = new Set(); - - return (props: MercureBoundProps & T) => { - const { createNewVisits, loadMercureInfo, mercureInfo } = props; - const { interval } = mercureInfo; - const params = useParams(); - - // Every time mercure info changes, re-bind - useEffect(() => { - const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit])); - const topics = getTopicsForProps(props, params); - const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo); - - if (!interval) { - return closeEventSource; - } - - const timer = setInterval(() => { - createNewVisits([...pendingUpdates]); - pendingUpdates.clear(); - }, interval * 1000 * 60); - - return pipe(() => clearInterval(timer), () => closeEventSource?.()); - }, [mercureInfo]); - - return ; - }; -} diff --git a/shlink-web-component/src/mercure/helpers/index.ts b/shlink-web-component/src/mercure/helpers/index.ts deleted file mode 100644 index 26d2f09a..00000000 --- a/shlink-web-component/src/mercure/helpers/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; -import type { MercureInfo } from '../reducers/mercureInfo'; - -export const bindToMercureTopic = (mercureInfo: MercureInfo, topics: string[], onMessage: (message: T) => void, onTokenExpired: () => void) => { // eslint-disable-line max-len - const { mercureHubUrl, token, loading, error } = mercureInfo; - - if (loading || error || !mercureHubUrl) { - return undefined; - } - - const onEventSourceMessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T); - const onEventSourceError = ({ status }: { status: number }) => status === 401 && onTokenExpired(); - - const subscriptions = topics.map((topic) => { - const hubUrl = new URL(mercureHubUrl); - - hubUrl.searchParams.append('topic', topic); - const es = new EventSource(hubUrl, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - es.onmessage = onEventSourceMessage; - es.onerror = onEventSourceError; - - return es; - }); - - return () => subscriptions.forEach((es) => es.close()); -}; diff --git a/shlink-web-component/src/mercure/reducers/mercureInfo.ts b/shlink-web-component/src/mercure/reducers/mercureInfo.ts deleted file mode 100644 index ac94f780..00000000 --- a/shlink-web-component/src/mercure/reducers/mercureInfo.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit'; -import type { ShlinkApiClient, ShlinkMercureInfo } from '../../api-contract'; -import { createAsyncThunk } from '../../utils/redux'; -import type { Settings } from '../../utils/settings'; - -const REDUCER_PREFIX = 'shlink/mercure'; - -export interface MercureInfo extends Partial { - interval?: number; - loading: boolean; - error: boolean; -} - -const initialState: MercureInfo = { - loading: true, - error: false, -}; - -export const mercureInfoReducerCreator = (apiClientFactory: () => ShlinkApiClient) => { - const loadMercureInfo = createAsyncThunk( - `${REDUCER_PREFIX}/loadMercureInfo`, - ({ realTimeUpdates }: Settings): Promise => { - if (realTimeUpdates && !realTimeUpdates.enabled) { - throw new Error('Real time updates not enabled'); - } - - return apiClientFactory().mercureInfo(); - }, - ); - - const { reducer } = createSlice({ - name: REDUCER_PREFIX, - initialState, - reducers: {}, - extraReducers: (builder) => { - builder.addCase(loadMercureInfo.pending, (state) => ({ ...state, loading: true, error: false })); - builder.addCase(loadMercureInfo.rejected, (state) => ({ ...state, loading: false, error: true })); - builder.addCase(loadMercureInfo.fulfilled, (_, { payload }) => ({ ...payload, loading: false, error: false })); - }, - }); - - return { loadMercureInfo, reducer }; -}; diff --git a/shlink-web-component/src/mercure/services/provideServices.ts b/shlink-web-component/src/mercure/services/provideServices.ts deleted file mode 100644 index 2eaabf4a..00000000 --- a/shlink-web-component/src/mercure/services/provideServices.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type Bottle from 'bottlejs'; -import { prop } from 'ramda'; -import { mercureInfoReducerCreator } from '../reducers/mercureInfo'; - -export const provideServices = (bottle: Bottle) => { - // Reducer - bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'apiClientFactory'); - bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator'); - - // Actions - bottle.serviceFactory('loadMercureInfo', prop('loadMercureInfo'), 'mercureInfoReducerCreator'); -}; diff --git a/shlink-web-component/src/overview/Overview.tsx b/shlink-web-component/src/overview/Overview.tsx deleted file mode 100644 index c51d48fb..00000000 --- a/shlink-web-component/src/overview/Overview.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import type { FC } from 'react'; -import { useEffect } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { Card, CardBody, CardHeader, Row } from 'reactstrap'; -import type { ShlinkShortUrlsListParams } from '../api-contract'; -import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; -import { Topics } from '../mercure/helpers/Topics'; -import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; -import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; -import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList'; -import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable'; -import type { TagsList } from '../tags/reducers/tagsList'; -import { prettify } from '../utils/helpers/numbers'; -import { useRoutesPrefix } from '../utils/routesPrefix'; -import { useSetting } from '../utils/settings'; -import type { VisitsOverview } from '../visits/reducers/visitsOverview'; -import { HighlightCard } from './helpers/HighlightCard'; -import { VisitsHighlightCard } from './helpers/VisitsHighlightCard'; - -interface OverviewConnectProps { - shortUrlsList: ShortUrlsListState; - listShortUrls: (params: ShlinkShortUrlsListParams) => void; - listTags: Function; - tagsList: TagsList; - visitsOverview: VisitsOverview; - loadVisitsOverview: Function; -} - -export const Overview = ( - ShortUrlsTable: ShortUrlsTableType, - CreateShortUrl: FC, -) => boundToMercureHub(({ - shortUrlsList, - listShortUrls, - listTags, - tagsList, - loadVisitsOverview, - visitsOverview, -}: OverviewConnectProps) => { - const { loading, shortUrls } = shortUrlsList; - const { loading: loadingTags } = tagsList; - const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview; - const routesPrefix = useRoutesPrefix(); - const navigate = useNavigate(); - const visits = useSetting('visits'); - - useEffect(() => { - listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } }); - listTags(); - loadVisitsOverview(); - }, []); - - return ( - <> - -
- -
-
- -
-
- - {loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)} - -
-
- - {loadingTags ? 'Loading...' : prettify(tagsList.tags.length)} - -
-
- - - - Create a short URL -
Create a short URL
- Advanced options » -
- - - -
- - - Recently created URLs -
Recently created URLs
- See all » -
- - navigate(`${routesPrefix}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)} - /> - -
- - ); -}, () => [Topics.visits, Topics.orphanVisits]); diff --git a/shlink-web-component/src/overview/helpers/HighlightCard.scss b/shlink-web-component/src/overview/helpers/HighlightCard.scss deleted file mode 100644 index a51b5309..00000000 --- a/shlink-web-component/src/overview/helpers/HighlightCard.scss +++ /dev/null @@ -1,21 +0,0 @@ -@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; - -.highlight-card.highlight-card.highlight-card { - text-align: center; - border-top: 3px solid var(--brand-color); - color: inherit; - text-decoration: none; -} - -.highlight-card__link-icon { - position: absolute; - right: 5px; - bottom: 5px; - opacity: .1; - transform: rotate(-45deg); -} - -.highlight-card__title { - text-transform: uppercase; - color: $textPlaceholder; -} diff --git a/shlink-web-component/src/overview/helpers/HighlightCard.tsx b/shlink-web-component/src/overview/helpers/HighlightCard.tsx deleted file mode 100644 index 72b99865..00000000 --- a/shlink-web-component/src/overview/helpers/HighlightCard.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useElementRef } from '@shlinkio/shlink-frontend-kit'; -import type { FC, PropsWithChildren, ReactNode } from 'react'; -import { Link } from 'react-router-dom'; -import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap'; -import './HighlightCard.scss'; - -export type HighlightCardProps = PropsWithChildren<{ - title: string; - link: string; - tooltip?: ReactNode; -}>; - -const buildExtraProps = (link: string) => ({ tag: Link, to: link }); - -export const HighlightCard: FC = ({ children, title, link, tooltip }) => { - const ref = useElementRef(); - - return ( - <> - - - {title} - {children} - - {tooltip && {tooltip}} - - ); -}; diff --git a/shlink-web-component/src/overview/helpers/VisitsHighlightCard.tsx b/shlink-web-component/src/overview/helpers/VisitsHighlightCard.tsx deleted file mode 100644 index 6da51057..00000000 --- a/shlink-web-component/src/overview/helpers/VisitsHighlightCard.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { FC } from 'react'; -import { prettify } from '../../utils/helpers/numbers'; -import type { PartialVisitsSummary } from '../../visits/reducers/visitsOverview'; -import type { HighlightCardProps } from './HighlightCard'; -import { HighlightCard } from './HighlightCard'; - -export type VisitsHighlightCardProps = Omit & { - loading: boolean; - excludeBots: boolean; - visitsSummary: PartialVisitsSummary; -}; - -export const VisitsHighlightCard: FC = ({ loading, excludeBots, visitsSummary, ...rest }) => ( - {excludeBots ? 'Plus' : 'Including'} {prettify(visitsSummary.bots)} potential bot visits - : undefined - } - {...rest} - > - {loading ? 'Loading...' : prettify( - excludeBots && visitsSummary.nonBots ? visitsSummary.nonBots : visitsSummary.total, - )} - -); diff --git a/shlink-web-component/src/overview/services/provideServices.ts b/shlink-web-component/src/overview/services/provideServices.ts deleted file mode 100644 index 1dce68ba..00000000 --- a/shlink-web-component/src/overview/services/provideServices.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type Bottle from 'bottlejs'; -import type { ConnectDecorator } from '../../container'; -import { Overview } from '../Overview'; - -export function provideServices(bottle: Bottle, connect: ConnectDecorator) { - bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl'); - bottle.decorator('Overview', connect( - ['shortUrlsList', 'tagsList', 'mercureInfo', 'visitsOverview'], - ['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'], - )); -} diff --git a/shlink-web-component/src/short-urls/CreateShortUrl.tsx b/shlink-web-component/src/short-urls/CreateShortUrl.tsx deleted file mode 100644 index 80428ded..00000000 --- a/shlink-web-component/src/short-urls/CreateShortUrl.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { ShlinkCreateShortUrlData } from '@shlinkio/shlink-web-component/api-contract'; -import type { FC } from 'react'; -import { useMemo } from 'react'; -import type { ShortUrlCreationSettings } from '../utils/settings'; -import { useSetting } from '../utils/settings'; -import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult'; -import type { ShortUrlCreation } from './reducers/shortUrlCreation'; -import type { ShortUrlFormProps } from './ShortUrlForm'; - -export interface CreateShortUrlProps { - basicMode?: boolean; -} - -interface CreateShortUrlConnectProps extends CreateShortUrlProps { - shortUrlCreation: ShortUrlCreation; - createShortUrl: (data: ShlinkCreateShortUrlData) => Promise; - resetCreateShortUrl: () => void; -} - -const getInitialState = (settings?: ShortUrlCreationSettings): ShlinkCreateShortUrlData => ({ - longUrl: '', - tags: [], - customSlug: '', - title: undefined, - shortCodeLength: undefined, - domain: '', - validSince: undefined, - validUntil: undefined, - maxVisits: undefined, - findIfExists: false, - validateUrl: settings?.validateUrls ?? false, - forwardQuery: settings?.forwardQuery ?? true, -}); - -export const CreateShortUrl = ( - ShortUrlForm: FC>, - CreateShortUrlResult: FC, -) => ({ - createShortUrl, - shortUrlCreation, - resetCreateShortUrl, - basicMode = false, -}: CreateShortUrlConnectProps) => { - const shortUrlCreationSettings = useSetting('shortUrlCreation'); - const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]); - - return ( - <> - { - resetCreateShortUrl(); - return createShortUrl(data); - }} - /> - - - ); -}; diff --git a/shlink-web-component/src/short-urls/EditShortUrl.tsx b/shlink-web-component/src/short-urls/EditShortUrl.tsx deleted file mode 100644 index 229f224d..00000000 --- a/shlink-web-component/src/short-urls/EditShortUrl.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Message, parseQuery, Result } from '@shlinkio/shlink-frontend-kit'; -import type { ShlinkEditShortUrlData } from '@shlinkio/shlink-web-component/api-contract'; -import type { FC } from 'react'; -import { useEffect, useMemo } from 'react'; -import { ExternalLink } from 'react-external-link'; -import { useLocation, useParams } from 'react-router-dom'; -import { Button, Card } from 'reactstrap'; -import { ShlinkApiError } from '../common/ShlinkApiError'; -import { useGoBack } from '../utils/helpers/hooks'; -import { useSetting } from '../utils/settings'; -import type { ShortUrlIdentifier } from './data'; -import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers'; -import type { ShortUrlDetail } from './reducers/shortUrlDetail'; -import type { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reducers/shortUrlEdition'; -import type { ShortUrlFormProps } from './ShortUrlForm'; - -interface EditShortUrlConnectProps { - shortUrlDetail: ShortUrlDetail; - shortUrlEdition: ShortUrlEdition; - getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void; - editShortUrl: (editShortUrl: EditShortUrlInfo) => void; -} - -export const EditShortUrl = (ShortUrlForm: FC>) => ({ - shortUrlDetail, - getShortUrlDetail, - shortUrlEdition, - editShortUrl, -}: EditShortUrlConnectProps) => { - const { search } = useLocation(); - const params = useParams<{ shortCode: string }>(); - const goBack = useGoBack(); - const { loading, error, errorData, shortUrl } = shortUrlDetail; - const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition; - const { domain } = parseQuery<{ domain?: string }>(search); - const shortUrlCreationSettings = useSetting('shortUrlCreation'); - const initialState = useMemo( - () => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings), - [shortUrl, shortUrlCreationSettings], - ); - - useEffect(() => { - params.shortCode && getShortUrlDetail({ shortCode: urlDecodeShortCode(params.shortCode), domain }); - }, []); - - if (loading) { - return ; - } - - if (error) { - return ( - - - - ); - } - - return ( - <> -
- -

- - - Edit - - -

-
-
- { - if (!shortUrl) { - return; - } - - editShortUrl({ ...shortUrl, data: shortUrlData }); - }} - /> - {saved && savingError && ( - - - - )} - {saved && !savingError && Short URL properly edited.} - - ); -}; diff --git a/shlink-web-component/src/short-urls/Paginator.tsx b/shlink-web-component/src/short-urls/Paginator.tsx deleted file mode 100644 index f2488585..00000000 --- a/shlink-web-component/src/short-urls/Paginator.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Link } from 'react-router-dom'; -import { Pagination, PaginationItem, PaginationLink } from 'reactstrap'; -import type { ShlinkPaginator } from '../api-contract'; -import type { - NumberOrEllipsis } from '../utils/helpers/pagination'; -import { - keyForPage, - pageIsEllipsis, - prettifyPageNumber, - progressivePagination, -} from '../utils/helpers/pagination'; -import { useRoutesPrefix } from '../utils/routesPrefix'; - -interface PaginatorProps { - paginator?: ShlinkPaginator; - currentQueryString?: string; -} - -export const Paginator = ({ paginator, currentQueryString = '' }: PaginatorProps) => { - const { currentPage = 0, pagesCount = 0 } = paginator ?? {}; - const routesPrefix = useRoutesPrefix(); - const urlForPage = (pageNumber: NumberOrEllipsis) => - `${routesPrefix}/list-short-urls/${pageNumber}${currentQueryString}`; - - if (pagesCount <= 1) { - return
; // Return some space - } - - const renderPages = () => - progressivePagination(currentPage, pagesCount).map((pageNumber, index) => ( - - - {prettifyPageNumber(pageNumber)} - - - )); - - return ( - - - - - {renderPages()} - = pagesCount}> - - - - ); -}; diff --git a/shlink-web-component/src/short-urls/ShortUrlForm.scss b/shlink-web-component/src/short-urls/ShortUrlForm.scss deleted file mode 100644 index 2b8bf66a..00000000 --- a/shlink-web-component/src/short-urls/ShortUrlForm.scss +++ /dev/null @@ -1,9 +0,0 @@ -@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; - -.short-url-form p:last-child { - margin-bottom: 0; -} - -.short-url-form .card { - height: 100%; -} diff --git a/shlink-web-component/src/short-urls/ShortUrlForm.tsx b/shlink-web-component/src/short-urls/ShortUrlForm.tsx deleted file mode 100644 index 85c73ba4..00000000 --- a/shlink-web-component/src/short-urls/ShortUrlForm.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import type { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons'; -import { faDesktop } from '@fortawesome/free-solid-svg-icons'; -import { Checkbox, SimpleCard } from '@shlinkio/shlink-frontend-kit'; -import classNames from 'classnames'; -import { parseISO } from 'date-fns'; -import { isEmpty } from 'ramda'; -import type { ChangeEvent, FC } from 'react'; -import { useEffect, useState } from 'react'; -import { Button, FormGroup, Input, Row } from 'reactstrap'; -import type { InputType } from 'reactstrap/types/lib/Input'; -import type { ShlinkCreateShortUrlData, ShlinkDeviceLongUrls, ShlinkEditShortUrlData } from '../api-contract'; -import type { DomainSelectorProps } from '../domains/DomainSelector'; -import type { TagsSelectorProps } from '../tags/helpers/TagsSelector'; -import { IconInput } from '../utils/components/IconInput'; -import type { DateTimeInputProps } from '../utils/dates/DateTimeInput'; -import { DateTimeInput } from '../utils/dates/DateTimeInput'; -import { formatIsoDate } from '../utils/dates/helpers/date'; -import { useFeature } from '../utils/features'; -import { handleEventPreventingDefault, hasValue } from '../utils/helpers'; -import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup'; -import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon'; -import './ShortUrlForm.scss'; - -export type Mode = 'create' | 'create-basic' | 'edit'; - -type DateFields = 'validSince' | 'validUntil'; -type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title'; - -export interface ShortUrlFormProps { - // FIXME Try to get rid of the mode param, and infer creation or edition from initialState if possible - mode: Mode; - saving: boolean; - initialState: T; - onSave: (shortUrlData: T) => Promise; -} - -const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date); - -const isCreationData = (data: ShlinkCreateShortUrlData | ShlinkEditShortUrlData): data is ShlinkCreateShortUrlData => - 'shortCodeLength' in data && 'customSlug' in data && 'domain' in data; - -export const ShortUrlForm = ( - TagsSelector: FC, - DomainSelector: FC, -) => function ShortUrlFormComp( - { mode, saving, onSave, initialState }: ShortUrlFormProps, -) { - const [shortUrlData, setShortUrlData] = useState(initialState); - const reset = () => setShortUrlData(initialState); - const supportsDeviceLongUrls = useFeature('deviceLongUrls'); - - const isEdit = mode === 'edit'; - const isCreation = isCreationData(shortUrlData); - const isBasicMode = mode === 'create-basic'; - const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags }); - const setResettableValue = (value: string, initialValue?: any) => { - if (hasValue(value)) { - return value; - } - - // If an initial value was provided for this when the input is "emptied", explicitly set it to null so that the - // value gets removed. Otherwise, set undefined so that it gets ignored. - return hasValue(initialValue) ? null : undefined; - }; - const submit = handleEventPreventingDefault(async () => onSave({ - ...shortUrlData, - validSince: formatIsoDate(shortUrlData.validSince) ?? null, - validUntil: formatIsoDate(shortUrlData.validUntil) ?? null, - maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits), - }).then(() => !isEdit && reset()).catch(() => {})); - - useEffect(() => { - setShortUrlData(initialState); - }, [initialState]); - - // TODO Consider extracting these functions to local components - const renderOptionalInput = ( - id: NonDateFields, - placeholder: string, - type: InputType = 'text', - props: any = {}, - fromGroupProps = {}, - ) => ( - - setShortUrlData({ ...shortUrlData, [id]: e.target.value }))} - {...props} - /> - - ); - const renderDeviceLongUrlInput = (id: keyof ShlinkDeviceLongUrls, placeholder: string, icon: IconProp) => ( - setShortUrlData({ - ...shortUrlData, - deviceLongUrls: { - ...(shortUrlData.deviceLongUrls ?? {}), - [id]: setResettableValue(e.target.value, initialState.deviceLongUrls?.[id]), - }, - })} - /> - ); - const renderDateInput = (id: DateFields, placeholder: string, props: Partial = {}) => ( - setShortUrlData({ ...shortUrlData, [id]: date })} - {...props} - /> - ); - const basicComponents = ( - <> - - setShortUrlData({ ...shortUrlData, longUrl: e.target.value })} - /> - - - {isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })} -
- -
-
- - ); - - return ( -
- {isBasicMode && basicComponents} - {!isBasicMode && ( - <> - -
- - {basicComponents} - -
- {supportsDeviceLongUrls && ( -
- - - {renderDeviceLongUrlInput('android', 'Android-specific redirection', faAndroid)} - - - {renderDeviceLongUrlInput('ios', 'iOS-specific redirection', faApple)} - - {renderDeviceLongUrlInput('desktop', 'Desktop-specific redirection', faDesktop)} - -
- )} -
- - -
- - {renderOptionalInput('title', 'Title', 'text', { - onChange: ({ target }: ChangeEvent) => setShortUrlData({ - ...shortUrlData, - title: setResettableValue(target.value, initialState.title), - }), - })} - {!isEdit && isCreation && ( - <> - -
- {renderOptionalInput('customSlug', 'Custom slug', 'text', { - disabled: hasValue(shortUrlData.shortCodeLength), - })} -
-
- {renderOptionalInput('shortCodeLength', 'Short code length', 'number', { - min: 4, - disabled: hasValue(shortUrlData.customSlug), - })} -
-
- setShortUrlData({ ...shortUrlData, domain })} - /> - - )} -
-
- -
- - {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} -
- {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })} -
- {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })} -
-
-
- - -
- - setShortUrlData({ ...shortUrlData, validateUrl })} - > - Validate URL - - {!isEdit && isCreation && ( -

- setShortUrlData({ ...shortUrlData, findIfExists })} - > - Use existing URL if found - - -

- )} -
-
-
- - setShortUrlData({ ...shortUrlData, crawlable })} - > - Make it crawlable - - setShortUrlData({ ...shortUrlData, forwardQuery })} - > - Forward query params on redirect - - -
-
- - )} - -
- -
-
- ); -}; diff --git a/shlink-web-component/src/short-urls/ShortUrlsFilteringBar.scss b/shlink-web-component/src/short-urls/ShortUrlsFilteringBar.scss deleted file mode 100644 index 32c75b20..00000000 --- a/shlink-web-component/src/short-urls/ShortUrlsFilteringBar.scss +++ /dev/null @@ -1,4 +0,0 @@ -.short-urls-filtering-bar__tags-icon { - vertical-align: bottom; - font-size: 1.6rem; -} diff --git a/shlink-web-component/src/short-urls/ShortUrlsFilteringBar.tsx b/shlink-web-component/src/short-urls/ShortUrlsFilteringBar.tsx deleted file mode 100644 index 9236df86..00000000 --- a/shlink-web-component/src/short-urls/ShortUrlsFilteringBar.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { faTag, faTags } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import type { OrderDir } from '@shlinkio/shlink-frontend-kit'; -import { OrderingDropdown, SearchField } from '@shlinkio/shlink-frontend-kit'; -import classNames from 'classnames'; -import { isEmpty, pipe } from 'ramda'; -import type { FC } from 'react'; -import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap'; -import type { TagsSelectorProps } from '../tags/helpers/TagsSelector'; -import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; -import { formatIsoDate } from '../utils/dates/helpers/date'; -import type { DateRange } from '../utils/dates/helpers/dateIntervals'; -import { datesToDateRange } from '../utils/dates/helpers/dateIntervals'; -import { useFeature } from '../utils/features'; -import { useSetting } from '../utils/settings'; -import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; -import { SHORT_URLS_ORDERABLE_FIELDS } from './data'; -import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn'; -import { useShortUrlsQuery } from './helpers/hooks'; -import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown'; -import './ShortUrlsFilteringBar.scss'; - -interface ShortUrlsFilteringProps { - order: ShortUrlsOrder; - handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void; - className?: string; - shortUrlsAmount?: number; -} - -export const ShortUrlsFilteringBar = ( - ExportShortUrlsBtn: FC, - TagsSelector: FC, -): FC => ({ className, shortUrlsAmount, order, handleOrderBy }) => { - const [filter, toFirstPage] = useShortUrlsQuery(); - const { - search, - tags, - startDate, - endDate, - excludeBots, - excludeMaxVisitsReached, - excludePastValidUntil, - tagsMode = 'any', - } = filter; - const supportsDisabledFiltering = useFeature('filterDisabledUrls'); - const visitsSettings = useSetting('visits'); - - const setDates = pipe( - ({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({ - startDate: formatIsoDate(theStartDate) ?? undefined, - endDate: formatIsoDate(theEndDate) ?? undefined, - }), - toFirstPage, - ); - const setSearch = pipe( - (searchTerm: string) => (isEmpty(searchTerm) ? undefined : searchTerm), - (searchTerm) => toFirstPage({ search: searchTerm }), - ); - const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags }); - const toggleTagsMode = pipe( - () => (tagsMode === 'any' ? 'all' : 'any'), - (mode) => toFirstPage({ tagsMode: mode }), - ); - - return ( -
- - - - - {tags.length > 1 && ( - <> - - - {tagsMode === 'all' ? 'With all the tags.' : 'With any of the tags.'} - - - )} - - - -
-
-
- -
- -
-
-
- -
-
- -
-
-
- ); -}; - -export type ShortUrlsFilteringBarType = ReturnType; diff --git a/shlink-web-component/src/short-urls/ShortUrlsList.tsx b/shlink-web-component/src/short-urls/ShortUrlsList.tsx deleted file mode 100644 index ad9d01b3..00000000 --- a/shlink-web-component/src/short-urls/ShortUrlsList.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import type { OrderDir } from '@shlinkio/shlink-frontend-kit'; -import { determineOrderDir } from '@shlinkio/shlink-frontend-kit'; -import { pipe } from 'ramda'; -import { useEffect, useState } from 'react'; -import { useLocation, useParams } from 'react-router-dom'; -import { Card } from 'reactstrap'; -import type { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api-contract'; -import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; -import { Topics } from '../mercure/helpers/Topics'; -import { useFeature } from '../utils/features'; -import { useSettings } from '../utils/settings'; -import { TableOrderIcon } from '../utils/table/TableOrderIcon'; -import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; -import { useShortUrlsQuery } from './helpers/hooks'; -import { Paginator } from './Paginator'; -import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; -import type { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar'; -import type { ShortUrlsTableType } from './ShortUrlsTable'; - -interface ShortUrlsListProps { - shortUrlsList: ShortUrlsListState; - listShortUrls: (params: ShlinkShortUrlsListParams) => void; -} - -const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = { - field: 'dateCreated', - dir: 'DESC', -}; - -export const ShortUrlsList = ( - ShortUrlsTable: ShortUrlsTableType, - ShortUrlsFilteringBar: ShortUrlsFilteringBarType, -) => boundToMercureHub(({ listShortUrls, shortUrlsList }: ShortUrlsListProps) => { - const { page } = useParams(); - const location = useLocation(); - const [filter, toFirstPage] = useShortUrlsQuery(); - const settings = useSettings(); - const { - tags, - search, - startDate, - endDate, - orderBy, - tagsMode, - excludeBots, - excludePastValidUntil, - excludeMaxVisitsReached, - } = filter; - const [actualOrderBy, setActualOrderBy] = useState( - // This separated state handling is needed to be able to fall back to settings value, but only once when loaded - orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING, - ); - const { pagination } = shortUrlsList?.shortUrls ?? {}; - const doExcludeBots = excludeBots ?? settings.visits?.excludeBots; - const supportsExcludingBots = useFeature('excludeBotsOnShortUrls'); - const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => { - toFirstPage({ orderBy: { field, dir } }); - setActualOrderBy({ field, dir }); - }; - const orderByColumn = (field: ShortUrlsOrderableFields) => () => - handleOrderBy(field, determineOrderDir(field, actualOrderBy.field, actualOrderBy.dir)); - const renderOrderIcon = (field: ShortUrlsOrderableFields) => - ; - const addTag = pipe( - (newTag: string) => [...new Set([...tags, newTag])], - (updatedTags) => toFirstPage({ tags: updatedTags }), - ); - const parseOrderByForShlink = ({ field, dir }: ShortUrlsOrder): ShlinkShortUrlsOrder => { - if (supportsExcludingBots && doExcludeBots && field === 'visits') { - return { field: 'nonBotVisits', dir }; - } - - return { field, dir }; - }; - - useEffect(() => { - listShortUrls({ - page, - searchTerm: search, - tags, - startDate, - endDate, - orderBy: parseOrderByForShlink(actualOrderBy), - tagsMode, - excludePastValidUntil, - excludeMaxVisitsReached, - }); - }, [ - page, - search, - tags, - startDate, - endDate, - actualOrderBy.field, - actualOrderBy.dir, - tagsMode, - excludePastValidUntil, - excludeMaxVisitsReached, - ]); - - return ( - <> - - - - - - - ); -}, () => [Topics.visits]); diff --git a/shlink-web-component/src/short-urls/ShortUrlsTable.scss b/shlink-web-component/src/short-urls/ShortUrlsTable.scss deleted file mode 100644 index 29ba354f..00000000 --- a/shlink-web-component/src/short-urls/ShortUrlsTable.scss +++ /dev/null @@ -1,7 +0,0 @@ -.short-urls-table.short-urls-table { - margin-bottom: -1px; -} - -.short-urls-table__header-cell--with-action { - cursor: pointer; -} diff --git a/shlink-web-component/src/short-urls/ShortUrlsTable.tsx b/shlink-web-component/src/short-urls/ShortUrlsTable.tsx deleted file mode 100644 index 53d09f59..00000000 --- a/shlink-web-component/src/short-urls/ShortUrlsTable.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import classNames from 'classnames'; -import { isEmpty } from 'ramda'; -import type { ReactNode } from 'react'; -import type { ShortUrlsOrderableFields } from './data'; -import type { ShortUrlsRowType } from './helpers/ShortUrlsRow'; -import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; -import './ShortUrlsTable.scss'; - -interface ShortUrlsTableProps { - orderByColumn?: (column: ShortUrlsOrderableFields) => () => void; - renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode; - shortUrlsList: ShortUrlsListState; - onTagClick?: (tag: string) => void; - className?: string; -} - -export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({ - orderByColumn, - renderOrderIcon, - shortUrlsList, - onTagClick, - className, -}: ShortUrlsTableProps) => { - const { error, loading, shortUrls } = shortUrlsList; - const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn }); - const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses); - const tableClasses = classNames('table table-hover responsive-table short-urls-table', className); - - const renderShortUrls = () => { - if (error) { - return ( - - - Something went wrong while loading short URLs :( - - - ); - } - - if (loading) { - return Loading...; - } - - if (!loading && isEmpty(shortUrls?.data)) { - return No results found; - } - - return shortUrls?.data.map((shortUrl) => ( - - )); - }; - - return ( - - - - - - - - - - - - {renderShortUrls()} - -
- Created at {renderOrderIcon?.('dateCreated')} - - Short URL {renderOrderIcon?.('shortCode')} - - - Title {renderOrderIcon?.('title')} - -   /   - - Long URL {renderOrderIcon?.('longUrl')} - - Tags - Visits {renderOrderIcon?.('visits')} - -
- ); -}; - -export type ShortUrlsTableType = ReturnType; diff --git a/shlink-web-component/src/short-urls/UseExistingIfFoundInfoIcon.scss b/shlink-web-component/src/short-urls/UseExistingIfFoundInfoIcon.scss deleted file mode 100644 index ff21131f..00000000 --- a/shlink-web-component/src/short-urls/UseExistingIfFoundInfoIcon.scss +++ /dev/null @@ -1,7 +0,0 @@ -.use-existing-if-found-info-icon__modal-quote { - margin-bottom: 0; - padding: 10px 15px; - font-size: 17.5px; - border-left: 5px solid #eeeeee; - background-color: #f9f9f9; -} diff --git a/shlink-web-component/src/short-urls/UseExistingIfFoundInfoIcon.tsx b/shlink-web-component/src/short-urls/UseExistingIfFoundInfoIcon.tsx deleted file mode 100644 index 0fea66a9..00000000 --- a/shlink-web-component/src/short-urls/UseExistingIfFoundInfoIcon.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useToggle } from '@shlinkio/shlink-frontend-kit'; -import { Modal, ModalBody, ModalHeader } from 'reactstrap'; -import './UseExistingIfFoundInfoIcon.scss'; - -const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => ( - - Info - -

- When the  - "Use existing URL if found" -  checkbox is checked, the server will return an existing short URL if it matches provided params. -

-

- These are the checks performed by Shlink in order to determine if an existing short URL should be returned: -

-
    -
  • - When only the long URL is provided: The most recent match will be returned, or a new short URL will be created - if none is found. -
  • -
  • - When long URL and custom slug and/or domain are provided: Same as in previous case, but it will try to match - the short URL using both the long URL and the slug, the long URL and the domain, or the three of them. -
    - If the slug is being used by another long URL, an error will be returned. -
  • -
  • - When other params are provided: Same as in previous cases, but it will try to match existing short URLs with - all provided data. If any of them does not match, a new short URL will be created -
  • -
-
-
-); - -export const UseExistingIfFoundInfoIcon = () => { - const [isModalOpen, toggleModal] = useToggle(); - - return ( - <> - - - - - - ); -}; diff --git a/shlink-web-component/src/short-urls/data/index.ts b/shlink-web-component/src/short-urls/data/index.ts deleted file mode 100644 index 64b8a2b2..00000000 --- a/shlink-web-component/src/short-urls/data/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Order } from '@shlinkio/shlink-frontend-kit'; -import type { ShlinkShortUrl } from '../../api-contract'; -import type { OptionalString } from '../../utils/helpers'; - -export interface ShortUrlIdentifier { - shortCode: string; - domain?: OptionalString; -} - -export interface ShortUrlModalProps { - shortUrl: ShlinkShortUrl; - isOpen: boolean; - toggle: () => void; -} - -export const SHORT_URLS_ORDERABLE_FIELDS = { - dateCreated: 'Created at', - shortCode: 'Short URL', - longUrl: 'Long URL', - title: 'Title', - visits: 'Visits', -}; - -export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS; - -export type ShortUrlsOrder = Order; - -export interface ExportableShortUrl { - createdAt: string; - title: string; - shortUrl: string; - domain?: string; - shortCode: string; - longUrl: string; - tags: string; - visits: number; -} - -export interface ShortUrlsFilter { - excludeBots?: boolean; - excludeMaxVisitsReached?: boolean; - excludePastValidUntil?: boolean; -} diff --git a/shlink-web-component/src/short-urls/helpers/CreateShortUrlResult.scss b/shlink-web-component/src/short-urls/helpers/CreateShortUrlResult.scss deleted file mode 100644 index 97ba3d87..00000000 --- a/shlink-web-component/src/short-urls/helpers/CreateShortUrlResult.scss +++ /dev/null @@ -1,4 +0,0 @@ -.create-short-url-result__copy-btn { - margin-left: 10px; - vertical-align: inherit; -} diff --git a/shlink-web-component/src/short-urls/helpers/CreateShortUrlResult.tsx b/shlink-web-component/src/short-urls/helpers/CreateShortUrlResult.tsx deleted file mode 100644 index 46ed1167..00000000 --- a/shlink-web-component/src/short-urls/helpers/CreateShortUrlResult.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons'; -import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Result } from '@shlinkio/shlink-frontend-kit'; -import { useEffect } from 'react'; -import CopyToClipboard from 'react-copy-to-clipboard'; -import { Tooltip } from 'reactstrap'; -import { ShlinkApiError } from '../../common/ShlinkApiError'; -import type { TimeoutToggle } from '../../utils/helpers/hooks'; -import type { ShortUrlCreation } from '../reducers/shortUrlCreation'; -import './CreateShortUrlResult.scss'; - -export interface CreateShortUrlResultProps { - creation: ShortUrlCreation; - resetCreateShortUrl: () => void; - canBeClosed?: boolean; -} - -export const CreateShortUrlResult = (useTimeoutToggle: TimeoutToggle) => ( - { creation, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps, -) => { - const [showCopyTooltip, setShowCopyTooltip] = useTimeoutToggle(); - const { error, saved } = creation; - - useEffect(() => { - resetCreateShortUrl(); - }, []); - - if (error) { - return ( - - {canBeClosed && } - - - ); - } - - if (!saved) { - return null; - } - - const { shortUrl } = creation.result; - - return ( - - {canBeClosed && } - Great! The short URL is {shortUrl} - - - - - - - Copied! - - - ); -}; diff --git a/shlink-web-component/src/short-urls/helpers/DeleteShortUrlModal.tsx b/shlink-web-component/src/short-urls/helpers/DeleteShortUrlModal.tsx deleted file mode 100644 index 1c4bf2dc..00000000 --- a/shlink-web-component/src/short-urls/helpers/DeleteShortUrlModal.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Result } from '@shlinkio/shlink-frontend-kit'; -import { pipe } from 'ramda'; -import { useEffect, useState } from 'react'; -import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; -import { isInvalidDeletionError } from '../../api-contract/utils'; -import { ShlinkApiError } from '../../common/ShlinkApiError'; -import { handleEventPreventingDefault } from '../../utils/helpers'; -import type { ShortUrlIdentifier, ShortUrlModalProps } from '../data'; -import type { ShortUrlDeletion } from '../reducers/shortUrlDeletion'; - -interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps { - shortUrlDeletion: ShortUrlDeletion; - deleteShortUrl: (shortUrl: ShortUrlIdentifier) => Promise; - shortUrlDeleted: (shortUrl: ShortUrlIdentifier) => void; - resetDeleteShortUrl: () => void; -} - -const DELETION_PATTERN = 'delete'; - -export const DeleteShortUrlModal = ({ - shortUrl, - toggle, - isOpen, - shortUrlDeletion, - resetDeleteShortUrl, - deleteShortUrl, - shortUrlDeleted, -}: DeleteShortUrlModalConnectProps) => { - const [inputValue, setInputValue] = useState(''); - - useEffect(() => resetDeleteShortUrl, []); - - const { loading, error, deleted, errorData } = shortUrlDeletion; - const close = pipe(resetDeleteShortUrl, toggle); - const handleDeleteUrl = handleEventPreventingDefault(() => deleteShortUrl(shortUrl).then(toggle)); - - return ( - deleted && shortUrlDeleted(shortUrl)}> -
- - Delete short URL - - -

Caution! You are about to delete a short URL.

-

This action cannot be undone. Once you have deleted it, all the visits stats will be lost.

-

Write {DELETION_PATTERN} to confirm deletion.

- - setInputValue(e.target.value)} - /> - - {error && ( - - - - )} -
- - - - -
-
- ); -}; diff --git a/shlink-web-component/src/short-urls/helpers/ExportShortUrlsBtn.tsx b/shlink-web-component/src/short-urls/helpers/ExportShortUrlsBtn.tsx deleted file mode 100644 index 073f846a..00000000 --- a/shlink-web-component/src/short-urls/helpers/ExportShortUrlsBtn.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useToggle } from '@shlinkio/shlink-frontend-kit'; -import type { FC } from 'react'; -import { useCallback } from 'react'; -import type { ShlinkApiClient, ShlinkShortUrl } from '../../api-contract'; -import { ExportBtn } from '../../utils/components/ExportBtn'; -import type { ReportExporter } from '../../utils/services/ReportExporter'; -import { useShortUrlsQuery } from './hooks'; - -export interface ExportShortUrlsBtnProps { - amount?: number; -} - -const itemsPerPage = 20; - -export const ExportShortUrlsBtn = ( - apiClientFactory: () => ShlinkApiClient, - { exportShortUrls }: ReportExporter, -): FC => ({ amount = 0 }) => { - const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery(); - const [loading,, startLoading, stopLoading] = useToggle(); - const exportAllUrls = useCallback(async () => { - const totalPages = amount / itemsPerPage; - const loadAllUrls = async (page = 1): Promise => { - const { data } = await apiClientFactory().listShortUrls( - { page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage }, - ); - - if (page >= totalPages) { - return data; - } - - // TODO Support paralelization - return data.concat(await loadAllUrls(page + 1)); - }; - - startLoading(); - const shortUrls = await loadAllUrls(); - - exportShortUrls(shortUrls.map((shortUrl) => { - const { hostname: domain, pathname } = new URL(shortUrl.shortUrl); - const shortCode = pathname.substring(1); // Remove trailing slash - - return { - createdAt: shortUrl.dateCreated, - domain, - shortCode, - shortUrl: shortUrl.shortUrl, - longUrl: shortUrl.longUrl, - title: shortUrl.title ?? '', - tags: shortUrl.tags.join('|'), - visits: shortUrl?.visitsSummary?.total ?? shortUrl.visitsCount, - }; - })); - stopLoading(); - }, []); - - return ; -}; diff --git a/shlink-web-component/src/short-urls/helpers/QrCodeModal.scss b/shlink-web-component/src/short-urls/helpers/QrCodeModal.scss deleted file mode 100644 index 40b9ca40..00000000 --- a/shlink-web-component/src/short-urls/helpers/QrCodeModal.scss +++ /dev/null @@ -1,4 +0,0 @@ -.qr-code-modal__img { - max-width: 100%; - box-shadow: 0 0 .25rem rgb(0 0 0 / .2); -} diff --git a/shlink-web-component/src/short-urls/helpers/QrCodeModal.tsx b/shlink-web-component/src/short-urls/helpers/QrCodeModal.tsx deleted file mode 100644 index 82dc3ab6..00000000 --- a/shlink-web-component/src/short-urls/helpers/QrCodeModal.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useMemo, useState } from 'react'; -import { ExternalLink } from 'react-external-link'; -import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; -import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon'; -import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes'; -import { buildQrCodeUrl } from '../../utils/helpers/qrCodes'; -import type { ImageDownloader } from '../../utils/services/ImageDownloader'; -import type { ShortUrlModalProps } from '../data'; -import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown'; -import { QrFormatDropdown } from './qr-codes/QrFormatDropdown'; -import './QrCodeModal.scss'; - -export const QrCodeModal = (imageDownloader: ImageDownloader) => ( - { shortUrl: { shortUrl, shortCode }, toggle, isOpen }: ShortUrlModalProps, -) => { - const [size, setSize] = useState(300); - const [margin, setMargin] = useState(0); - const [format, setFormat] = useState('png'); - const [errorCorrection, setErrorCorrection] = useState('L'); - const qrCodeUrl = useMemo( - () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }), - [shortUrl, size, format, margin, errorCorrection], - ); - const totalSize = useMemo(() => size + margin, [size, margin]); - const modalSize = useMemo(() => { - if (totalSize < 500) { - return undefined; - } - - return totalSize < 800 ? 'lg' : 'xl'; - }, [totalSize]); - - return ( - - - QR code for {shortUrl} - - - - - - setSize(Number(e.target.value))} - /> - - - - setMargin(Number(e.target.value))} - /> - - - - - - - - -
-
- - -
- QR code -
- -
-
-
-
- ); -}; diff --git a/shlink-web-component/src/short-urls/helpers/ShortUrlDetailLink.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlDetailLink.tsx deleted file mode 100644 index 0f45e8a5..00000000 --- a/shlink-web-component/src/short-urls/helpers/ShortUrlDetailLink.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { FC } from 'react'; -import { Link } from 'react-router-dom'; -import type { ShlinkShortUrl } from '../../api-contract'; -import { useRoutesPrefix } from '../../utils/routesPrefix'; -import { urlEncodeShortCode } from './index'; - -export type LinkSuffix = 'visits' | 'edit'; - -export interface ShortUrlDetailLinkProps { - shortUrl?: ShlinkShortUrl | null; - suffix: LinkSuffix; - asLink?: boolean; -} - -const buildUrl = (routePrefix: string, { shortCode, domain }: ShlinkShortUrl, suffix: LinkSuffix) => { - const query = domain ? `?domain=${domain}` : ''; - return `${routePrefix}/short-code/${urlEncodeShortCode(shortCode)}/${suffix}${query}`; -}; - -export const ShortUrlDetailLink: FC> = ( - { shortUrl, suffix, asLink, children, ...rest }, -) => { - const routePrefix = useRoutesPrefix(); - if (!asLink || !shortUrl) { - return {children}; - } - - return {children}; -}; diff --git a/shlink-web-component/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx deleted file mode 100644 index af7c88d7..00000000 --- a/shlink-web-component/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Checkbox } from '@shlinkio/shlink-frontend-kit'; -import type { ChangeEvent, FC, PropsWithChildren } from 'react'; -import { InfoTooltip } from '../../utils/components/InfoTooltip'; - -type ShortUrlFormCheckboxGroupProps = PropsWithChildren<{ - checked?: boolean; - onChange?: (checked: boolean, e: ChangeEvent) => void; - infoTooltip?: string; -}>; - -export const ShortUrlFormCheckboxGroup: FC = ( - { children, infoTooltip, checked, onChange }, -) => ( -

- - {children} - - {infoTooltip && {infoTooltip}} -

-); diff --git a/shlink-web-component/src/short-urls/helpers/ShortUrlStatus.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlStatus.tsx deleted file mode 100644 index 419706f0..00000000 --- a/shlink-web-component/src/short-urls/helpers/ShortUrlStatus.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { IconDefinition } from '@fortawesome/fontawesome-common-types'; -import { faCalendarXmark, faCheck, faLinkSlash } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useElementRef } from '@shlinkio/shlink-frontend-kit'; -import { isBefore } from 'date-fns'; -import type { FC, ReactNode } from 'react'; -import { UncontrolledTooltip } from 'reactstrap'; -import type { ShlinkShortUrl } from '../../api-contract'; -import { formatHumanFriendly, now, parseISO } from '../../utils/dates/helpers/date'; - -interface ShortUrlStatusProps { - shortUrl: ShlinkShortUrl; -} - -interface StatusResult { - icon: IconDefinition; - className: string; - description: ReactNode; -} - -const resolveShortUrlStatus = (shortUrl: ShlinkShortUrl): StatusResult => { - const { meta, visitsCount, visitsSummary } = shortUrl; - const { maxVisits, validSince, validUntil } = meta; - const totalVisits = visitsSummary?.total ?? visitsCount; - - if (maxVisits && totalVisits >= maxVisits) { - return { - icon: faLinkSlash, - className: 'text-danger', - description: ( - <> - This short URL cannot be currently visited because it has reached the maximum - amount of {maxVisits} visit{maxVisits > 1 ? 's' : ''}. - - ), - }; - } - - if (validUntil && isBefore(parseISO(validUntil), now())) { - return { - icon: faCalendarXmark, - className: 'text-danger', - description: ( - <> - This short URL cannot be visited - since {formatHumanFriendly(parseISO(validUntil))}. - - ), - }; - } - - if (validSince && isBefore(now(), parseISO(validSince))) { - return { - icon: faCalendarXmark, - className: 'text-warning', - description: ( - <> - This short URL will start working - on {formatHumanFriendly(parseISO(validSince))}. - - ), - }; - } - - return { - icon: faCheck, - className: 'text-primary', - description: 'This short URL can be visited normally.', - }; -}; - -export const ShortUrlStatus: FC = ({ shortUrl }) => { - const tooltipRef = useElementRef(); - const { icon, className, description } = resolveShortUrlStatus(shortUrl); - - return ( - <> - - - - - {description} - - - ); -}; diff --git a/shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.scss b/shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.scss deleted file mode 100644 index 7ae9b852..00000000 --- a/shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.scss +++ /dev/null @@ -1,16 +0,0 @@ -.short-urls-visits-count__max-visits-control { - cursor: help; -} - -.short-url-visits-count__amount { - transition: transform .3s ease; - display: inline-block; -} - -.short-url-visits-count__amount--big { - transform: scale(1.5); -} - -.short-url-visits-count__tooltip-list-item:not(:last-child) { - margin-bottom: .5rem; -} diff --git a/shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.tsx deleted file mode 100644 index a66e16f9..00000000 --- a/shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useElementRef } from '@shlinkio/shlink-frontend-kit'; -import classNames from 'classnames'; -import { UncontrolledTooltip } from 'reactstrap'; -import type { ShlinkShortUrl } from '../../api-contract'; -import { formatHumanFriendly, parseISO } from '../../utils/dates/helpers/date'; -import { prettify } from '../../utils/helpers/numbers'; -import { ShortUrlDetailLink } from './ShortUrlDetailLink'; -import './ShortUrlVisitsCount.scss'; - -interface ShortUrlVisitsCountProps { - shortUrl?: ShlinkShortUrl | null; - visitsCount: number; - active?: boolean; - asLink?: boolean; -} - -export const ShortUrlVisitsCount = ( - { visitsCount, shortUrl, active = false, asLink = false }: ShortUrlVisitsCountProps, -) => { - const { maxVisits, validSince, validUntil } = shortUrl?.meta ?? {}; - const hasLimit = !!maxVisits || !!validSince || !!validUntil; - const visitsLink = ( - - - {prettify(visitsCount)} - - - ); - - if (!hasLimit) { - return visitsLink; - } - - const tooltipRef = useElementRef(); - - return ( - <> - - {visitsLink} - - {maxVisits && <> / {prettify(maxVisits)}} - - - - - - -
    - {maxVisits && ( -
  • - This short URL will not accept more than {prettify(maxVisits)} visit{maxVisits === 1 ? '' : 's'}. -
  • - )} - {validSince && ( -
  • - This short URL will not accept visits - before {formatHumanFriendly(parseISO(validSince))}. -
  • - )} - {validUntil && ( -
  • - This short URL will not accept visits - after {formatHumanFriendly(parseISO(validUntil))}. -
  • - )} -
-
- - ); -}; diff --git a/shlink-web-component/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx deleted file mode 100644 index fd1bb21f..00000000 --- a/shlink-web-component/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { DropdownBtn } from '@shlinkio/shlink-frontend-kit'; -import { DropdownItem } from 'reactstrap'; -import { hasValue } from '../../utils/helpers'; -import type { ShortUrlsFilter } from '../data'; - -interface ShortUrlsFilterDropdownProps { - onChange: (filters: ShortUrlsFilter) => void; - supportsDisabledFiltering: boolean; - selected?: ShortUrlsFilter; - className?: string; -} - -export const ShortUrlsFilterDropdown = ( - { onChange, selected = {}, className, supportsDisabledFiltering }: ShortUrlsFilterDropdownProps, -) => { - const { excludeBots = false, excludeMaxVisitsReached = false, excludePastValidUntil = false } = selected; - const onFilterClick = (key: keyof ShortUrlsFilter) => () => onChange({ ...selected, [key]: !selected?.[key] }); - - return ( - - Visits: - Ignore visits from bots - - {supportsDisabledFiltering && ( - <> - - Short URLs: - - Exclude with visits reached - - - Exclude enabled in the past - - - )} - - - onChange({ excludeBots: false, excludeMaxVisitsReached: false, excludePastValidUntil: false })} - > - Clear filters - - - ); -}; diff --git a/shlink-web-component/src/short-urls/helpers/ShortUrlsRow.scss b/shlink-web-component/src/short-urls/helpers/ShortUrlsRow.scss deleted file mode 100644 index f4278ea1..00000000 --- a/shlink-web-component/src/short-urls/helpers/ShortUrlsRow.scss +++ /dev/null @@ -1,46 +0,0 @@ -@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; -@import '../../utils/mixins/vertical-align'; - -@mixin text-ellipsis() { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; -} - -.short-urls-row__cell.short-urls-row__cell { - vertical-align: middle !important; -} - -.short-urls-row__cell--break { - word-break: break-all; -} - -.short-urls-row__cell--indivisible { - @media (min-width: $lgMin) { - white-space: nowrap; - } -} - -.short-urls-row__short-url-wrapper { - @media (max-width: $mdMax) { - word-break: break-all; - } - - @media (min-width: $lgMin) { - @include text-ellipsis(); - - vertical-align: bottom; - display: inline-block; - max-width: 18rem; - } -} - -.short-urls-row__copy-hint { - @include vertical-align(translateX(10px)); - - box-shadow: 0 3px 15px rgb(0 0 0 / .25); - - @media (max-width: $responsiveTableBreakpoint) { - @include vertical-align(translateX(calc(-100% - 20px))); - } -} diff --git a/shlink-web-component/src/short-urls/helpers/ShortUrlsRow.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlsRow.tsx deleted file mode 100644 index 41f736a0..00000000 --- a/shlink-web-component/src/short-urls/helpers/ShortUrlsRow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import type { FC } from 'react'; -import { useEffect, useRef } from 'react'; -import { ExternalLink } from 'react-external-link'; -import type { ShlinkShortUrl } from '../../api-contract'; -import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon'; -import { Time } from '../../utils/dates/Time'; -import type { TimeoutToggle } from '../../utils/helpers/hooks'; -import type { ColorGenerator } from '../../utils/services/ColorGenerator'; -import { useSetting } from '../../utils/settings'; -import { useShortUrlsQuery } from './hooks'; -import type { ShortUrlsRowMenuType } from './ShortUrlsRowMenu'; -import { ShortUrlStatus } from './ShortUrlStatus'; -import { ShortUrlVisitsCount } from './ShortUrlVisitsCount'; -import { Tags } from './Tags'; -import './ShortUrlsRow.scss'; - -interface ShortUrlsRowProps { - onTagClick?: (tag: string) => void; - shortUrl: ShlinkShortUrl; -} - -export type ShortUrlsRowType = FC; - -export const ShortUrlsRow = ( - ShortUrlsRowMenu: ShortUrlsRowMenuType, - colorGenerator: ColorGenerator, - useTimeoutToggle: TimeoutToggle, -) => ({ shortUrl, onTagClick }: ShortUrlsRowProps) => { - const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle(); - const [active, setActive] = useTimeoutToggle(false, 500); - const isFirstRun = useRef(true); - const [{ excludeBots }] = useShortUrlsQuery(); - const visits = useSetting('visits'); - const doExcludeBots = excludeBots ?? visits?.excludeBots; - - useEffect(() => { - !isFirstRun.current && setActive(); - isFirstRun.current = false; - }, [shortUrl.visitsSummary?.total, shortUrl.visitsSummary?.nonBots, shortUrl.visitsCount]); - - return ( - - -
- )} - /> - - } /> - - - - ); - }; - - return ( - <> - {children} - -
-
-
-
-
- -
- updateFiltering({ visitsFilter: newVisitsFilter })} - /> -
-
- {visits.length > 0 && ( -
-
- exportCsv(normalizedVisits)} - /> - -
-
- )} -
-
- -
- {renderVisitsContent()} -
- - ); -}; diff --git a/shlink-web-component/src/visits/VisitsTable.scss b/shlink-web-component/src/visits/VisitsTable.scss deleted file mode 100644 index 0aa5736f..00000000 --- a/shlink-web-component/src/visits/VisitsTable.scss +++ /dev/null @@ -1,39 +0,0 @@ -@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; -@import '../utils/mixins/sticky-cell'; - -.visits-table { - margin: 1.5rem 0 0; - position: relative; - background-color: var(--primary-color); - overflow-y: hidden; -} - -.visits-table__header-cell { - cursor: pointer; - margin-bottom: 55px; - - @include sticky-cell(); - - @media (min-width: $mdMin) { - &.visits-table__sticky { - top: $headerHeight + 40px; - } - } -} - -.visits-table__header-icon { - float: right; - margin-top: 3px; -} - -.visits-table__footer-cell.visits-table__footer-cell { - bottom: 0; - margin-top: 34px; - padding: .5rem; - - @include sticky-cell(); -} - -.visits-table__sticky.visits-table__sticky { - position: sticky; -} diff --git a/shlink-web-component/src/visits/VisitsTable.tsx b/shlink-web-component/src/visits/VisitsTable.tsx deleted file mode 100644 index 39a18c7a..00000000 --- a/shlink-web-component/src/visits/VisitsTable.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import type { Order } from '@shlinkio/shlink-frontend-kit'; -import { determineOrderDir, SearchField, sortList } from '@shlinkio/shlink-frontend-kit'; -import classNames from 'classnames'; -import { min, splitEvery } from 'ramda'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { UncontrolledTooltip } from 'reactstrap'; -import { SimplePaginator } from '../utils/components/SimplePaginator'; -import { Time } from '../utils/dates/Time'; -import { prettify } from '../utils/helpers/numbers'; -import { TableOrderIcon } from '../utils/table/TableOrderIcon'; -import type { MediaMatcher } from '../utils/types'; -import type { NormalizedOrphanVisit, NormalizedVisit } from './types'; -import './VisitsTable.scss'; - -export interface VisitsTableProps { - visits: NormalizedVisit[]; - selectedVisits?: NormalizedVisit[]; - setSelectedVisits: (visits: NormalizedVisit[]) => void; - matchMedia?: MediaMatcher; - isOrphanVisits?: boolean; -} - -type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot'; -type VisitsOrder = Order; - -const PAGE_SIZE = 20; -const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) => - `${browser} ${os} ${referer} ${country} ${city} ${(rest as NormalizedOrphanVisit).visitedUrl}`.toLowerCase().includes( - searchTerm.toLowerCase(), - ); -const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) => - visits.filter((visit) => visitMatchesSearch(visit, searchTerm)); -const sortVisits = (order: VisitsOrder, visits: NormalizedVisit[]) => sortList(visits, order as any); -const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: VisitsOrder) => { - const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [...allVisits]; - const sortedVisits = sortVisits(order, filteredVisits); - const total = sortedVisits.length; - const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits); - - return { visitsGroups, total }; -}; - -export const VisitsTable = ({ - visits, - selectedVisits = [], - setSelectedVisits, - matchMedia = window.matchMedia, - isOrphanVisits = false, -}: VisitsTableProps) => { - const headerCellsClass = 'visits-table__header-cell visits-table__sticky'; - const matchMobile = () => matchMedia('(max-width: 767px)').matches; - - const [isMobileDevice, setIsMobileDevice] = useState(matchMobile()); - const [searchTerm, setSearchTerm] = useState(undefined); - const [order, setOrder] = useState({}); - const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [searchTerm, order]); - const isFirstLoad = useRef(true); - const [page, setPage] = useState(1); - const end = page * PAGE_SIZE; - const start = end - PAGE_SIZE; - const fullSizeColSpan = 8 + Number(isOrphanVisits); - - const orderByColumn = (field: OrderableFields) => - () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); - const renderOrderIcon = (field: OrderableFields) => - ; - - useEffect(() => { - const listener = () => setIsMobileDevice(matchMobile()); - - window.addEventListener('resize', listener); - - return () => window.removeEventListener('resize', listener); - }, []); - useEffect(() => { - setPage(1); - - !isFirstLoad.current && setSelectedVisits([]); - isFirstLoad.current = false; - }, [searchTerm]); - - return ( -
- - - - - - - - - - - - {isOrphanVisits && ( - - )} - - - - - - - {!resultSet.visitsGroups[page - 1]?.length && ( - - - - )} - {resultSet.visitsGroups[page - 1]?.map((visit, index) => { - const isSelected = selectedVisits.includes(visit); - - return ( - setSelectedVisits( - isSelected ? selectedVisits.filter((v) => v !== visit) : [...selectedVisits, visit], - )} - > - - - - - - - - - {isOrphanVisits && } - - ); - })} - - {resultSet.total > PAGE_SIZE && ( - - - - - - )} -
setSelectedVisits( - selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [], - )} - > - 0 })} /> - - - {renderOrderIcon('potentialBot')} - - Date - {renderOrderIcon('date')} - - Country - {renderOrderIcon('country')} - - City - {renderOrderIcon('city')} - - Browser - {renderOrderIcon('browser')} - - OS - {renderOrderIcon('os')} - - Referrer - {renderOrderIcon('referer')} - - Visited URL - {renderOrderIcon('visitedUrl')} -
- -
- No visits found with current filtering -
- {isSelected && } - - {visit.potentialBot && ( - <> - - - Potentially a visit from a bot or crawler - - - )} - {visit.country}{visit.city}{visit.browser}{visit.os}{visit.referer}{(visit as NormalizedOrphanVisit).visitedUrl}
-
-
- -
-
-
- Visits {prettify(start + 1)} to{' '} - {prettify(min(end, resultSet.total))} of{' '} - {prettify(resultSet.total)} -
-
-
-
-
- ); -}; diff --git a/shlink-web-component/src/visits/charts/ChartCard.scss b/shlink-web-component/src/visits/charts/ChartCard.scss deleted file mode 100644 index f3220ebf..00000000 --- a/shlink-web-component/src/visits/charts/ChartCard.scss +++ /dev/null @@ -1,4 +0,0 @@ -.chart-card__footer--sticky { - position: sticky; - bottom: 0; -} diff --git a/shlink-web-component/src/visits/charts/ChartCard.tsx b/shlink-web-component/src/visits/charts/ChartCard.tsx deleted file mode 100644 index ed2f0be6..00000000 --- a/shlink-web-component/src/visits/charts/ChartCard.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { FC, PropsWithChildren, ReactNode } from 'react'; -import { Card, CardBody, CardFooter, CardHeader } from 'reactstrap'; -import './ChartCard.scss'; - -type ChartCardProps = PropsWithChildren<{ - title: Function | string; - footer?: ReactNode; -}>; - -export const ChartCard: FC = ({ title, footer, children }) => ( - - {typeof title === 'function' ? title() : title} - {children} - {footer && {footer}} - -); diff --git a/shlink-web-component/src/visits/charts/DoughnutChart.tsx b/shlink-web-component/src/visits/charts/DoughnutChart.tsx deleted file mode 100644 index 57843a76..00000000 --- a/shlink-web-component/src/visits/charts/DoughnutChart.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '@shlinkio/shlink-frontend-kit'; -import type { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js'; -import { keys, values } from 'ramda'; -import type { FC } from 'react'; -import { memo, useState } from 'react'; -import { Doughnut } from 'react-chartjs-2'; -import { renderPieChartLabel } from '../../utils/helpers/charts'; -import type { Stats } from '../types'; -import { DoughnutChartLegend } from './DoughnutChartLegend'; - -interface DoughnutChartProps { - stats: Stats; -} - -const generateChartDatasets = (data: number[]): ChartDataset[] => [ - { - data, - backgroundColor: [ - '#97BBCD', - '#F7464A', - '#46BFBD', - '#FDB45C', - '#949FB1', - '#57A773', - '#414066', - '#08B2E3', - '#B6C454', - '#DCDCDC', - '#463730', - ], - borderColor: isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR, - borderWidth: 2, - }, -]; -const generateChartData = (labels: string[], data: number[]): ChartData => ({ - labels, - datasets: generateChartDatasets(data), -}); - -export const DoughnutChart: FC = memo(({ stats }) => { - // Cannot use useRef here, as we need to re-render as soon as the ref is set - const [chartRef, setChartRef] = useState(); - const labels = keys(stats); - const data = values(stats); - - const options: ChartOptions = { - plugins: { - legend: { display: false }, - tooltip: { - intersect: true, - callbacks: { label: renderPieChartLabel }, - }, - }, - }; - const chartData = generateChartData(labels, data); - - return ( -
-
- { - if (element) { - setChartRef(element as Chart); - } - }} - /> -
-
- {chartRef && } -
-
- ); -}); diff --git a/shlink-web-component/src/visits/charts/DoughnutChartCard.tsx b/shlink-web-component/src/visits/charts/DoughnutChartCard.tsx deleted file mode 100644 index 176f84e4..00000000 --- a/shlink-web-component/src/visits/charts/DoughnutChartCard.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { FC } from 'react'; -import type { Stats } from '../types'; -import { ChartCard } from './ChartCard'; -import { DoughnutChart } from './DoughnutChart'; - -interface DoughnutChartCardProps { - title: string; - stats: Stats; -} - -export const DoughnutChartCard: FC = ({ title, stats }) => ( - - - -); diff --git a/shlink-web-component/src/visits/charts/DoughnutChartLegend.scss b/shlink-web-component/src/visits/charts/DoughnutChartLegend.scss deleted file mode 100644 index 69eaee27..00000000 --- a/shlink-web-component/src/visits/charts/DoughnutChartLegend.scss +++ /dev/null @@ -1,29 +0,0 @@ -@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; - -.doughnut-chart-legend { - list-style-type: none; - padding: 0; - margin: 0; - - @media (max-width: $smMax) { - margin-top: 1rem; - } -} - -.doughnut-chart-legend__item:not(:first-child) { - margin-top: .3rem; -} - -.doughnut-chart-legend__item-color { - width: 20px; - min-width: 20px; - height: 20px; - margin-right: 5px; - border-radius: 10px; -} - -.doughnut-chart-legend__item-text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/shlink-web-component/src/visits/charts/DoughnutChartLegend.tsx b/shlink-web-component/src/visits/charts/DoughnutChartLegend.tsx deleted file mode 100644 index aa70f47a..00000000 --- a/shlink-web-component/src/visits/charts/DoughnutChartLegend.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Chart } from 'chart.js'; -import type { FC } from 'react'; -import './DoughnutChartLegend.scss'; - -interface DoughnutChartLegendProps { - chart: Chart; -} - -export const DoughnutChartLegend: FC = ({ chart }) => { - const { config } = chart; - const { labels = [], datasets = [] } = config.data ?? {}; - const [{ backgroundColor: colors }] = datasets; - const { defaultColor } = config.options ?? {} as any; - - return ( -
    - {(labels as string[]).map((label, index) => ( -
  • -
    - {label} -
  • - ))} -
- ); -}; diff --git a/shlink-web-component/src/visits/charts/HorizontalBarChart.tsx b/shlink-web-component/src/visits/charts/HorizontalBarChart.tsx deleted file mode 100644 index 600ea5ea..00000000 --- a/shlink-web-component/src/visits/charts/HorizontalBarChart.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '@shlinkio/shlink-frontend-kit'; -import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js'; -import { keys, values } from 'ramda'; -import type { FC, MutableRefObject } from 'react'; -import { useRef } from 'react'; -import { Bar, getElementAtEvent } from 'react-chartjs-2'; -import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts'; -import { prettify } from '../../utils/helpers/numbers'; -import type { Stats } from '../types'; -import { fillTheGaps } from '../utils'; - -export interface HorizontalBarChartProps { - stats: Stats; - max?: number; - highlightedStats?: Stats; - highlightedLabel?: string; - onClick?: (label: string) => void; -} - -const dropLabelIfHidden = (label: string) => (label.startsWith('hidden') ? '' : label); -const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0; -const determineHeight = (labels: string[]): number | undefined => (labels.length > 20 ? labels.length * 10 : undefined); - -const generateChartDatasets = ( - data: number[], - highlightedData: number[], - highlightedLabel?: string, -): ChartDataset[] => { - const mainDataset: ChartDataset = { - data, - label: highlightedLabel ? 'Non-selected' : 'Visits', - backgroundColor: MAIN_COLOR_ALPHA, - borderColor: MAIN_COLOR, - borderWidth: 2, - }; - - if (highlightedData.every((value) => value === 0)) { - return [mainDataset]; - } - - const highlightedDataset: ChartDataset = { - label: highlightedLabel ?? 'Selected', - data: highlightedData, - backgroundColor: HIGHLIGHTED_COLOR_ALPHA, - borderColor: HIGHLIGHTED_COLOR, - borderWidth: 2, - }; - - return [mainDataset, highlightedDataset]; -}; -const generateChartData = ( - labels: string[], - data: number[], - highlightedData: number[], - highlightedLabel?: string, -): ChartData => ({ - labels, - datasets: generateChartDatasets(data, highlightedData, highlightedLabel), -}); - -const chartElementAtEvent = (labels: string[], [chart]: InteractionItem[], onClick?: (label: string) => void) => { - if (!onClick || !chart) { - return; - } - - onClick(labels[chart.index]); -}; - -export const HorizontalBarChart: FC = ( - { stats, highlightedStats, highlightedLabel, onClick, max }, -) => { - const labels = keys(stats).map(dropLabelIfHidden); - const data = values( - !statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => { - if (acc[highlightedKey]) { - acc[highlightedKey] -= highlightedStats[highlightedKey]; - } - - return acc; - }, { ...stats }), - ); - const highlightedData = fillTheGaps(highlightedStats ?? {}, labels); - const refWithStats = useRef(null); - const refWithoutStats = useRef(null); - - const options: ChartOptions = { - plugins: { - legend: { display: false }, - tooltip: { - mode: 'y', - // Do not show tooltip on items with empty label when in a bar chart - filter: ({ label }) => label !== '', - callbacks: { label: renderChartLabel }, - }, - }, - scales: { - x: { - beginAtZero: true, - stacked: true, - max, - ticks: { - precision: 0, - callback: prettify, - }, - }, - y: { stacked: true }, - }, - onHover: pointerOnHover, - indexAxis: 'y', - }; - const chartData = generateChartData(labels, data, highlightedData, highlightedLabel); - const height = determineHeight(labels); - - // Provide a key based on the height, to force re-render every time the dataset changes (example, due to pagination) - const renderChartComponent = (customKey: string, theRef: MutableRefObject) => ( - chartElementAtEvent(labels, getElementAtEvent(theRef.current, e), onClick)} - /> - ); - - return ( - <> - {/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */} - {/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */} - {highlightedStats !== undefined && renderChartComponent('with_stats', refWithStats)} - {highlightedStats === undefined && renderChartComponent('without_stats', refWithoutStats)} - - ); -}; diff --git a/shlink-web-component/src/visits/charts/LineChartCard.scss b/shlink-web-component/src/visits/charts/LineChartCard.scss deleted file mode 100644 index 9b4d7ec8..00000000 --- a/shlink-web-component/src/visits/charts/LineChartCard.scss +++ /dev/null @@ -1,9 +0,0 @@ -@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; - -.line-chart-card__body canvas { - height: 300px !important; - - @media (min-width: $mdMin) { - height: 400px !important; - } -} diff --git a/shlink-web-component/src/visits/charts/LineChartCard.tsx b/shlink-web-component/src/visits/charts/LineChartCard.tsx deleted file mode 100644 index 441e3c2b..00000000 --- a/shlink-web-component/src/visits/charts/LineChartCard.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { HIGHLIGHTED_COLOR, MAIN_COLOR, ToggleSwitch, useToggle } from '@shlinkio/shlink-frontend-kit'; -import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js'; -import { - add, - differenceInDays, - differenceInHours, - differenceInMonths, - differenceInWeeks, - endOfISOWeek, - format, - parseISO, - startOfISOWeek, -} from 'date-fns'; -import { always, cond, countBy, reverse } from 'ramda'; -import type { MutableRefObject } from 'react'; -import { useMemo, useRef, useState } from 'react'; -import { getElementAtEvent, Line } from 'react-chartjs-2'; -import { - Card, - CardBody, - CardHeader, - DropdownItem, - DropdownMenu, - DropdownToggle, - UncontrolledDropdown, -} from 'reactstrap'; -import { formatInternational } from '../../utils/dates/helpers/date'; -import { rangeOf } from '../../utils/helpers'; -import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts'; -import { prettify } from '../../utils/helpers/numbers'; -import type { NormalizedVisit, Stats } from '../types'; -import { fillTheGaps } from '../utils'; -import './LineChartCard.scss'; - -interface LineChartCardProps { - title: string; - highlightedLabel?: string; - visits: NormalizedVisit[]; - highlightedVisits: NormalizedVisit[]; - setSelectedVisits?: (visits: NormalizedVisit[]) => void; -} - -type Step = 'monthly' | 'weekly' | 'daily' | 'hourly'; - -const STEPS_MAP: Record = { - monthly: 'Month', - weekly: 'Week', - daily: 'Day', - hourly: 'Hour', -}; - -const STEP_TO_DURATION_MAP: Record Duration> = { - hourly: (hours: number) => ({ hours }), - daily: (days: number) => ({ days }), - weekly: (weeks: number) => ({ weeks }), - monthly: (months: number) => ({ months }), -}; - -const STEP_TO_DIFF_FUNC_MAP: Record number> = { - hourly: differenceInHours, - daily: differenceInDays, - weekly: differenceInWeeks, - monthly: differenceInMonths, -}; - -const STEP_TO_DATE_FORMAT: Record string> = { - hourly: (date) => format(date, 'yyyy-MM-dd HH:00'), - // TODO Fix formatInternational return type - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - daily: (date) => formatInternational(date)!, - weekly(date) { - // TODO Fix formatInternational return type - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const firstWeekDay = formatInternational(startOfISOWeek(date))!; - // TODO Fix formatInternational return type - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const lastWeekDay = formatInternational(endOfISOWeek(date))!; - - return `${firstWeekDay} - ${lastWeekDay}`; - }, - monthly: (date) => format(date, 'yyyy-MM'), -}; - -const determineInitialStep = (oldestVisitDate: string): Step => { - const now = new Date(); - const oldestDate = parseISO(oldestVisitDate); - const matcher = cond([ - [() => differenceInDays(now, oldestDate) <= 2, always('hourly')], // Less than 2 days - [() => differenceInMonths(now, oldestDate) <= 1, always('daily')], // Between 2 days and 1 month - [() => differenceInMonths(now, oldestDate) <= 6, always('weekly')], // Between 1 and 6 months - ]); - - return matcher() ?? 'monthly'; -}; - -const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy( - (visit) => STEP_TO_DATE_FORMAT[step](parseISO(visit.date)), - visits, -); - -const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) => - visits.reduce>( - (acc, visit) => { - const key = STEP_TO_DATE_FORMAT[step](parseISO(visit.date)); - - acc[key] = acc[key] ?? []; - acc[key].push(visit); - - return acc; - }, - {}, - ); - -const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => { - const diffFunc = STEP_TO_DIFF_FUNC_MAP[step]; - const formatter = STEP_TO_DATE_FORMAT[step]; - const newerDate = parseISO(visits[0].date); - const oldestDate = parseISO(visits[visits.length - 1].date); - const size = diffFunc(newerDate, oldestDate); - const duration = STEP_TO_DURATION_MAP[step]; - - return [ - formatter(oldestDate), - ...rangeOf(size, (num) => formatter(add(oldestDate, duration(num)))), - ]; -}; - -const generateLabelsAndGroupedVisits = ( - visits: NormalizedVisit[], - groupedVisitsWithGaps: Stats, - step: Step, - skipNoElements: boolean, -): [string[], number[]] => { - if (skipNoElements) { - return [Object.keys(groupedVisitsWithGaps), Object.values(groupedVisitsWithGaps)]; - } - - const labels = generateLabels(step, visits); - - return [labels, fillTheGaps(groupedVisitsWithGaps, labels)]; -}; - -const generateDataset = (data: number[], label: string, color: string): ChartDataset => ({ - label, - data, - fill: false, - tension: 0.2, - borderColor: color, - backgroundColor: color, -}); - -let selectedLabel: string | null = null; - -const chartElementAtEvent = ( - labels: string[], - datasetsByPoint: Record, - [chart]: InteractionItem[], - setSelectedVisits?: (visits: NormalizedVisit[]) => void, -) => { - if (!setSelectedVisits || !chart) { - return; - } - - const { index } = chart; - - if (selectedLabel === labels[index]) { - setSelectedVisits([]); - selectedLabel = null; - } else { - setSelectedVisits(labels[index] && datasetsByPoint[labels[index]] ? datasetsByPoint[labels[index]] : []); - selectedLabel = labels[index] ?? null; - } -}; - -export const LineChartCard = ( - { title, visits, highlightedVisits, highlightedLabel = 'Selected', setSelectedVisits }: LineChartCardProps, -) => { - const [step, setStep] = useState( - visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly', - ); - const [skipNoVisits, toggleSkipNoVisits] = useToggle(true); - const refWithHighlightedVisits = useRef(null); - const refWithoutHighlightedVisits = useRef(null); - - const datasetsByPoint = useMemo(() => visitsToDatasetGroups(step, visits), [step, visits]); - const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [step, visits]); - const [labels, groupedVisits] = useMemo( - () => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits), - [visits, step, skipNoVisits], - ); - const groupedHighlighted = useMemo( - () => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels), - [highlightedVisits, step, labels], - ); - const generateChartDatasets = (): ChartDataset[] => { - const mainDataset = generateDataset(groupedVisits, 'Visits', MAIN_COLOR); - - if (highlightedVisits.length === 0) { - return [mainDataset]; - } - - const highlightedDataset = generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR); - - return [mainDataset, highlightedDataset]; - }; - const generateChartData = (): ChartData => ({ labels, datasets: generateChartDatasets() }); - - const options: ChartOptions = { - maintainAspectRatio: false, - plugins: { - legend: { display: false }, - tooltip: { - intersect: false, - axis: 'x', - callbacks: { label: renderChartLabel }, - }, - }, - scales: { - y: { - beginAtZero: true, - ticks: { - precision: 0, - callback: prettify, - }, - }, - x: { - title: { display: true, text: STEPS_MAP[step] }, - }, - }, - onHover: pointerOnHover, - }; - const renderLineChart = (theRef: MutableRefObject) => ( - - chartElementAtEvent(labels, datasetsByPoint, getElementAtEvent(theRef.current, e), setSelectedVisits)} - /> - ); - - return ( - - - {title} -
- - - Group by - - - {Object.entries(STEPS_MAP).map(([value, menuText]) => ( - setStep(value as Step)}> - {menuText} - - ))} - - -
-
- - Skip dates with no visits - -
-
- - {/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */} - {/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */} - {highlightedVisits.length > 0 && renderLineChart(refWithHighlightedVisits)} - {highlightedVisits.length === 0 && renderLineChart(refWithoutHighlightedVisits)} - -
- ); -}; diff --git a/shlink-web-component/src/visits/charts/SortableBarChartCard.tsx b/shlink-web-component/src/visits/charts/SortableBarChartCard.tsx deleted file mode 100644 index 8cedb724..00000000 --- a/shlink-web-component/src/visits/charts/SortableBarChartCard.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { Order } from '@shlinkio/shlink-frontend-kit'; -import { OrderingDropdown } from '@shlinkio/shlink-frontend-kit'; -import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda'; -import type { FC, ReactNode } from 'react'; -import { useState } from 'react'; -import { PaginationDropdown } from '../../utils/components/PaginationDropdown'; -import { SimplePaginator } from '../../utils/components/SimplePaginator'; -import { rangeOf } from '../../utils/helpers'; -import { roundTen } from '../../utils/helpers/numbers'; -import type { Stats, StatsRow } from '../types'; -import { ChartCard } from './ChartCard'; -import type { HorizontalBarChartProps } from './HorizontalBarChart'; -import { HorizontalBarChart } from './HorizontalBarChart'; - -interface SortableBarChartCardProps extends Omit { - title: Function | string; - sortingItems: Record; - withPagination?: boolean; - extraHeaderContent?: (activeCities?: string[]) => ReactNode; -} - -const toLowerIfString = (value: any) => (type(value) === 'String' ? toLower(value) : value); -const pickKeyFromPair = ([key]: StatsRow) => key; -const pickValueFromPair = ([, value]: StatsRow) => value; - -export const SortableBarChartCard: FC = ({ - stats, - highlightedStats, - title, - sortingItems, - extraHeaderContent, - withPagination = true, - ...rest -}) => { - const [order, setOrder] = useState>({}); - const [currentPage, setCurrentPage] = useState(1); - const [itemsPerPage, setItemsPerPage] = useState(50); - - const getSortedPairsForStats = (statsToSort: Stats, sorting: Record) => { - const pairs = toPairs(statsToSort); - const sortedPairs = !order.field ? pairs : sortBy( - pipe( - order.field === Object.keys(sorting)[0] ? pickKeyFromPair : pickValueFromPair, - toLowerIfString, - ), - pairs, - ); - - return !order.dir || order.dir === 'ASC' ? sortedPairs : reverse(sortedPairs); - }; - const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => { - const page = pages[currentPage - 1]; - - if (currentPage < pages.length) { - return page; - } - - const firstPageLength = pages[0].length; - - // Using the "hidden" key, the chart will just replace the label by an empty string - return [...page, ...rangeOf(firstPageLength - page.length, (i): StatsRow => [`hidden_${i}`, 0])]; - }; - const renderPagination = (pagesCount: number) => - ; - const determineStats = (statsToSort: Stats, sorting: Record, theHighlightedStats?: Stats) => { - const sortedPairs = getSortedPairsForStats(statsToSort, sorting); - const sortedKeys = sortedPairs.map(pickKeyFromPair); - // The highlighted stats have to be ordered based on the regular stats, not on its own values - const sortedHighlightedPairs = theHighlightedStats && toPairs( - { ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...theHighlightedStats }, - ); - - if (sortedPairs.length <= itemsPerPage) { - return { - currentPageStats: fromPairs(sortedPairs), - currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs), - }; - } - - const pages = splitEvery(itemsPerPage, sortedPairs); - const highlightedPages = sortedHighlightedPairs && splitEvery(itemsPerPage, sortedHighlightedPairs); - - return { - currentPageStats: fromPairs(determineCurrentPagePairs(pages)), - currentPageHighlightedStats: highlightedPages && fromPairs(determineCurrentPagePairs(highlightedPages)), - pagination: renderPagination(pages.length), - max: roundTen(Math.max(...sortedPairs.map(pickValueFromPair))), - }; - }; - - const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats( - stats, - sortingItems, - highlightedStats && Object.keys(highlightedStats).length > 0 ? highlightedStats : undefined, - ); - const activeCities = Object.keys(currentPageStats); - const computeTitle = () => ( - <> - {title} -
- { - setOrder({ field, dir }); - setCurrentPage(1); - }} - /> -
- {withPagination && Object.keys(stats).length > 50 && ( -
- { - setItemsPerPage(value); - setCurrentPage(1); - }} - /> -
- )} - {extraHeaderContent && ( -
- {extraHeaderContent(pagination ? activeCities : undefined)} -
- )} - - ); - - return ( - - - - ); -}; diff --git a/shlink-web-component/src/visits/helpers/MapModal.scss b/shlink-web-component/src/visits/helpers/MapModal.scss deleted file mode 100644 index 9401b83a..00000000 --- a/shlink-web-component/src/visits/helpers/MapModal.scss +++ /dev/null @@ -1,45 +0,0 @@ -@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; -@import '../../utils/mixins/fit-with-margin'; - -.map-modal__modal.map-modal__modal { - @media (min-width: $mdMin) { - $margin: 20px; - - @include fit-with-margin($margin); - } - - @media (max-width: $smMax) { - $margin: 10px; - - @include fit-with-margin($margin); - } -} - -.map-modal__modal-content.map-modal__modal-content { - height: 100%; -} - -.map-modal__modal-title.map-modal__modal-title { - position: absolute; - width: 100%; - z-index: 1001; - padding: .5rem 1rem 1rem; - margin: 0; - color: #fff; - background: linear-gradient(rgb(0 0 0 / .5), rgb(0 0 0 / 0)); -} - -.map-modal__modal-body.map-modal__modal-body { - padding: 0; - display: flex; - overflow: hidden; -} - -.map-modal__modal.map-modal__modal .leaflet-container.leaflet-container { - flex: 1 1 auto; - border-radius: .3rem; -} - -.map-modal__modal.map-modal__modal .leaflet-top.leaflet-top .leaflet-control.leaflet-control { - margin-top: 60px; -} diff --git a/shlink-web-component/src/visits/helpers/MapModal.tsx b/shlink-web-component/src/visits/helpers/MapModal.tsx deleted file mode 100644 index bc0a1bd9..00000000 --- a/shlink-web-component/src/visits/helpers/MapModal.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { prop } from 'ramda'; -import type { FC } from 'react'; -import type { MapContainerProps } from 'react-leaflet'; -import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'; -import { Modal, ModalBody } from 'reactstrap'; -import type { CityStats } from '../types'; -import './MapModal.scss'; - -interface MapModalProps { - toggle: () => void; - isOpen: boolean; - title: string; - locations?: CityStats[]; -} - -const OpenStreetMapTile: FC = () => ( - -); - -const calculateMapProps = (locations: CityStats[]): MapContainerProps => { - if (locations.length === 0) { - return {}; - } - - if (locations.length > 1) { - return { bounds: locations.map(prop('latLong')) }; - } - - // When there's only one location, an error is thrown if trying to calculate the bounds. - // When that happens, we use "zoom" and "center" as a workaround - const [{ latLong: center }] = locations; - - return { zoom: 10, center }; -}; - -export const MapModal = ({ toggle, isOpen, title, locations = [] }: MapModalProps) => ( - - -

- {title} -

- - - {locations.map(({ cityName, latLong, count }, index) => ( - - {count} visit{count > 1 ? 's' : ''} from {cityName} - - ))} - -
-
-); diff --git a/shlink-web-component/src/visits/helpers/OpenMapModalBtn.scss b/shlink-web-component/src/visits/helpers/OpenMapModalBtn.scss deleted file mode 100644 index 007802ae..00000000 --- a/shlink-web-component/src/visits/helpers/OpenMapModalBtn.scss +++ /dev/null @@ -1,4 +0,0 @@ -.open-map-modal-btn__btn.open-map-modal-btn__btn { - padding: 0; - margin-right: 1rem; -} diff --git a/shlink-web-component/src/visits/helpers/OpenMapModalBtn.tsx b/shlink-web-component/src/visits/helpers/OpenMapModalBtn.tsx deleted file mode 100644 index f36fa147..00000000 --- a/shlink-web-component/src/visits/helpers/OpenMapModalBtn.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useDomId, useToggle } from '@shlinkio/shlink-frontend-kit'; -import { useState } from 'react'; -import { Button, Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap'; -import type { CityStats } from '../types'; -import { MapModal } from './MapModal'; -import './OpenMapModalBtn.scss'; - -interface OpenMapModalBtnProps { - modalTitle: string; - activeCities?: string[]; - locations?: CityStats[]; -} - -export const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: OpenMapModalBtnProps) => { - const [mapIsOpened, , openMap, closeMap] = useToggle(); - const [dropdownIsOpened, toggleDropdown, openDropdown] = useToggle(); - const [locationsToShow, setLocationsToShow] = useState([]); - const id = useDomId(); - - const filterLocations = (cities: CityStats[]) => ( - !activeCities ? cities : cities.filter(({ cityName }) => activeCities?.includes(cityName)) - ); - const onClick = () => { - if (!activeCities) { - setLocationsToShow(locations); - openMap(); - - return; - } - - openDropdown(); - }; - const openMapWithLocations = (filtered: boolean) => () => { - setLocationsToShow(filtered ? filterLocations(locations) : locations); - openMap(); - }; - - return ( - <> - - Show in map - - - Show all locations - Show locations in current page - - - - - ); -}; diff --git a/shlink-web-component/src/visits/helpers/VisitsFilterDropdown.tsx b/shlink-web-component/src/visits/helpers/VisitsFilterDropdown.tsx deleted file mode 100644 index 63882165..00000000 --- a/shlink-web-component/src/visits/helpers/VisitsFilterDropdown.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { DropdownBtn } from '@shlinkio/shlink-frontend-kit'; -import type { DropdownItemProps } from 'reactstrap'; -import { DropdownItem } from 'reactstrap'; -import { hasValue } from '../../utils/helpers'; -import type { OrphanVisitType, VisitsFilter } from '../types'; - -interface VisitsFilterDropdownProps { - onChange: (filters: VisitsFilter) => void; - selected?: VisitsFilter; - className?: string; - isOrphanVisits: boolean; -} - -export const VisitsFilterDropdown = ( - { onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps, -) => { - const { orphanVisitsType, excludeBots = false } = selected; - const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({ - active: orphanVisitsType === type, - onClick: () => onChange({ ...selected, orphanVisitsType: type === selected?.orphanVisitsType ? undefined : type }), - }); - const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots }); - - return ( - - Bots: - Exclude potential bots - - {isOrphanVisits && ( - <> - - Orphan visits type: - Base URL - Invalid short URL - Regular 404 - - )} - - - onChange({ excludeBots: false, orphanVisitsType: undefined })} - > - Clear filters - - - ); -}; diff --git a/shlink-web-component/src/visits/helpers/hooks.ts b/shlink-web-component/src/visits/helpers/hooks.ts deleted file mode 100644 index bab38a1b..00000000 --- a/shlink-web-component/src/visits/helpers/hooks.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { DeepPartial } from '@reduxjs/toolkit'; -import { parseQuery, stringifyQuery } from '@shlinkio/shlink-frontend-kit'; -import { isEmpty, isNil, mergeDeepRight, pipe } from 'ramda'; -import { useMemo } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { formatIsoDate } from '../../utils/dates/helpers/date'; -import type { DateRange } from '../../utils/dates/helpers/dateIntervals'; -import { datesToDateRange } from '../../utils/dates/helpers/dateIntervals'; -import type { BooleanString } from '../../utils/helpers'; -import { parseBooleanToString } from '../../utils/helpers'; -import type { OrphanVisitType, VisitsFilter } from '../types'; - -interface VisitsQuery { - startDate?: string; - endDate?: string; - orphanVisitsType?: OrphanVisitType; - excludeBots?: BooleanString; - domain?: string; -} - -interface VisitsFiltering { - dateRange?: DateRange; - visitsFilter: VisitsFilter; -} - -interface VisitsFilteringAndDomain { - filtering: VisitsFiltering; - domain?: string; -} - -type UpdateFiltering = (extra: DeepPartial) => void; - -export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => { - const navigate = useNavigate(); - const { search } = useLocation(); - - const { filtering, domain: theDomain } = useMemo( - pipe( - () => parseQuery(search), - ({ startDate, endDate, orphanVisitsType, excludeBots, domain }: VisitsQuery): VisitsFilteringAndDomain => ({ - domain, - filtering: { - dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined, - visitsFilter: { orphanVisitsType, excludeBots: !isNil(excludeBots) ? excludeBots === 'true' : undefined }, - }, - }), - ), - [search], - ); - const updateFiltering = (extra: DeepPartial) => { - const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra); - const { excludeBots, orphanVisitsType } = visitsFilter; - const query: VisitsQuery = { - startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '', - endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '', - excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots), - orphanVisitsType, - domain: theDomain, - }; - const stringifiedQuery = stringifyQuery(query); - const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`; - - navigate(queryString, { replace: true, relative: 'route' }); - }; - - return [filtering, updateFiltering]; -}; diff --git a/shlink-web-component/src/visits/reducers/common.ts b/shlink-web-component/src/visits/reducers/common.ts deleted file mode 100644 index d8d6f528..00000000 --- a/shlink-web-component/src/visits/reducers/common.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { createAction, createSlice } from '@reduxjs/toolkit'; -import { flatten, prop, range, splitEvery } from 'ramda'; -import type { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api-contract'; -import { parseApiError } from '../../api-contract/utils'; -import type { RootState } from '../../container/store'; -import type { DateInterval } from '../../utils/dates/helpers/dateIntervals'; -import { dateToMatchingInterval } from '../../utils/dates/helpers/dateIntervals'; -import { createAsyncThunk } from '../../utils/redux'; -import type { CreateVisit, Visit } from '../types'; -import type { LoadVisits, VisitsInfo, VisitsLoaded } from './types'; -import { createNewVisits } from './visitCreation'; - -const ITEMS_PER_PAGE = 5000; -const PARALLEL_REQUESTS_COUNT = 4; -const PARALLEL_STARTING_PAGE = 2; - -const isLastPage = ({ currentPage, pagesCount }: ShlinkPaginator): boolean => currentPage >= pagesCount; -const calcProgress = (total: number, current: number): number => (current * 100) / total; - -type VisitsLoader = (page: number, itemsPerPage: number) => Promise; -type LastVisitLoader = (excludeBots?: boolean) => Promise; - -interface VisitsAsyncThunkOptions { - typePrefix: string; - createLoaders: (params: T) => [VisitsLoader, LastVisitLoader]; - getExtraFulfilledPayload: (params: T) => Partial; - shouldCancel: (getState: () => RootState) => boolean; -} - -export const createVisitsAsyncThunk = ( - { typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, -) => { - const progressChanged = createAction(`${typePrefix}/progressChanged`); - const large = createAction(`${typePrefix}/large`); - const fallbackToInterval = createAction(`${typePrefix}/fallbackToInterval`); - - const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise> => { - const [visitsLoader, lastVisitLoader] = createLoaders(params); - - const loadVisitsInParallel = async (pages: number[]): Promise => - Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); - - const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise => { - if (shouldCancel(getState)) { - return []; - } - - const data = await loadVisitsInParallel(pagesBlocks[index]); - - dispatch(progressChanged(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE))); - - if (index < pagesBlocks.length - 1) { - return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); - } - - return data; - }; - - const loadVisits = async (page = 1) => { - const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE); - - // If pagination was not returned, then this is an old shlink version. Just return data - if (!pagination || isLastPage(pagination)) { - return data; - } - - // If there are more pages, make requests in blocks of 4 - const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1); - const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange); - - if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) { - dispatch(large()); - } - - return data.concat(await loadPagesBlocks(pagesBlocks)); - }; - - const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader(params.query?.excludeBots)]); - - if (!visits.length && lastVisit) { - dispatch(fallbackToInterval(dateToMatchingInterval(lastVisit.date))); - } - - return { ...getExtraFulfilledPayload(params), visits }; - }); - - // Enhance the async thunk with extra actions - return Object.assign(asyncThunk, { progressChanged, large, fallbackToInterval }); -}; - -export const lastVisitLoaderForLoader = ( - doIntervalFallback: boolean, - loader: (params: ShlinkVisitsParams) => Promise, -): LastVisitLoader => async (excludeBots?: boolean) => ( - !doIntervalFallback - ? Promise.resolve(undefined) - : loader({ page: 1, itemsPerPage: 1, excludeBots }).then(({ data }) => data[0]) -); - -interface VisitsReducerOptions> { - name: string; - asyncThunkCreator: AT; - initialState: State; - filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[]; -} - -export const createVisitsReducer = >( - { name, asyncThunkCreator, initialState, filterCreatedVisits }: VisitsReducerOptions, -) => { - const { pending, rejected, fulfilled, large, progressChanged, fallbackToInterval } = asyncThunkCreator; - const { reducer, actions } = createSlice({ - name, - initialState, - reducers: { - cancelGetVisits: (state) => ({ ...state, cancelLoad: true }), - }, - extraReducers: (builder) => { - builder.addCase(pending, () => ({ ...initialState, loading: true })); - builder.addCase(rejected, (_, { error }) => ( - { ...initialState, error: true, errorData: parseApiError(error) } - )); - builder.addCase(fulfilled, (state, { payload }) => ( - { ...state, ...payload, loading: false, loadingLarge: false, error: false } - )); - - builder.addCase(large, (state) => ({ ...state, loadingLarge: true })); - builder.addCase(progressChanged, (state, { payload: progress }) => ({ ...state, progress })); - builder.addCase(fallbackToInterval, (state, { payload: fallbackInterval }) => ( - { ...state, fallbackInterval } - )); - - builder.addCase(createNewVisits, (state, { payload }) => { - const { visits } = state; - // @ts-expect-error TODO Fix type inference - const newVisits = filterCreatedVisits(state, payload.createdVisits).map(({ visit }) => visit); - - return !newVisits.length ? state : { ...state, visits: [...newVisits, ...visits] }; - }); - }, - }); - const { cancelGetVisits } = actions; - - return { reducer, cancelGetVisits }; -}; diff --git a/shlink-web-component/src/visits/reducers/domainVisits.ts b/shlink-web-component/src/visits/reducers/domainVisits.ts deleted file mode 100644 index 9055347d..00000000 --- a/shlink-web-component/src/visits/reducers/domainVisits.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { ShlinkApiClient } from '../../api-contract'; -import { domainMatches } from '../../short-urls/helpers'; -import { isBetween } from '../../utils/dates/helpers/date'; -import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; -import type { LoadVisits, VisitsInfo } from './types'; - -const REDUCER_PREFIX = 'shlink/domainVisits'; - -export const DEFAULT_DOMAIN = 'DEFAULT'; - -interface WithDomain { - domain: string; -} - -export interface DomainVisits extends VisitsInfo, WithDomain {} - -export interface LoadDomainVisits extends LoadVisits, WithDomain {} - -const initialState: DomainVisits = { - visits: [], - domain: '', - loading: false, - loadingLarge: false, - error: false, - cancelLoad: false, - progress: 0, -}; - -export const getDomainVisits = (apiClientFactory: () => ShlinkApiClient) => createVisitsAsyncThunk({ - typePrefix: `${REDUCER_PREFIX}/getDomainVisits`, - createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits) => { - const { getDomainVisits: getVisits } = apiClientFactory(); - const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( - domain, - { ...query, page, itemsPerPage }, - ); - const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params)); - - return [visitsLoader, lastVisitLoader]; - }, - getExtraFulfilledPayload: ({ domain, query = {} }: LoadDomainVisits) => ({ domain, query }), - shouldCancel: (getState) => getState().domainVisits.cancelLoad, -}); - -export const domainVisitsReducerCreator = ( - asyncThunkCreator: ReturnType, -) => createVisitsReducer({ - name: REDUCER_PREFIX, - initialState, - // @ts-expect-error TODO Fix type inference - asyncThunkCreator, - filterCreatedVisits: ({ domain, query = {} }, createdVisits) => { - const { startDate, endDate } = query; - return createdVisits.filter( - ({ shortUrl, visit }) => - shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate), - ); - }, -}); diff --git a/shlink-web-component/src/visits/reducers/nonOrphanVisits.ts b/shlink-web-component/src/visits/reducers/nonOrphanVisits.ts deleted file mode 100644 index 59a48143..00000000 --- a/shlink-web-component/src/visits/reducers/nonOrphanVisits.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ShlinkApiClient } from '../../api-contract'; -import { isBetween } from '../../utils/dates/helpers/date'; -import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; -import type { VisitsInfo } from './types'; - -const REDUCER_PREFIX = 'shlink/orphanVisits'; - -const initialState: VisitsInfo = { - visits: [], - loading: false, - loadingLarge: false, - error: false, - cancelLoad: false, - progress: 0, -}; - -export const getNonOrphanVisits = (apiClientFactory: () => ShlinkApiClient) => createVisitsAsyncThunk({ - typePrefix: `${REDUCER_PREFIX}/getNonOrphanVisits`, - createLoaders: ({ query = {}, doIntervalFallback = false }) => { - const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = apiClientFactory(); - const visitsLoader = async (page: number, itemsPerPage: number) => - shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage }); - const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits); - - return [visitsLoader, lastVisitLoader]; - }, - getExtraFulfilledPayload: ({ query = {} }) => ({ query }), - shouldCancel: (getState) => getState().orphanVisits.cancelLoad, -}); - -export const nonOrphanVisitsReducerCreator = ( - asyncThunkCreator: ReturnType, -) => createVisitsReducer({ - name: REDUCER_PREFIX, - initialState, - asyncThunkCreator, - filterCreatedVisits: ({ query = {} }, createdVisits) => { - const { startDate, endDate } = query; - return createdVisits.filter(({ visit }) => isBetween(visit.date, startDate, endDate)); - }, -}); diff --git a/shlink-web-component/src/visits/reducers/orphanVisits.ts b/shlink-web-component/src/visits/reducers/orphanVisits.ts deleted file mode 100644 index 0abf619f..00000000 --- a/shlink-web-component/src/visits/reducers/orphanVisits.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ShlinkApiClient } from '../../api-contract'; -import { isBetween } from '../../utils/dates/helpers/date'; -import type { OrphanVisit, OrphanVisitType } from '../types'; -import { isOrphanVisit } from '../types/helpers'; -import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; -import type { LoadVisits, VisitsInfo } from './types'; - -const REDUCER_PREFIX = 'shlink/orphanVisits'; - -export interface LoadOrphanVisits extends LoadVisits { - orphanVisitsType?: OrphanVisitType; -} - -const initialState: VisitsInfo = { - visits: [], - loading: false, - loadingLarge: false, - error: false, - cancelLoad: false, - progress: 0, -}; - -const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) => - !orphanVisitsType || orphanVisitsType === visit.type; - -export const getOrphanVisits = (apiClientFactory: () => ShlinkApiClient) => createVisitsAsyncThunk({ - typePrefix: `${REDUCER_PREFIX}/getOrphanVisits`, - createLoaders: ({ orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits) => { - const { getOrphanVisits: getVisits } = apiClientFactory(); - const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage }) - .then((result) => { - const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType)); - return { ...result, data: visits }; - }); - const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits); - - return [visitsLoader, lastVisitLoader]; - }, - getExtraFulfilledPayload: ({ query = {} }: LoadOrphanVisits) => ({ query }), - shouldCancel: (getState) => getState().orphanVisits.cancelLoad, -}); - -export const orphanVisitsReducerCreator = ( - asyncThunkCreator: ReturnType, -) => createVisitsReducer({ - name: REDUCER_PREFIX, - initialState, - asyncThunkCreator, - filterCreatedVisits: ({ query = {} }, createdVisits) => { - const { startDate, endDate } = query; - return createdVisits.filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate)); - }, -}); diff --git a/shlink-web-component/src/visits/reducers/shortUrlVisits.ts b/shlink-web-component/src/visits/reducers/shortUrlVisits.ts deleted file mode 100644 index b76ee21d..00000000 --- a/shlink-web-component/src/visits/reducers/shortUrlVisits.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { ShlinkApiClient } from '../../api-contract'; -import type { ShortUrlIdentifier } from '../../short-urls/data'; -import { shortUrlMatches } from '../../short-urls/helpers'; -import { isBetween } from '../../utils/dates/helpers/date'; -import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; -import type { LoadVisits, VisitsInfo } from './types'; - -const REDUCER_PREFIX = 'shlink/shortUrlVisits'; - -export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {} - -export interface LoadShortUrlVisits extends LoadVisits { - shortCode: string; -} - -const initialState: ShortUrlVisits = { - visits: [], - shortCode: '', - domain: undefined, // Deprecated. Value from query params can be used instead - loading: false, - loadingLarge: false, - error: false, - cancelLoad: false, - progress: 0, -}; - -export const getShortUrlVisits = (apiClientFactory: () => ShlinkApiClient) => createVisitsAsyncThunk({ - typePrefix: `${REDUCER_PREFIX}/getShortUrlVisits`, - createLoaders: ({ shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits) => { - const { getShortUrlVisits: shlinkGetShortUrlVisits } = apiClientFactory(); - const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits( - shortCode, - { ...query, page, itemsPerPage }, - ); - const lastVisitLoader = lastVisitLoaderForLoader( - doIntervalFallback, - async (params) => shlinkGetShortUrlVisits(shortCode, { ...params, domain: query.domain }), - ); - - return [visitsLoader, lastVisitLoader]; - }, - getExtraFulfilledPayload: ({ shortCode, query = {} }: LoadShortUrlVisits) => ( - { shortCode, query, domain: query.domain } - ), - shouldCancel: (getState) => getState().shortUrlVisits.cancelLoad, -}); - -export const shortUrlVisitsReducerCreator = ( - asyncThunkCreator: ReturnType, -) => createVisitsReducer({ - name: REDUCER_PREFIX, - initialState, - // @ts-expect-error TODO Fix type inference - asyncThunkCreator, - filterCreatedVisits: ({ shortCode, domain, query = {} }: ShortUrlVisits, createdVisits) => { - const { startDate, endDate } = query; - return createdVisits.filter( - ({ shortUrl, visit }) => - shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate), - ); - }, -}); diff --git a/shlink-web-component/src/visits/reducers/tagVisits.ts b/shlink-web-component/src/visits/reducers/tagVisits.ts deleted file mode 100644 index 6fdf5dae..00000000 --- a/shlink-web-component/src/visits/reducers/tagVisits.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ShlinkApiClient } from '../../api-contract'; -import { isBetween } from '../../utils/dates/helpers/date'; -import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; -import type { LoadVisits, VisitsInfo } from './types'; - -const REDUCER_PREFIX = 'shlink/tagVisits'; - -interface WithTag { - tag: string; -} - -export interface TagVisits extends VisitsInfo, WithTag {} - -export interface LoadTagVisits extends LoadVisits, WithTag {} - -const initialState: TagVisits = { - visits: [], - tag: '', - loading: false, - loadingLarge: false, - error: false, - cancelLoad: false, - progress: 0, -}; - -export const getTagVisits = (apiClientFactory: () => ShlinkApiClient) => createVisitsAsyncThunk({ - typePrefix: `${REDUCER_PREFIX}/getTagVisits`, - createLoaders: ({ tag, query = {}, doIntervalFallback = false }: LoadTagVisits) => { - const { getTagVisits: getVisits } = apiClientFactory(); - const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( - tag, - { ...query, page, itemsPerPage }, - ); - const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(tag, params)); - - return [visitsLoader, lastVisitLoader]; - }, - getExtraFulfilledPayload: ({ tag, query = {} }: LoadTagVisits) => ({ tag, query }), - shouldCancel: (getState) => getState().tagVisits.cancelLoad, -}); - -export const tagVisitsReducerCreator = (asyncThunkCreator: ReturnType) => createVisitsReducer({ - name: REDUCER_PREFIX, - initialState, - // @ts-expect-error TODO Fix type inference - asyncThunkCreator, - filterCreatedVisits: ({ tag, query = {} }: TagVisits, createdVisits) => { - const { startDate, endDate } = query; - return createdVisits.filter( - ({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate), - ); - }, -}); diff --git a/shlink-web-component/src/visits/reducers/types/index.ts b/shlink-web-component/src/visits/reducers/types/index.ts deleted file mode 100644 index a93fadee..00000000 --- a/shlink-web-component/src/visits/reducers/types/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ProblemDetailsError, ShlinkVisitsParams } from '../../../api-contract'; -import type { DateInterval } from '../../../utils/dates/helpers/dateIntervals'; -import type { Visit } from '../../types'; - -export interface VisitsInfo { - visits: Visit[]; - loading: boolean; - loadingLarge: boolean; - error: boolean; - errorData?: ProblemDetailsError; - progress: number; - cancelLoad: boolean; - query?: ShlinkVisitsParams; - fallbackInterval?: DateInterval; -} - -export interface LoadVisits { - query?: ShlinkVisitsParams; - doIntervalFallback?: boolean; -} - -export type VisitsLoaded = T & { - visits: Visit[]; - query?: ShlinkVisitsParams; -}; diff --git a/shlink-web-component/src/visits/reducers/visitCreation.ts b/shlink-web-component/src/visits/reducers/visitCreation.ts deleted file mode 100644 index 3504251e..00000000 --- a/shlink-web-component/src/visits/reducers/visitCreation.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { PayloadAction } from '@reduxjs/toolkit'; -import { createAction } from '@reduxjs/toolkit'; -import type { CreateVisit } from '../types'; - -export type CreateVisitsAction = PayloadAction<{ - createdVisits: CreateVisit[]; -}>; - -export const createNewVisits = createAction( - 'shlink/visitCreation/createNewVisits', - (createdVisits: CreateVisit[]) => ({ payload: { createdVisits } }), -); diff --git a/shlink-web-component/src/visits/reducers/visitsOverview.ts b/shlink-web-component/src/visits/reducers/visitsOverview.ts deleted file mode 100644 index 259a4492..00000000 --- a/shlink-web-component/src/visits/reducers/visitsOverview.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { PayloadAction } from '@reduxjs/toolkit'; -import { createSlice } from '@reduxjs/toolkit'; -import type { ShlinkApiClient, ShlinkVisitsOverview } from '../../api-contract'; -import { createAsyncThunk } from '../../utils/redux'; -import type { CreateVisit } from '../types'; -import { groupNewVisitsByType } from '../types/helpers'; -import { createNewVisits } from './visitCreation'; - -const REDUCER_PREFIX = 'shlink/visitsOverview'; - -export type PartialVisitsSummary = { - total: number; - nonBots?: number; - bots?: number; -}; - -export type ParsedVisitsOverview = { - nonOrphanVisits: PartialVisitsSummary; - orphanVisits: PartialVisitsSummary; -}; - -export interface VisitsOverview extends ParsedVisitsOverview { - loading: boolean; - error: boolean; -} - -export type GetVisitsOverviewAction = PayloadAction; - -const initialState: VisitsOverview = { - nonOrphanVisits: { - total: 0, - }, - orphanVisits: { - total: 0, - }, - loading: false, - error: false, -}; - -const countBots = (visits: CreateVisit[]) => visits.filter(({ visit }) => visit.potentialBot).length; - -export const loadVisitsOverview = (apiClientFactory: () => ShlinkApiClient) => createAsyncThunk( - `${REDUCER_PREFIX}/loadVisitsOverview`, - (): Promise => apiClientFactory().getVisitsOverview().then( - ({ nonOrphanVisits, visitsCount, orphanVisits, orphanVisitsCount }) => ({ - nonOrphanVisits: { - total: nonOrphanVisits?.total ?? visitsCount, - nonBots: nonOrphanVisits?.nonBots, - bots: nonOrphanVisits?.bots, - }, - orphanVisits: { - total: orphanVisits?.total ?? orphanVisitsCount, - nonBots: orphanVisits?.nonBots, - bots: orphanVisits?.bots, - }, - }), - ), -); - -export const visitsOverviewReducerCreator = ( - loadVisitsOverviewThunk: ReturnType, -) => createSlice({ - name: REDUCER_PREFIX, - initialState, - reducers: {}, - extraReducers: (builder) => { - builder.addCase(loadVisitsOverviewThunk.pending, () => ({ ...initialState, loading: true })); - builder.addCase(loadVisitsOverviewThunk.rejected, () => ({ ...initialState, error: true })); - builder.addCase(loadVisitsOverviewThunk.fulfilled, (_, { payload }) => ({ ...initialState, ...payload })); - - builder.addCase(createNewVisits, ({ nonOrphanVisits, orphanVisits, ...rest }, { payload }) => { - const { nonOrphanVisits: newNonOrphanVisits, orphanVisits: newOrphanVisits } = groupNewVisitsByType( - payload.createdVisits, - ); - - const newNonOrphanTotalVisits = newNonOrphanVisits.length; - const newNonOrphanBotVisits = countBots(newNonOrphanVisits); - const newNonOrphanNonBotVisits = newNonOrphanTotalVisits - newNonOrphanBotVisits; - - const newOrphanTotalVisits = newOrphanVisits.length; - const newOrphanBotVisits = countBots(newOrphanVisits); - const newOrphanNonBotVisits = newOrphanTotalVisits - newOrphanBotVisits; - - return { - ...rest, - nonOrphanVisits: { - total: nonOrphanVisits.total + newNonOrphanTotalVisits, - bots: nonOrphanVisits.bots && nonOrphanVisits.bots + newNonOrphanBotVisits, - nonBots: nonOrphanVisits.nonBots && nonOrphanVisits.nonBots + newNonOrphanNonBotVisits, - }, - orphanVisits: { - total: orphanVisits.total + newOrphanTotalVisits, - bots: orphanVisits.bots && orphanVisits.bots + newOrphanBotVisits, - nonBots: orphanVisits.nonBots && orphanVisits.nonBots + newOrphanNonBotVisits, - }, - }; - }); - }, -}); diff --git a/shlink-web-component/src/visits/services/VisitsParser.ts b/shlink-web-component/src/visits/services/VisitsParser.ts deleted file mode 100644 index 3a81dfa0..00000000 --- a/shlink-web-component/src/visits/services/VisitsParser.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { isNil, map } from 'ramda'; -import { hasValue } from '../../utils/helpers'; -import type { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types'; -import { isNormalizedOrphanVisit, isOrphanVisit } from '../types/helpers'; -import { extractDomain, parseUserAgent } from '../utils'; - -/* eslint-disable no-param-reassign */ -const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) => - !isNil(visit) && hasValue(visit[propertyName]); - -const optionalNumericToNumber = (numeric: string | number | null | undefined): number => { - if (typeof numeric === 'number') { - return numeric; - } - - return numeric ? parseFloat(numeric) : 0; -}; - -const updateOsStatsForVisit = (osStats: Stats, { os }: NormalizedVisit) => { - osStats[os] = (osStats[os] || 0) + 1; -}; - -const updateBrowsersStatsForVisit = (browsersStats: Stats, { browser }: NormalizedVisit) => { - browsersStats[browser] = (browsersStats[browser] || 0) + 1; -}; - -const updateReferrersStatsForVisit = (referrersStats: Stats, { referer: domain }: NormalizedVisit) => { - referrersStats[domain] = (referrersStats[domain] || 0) + 1; -}; - -const updateLocationsStatsForVisit = (propertyName: 'country' | 'city') => (stats: Stats, visit: NormalizedVisit) => { - const hasLocationProperty = visitHasProperty(visit, propertyName); - const value = hasLocationProperty ? visit[propertyName] : 'Unknown'; - - stats[value] = (stats[value] || 0) + 1; -}; - -const updateCountriesStatsForVisit = updateLocationsStatsForVisit('country'); -const updateCitiesStatsForVisit = updateLocationsStatsForVisit('city'); - -const updateCitiesForMapForVisit = (citiesForMapStats: Record, visit: NormalizedVisit) => { - if (!visitHasProperty(visit, 'city') || visit.city === 'Unknown') { - return; - } - - const { city, latitude, longitude } = visit; - const currentCity = citiesForMapStats[city] || { - cityName: city, - count: 0, - latLong: [optionalNumericToNumber(latitude), optionalNumericToNumber(longitude)], - }; - - currentCity.count += 1; - - citiesForMapStats[city] = currentCity; -}; - -const updateVisitedUrlsForVisit = (visitedUrlsStats: Stats, visit: NormalizedVisit) => { - if (!isNormalizedOrphanVisit(visit)) { - return; - } - - const { visitedUrl } = visit; - - visitedUrlsStats[visitedUrl] = (visitedUrlsStats[visitedUrl] || 0) + 1; -}; - -export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.reduce( - (stats: VisitsStats, visit: NormalizedVisit) => { - // We mutate the original object because it has a big performance impact when large data sets are processed - updateOsStatsForVisit(stats.os, visit); - updateBrowsersStatsForVisit(stats.browsers, visit); - updateReferrersStatsForVisit(stats.referrers, visit); - updateCountriesStatsForVisit(stats.countries, visit); - updateCitiesStatsForVisit(stats.cities, visit); - updateCitiesForMapForVisit(stats.citiesForMap, visit); - updateVisitedUrlsForVisit(stats.visitedUrls, visit); - - return stats; - }, - { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {}, visitedUrls: {} }, -); - -export const normalizeVisits = map((visit: Visit): NormalizedVisit => { - const { userAgent, date, referer, visitLocation, potentialBot } = visit; - const common = { - date, - potentialBot, - ...parseUserAgent(userAgent), - referer: extractDomain(referer), - country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing - city: visitLocation?.cityName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing - latitude: visitLocation?.latitude, - longitude: visitLocation?.longitude, - }; - - if (!isOrphanVisit(visit)) { - return common; - } - - return { ...common, type: visit.type, visitedUrl: visit.visitedUrl }; -}); - -export interface VisitsParser { - processStatsFromVisits: (normalizedVisits: NormalizedVisit[]) => VisitsStats; - normalizeVisits: (visits: Visit[]) => NormalizedVisit[]; -} diff --git a/shlink-web-component/src/visits/services/provideServices.ts b/shlink-web-component/src/visits/services/provideServices.ts deleted file mode 100644 index 59cf5964..00000000 --- a/shlink-web-component/src/visits/services/provideServices.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type Bottle from 'bottlejs'; -import { prop } from 'ramda'; -import type { ConnectDecorator } from '../../container'; -import { DomainVisits } from '../DomainVisits'; -import { MapModal } from '../helpers/MapModal'; -import { NonOrphanVisits } from '../NonOrphanVisits'; -import { OrphanVisits } from '../OrphanVisits'; -import { domainVisitsReducerCreator, getDomainVisits } from '../reducers/domainVisits'; -import { getNonOrphanVisits, nonOrphanVisitsReducerCreator } from '../reducers/nonOrphanVisits'; -import { getOrphanVisits, orphanVisitsReducerCreator } from '../reducers/orphanVisits'; -import { getShortUrlVisits, shortUrlVisitsReducerCreator } from '../reducers/shortUrlVisits'; -import { getTagVisits, tagVisitsReducerCreator } from '../reducers/tagVisits'; -import { createNewVisits } from '../reducers/visitCreation'; -import { loadVisitsOverview, visitsOverviewReducerCreator } from '../reducers/visitsOverview'; -import { ShortUrlVisits } from '../ShortUrlVisits'; -import { TagVisits } from '../TagVisits'; -import * as visitsParser from './VisitsParser'; - -export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { - // Components - bottle.serviceFactory('MapModal', () => MapModal); - - bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'ReportExporter'); - bottle.decorator('ShortUrlVisits', connect( - ['shortUrlVisits', 'shortUrlDetail', 'mercureInfo'], - ['getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo'], - )); - - bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'ReportExporter'); - bottle.decorator('TagVisits', connect( - ['tagVisits', 'mercureInfo'], - ['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'], - )); - - bottle.serviceFactory('DomainVisits', DomainVisits, 'ReportExporter'); - bottle.decorator('DomainVisits', connect( - ['domainVisits', 'mercureInfo'], - ['getDomainVisits', 'cancelGetDomainVisits', 'createNewVisits', 'loadMercureInfo'], - )); - - bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter'); - bottle.decorator('OrphanVisits', connect( - ['orphanVisits', 'mercureInfo'], - ['getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo'], - )); - - bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'ReportExporter'); - bottle.decorator('NonOrphanVisits', connect( - ['nonOrphanVisits', 'mercureInfo'], - ['getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo'], - )); - - // Services - bottle.serviceFactory('VisitsParser', () => visitsParser); - - // Actions - bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'apiClientFactory'); - bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetVisits'), 'shortUrlVisitsReducerCreator'); - - bottle.serviceFactory('getTagVisits', getTagVisits, 'apiClientFactory'); - bottle.serviceFactory('cancelGetTagVisits', prop('cancelGetVisits'), 'tagVisitsReducerCreator'); - - bottle.serviceFactory('getDomainVisits', getDomainVisits, 'apiClientFactory'); - bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetVisits'), 'domainVisitsReducerCreator'); - - bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'apiClientFactory'); - bottle.serviceFactory('cancelGetOrphanVisits', prop('cancelGetVisits'), 'orphanVisitsReducerCreator'); - - bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'apiClientFactory'); - bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetVisits'), 'nonOrphanVisitsReducerCreator'); - - bottle.serviceFactory('createNewVisits', () => createNewVisits); - bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'apiClientFactory'); - - // Reducers - bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview'); - bottle.serviceFactory('visitsOverviewReducer', prop('reducer'), 'visitsOverviewReducerCreator'); - - bottle.serviceFactory('domainVisitsReducerCreator', domainVisitsReducerCreator, 'getDomainVisits'); - bottle.serviceFactory('domainVisitsReducer', prop('reducer'), 'domainVisitsReducerCreator'); - - bottle.serviceFactory('nonOrphanVisitsReducerCreator', nonOrphanVisitsReducerCreator, 'getNonOrphanVisits'); - bottle.serviceFactory('nonOrphanVisitsReducer', prop('reducer'), 'nonOrphanVisitsReducerCreator'); - - bottle.serviceFactory('orphanVisitsReducerCreator', orphanVisitsReducerCreator, 'getOrphanVisits'); - bottle.serviceFactory('orphanVisitsReducer', prop('reducer'), 'orphanVisitsReducerCreator'); - - bottle.serviceFactory('shortUrlVisitsReducerCreator', shortUrlVisitsReducerCreator, 'getShortUrlVisits'); - bottle.serviceFactory('shortUrlVisitsReducer', prop('reducer'), 'shortUrlVisitsReducerCreator'); - - bottle.serviceFactory('tagVisitsReducerCreator', tagVisitsReducerCreator, 'getTagVisits'); - bottle.serviceFactory('tagVisitsReducer', prop('reducer'), 'tagVisitsReducerCreator'); -}; diff --git a/shlink-web-component/src/visits/types/helpers.ts b/shlink-web-component/src/visits/types/helpers.ts deleted file mode 100644 index 15dfa3c8..00000000 --- a/shlink-web-component/src/visits/types/helpers.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ShlinkVisitsParams } from '@shlinkio/shlink-web-component/api-contract'; -import { countBy, groupBy, pipe, prop } from 'ramda'; -import { formatIsoDate } from '../../utils/dates/helpers/date'; -import type { CreateVisit, NormalizedOrphanVisit, NormalizedVisit, OrphanVisit, Stats, Visit, VisitsParams } from './index'; - -export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => (visit as OrphanVisit).visitedUrl !== undefined; - -export const isNormalizedOrphanVisit = (visit: NormalizedVisit): visit is NormalizedOrphanVisit => - (visit as NormalizedOrphanVisit).visitedUrl !== undefined; - -export interface GroupedNewVisits { - orphanVisits: CreateVisit[]; - nonOrphanVisits: CreateVisit[]; -} - -export const groupNewVisitsByType = pipe( - groupBy((newVisit: CreateVisit) => (isOrphanVisit(newVisit.visit) ? 'orphanVisits' : 'nonOrphanVisits')), - // @ts-expect-error Type declaration on groupBy is not correct. It can return undefined props - (result): GroupedNewVisits => ({ orphanVisits: [], nonOrphanVisits: [], ...result }), -); - -export type HighlightableProps = T extends NormalizedOrphanVisit - ? ('referer' | 'country' | 'city' | 'visitedUrl') - : ('referer' | 'country' | 'city'); - -export const highlightedVisitsToStats = ( - highlightedVisits: T[], - property: HighlightableProps, -): Stats => countBy(prop(property) as any, highlightedVisits); - -export const toApiParams = ({ page, itemsPerPage, filter, dateRange }: VisitsParams): ShlinkVisitsParams => { - const startDate = (dateRange?.startDate && formatIsoDate(dateRange?.startDate)) ?? undefined; - const endDate = (dateRange?.endDate && formatIsoDate(dateRange?.endDate)) ?? undefined; - const excludeBots = filter?.excludeBots || undefined; - - return { page, itemsPerPage, startDate, endDate, excludeBots }; -}; diff --git a/shlink-web-component/src/visits/types/index.ts b/shlink-web-component/src/visits/types/index.ts deleted file mode 100644 index d10829a8..00000000 --- a/shlink-web-component/src/visits/types/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { ShlinkShortUrl } from '../../api-contract'; -import type { DateRange } from '../../utils/dates/helpers/dateIntervals'; - -export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; - -interface VisitLocation { - countryCode: string | null; - countryName: string | null; - regionName: string | null; - cityName: string | null; - latitude: number | null; - longitude: number | null; - timezone: string | null; - isEmpty: boolean; -} - -export interface RegularVisit { - referer: string; - date: string; - userAgent: string; - visitLocation: VisitLocation | null; - potentialBot: boolean; -} - -export interface OrphanVisit extends RegularVisit { - visitedUrl: string; - type: OrphanVisitType; -} - -export type Visit = RegularVisit | OrphanVisit; - -export interface UserAgent { - browser: string; - os: string; -} - -export interface NormalizedRegularVisit extends UserAgent { - date: string; - referer: string; - country: string; - city: string; - latitude?: number | null; - longitude?: number | null; - potentialBot: boolean; -} - -export interface NormalizedOrphanVisit extends NormalizedRegularVisit { - visitedUrl: string; - type: OrphanVisitType; -} - -export type NormalizedVisit = NormalizedRegularVisit | NormalizedOrphanVisit; - -export interface CreateVisit { - shortUrl?: ShlinkShortUrl; - visit: Visit; -} - -export type Stats = Record; - -export type StatsRow = [string, number]; - -export interface CityStats { - cityName: string; - count: number; - latLong: [number, number]; -} - -export interface VisitsStats { - os: Stats; - browsers: Stats; - referrers: Stats; - countries: Stats; - cities: Stats; - citiesForMap: Record; - visitedUrls: Stats; -} - -export interface VisitsFilter { - orphanVisitsType?: OrphanVisitType | undefined; - excludeBots?: boolean; -} - -export interface VisitsParams { - page?: number; - itemsPerPage?: number; - dateRange?: DateRange; - filter?: VisitsFilter; -} diff --git a/shlink-web-component/src/visits/utils/index.ts b/shlink-web-component/src/visits/utils/index.ts deleted file mode 100644 index 9f6f2975..00000000 --- a/shlink-web-component/src/visits/utils/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import bowser from 'bowser'; -import { zipObj } from 'ramda'; -import type { Empty } from '../../utils/helpers'; -import { hasValue } from '../../utils/helpers'; -import type { Stats, UserAgent } from '../types'; - -const DEFAULT = 'Others'; -const BROWSERS_WHITELIST = [ - 'Android Browser', - 'Chrome', - 'Chromium', - 'Firefox', - 'Internet Explorer', - 'Microsoft Edge', - 'Opera', - 'Safari', - 'Samsung Internet for Android', - 'Vivaldi', - 'WeChat', -]; - -export const parseUserAgent = (userAgent: string | Empty): UserAgent => { - if (!hasValue(userAgent)) { - return { browser: DEFAULT, os: DEFAULT }; - } - - const { browser: { name: browser }, os: { name: os } } = bowser.parse(userAgent); - - return { os: os ?? DEFAULT, browser: browser && BROWSERS_WHITELIST.includes(browser) ? browser : DEFAULT }; -}; - -export const extractDomain = (url: string | Empty): string => { - if (!hasValue(url)) { - return 'Direct'; - } - - return url.split('/')[url.includes('://') ? 2 : 0]?.split(':')[0] ?? ''; -}; - -export const fillTheGaps = (stats: Stats, labels: string[]): number[] => - Object.values({ ...zipObj(labels, labels.map(() => 0)), ...stats }); diff --git a/shlink-web-component/test/Main.test.tsx b/shlink-web-component/test/Main.test.tsx deleted file mode 100644 index 33b702bf..00000000 --- a/shlink-web-component/test/Main.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { createMemoryHistory } from 'history'; -import { Router } from 'react-router-dom'; -import type { MainProps } from '../src/Main'; -import { Main as createMain } from '../src/Main'; -import { FeaturesProvider } from '../src/utils/features'; - -type SetUpOptions = { - currentPath: string - createNotFound?: MainProps['createNotFound']; - domainVisitsSupported?: boolean; -}; - -describe('
', () => { - const Main = createMain( - () => <>TagsList, - () => <>ShortUrlsList, - () => <>CreateShortUrl, - () => <>ShortUrlVisits, - () => <>TagVisits, - () => <>DomainVisits, - () => <>OrphanVisits, - () => <>NonOrphanVisits, - () => <>OverviewRoute, - () => <>EditShortUrl, - () => <>ManageDomains, - ); - const setUp = ({ createNotFound, currentPath, domainVisitsSupported = true }: SetUpOptions) => { - const history = createMemoryHistory(); - history.push(currentPath); - - return render( - - -
- - , - ); - }; - - it.each([ - ['/overview', 'OverviewRoute'], - ['/list-short-urls/1', 'ShortUrlsList'], - ['/create-short-url', 'CreateShortUrl'], - ['/short-code/abc123/visits/foo', 'ShortUrlVisits'], - ['/short-code/abc123/edit', 'EditShortUrl'], - ['/tag/foo/visits/foo', 'TagVisits'], - ['/orphan-visits/foo', 'OrphanVisits'], - ['/manage-tags', 'TagsList'], - ['/domain/domain.com/visits/foo', 'DomainVisits'], - ['/non-orphan-visits/foo', 'NonOrphanVisits'], - ['/manage-domains', 'ManageDomains'], - ])( - 'renders expected component based on location and server version', - (currentPath, expectedContent) => { - setUp({ currentPath }); - expect(screen.getByText(expectedContent)).toBeInTheDocument(); - }, - ); - - it.each([ - ['/domain/domain.com/visits/foo', false], - ['/foo/bar/baz', true], - ])('renders not-found when trying to navigate to invalid route', (currentPath, domainVisitsSupported) => { - const createNotFound = () => <>Oops! Route not found.; - - setUp({ currentPath, domainVisitsSupported, createNotFound }); - - expect(screen.getByText('Oops! Route not found.')).toBeInTheDocument(); - }); -}); diff --git a/shlink-web-component/test/ShlinkWebComponent.test.tsx b/shlink-web-component/test/ShlinkWebComponent.test.tsx deleted file mode 100644 index 1f7023f9..00000000 --- a/shlink-web-component/test/ShlinkWebComponent.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import Bottle from 'bottlejs'; -import type { TagColorsStorage } from '../src'; -import type { ShlinkApiClient } from '../src/api-contract'; -import { createShlinkWebComponent } from '../src/ShlinkWebComponent'; - -describe('', () => { - let bottle: Bottle; - const dispatch = vi.fn(); - const loadMercureInfo = vi.fn(); - const apiClient = fromPartial({}); - - const setUp = (tagColorsStorage?: TagColorsStorage) => { - const ShlinkWebComponent = createShlinkWebComponent(bottle); - return render( - , - ); - }; - - beforeEach(() => { - bottle = new Bottle(); - - bottle.value('Main', () => <>Main); - bottle.value('store', { - dispatch, - getState: vi.fn().mockReturnValue({}), - subscribe: vi.fn(), - }); - bottle.value('loadMercureInfo', loadMercureInfo); - }); - - it('registers services when mounted', async () => { - expect(bottle.container.TagColorsStorage).not.toBeDefined(); - expect(bottle.container.apiClientFactory).not.toBeDefined(); - - setUp(fromPartial({})); - - await waitFor(() => expect(bottle.container.TagColorsStorage).toBeDefined()); - expect(bottle.container.apiClientFactory).toBeDefined(); - }); - - it('renders main content', async () => { - setUp(); - await waitFor(() => expect(screen.getByText('Main')).toBeInTheDocument()); - }); - - it('loads mercure on mount', async () => { - setUp(); - - await waitFor(() => expect(dispatch).toHaveBeenCalledOnce()); - expect(loadMercureInfo).toHaveBeenCalledOnce(); - }); -}); diff --git a/shlink-web-component/test/__helpers__/TestModalWrapper.tsx b/shlink-web-component/test/__helpers__/TestModalWrapper.tsx deleted file mode 100644 index 18959df7..00000000 --- a/shlink-web-component/test/__helpers__/TestModalWrapper.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useToggle } from '@shlinkio/shlink-frontend-kit'; -import type { FC, ReactElement } from 'react'; - -interface RenderModalArgs { - isOpen: boolean; - toggle: () => void; -} - -export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = ( - { renderModal }, -) => { - const [isOpen, toggle] = useToggle(true); - return renderModal({ isOpen, toggle }); -}; diff --git a/shlink-web-component/test/__helpers__/setUpTest.ts b/shlink-web-component/test/__helpers__/setUpTest.ts deleted file mode 100644 index 5d125c73..00000000 --- a/shlink-web-component/test/__helpers__/setUpTest.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { ReactElement } from 'react'; - -export const setUpCanvas = (element: ReactElement) => { - const result = render(element); - const { container } = result; - const getEvents = () => { - const context = container.querySelector('canvas')?.getContext('2d'); - // @ts-expect-error __getEvents is set by vitest-canvas-mock - return context?.__getEvents(); // eslint-disable-line no-underscore-dangle - }; - - return { ...result, events: getEvents(), getEvents }; -}; - -export const renderWithEvents = (element: ReactElement) => ({ - user: userEvent.setup(), - ...render(element), -}); diff --git a/shlink-web-component/test/__mocks__/Window.mock.ts b/shlink-web-component/test/__mocks__/Window.mock.ts deleted file mode 100644 index 8b718753..00000000 --- a/shlink-web-component/test/__mocks__/Window.mock.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { fromAny, fromPartial } from '@total-typescript/shoehorn'; - -const createLinkMock = () => ({ - setAttribute: vi.fn(), - click: vi.fn(), - style: {}, -}); - -export const appendChild = vi.fn(); - -export const removeChild = vi.fn(); - -export const windowMock = fromPartial({ - document: fromAny({ - createElement: vi.fn(createLinkMock), - body: { appendChild, removeChild }, - }), -}); diff --git a/shlink-web-component/test/common/AsideMenu.test.tsx b/shlink-web-component/test/common/AsideMenu.test.tsx deleted file mode 100644 index 0b0d7bbb..00000000 --- a/shlink-web-component/test/common/AsideMenu.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router'; -import { AsideMenu } from '../../src/common/AsideMenu'; - -describe('', () => { - const setUp = () => render( - - - , - ); - - it('contains links to different sections', () => { - setUp(); - - const links = screen.getAllByRole('link'); - - expect.assertions(links.length + 1); - expect(links).toHaveLength(5); - links.forEach((link) => expect(link.getAttribute('href')).toContain('abc123')); - }); -}); diff --git a/shlink-web-component/test/common/ShlinkApiError.test.tsx b/shlink-web-component/test/common/ShlinkApiError.test.tsx deleted file mode 100644 index 8d614e66..00000000 --- a/shlink-web-component/test/common/ShlinkApiError.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { InvalidArgumentError, ProblemDetailsError } from '../../src/api-contract'; -import { ErrorTypeV2, ErrorTypeV3 } from '../../src/api-contract'; -import type { ShlinkApiErrorProps } from '../../src/common/ShlinkApiError'; -import { ShlinkApiError } from '../../src/common/ShlinkApiError'; - -describe('', () => { - const setUp = (props: ShlinkApiErrorProps) => render(); - - it.each([ - [undefined, 'the fallback', 'the fallback'], - [fromPartial({}), 'the fallback', 'the fallback'], - [fromPartial({ detail: 'the detail' }), 'the fallback', 'the detail'], - ])('renders proper message', (errorData, fallbackMessage, expectedMessage) => { - const { container } = setUp({ errorData, fallbackMessage }); - - expect(container.firstChild).toHaveTextContent(expectedMessage); - expect(screen.queryByRole('paragraph')).not.toBeInTheDocument(); - }); - - it.each([ - [undefined, 0], - [fromPartial({}), 0], - [fromPartial({ type: ErrorTypeV2.INVALID_ARGUMENT, invalidElements: [] }), 1], - [fromPartial({ type: ErrorTypeV3.INVALID_ARGUMENT, invalidElements: [] }), 1], - ])('renders list of invalid elements when provided error is an InvalidError', (errorData, expectedElementsCount) => { - setUp({ errorData }); - expect(screen.queryAllByText(/^Invalid elements/)).toHaveLength(expectedElementsCount); - }); -}); diff --git a/shlink-web-component/test/domains/DomainRow.test.tsx b/shlink-web-component/test/domains/DomainRow.test.tsx deleted file mode 100644 index 967b77ec..00000000 --- a/shlink-web-component/test/domains/DomainRow.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkDomainRedirects } from '../../src/api-contract'; -import type { Domain } from '../../src/domains/data'; -import { DomainRow } from '../../src/domains/DomainRow'; - -describe('', () => { - const redirectsCombinations = [ - [fromPartial({ baseUrlRedirect: 'foo' })], - [fromPartial({ invalidShortUrlRedirect: 'bar' })], - [fromPartial({ baseUrlRedirect: 'baz', regular404Redirect: 'foo' })], - [ - fromPartial( - { baseUrlRedirect: 'baz', regular404Redirect: 'bar', invalidShortUrlRedirect: 'foo' }, - ), - ], - ]; - const setUp = (domain: Domain, defaultRedirects?: ShlinkDomainRedirects) => render( - - - - -
, - ); - - it.each(redirectsCombinations)('shows expected redirects', (redirects) => { - setUp(fromPartial({ domain: '', isDefault: true, redirects })); - const cells = screen.getAllByRole('cell'); - - redirects?.baseUrlRedirect && expect(cells[1]).toHaveTextContent(redirects.baseUrlRedirect); - redirects?.regular404Redirect && expect(cells[2]).toHaveTextContent(redirects.regular404Redirect); - redirects?.invalidShortUrlRedirect && expect(cells[3]).toHaveTextContent(redirects.invalidShortUrlRedirect); - expect(screen.queryByText('(as fallback)')).not.toBeInTheDocument(); - }); - - it.each([ - [undefined], - [fromPartial({})], - ])('shows expected "no redirects"', (redirects) => { - setUp(fromPartial({ domain: '', isDefault: true, redirects })); - const cells = screen.getAllByRole('cell'); - - expect(cells[1]).toHaveTextContent('No redirect'); - expect(cells[2]).toHaveTextContent('No redirect'); - expect(cells[3]).toHaveTextContent('No redirect'); - expect(screen.queryByText('(as fallback)')).not.toBeInTheDocument(); - }); - - it.each(redirectsCombinations)('shows expected fallback redirects', (fallbackRedirects) => { - setUp(fromPartial({ domain: '', isDefault: true }), fallbackRedirects); - const cells = screen.getAllByRole('cell'); - - fallbackRedirects?.baseUrlRedirect && expect(cells[1]).toHaveTextContent( - `${fallbackRedirects.baseUrlRedirect} (as fallback)`, - ); - fallbackRedirects?.regular404Redirect && expect(cells[2]).toHaveTextContent( - `${fallbackRedirects.regular404Redirect} (as fallback)`, - ); - fallbackRedirects?.invalidShortUrlRedirect && expect(cells[3]).toHaveTextContent( - `${fallbackRedirects.invalidShortUrlRedirect} (as fallback)`, - ); - }); - - it.each([[true], [false]])('shows icon on default domain only', (isDefault) => { - const { container } = setUp(fromPartial({ domain: '', isDefault })); - - if (isDefault) { - expect(container.querySelector('#defaultDomainIcon')).toBeInTheDocument(); - } else { - expect(container.querySelector('#defaultDomainIcon')).not.toBeInTheDocument(); - } - }); -}); diff --git a/shlink-web-component/test/domains/DomainSelector.test.tsx b/shlink-web-component/test/domains/DomainSelector.test.tsx deleted file mode 100644 index c683672d..00000000 --- a/shlink-web-component/test/domains/DomainSelector.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { DomainSelector } from '../../src/domains/DomainSelector'; -import type { DomainsList } from '../../src/domains/reducers/domainsList'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -describe('', () => { - const domainsList = fromPartial({ - domains: [ - fromPartial({ domain: 'default.com', isDefault: true }), - fromPartial({ domain: 'foo.com' }), - fromPartial({ domain: 'bar.com' }), - ], - }); - const setUp = (value = '') => renderWithEvents( - , - ); - - it.each([ - ['', 'Domain', 'domains-dropdown__toggle-btn'], - ['my-domain.com', 'Domain: my-domain.com', 'domains-dropdown__toggle-btn--active'], - ])('shows dropdown by default', async (value, expectedText, expectedClassName) => { - const { user } = setUp(value); - const btn = screen.getByRole('button', { name: expectedText }); - - expect(screen.queryByPlaceholderText('Domain')).not.toBeInTheDocument(); - expect(btn).toHaveClass( - `dropdown-btn__toggle ${expectedClassName} btn-block dropdown-btn__toggle--with-caret dropdown-toggle btn btn-primary`, - ); - await user.click(btn); - - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); - expect(screen.getAllByRole('menuitem')).toHaveLength(4); - }); - - it('allows toggling between dropdown and input', async () => { - const { user } = setUp(); - - expect(screen.queryByPlaceholderText('Domain')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Domain' })).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Domain' })); - await user.click(await screen.findByText('New domain')); - - expect(screen.getByPlaceholderText('Domain')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Domain' })).not.toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Back to domains list' })); - - expect(screen.queryByPlaceholderText('Domain')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Domain' })).toBeInTheDocument(); - }); - - it.each([ - [0, 'default.comdefault'], - [1, 'foo.com'], - [2, 'bar.com'], - ])('shows expected content on every item', async (index, expectedContent) => { - const { user } = setUp(); - - await user.click(screen.getByRole('button', { name: 'Domain' })); - const items = await screen.findAllByRole('menuitem'); - - expect(items[index]).toHaveTextContent(expectedContent); - }); -}); diff --git a/shlink-web-component/test/domains/ManageDomains.test.tsx b/shlink-web-component/test/domains/ManageDomains.test.tsx deleted file mode 100644 index 4f6050b8..00000000 --- a/shlink-web-component/test/domains/ManageDomains.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ProblemDetailsError, ShlinkDomain } from '../../src/api-contract'; -import { ManageDomains } from '../../src/domains/ManageDomains'; -import type { DomainsList } from '../../src/domains/reducers/domainsList'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -describe('', () => { - const listDomains = vi.fn(); - const filterDomains = vi.fn(); - const setUp = (domainsList: DomainsList) => renderWithEvents( - , - ); - - it('shows loading message while domains are loading', () => { - setUp(fromPartial({ loading: true, filteredDomains: [] })); - - expect(screen.getByText('Loading...')).toBeInTheDocument(); - expect(screen.queryByText('Error loading domains :(')).not.toBeInTheDocument(); - }); - - it.each([ - [undefined, 'Error loading domains :('], - [fromPartial({}), 'Error loading domains :('], - [fromPartial({ detail: 'Foo error!!' }), 'Foo error!!'], - ])('shows error result when domains loading fails', (errorData, expectedErrorMessage) => { - setUp(fromPartial({ loading: false, error: true, errorData, filteredDomains: [] })); - - expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); - expect(screen.getByText(expectedErrorMessage)).toBeInTheDocument(); - }); - - it('filters domains when SearchField changes', async () => { - const { user } = setUp(fromPartial({ loading: false, error: false, filteredDomains: [] })); - - expect(filterDomains).not.toHaveBeenCalled(); - await user.type(screen.getByPlaceholderText('Search...'), 'Foo'); - await waitFor(() => expect(filterDomains).toHaveBeenCalledWith('Foo')); - }); - - it('shows expected headers and one row when list of domains is empty', () => { - setUp(fromPartial({ loading: false, error: false, filteredDomains: [] })); - - expect(screen.getAllByRole('columnheader')).toHaveLength(7); - expect(screen.getByText('No results found')).toBeInTheDocument(); - }); - - it('has many rows if multiple domains are provided', () => { - const filteredDomains: ShlinkDomain[] = [ - fromPartial({ domain: 'foo' }), - fromPartial({ domain: 'bar' }), - fromPartial({ domain: 'baz' }), - ]; - setUp(fromPartial({ loading: false, error: false, filteredDomains })); - - expect(screen.getAllByRole('row')).toHaveLength(filteredDomains.length + 1); - expect(screen.getByText('foo')).toBeInTheDocument(); - expect(screen.getByText('bar')).toBeInTheDocument(); - expect(screen.getByText('baz')).toBeInTheDocument(); - }); -}); diff --git a/shlink-web-component/test/domains/helpers/DomainDropdown.test.tsx b/shlink-web-component/test/domains/helpers/DomainDropdown.test.tsx deleted file mode 100644 index 4044f98b..00000000 --- a/shlink-web-component/test/domains/helpers/DomainDropdown.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { screen, waitForElementToBeRemoved } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { MemoryRouter } from 'react-router-dom'; -import type { Domain } from '../../../src/domains/data'; -import { DomainDropdown } from '../../../src/domains/helpers/DomainDropdown'; -import { FeaturesProvider } from '../../../src/utils/features'; -import { RoutesPrefixProvider } from '../../../src/utils/routesPrefix'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const editDomainRedirects = vi.fn().mockResolvedValue(undefined); - const setUp = ({ domain, withVisits = true }: { domain?: Domain; withVisits?: boolean } = {}) => renderWithEvents( - - - - - - - , - ); - - it('renders expected menu items', () => { - setUp({ withVisits: false }); - - expect(screen.queryByText('Visit stats')).not.toBeInTheDocument(); - expect(screen.getByText('Edit redirects')).toBeInTheDocument(); - }); - - it.each([ - [true, '_DEFAULT'], - [false, ''], - ])('points first link to the proper section', (isDefault, expectedLink) => { - setUp({ domain: fromPartial({ domain: 'foo.com', isDefault }) }); - - expect(screen.getByText('Visit stats')).toHaveAttribute('href', `/server/123/domain/foo.com${expectedLink}/visits`); - }); - - it.each([ - ['foo.com'], - ['bar.org'], - ['baz.net'], - ])('displays modal when editing redirects', async (domain) => { - const { user } = setUp({ domain: fromPartial({ domain, isDefault: false }) }); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.queryByRole('form')).not.toBeInTheDocument(); - await user.click(screen.getByText('Edit redirects')); - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - - expect(editDomainRedirects).not.toHaveBeenCalled(); - await user.click(screen.getByText('Save')); - expect(editDomainRedirects).toHaveBeenCalledWith(expect.objectContaining({ domain })); - - await waitForElementToBeRemoved(() => screen.queryByRole('dialog')); - }); - - it('displays dropdown when clicked', async () => { - const { user } = setUp(); - - expect(screen.queryByRole('menu')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { expanded: false })); - expect(await screen.findByRole('menu')).toBeInTheDocument(); - }); -}); diff --git a/shlink-web-component/test/domains/helpers/DomainStatusIcon.test.tsx b/shlink-web-component/test/domains/helpers/DomainStatusIcon.test.tsx deleted file mode 100644 index 458f2130..00000000 --- a/shlink-web-component/test/domains/helpers/DomainStatusIcon.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { DomainStatus } from '../../../src/domains/data'; -import { DomainStatusIcon } from '../../../src/domains/helpers/DomainStatusIcon'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const matchMedia = vi.fn().mockReturnValue(fromPartial({ matches: false })); - const setUp = (status: DomainStatus) => renderWithEvents( - , - ); - - it.each([ - ['validating' as DomainStatus], - ['invalid' as DomainStatus], - ['valid' as DomainStatus], - ])('renders expected icon and tooltip when status is not validating', (status) => { - const { container } = setUp(status); - expect(container.firstChild).toMatchSnapshot(); - }); - - it.each([ - ['invalid' as DomainStatus], - ['valid' as DomainStatus], - ])('renders proper tooltip based on state', async (status) => { - const { container, user } = setUp(status); - - container.firstElementChild && await user.hover(container.firstElementChild); - expect(await screen.findByRole('tooltip')).toMatchSnapshot(); - }); -}); diff --git a/shlink-web-component/test/domains/helpers/EditDomainRedirectsModal.test.tsx b/shlink-web-component/test/domains/helpers/EditDomainRedirectsModal.test.tsx deleted file mode 100644 index 2a505ad0..00000000 --- a/shlink-web-component/test/domains/helpers/EditDomainRedirectsModal.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkDomain } from '../../../src/api-contract'; -import { EditDomainRedirectsModal } from '../../../src/domains/helpers/EditDomainRedirectsModal'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const editDomainRedirects = vi.fn().mockResolvedValue(undefined); - const toggle = vi.fn(); - const domain = fromPartial({ - domain: 'foo.com', - redirects: { - baseUrlRedirect: 'baz', - }, - }); - const setUp = () => renderWithEvents( - , - ); - - it('renders domain in header', () => { - setUp(); - expect(screen.getByRole('heading')).toHaveTextContent('Edit redirects for foo.com'); - }); - - it('has different handlers to toggle the modal', async () => { - const { user } = setUp(); - - expect(toggle).not.toHaveBeenCalled(); - await user.click(screen.getByLabelText('Close')); - await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(toggle).toHaveBeenCalledTimes(2); - }); - - it('saves expected values when form is submitted', async () => { - const { user } = setUp(); - // TODO Using fire event because userEvent.click on the Submit button does not submit the form - const submitForm = () => fireEvent.submit(screen.getByRole('form')); - - expect(editDomainRedirects).not.toHaveBeenCalled(); - submitForm(); - await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({ - domain: 'foo.com', - redirects: { - baseUrlRedirect: 'baz', - regular404Redirect: null, - invalidShortUrlRedirect: null, - }, - })); - - await user.clear(screen.getByDisplayValue('baz')); - await user.type(screen.getAllByPlaceholderText('No redirect')[0], 'new_base_url'); - await user.type(screen.getAllByPlaceholderText('No redirect')[2], 'new_invalid_short_url'); - submitForm(); - await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({ - domain: 'foo.com', - redirects: { - baseUrlRedirect: 'new_base_url', - regular404Redirect: null, - invalidShortUrlRedirect: 'new_invalid_short_url', - }, - })); - - await user.type(screen.getAllByPlaceholderText('No redirect')[1], 'new_regular_404'); - await user.clear(screen.getByDisplayValue('new_invalid_short_url')); - submitForm(); - await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({ - domain: 'foo.com', - redirects: { - baseUrlRedirect: 'new_base_url', - regular404Redirect: 'new_regular_404', - invalidShortUrlRedirect: null, - }, - })); - - await Promise.all(screen.getAllByPlaceholderText('No redirect').map((element) => user.clear(element))); - submitForm(); - await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({ - domain: 'foo.com', - redirects: { - baseUrlRedirect: null, - regular404Redirect: null, - invalidShortUrlRedirect: null, - }, - })); - }); -}); diff --git a/shlink-web-component/test/domains/helpers/__snapshots__/DomainStatusIcon.test.tsx.snap b/shlink-web-component/test/domains/helpers/__snapshots__/DomainStatusIcon.test.tsx.snap deleted file mode 100644 index 44dbadf7..00000000 --- a/shlink-web-component/test/domains/helpers/__snapshots__/DomainStatusIcon.test.tsx.snap +++ /dev/null @@ -1,89 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > renders expected icon and tooltip when status is not validating 1`] = ` - -`; - -exports[` > renders expected icon and tooltip when status is not validating 2`] = ` - - - -`; - -exports[` > renders expected icon and tooltip when status is not validating 3`] = ` - - - -`; - -exports[` > renders proper tooltip based on state 1`] = ` - -`; - -exports[` > renders proper tooltip based on state 2`] = ` - -`; diff --git a/shlink-web-component/test/domains/reducers/domainRedirects.test.ts b/shlink-web-component/test/domains/reducers/domainRedirects.test.ts deleted file mode 100644 index 40004a5d..00000000 --- a/shlink-web-component/test/domains/reducers/domainRedirects.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient, ShlinkDomainRedirects } from '../../../src/api-contract'; -import { editDomainRedirects } from '../../../src/domains/reducers/domainRedirects'; - -describe('domainRedirectsReducer', () => { - describe('editDomainRedirects', () => { - const domain = 'example.com'; - const redirects = fromPartial({}); - const dispatch = vi.fn(); - const getState = vi.fn(); - const editDomainRedirectsCall = vi.fn(); - const buildShlinkApiClient = () => fromPartial({ editDomainRedirects: editDomainRedirectsCall }); - const editDomainRedirectsAction = editDomainRedirects(buildShlinkApiClient); - - it('dispatches domain and redirects once loaded', async () => { - editDomainRedirectsCall.mockResolvedValue(redirects); - - await editDomainRedirectsAction(fromPartial({ domain }))(dispatch, getState, {}); - - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ - payload: { domain, redirects }, - })); - expect(editDomainRedirectsCall).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/shlink-web-component/test/domains/reducers/domainsList.test.ts b/shlink-web-component/test/domains/reducers/domainsList.test.ts deleted file mode 100644 index ed4afc89..00000000 --- a/shlink-web-component/test/domains/reducers/domainsList.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient, ShlinkDomainRedirects } from '../../../src/api-contract'; -import { parseApiError } from '../../../src/api-contract/utils'; -import type { Domain } from '../../../src/domains/data'; -import type { EditDomainRedirects } from '../../../src/domains/reducers/domainRedirects'; -import { editDomainRedirects } from '../../../src/domains/reducers/domainRedirects'; -import { - domainsListReducerCreator, - replaceRedirectsOnDomain, - replaceStatusOnDomain, -} from '../../../src/domains/reducers/domainsList'; - -describe('domainsListReducer', () => { - const dispatch = vi.fn(); - const getState = vi.fn(); - const listDomains = vi.fn(); - const health = vi.fn(); - const apiClientFactory = () => fromPartial({ listDomains, health }); - const filteredDomains: Domain[] = [ - fromPartial({ domain: 'foo', status: 'validating' }), - fromPartial({ domain: 'Boo', status: 'validating' }), - ]; - const domains: Domain[] = [...filteredDomains, fromPartial({ domain: 'bar', status: 'validating' })]; - const error = { type: 'NOT_FOUND', status: 404 } as unknown as Error; - const editDomainRedirectsThunk = editDomainRedirects(apiClientFactory); - const { reducer, listDomains: listDomainsAction, checkDomainHealth, filterDomains } = domainsListReducerCreator( - apiClientFactory, - editDomainRedirectsThunk, - ); - - describe('reducer', () => { - it('returns loading on LIST_DOMAINS_START', () => { - expect(reducer(undefined, listDomainsAction.pending('', {}))).toEqual( - { domains: [], filteredDomains: [], loading: true, error: false }, - ); - }); - - it('returns error on LIST_DOMAINS_ERROR', () => { - expect(reducer(undefined, listDomainsAction.rejected(error, '', {}))).toEqual( - { domains: [], filteredDomains: [], loading: false, error: true, errorData: parseApiError(error) }, - ); - }); - - it('returns domains on LIST_DOMAINS', () => { - expect( - reducer(undefined, listDomainsAction.fulfilled({ domains }, '', {})), - ).toEqual({ domains, filteredDomains: domains, loading: false, error: false }); - }); - - it('filters domains on FILTER_DOMAINS', () => { - expect(reducer(fromPartial({ domains }), filterDomains('oO'))).toEqual({ domains, filteredDomains }); - }); - - it.each([ - ['foo'], - ['bar'], - ['does_not_exist'], - ])('replaces redirects on proper domain on EDIT_DOMAIN_REDIRECTS', (domain) => { - const redirects: ShlinkDomainRedirects = { - baseUrlRedirect: 'bar', - regular404Redirect: 'foo', - invalidShortUrlRedirect: null, - }; - const editDomainRedirects: EditDomainRedirects = { domain, redirects }; - - expect(reducer( - fromPartial({ domains, filteredDomains }), - editDomainRedirectsThunk.fulfilled(editDomainRedirects, '', editDomainRedirects), - )).toEqual({ - domains: domains.map(replaceRedirectsOnDomain(editDomainRedirects)), - filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(editDomainRedirects)), - }); - }); - - it.each([ - ['foo'], - ['bar'], - ['does_not_exist'], - ])('replaces status on proper domain on VALIDATE_DOMAIN', (domain) => { - expect(reducer( - fromPartial({ domains, filteredDomains }), - checkDomainHealth.fulfilled({ domain, status: 'valid' }, '', ''), - )).toEqual({ - domains: domains.map(replaceStatusOnDomain(domain, 'valid')), - filteredDomains: filteredDomains.map(replaceStatusOnDomain(domain, 'valid')), - }); - }); - }); - - describe('listDomains', () => { - it('dispatches domains once loaded', async () => { - listDomains.mockResolvedValue({ data: domains }); - - await listDomainsAction({})(dispatch, getState, {}); - - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ - payload: { domains }, - })); - expect(listDomains).toHaveBeenCalledTimes(1); - }); - }); - - describe('filterDomains', () => { - it.each([ - ['foo'], - ['bar'], - ['something'], - ])('creates action as expected', (searchTerm) => { - expect(filterDomains(searchTerm).payload).toEqual(searchTerm); - }); - }); - - describe('checkDomainHealth', () => { - const domain = 'example.com'; - - it('dispatches invalid status when health endpoint returns an error', async () => { - health.mockRejectedValue({}); - - await checkDomainHealth(domain)(dispatch, getState, {}); - - expect(health).toHaveBeenCalledTimes(1); - expect(health).toHaveBeenCalledWith(domain); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ - payload: { domain, status: 'invalid' }, - })); - }); - - it.each([ - ['pass', 'valid'], - ['fail', 'invalid'], - ])('dispatches proper status based on status returned from health endpoint', async ( - healthStatus, - expectedStatus, - ) => { - health.mockResolvedValue({ status: healthStatus }); - - await checkDomainHealth(domain)(dispatch, getState, {}); - - expect(health).toHaveBeenCalledTimes(1); - expect(health).toHaveBeenCalledWith(domain); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ - payload: { domain, status: expectedStatus }, - })); - }); - }); -}); diff --git a/shlink-web-component/test/mercure/helpers/index.test.tsx b/shlink-web-component/test/mercure/helpers/index.test.tsx deleted file mode 100644 index 0d620272..00000000 --- a/shlink-web-component/test/mercure/helpers/index.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import { EventSourcePolyfill } from 'event-source-polyfill'; -import { identity } from 'ramda'; -import { bindToMercureTopic } from '../../../src/mercure/helpers'; -import type { MercureInfo } from '../../../src/mercure/reducers/mercureInfo'; - -vi.mock('event-source-polyfill'); - -describe('helpers', () => { - describe('bindToMercureTopic', () => { - const onMessage = vi.fn(); - const onTokenExpired = vi.fn(); - - it.each([ - [fromPartial({ loading: true, error: false, mercureHubUrl: 'foo' })], - [fromPartial({ loading: false, error: true, mercureHubUrl: 'foo' })], - [fromPartial({ loading: true, error: true, mercureHubUrl: 'foo' })], - [fromPartial({ loading: false, error: false, mercureHubUrl: undefined })], - [fromPartial({ loading: true, error: true, mercureHubUrl: undefined })], - ])('does not bind an EventSource when loading, error or no hub URL', (mercureInfo) => { - bindToMercureTopic(mercureInfo, [''], identity, () => {}); - - expect(EventSourcePolyfill).not.toHaveBeenCalled(); - expect(onMessage).not.toHaveBeenCalled(); - expect(onTokenExpired).not.toHaveBeenCalled(); - }); - - it('binds an EventSource when mercure info is properly loaded', () => { - const token = 'abc.123.efg'; - const mercureHubUrl = 'https://example.com/.well-known/mercure'; - const topic = 'foo'; - const hubUrl = new URL(mercureHubUrl); - - hubUrl.searchParams.append('topic', topic); - - const callback = bindToMercureTopic({ - loading: false, - error: false, - mercureHubUrl, - token, - }, [topic], onMessage, onTokenExpired); - - expect(EventSourcePolyfill).toHaveBeenCalledWith(hubUrl, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - const [es] = (EventSourcePolyfill as any).mock.instances as EventSourcePolyfill[]; - - es.onmessage?.({ data: '{"foo": "bar"}' }); - es.onerror?.({ status: 401 }); - expect(onMessage).toHaveBeenCalledWith({ foo: 'bar' }); - expect(onTokenExpired).toHaveBeenCalled(); - - callback?.(); - expect(es.close).toHaveBeenCalled(); - }); - }); -}); diff --git a/shlink-web-component/test/mercure/reducers/mercureInfo.test.ts b/shlink-web-component/test/mercure/reducers/mercureInfo.test.ts deleted file mode 100644 index acf3441d..00000000 --- a/shlink-web-component/test/mercure/reducers/mercureInfo.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { Settings } from '../../../src'; -import type { ShlinkApiClient } from '../../../src/api-contract'; -import { mercureInfoReducerCreator } from '../../../src/mercure/reducers/mercureInfo'; - -describe('mercureInfoReducer', () => { - const mercureInfo = { - mercureHubUrl: 'http://example.com/.well-known/mercure', - token: 'abc.123.def', - }; - const getMercureInfo = vi.fn(); - const buildShlinkApiClient = () => fromPartial({ mercureInfo: getMercureInfo }); - const { loadMercureInfo, reducer } = mercureInfoReducerCreator(buildShlinkApiClient); - - describe('reducer', () => { - it('returns loading on GET_MERCURE_INFO_START', () => { - expect(reducer(undefined, loadMercureInfo.pending('', {}))).toEqual({ - loading: true, - error: false, - }); - }); - - it('returns error on GET_MERCURE_INFO_ERROR', () => { - expect(reducer(undefined, loadMercureInfo.rejected(null, '', {}))).toEqual({ - loading: false, - error: true, - }); - }); - - it('returns mercure info on GET_MERCURE_INFO', () => { - expect(reducer(undefined, loadMercureInfo.fulfilled(mercureInfo, '', {}))).toEqual( - expect.objectContaining({ ...mercureInfo, loading: false, error: false }), - ); - }); - }); - - describe('loadMercureInfo', () => { - const dispatch = vi.fn(); - const createSettings = (enabled: boolean): Settings => fromPartial({ - realTimeUpdates: { enabled }, - }); - - it('dispatches error when real time updates are disabled', async () => { - getMercureInfo.mockResolvedValue(mercureInfo); - const settings = createSettings(false); - - await loadMercureInfo(settings)(dispatch, vi.fn(), {}); - - expect(getMercureInfo).not.toHaveBeenCalled(); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ - error: new Error('Real time updates not enabled'), - })); - }); - - it('calls API on success', async () => { - getMercureInfo.mockResolvedValue(mercureInfo); - const settings = createSettings(true); - - await loadMercureInfo(settings)(dispatch, vi.fn(), {}); - - expect(getMercureInfo).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: mercureInfo })); - }); - }); -}); diff --git a/shlink-web-component/test/overview/Overview.test.tsx b/shlink-web-component/test/overview/Overview.test.tsx deleted file mode 100644 index 9b4cf5d2..00000000 --- a/shlink-web-component/test/overview/Overview.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { MemoryRouter } from 'react-router-dom'; -import type { MercureInfo } from '../../src/mercure/reducers/mercureInfo'; -import { Overview as overviewCreator } from '../../src/overview/Overview'; -import { prettify } from '../../src/utils/helpers/numbers'; -import { RoutesPrefixProvider } from '../../src/utils/routesPrefix'; -import { SettingsProvider } from '../../src/utils/settings'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -describe('', () => { - const ShortUrlsTable = () => <>ShortUrlsTable; - const CreateShortUrl = () => <>CreateShortUrl; - const listShortUrls = vi.fn(); - const listTags = vi.fn(); - const loadVisitsOverview = vi.fn(); - const Overview = overviewCreator(ShortUrlsTable, CreateShortUrl); - const shortUrls = { - pagination: { totalItems: 83710 }, - }; - const routesPrefix = '/server/123'; - const setUp = (loading = false, excludeBots = false) => renderWithEvents( - - - - ({})} - /> - - - , - ); - - it('displays loading messages when still loading', () => { - setUp(true); - expect(screen.getAllByText('Loading...')).toHaveLength(4); - }); - - it.each([ - [false, 3456, 28], - [true, 2456, 13], - ])('displays amounts in cards after finishing loading', (excludeBots, expectedVisits, expectedOrphanVisits) => { - setUp(false, excludeBots); - - const headingElements = screen.getAllByRole('heading'); - - expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); - expect(headingElements[0]).toHaveTextContent('Visits'); - expect(headingElements[1]).toHaveTextContent(prettify(expectedVisits)); - expect(headingElements[2]).toHaveTextContent('Orphan visits'); - expect(headingElements[3]).toHaveTextContent(prettify(expectedOrphanVisits)); - expect(headingElements[4]).toHaveTextContent('Short URLs'); - expect(headingElements[5]).toHaveTextContent(prettify(83710)); - expect(headingElements[6]).toHaveTextContent('Tags'); - expect(headingElements[7]).toHaveTextContent(prettify(3)); - }); - - it('nests injected components', () => { - setUp(); - - expect(screen.queryByText('ShortUrlsTable')).toBeInTheDocument(); - expect(screen.queryByText('CreateShortUrl')).toBeInTheDocument(); - }); - - it('displays links to other sections', () => { - setUp(); - - const links = screen.getAllByRole('link'); - - expect(links).toHaveLength(6); - expect(links[0]).toHaveAttribute('href', `${routesPrefix}/non-orphan-visits`); - expect(links[1]).toHaveAttribute('href', `${routesPrefix}/orphan-visits`); - expect(links[2]).toHaveAttribute('href', `${routesPrefix}/list-short-urls/1`); - expect(links[3]).toHaveAttribute('href', `${routesPrefix}/manage-tags`); - expect(links[4]).toHaveAttribute('href', `${routesPrefix}/create-short-url`); - expect(links[5]).toHaveAttribute('href', `${routesPrefix}/list-short-urls/1`); - }); - - it.each([ - [true], - [false], - ])('displays amounts of bots when hovering visits cards', async (excludeBots) => { - const { user } = setUp(false, excludeBots); - const expectTooltipToBeInTheDocument = async (tooltip: string) => waitFor( - () => expect(screen.getByText(/potential bot visits$/)).toHaveTextContent(tooltip), - ); - - await user.hover(screen.getByText(/^Visits/)); - await expectTooltipToBeInTheDocument(`${excludeBots ? 'Plus' : 'Including'} 1,000 potential bot visits`); - - await user.hover(screen.getByText(/^Orphan visits/)); - await expectTooltipToBeInTheDocument(`${excludeBots ? 'Plus' : 'Including'} 15 potential bot visits`); - }); -}); diff --git a/shlink-web-component/test/overview/helpers/HighlightCard.test.tsx b/shlink-web-component/test/overview/helpers/HighlightCard.test.tsx deleted file mode 100644 index 3306b77e..00000000 --- a/shlink-web-component/test/overview/helpers/HighlightCard.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import type { PropsWithChildren } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import type { HighlightCardProps } from '../../../src/overview/helpers/HighlightCard'; -import { HighlightCard } from '../../../src/overview/helpers/HighlightCard'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const setUp = (props: PropsWithChildren>) => renderWithEvents( - - - , - ); - - it.each([ - ['foo'], - ['bar'], - ['baz'], - ])('renders provided title', (title) => { - setUp({ title }); - expect(screen.getByText(title)).toHaveClass('highlight-card__title'); - }); - - it.each([ - ['foo'], - ['bar'], - ['baz'], - ])('renders provided children', (children) => { - setUp({ title: 'title', children }); - expect(screen.getByText(children)).toHaveClass('card-text'); - }); - - it.each([ - ['foo'], - ['bar'], - ['baz'], - ])('adds extra props when a link is provided', (link) => { - setUp({ title: 'title', link }); - - expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); - expect(screen.getByRole('link')).toHaveAttribute('href', `/${link}`); - }); - - it('renders tooltip when provided', async () => { - const { user } = setUp({ title: 'title', children: 'Foo', tooltip: 'This is the tooltip' }); - - await user.hover(screen.getByText('Foo')); - await waitFor(() => expect(screen.getByText('This is the tooltip')).toBeInTheDocument()); - }); -}); diff --git a/shlink-web-component/test/overview/helpers/VisitsHighlightCard.test.tsx b/shlink-web-component/test/overview/helpers/VisitsHighlightCard.test.tsx deleted file mode 100644 index 1428de12..00000000 --- a/shlink-web-component/test/overview/helpers/VisitsHighlightCard.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router'; -import type { VisitsHighlightCardProps } from '../../../src/overview/helpers/VisitsHighlightCard'; -import { VisitsHighlightCard } from '../../../src/overview/helpers/VisitsHighlightCard'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const setUp = (props: Partial = {}) => renderWithEvents( - - - , - ); - - it.each([ - [true, () => expect(screen.getByText('Loading...')).toBeInTheDocument()], - [false, () => expect(screen.queryByText('Loading...')).not.toBeInTheDocument()], - ])('displays loading message on loading', (loading, assert) => { - setUp({ loading }); - assert(); - }); - - it('does not render tooltip when summary has no bots', async () => { - const { user } = setUp({ title: 'Foo' }); - - await user.hover(screen.getByText('Foo')); - await waitFor(() => expect(screen.queryByText(/potential bot visits$/)).not.toBeInTheDocument()); - }); - - it('renders tooltip when summary has bots', async () => { - const { user } = setUp({ - title: 'Foo', - visitsSummary: { total: 50, bots: 30 }, - }); - - await user.hover(screen.getByText('Foo')); - await waitFor(() => expect(screen.getByText(/potential bot visits$/)).toBeInTheDocument()); - }); - - it.each([ - [true, 20, () => { - expect(screen.getByText('20')).toBeInTheDocument(); - expect(screen.queryByText('50')).not.toBeInTheDocument(); - }], - [true, undefined, () => { - expect(screen.getByText('50')).toBeInTheDocument(); - expect(screen.queryByText('20')).not.toBeInTheDocument(); - }], - [false, 20, () => { - expect(screen.getByText('50')).toBeInTheDocument(); - expect(screen.queryByText('20')).not.toBeInTheDocument(); - }], - [false, undefined, () => { - expect(screen.getByText('50')).toBeInTheDocument(); - expect(screen.queryByText('20')).not.toBeInTheDocument(); - }], - ])('displays non-bots when present and bots are excluded', (excludeBots, nonBots, assert) => { - setUp({ - excludeBots, - visitsSummary: { total: 50, nonBots }, - }); - assert(); - }); -}); diff --git a/shlink-web-component/test/short-urls/CreateShortUrl.test.tsx b/shlink-web-component/test/short-urls/CreateShortUrl.test.tsx deleted file mode 100644 index 36eb835e..00000000 --- a/shlink-web-component/test/short-urls/CreateShortUrl.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { CreateShortUrl as createShortUrlsCreator } from '../../src/short-urls/CreateShortUrl'; -import type { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation'; -import { SettingsProvider } from '../../src/utils/settings'; - -describe('', () => { - const ShortUrlForm = () => ShortUrlForm; - const CreateShortUrlResult = () => CreateShortUrlResult; - const shortUrlCreation = { validateUrls: true }; - const shortUrlCreationResult = fromPartial({}); - const createShortUrl = vi.fn(async () => Promise.resolve()); - const CreateShortUrl = createShortUrlsCreator(ShortUrlForm, CreateShortUrlResult); - const setUp = () => render( - - {}} - /> - , - ); - - it('renders computed initial state', () => { - setUp(); - - expect(screen.getByText('ShortUrlForm')).toBeInTheDocument(); - expect(screen.getByText('CreateShortUrlResult')).toBeInTheDocument(); - }); -}); diff --git a/shlink-web-component/test/short-urls/EditShortUrl.test.tsx b/shlink-web-component/test/short-urls/EditShortUrl.test.tsx deleted file mode 100644 index 05a0f619..00000000 --- a/shlink-web-component/test/short-urls/EditShortUrl.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { MemoryRouter } from 'react-router-dom'; -import { EditShortUrl as createEditShortUrl } from '../../src/short-urls/EditShortUrl'; -import type { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail'; -import type { ShortUrlEdition } from '../../src/short-urls/reducers/shortUrlEdition'; -import { SettingsProvider } from '../../src/utils/settings'; - -describe('', () => { - const shortUrlCreation = { validateUrls: true }; - const EditShortUrl = createEditShortUrl(() => ShortUrlForm); - const setUp = (detail: Partial = {}, edition: Partial = {}) => render( - - - Promise.resolve())} - /> - - , - ); - - it('renders loading message while loading detail', () => { - setUp({ loading: true }); - - expect(screen.getByText('Loading...')).toBeInTheDocument(); - expect(screen.queryByText('ShortUrlForm')).not.toBeInTheDocument(); - }); - - it('renders error when loading detail fails', () => { - setUp({ error: true }); - - expect(screen.getByText('An error occurred while loading short URL detail :(')).toBeInTheDocument(); - expect(screen.queryByText('ShortUrlForm')).not.toBeInTheDocument(); - }); - - it('renders form when detail properly loads', () => { - setUp({ shortUrl: fromPartial({ meta: {} }) }); - - expect(screen.getByText('ShortUrlForm')).toBeInTheDocument(); - expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); - expect(screen.queryByText('An error occurred while loading short URL detail :(')).not.toBeInTheDocument(); - }); - - it('shows error when saving data has failed', () => { - setUp({}, { error: true, saved: true }); - - expect(screen.getByText('An error occurred while updating short URL :(')).toBeInTheDocument(); - expect(screen.getByText('ShortUrlForm')).toBeInTheDocument(); - }); - - it('shows message when saving data succeeds', () => { - setUp({}, { error: false, saved: true }); - - expect(screen.getByText('Short URL properly edited.')).toBeInTheDocument(); - expect(screen.getByText('ShortUrlForm')).toBeInTheDocument(); - }); -}); diff --git a/shlink-web-component/test/short-urls/Paginator.test.tsx b/shlink-web-component/test/short-urls/Paginator.test.tsx deleted file mode 100644 index 36921ede..00000000 --- a/shlink-web-component/test/short-urls/Paginator.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { MemoryRouter } from 'react-router-dom'; -import type { ShlinkPaginator } from '../../src/api-contract'; -import { Paginator } from '../../src/short-urls/Paginator'; -import { ELLIPSIS } from '../../src/utils/helpers/pagination'; - -describe('', () => { - const buildPaginator = (pagesCount?: number) => fromPartial({ pagesCount, currentPage: 1 }); - const setUp = (paginator?: ShlinkPaginator, currentQueryString?: string) => render( - - - , - ); - - it.each([ - [undefined], - [buildPaginator()], - [buildPaginator(0)], - [buildPaginator(1)], - ])('renders an empty gap if the number of pages is below 2', (paginator) => { - const { container } = setUp(paginator); - - expect(container.firstChild).toBeEmptyDOMElement(); - expect(container.firstChild).toHaveClass('pb-3'); - }); - - it.each([ - [buildPaginator(2), 4, 0], - [buildPaginator(3), 5, 0], - [buildPaginator(4), 6, 0], - [buildPaginator(5), 7, 1], - [buildPaginator(6), 7, 1], - [buildPaginator(23), 7, 1], - ])('renders previous, next and the list of pages, with ellipses when expected', ( - paginator, - expectedPages, - expectedEllipsis, - ) => { - setUp(paginator); - - const links = screen.getAllByRole('link'); - const ellipsis = screen.queryAllByText(ELLIPSIS); - - expect(links).toHaveLength(expectedPages); - expect(ellipsis).toHaveLength(expectedEllipsis); - }); - - it('appends query string to all pages', () => { - const paginator = buildPaginator(3); - const currentQueryString = '?foo=bar'; - - setUp(paginator, currentQueryString); - const links = screen.getAllByRole('link'); - - expect(links).toHaveLength(5); - links.forEach((link) => expect(link).toHaveAttribute('href', expect.stringContaining(currentQueryString))); - }); -}); diff --git a/shlink-web-component/test/short-urls/ShortUrlForm.test.tsx b/shlink-web-component/test/short-urls/ShortUrlForm.test.tsx deleted file mode 100644 index 41234ef3..00000000 --- a/shlink-web-component/test/short-urls/ShortUrlForm.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { screen } from '@testing-library/react'; -import type { UserEvent } from '@testing-library/user-event/setup/setup'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { formatISO } from 'date-fns'; -import type { Mode } from '../../src/short-urls/ShortUrlForm'; -import { ShortUrlForm as createShortUrlForm } from '../../src/short-urls/ShortUrlForm'; -import { parseDate } from '../../src/utils/dates/helpers/date'; -import { FeaturesProvider } from '../../src/utils/features'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -describe('', () => { - const createShortUrl = vi.fn(async () => Promise.resolve()); - const ShortUrlForm = createShortUrlForm(() => TagsSelector, () => DomainSelector); - const setUp = (withDeviceLongUrls = false, mode: Mode = 'create', title?: string | null) => - renderWithEvents( - - - , - ); - - it.each([ - [ - async (user: UserEvent) => { - await user.type(screen.getByPlaceholderText('Custom slug'), 'my-slug'); - }, - { customSlug: 'my-slug' }, - false, - ], - [ - async (user: UserEvent) => { - await user.type(screen.getByPlaceholderText('Short code length'), '15'); - }, - { shortCodeLength: '15' }, - false, - ], - [ - async (user: UserEvent) => { - await user.type(screen.getByPlaceholderText('Android-specific redirection'), 'https://android.com'); - await user.type(screen.getByPlaceholderText('iOS-specific redirection'), 'https://ios.com'); - }, - { - deviceLongUrls: { - android: 'https://android.com', - ios: 'https://ios.com', - }, - }, - true, - ], - ])('saves short URL with data set in form controls', async (extraFields, extraExpectedValues, withDeviceLongUrls) => { - const { user } = setUp(withDeviceLongUrls); - const validSince = parseDate('2017-01-01', 'yyyy-MM-dd'); - const validUntil = parseDate('2017-01-06', 'yyyy-MM-dd'); - - await user.type(screen.getByPlaceholderText('URL to be shortened'), 'https://long-domain.com/foo/bar'); - await user.type(screen.getByPlaceholderText('Title'), 'the title'); - await user.type(screen.getByPlaceholderText('Maximum number of visits allowed'), '20'); - await user.type(screen.getByPlaceholderText('Enabled since...'), '2017-01-01'); - await user.type(screen.getByPlaceholderText('Enabled until...'), '2017-01-06'); - await extraFields(user); - - expect(createShortUrl).not.toHaveBeenCalled(); - await user.click(screen.getByRole('button', { name: 'Save' })); - expect(createShortUrl).toHaveBeenCalledWith({ - longUrl: 'https://long-domain.com/foo/bar', - title: 'the title', - validSince: formatISO(validSince), - validUntil: formatISO(validUntil), - maxVisits: 20, - findIfExists: false, - validateUrl: true, - domain: undefined, - shortCodeLength: undefined, - customSlug: undefined, - ...extraExpectedValues, - }); - }); - - it.each([ - ['create' as Mode, 5], - ['create-basic' as Mode, 0], - ])( - 'renders expected amount of cards based on server capabilities and mode', - (mode, expectedAmountOfCards) => { - setUp(false, mode); - const cards = screen.queryAllByRole('heading'); - - expect(cards).toHaveLength(expectedAmountOfCards); - }, - ); - - it.each([ - [null, true, 'new title'], - [undefined, true, 'new title'], - ['', true, 'new title'], - ['old title', true, 'new title'], - [null, false, null], - ['', false, ''], - [undefined, false, undefined], - ['old title', false, null], - ])('sends expected title based on original and new values', async (originalTitle, withNewTitle, expectedSentTitle) => { - const { user } = setUp(false, 'create', originalTitle); - - await user.type(screen.getByPlaceholderText('URL to be shortened'), 'https://long-domain.com/foo/bar'); - await user.clear(screen.getByPlaceholderText('Title')); - if (withNewTitle) { - await user.type(screen.getByPlaceholderText('Title'), 'new title'); - } - await user.click(screen.getByRole('button', { name: 'Save' })); - - expect(createShortUrl).toHaveBeenCalledWith(expect.objectContaining({ - title: expectedSentTitle, - })); - }); - - it('shows device-specific long URLs only when supported', () => { - setUp(true); - - const placeholders = ['Android-specific redirection', 'iOS-specific redirection', 'Desktop-specific redirection']; - placeholders.forEach((placeholder) => expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument()); - }); -}); diff --git a/shlink-web-component/test/short-urls/ShortUrlsFilteringBar.test.tsx b/shlink-web-component/test/short-urls/ShortUrlsFilteringBar.test.tsx deleted file mode 100644 index a01cf169..00000000 --- a/shlink-web-component/test/short-urls/ShortUrlsFilteringBar.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { endOfDay, formatISO, startOfDay } from 'date-fns'; -import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; -import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar'; -import { formatIsoDate } from '../../src/utils/dates/helpers/date'; -import type { DateRange } from '../../src/utils/dates/helpers/dateIntervals'; -import { FeaturesProvider } from '../../src/utils/features'; -import { RoutesPrefixProvider } from '../../src/utils/routesPrefix'; -import { SettingsProvider } from '../../src/utils/settings'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -vi.mock('react-router-dom', async () => ({ - ...(await vi.importActual('react-router-dom')), - useParams: vi.fn().mockReturnValue({ serverId: '1' }), - useNavigate: vi.fn(), - useLocation: vi.fn().mockReturnValue({}), -})); - -describe('', () => { - const ShortUrlsFilteringBar = filteringBarCreator(() => <>ExportShortUrlsBtn, () => <>TagsSelector); - const navigate = vi.fn(); - const handleOrderBy = vi.fn(); - const now = new Date(); - const setUp = (search = '', filterDisabledUrls = true) => { - (useLocation as any).mockReturnValue({ search }); - (useNavigate as any).mockReturnValue(navigate); - - return renderWithEvents( - - - - - - - - - , - ); - }; - - it('renders expected children components', () => { - setUp(); - - expect(screen.getByText('ExportShortUrlsBtn')).toBeInTheDocument(); - expect(screen.getByText('TagsSelector')).toBeInTheDocument(); - }); - - it('redirects to first page when search field changes', async () => { - const { user } = setUp(); - - expect(navigate).not.toHaveBeenCalled(); - await user.type(screen.getByPlaceholderText('Search...'), 'search-term'); - await waitFor(() => expect(navigate).toHaveBeenCalledWith('/server/1/list-short-urls/1?search=search-term')); - }); - - it.each([ - [{ startDate: now } as DateRange, `startDate=${encodeURIComponent(formatISO(startOfDay(now)))}`], - [{ endDate: now } as DateRange, `endDate=${encodeURIComponent(formatISO(endOfDay(now)))}`], - [ - { startDate: now, endDate: now } as DateRange, - `startDate=${encodeURIComponent(formatISO(startOfDay(now)))}&endDate=${encodeURIComponent(formatISO(endOfDay(now)))}`, - ], - ])('redirects to first page when date range changes', async (dates, expectedQuery) => { - const { user } = setUp(); - - await user.click(screen.getByRole('button', { name: 'All short URLs' })); - expect(await screen.findByRole('menu')).toBeInTheDocument(); - - expect(navigate).not.toHaveBeenCalled(); - dates.startDate && await user.type(screen.getByPlaceholderText('Since...'), formatIsoDate(dates.startDate) ?? ''); - dates.endDate && await user.type(screen.getByPlaceholderText('Until...'), formatIsoDate(dates.endDate) ?? ''); - expect(navigate).toHaveBeenLastCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`); - }); - - it.each([ - { search: 'tags=foo,bar,baz', shouldHaveComponent: true }, - { search: 'tags=foo,bar', shouldHaveComponent: true }, - { search: 'tags=foo', shouldHaveComponent: false }, - { search: '', shouldHaveComponent: false }, - ])( - 'renders tags mode toggle if there is more than one tag selected', - ({ search, shouldHaveComponent }) => { - setUp(search); - - if (shouldHaveComponent) { - expect(screen.getByLabelText('Change tags mode')).toBeInTheDocument(); - } else { - expect(screen.queryByLabelText('Change tags mode')).not.toBeInTheDocument(); - } - }, - ); - - it.each([ - ['', 'With any of the tags.'], - ['&tagsMode=all', 'With all the tags.'], - ['&tagsMode=any', 'With any of the tags.'], - ])('expected tags mode tooltip title', async (initialTagsMode, expectedToggleText) => { - const { user } = setUp(`tags=foo,bar${initialTagsMode}`, true); - - await user.hover(screen.getByLabelText('Change tags mode')); - expect(await screen.findByRole('tooltip')).toHaveTextContent(expectedToggleText); - }); - - it.each([ - ['', 'tagsMode=all'], - ['&tagsMode=all', 'tagsMode=any'], - ['&tagsMode=any', 'tagsMode=all'], - ])('redirects to first page when tags mode changes', async (initialTagsMode, expectedRedirectTagsMode) => { - const { user } = setUp(`tags=foo,bar${initialTagsMode}`, true); - - expect(navigate).not.toHaveBeenCalled(); - await user.click(screen.getByLabelText('Change tags mode')); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode)); - }); - - it.each([ - ['', /Ignore visits from bots/, 'excludeBots=true'], - ['excludeBots=false', /Ignore visits from bots/, 'excludeBots=true'], - ['excludeBots=true', /Ignore visits from bots/, 'excludeBots=false'], - ['', /Exclude with visits reached/, 'excludeMaxVisitsReached=true'], - ['excludeMaxVisitsReached=false', /Exclude with visits reached/, 'excludeMaxVisitsReached=true'], - ['excludeMaxVisitsReached=true', /Exclude with visits reached/, 'excludeMaxVisitsReached=false'], - ['', /Exclude enabled in the past/, 'excludePastValidUntil=true'], - ['excludePastValidUntil=false', /Exclude enabled in the past/, 'excludePastValidUntil=true'], - ['excludePastValidUntil=true', /Exclude enabled in the past/, 'excludePastValidUntil=false'], - ])('allows to toggle filters through filtering dropdown', async (search, menuItemName, expectedQuery) => { - const { user } = setUp(search, true); - const toggleFilter = async (name: RegExp) => { - await user.click(screen.getByRole('button', { name: 'Filters' })); - await waitFor(() => screen.findByRole('menu')); - await user.click(screen.getByRole('menuitem', { name })); - }; - - await toggleFilter(menuItemName); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedQuery)); - }); - - it('handles order through dropdown', async () => { - const { user } = setUp(); - const clickMenuItem = async (name: string | RegExp) => { - await user.click(screen.getByRole('button', { name: 'Order by...' })); - await user.click(await screen.findByRole('menuitem', { name })); - }; - - await clickMenuItem(/^Short URL/); - expect(handleOrderBy).toHaveBeenCalledWith('shortCode', 'ASC'); - - await clickMenuItem(/^Title/); - expect(handleOrderBy).toHaveBeenCalledWith('title', 'ASC'); - - await clickMenuItem(/^Long URL/); - expect(handleOrderBy).toHaveBeenCalledWith('longUrl', 'ASC'); - }); -}); diff --git a/shlink-web-component/test/short-urls/ShortUrlsList.test.tsx b/shlink-web-component/test/short-urls/ShortUrlsList.test.tsx deleted file mode 100644 index f299c6ec..00000000 --- a/shlink-web-component/test/short-urls/ShortUrlsList.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { MemoryRouter, useNavigate } from 'react-router-dom'; -import type { Settings } from '../../src'; -import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; -import type { ShortUrlsOrder } from '../../src/short-urls/data'; -import type { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; -import { ShortUrlsList as createShortUrlsList } from '../../src/short-urls/ShortUrlsList'; -import type { ShortUrlsTableType } from '../../src/short-urls/ShortUrlsTable'; -import { FeaturesProvider } from '../../src/utils/features'; -import { SettingsProvider } from '../../src/utils/settings'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -vi.mock('react-router-dom', async () => ({ - ...(await vi.importActual('react-router-dom')), - useNavigate: vi.fn().mockReturnValue(vi.fn()), - useLocation: vi.fn().mockReturnValue({ search: '?tags=test%20tag&search=example.com' }), -})); - -describe('', () => { - const ShortUrlsTable: ShortUrlsTableType = ({ onTagClick }) => onTagClick?.('foo')}>ShortUrlsTable; - const ShortUrlsFilteringBar = () => ShortUrlsFilteringBar; - const listShortUrlsMock = vi.fn(); - const navigate = vi.fn(); - const shortUrlsList = fromPartial({ - shortUrls: { - data: [ - { - shortCode: 'testShortCode', - shortUrl: 'https://www.example.com/testShortUrl', - longUrl: 'https://www.example.com/testLongUrl', - tags: ['test tag'], - }, - ], - pagination: { pagesCount: 3 }, - }, - }); - const ShortUrlsList = createShortUrlsList(ShortUrlsTable, ShortUrlsFilteringBar); - const setUp = (settings: Partial = {}, excludeBotsOnShortUrls = true) => renderWithEvents( - - - - ({ mercureInfo: { loading: true } })} - listShortUrls={listShortUrlsMock} - shortUrlsList={shortUrlsList} - /> - - - , - ); - - beforeEach(() => { - (useNavigate as any).mockReturnValue(navigate); - }); - - it('wraps expected components', () => { - setUp(); - - expect(screen.getByText('ShortUrlsTable')).toBeInTheDocument(); - expect(screen.getByText('ShortUrlsFilteringBar')).toBeInTheDocument(); - }); - - it('passes current query to paginator', () => { - setUp(); - - const links = screen.getAllByRole('link'); - - expect(links.length > 0).toEqual(true); - links.forEach( - (link) => expect(link).toHaveAttribute('href', expect.stringContaining('?tags=test%20tag&search=example.com')), - ); - }); - - it('gets list refreshed every time a tag is clicked', async () => { - const { user } = setUp(); - - expect(navigate).not.toHaveBeenCalled(); - await user.click(screen.getByText('ShortUrlsTable')); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining(`tags=${encodeURIComponent('test tag,foo')}`)); - }); - - it.each([ - [fromPartial({ field: 'visits', dir: 'ASC' }), 'visits', 'ASC'], - [fromPartial({ field: 'title', dir: 'DESC' }), 'title', 'DESC'], - [fromPartial({}), undefined, undefined], - ])('has expected initial ordering based on settings', (defaultOrdering, field, dir) => { - setUp({ shortUrlsList: { defaultOrdering } }); - expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({ - orderBy: { field, dir }, - })); - }); - - it.each([ - [fromPartial({ - shortUrlsList: { - defaultOrdering: { field: 'visits', dir: 'ASC' }, - }, - }), false, { field: 'visits', dir: 'ASC' }], - [fromPartial({ - shortUrlsList: { - defaultOrdering: { field: 'visits', dir: 'ASC' }, - }, - visits: { excludeBots: true }, - }), false, { field: 'visits', dir: 'ASC' }], - [fromPartial({ - shortUrlsList: { - defaultOrdering: { field: 'visits', dir: 'ASC' }, - }, - }), true, { field: 'visits', dir: 'ASC' }], - [fromPartial({ - shortUrlsList: { - defaultOrdering: { field: 'visits', dir: 'ASC' }, - }, - visits: { excludeBots: true }, - }), true, { field: 'nonBotVisits', dir: 'ASC' }], - ])('parses order by based on supported features version and config', (settings, excludeBotsOnShortUrls, expectedOrderBy) => { - setUp(settings, excludeBotsOnShortUrls); - expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({ orderBy: expectedOrderBy })); - }); -}); diff --git a/shlink-web-component/test/short-urls/ShortUrlsTable.test.tsx b/shlink-web-component/test/short-urls/ShortUrlsTable.test.tsx deleted file mode 100644 index 7fc32db0..00000000 --- a/shlink-web-component/test/short-urls/ShortUrlsTable.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { fireEvent, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShortUrlsOrderableFields } from '../../src/short-urls/data'; -import { SHORT_URLS_ORDERABLE_FIELDS } from '../../src/short-urls/data'; -import type { ShortUrlsList } from '../../src/short-urls/reducers/shortUrlsList'; -import { ShortUrlsTable as shortUrlsTableCreator } from '../../src/short-urls/ShortUrlsTable'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -describe('', () => { - const shortUrlsList = fromPartial({}); - const orderByColumn = vi.fn(); - const ShortUrlsTable = shortUrlsTableCreator(() => ShortUrlsRow); - const setUp = () => renderWithEvents( - orderByColumn} />, - ); - - it('should render inner table by default', () => { - setUp(); - expect(screen.getByRole('table')).toBeInTheDocument(); - }); - - it('should render row groups by default', () => { - setUp(); - expect(screen.getAllByRole('rowgroup')).toHaveLength(2); - }); - - it('should render 6 table header cells by default', () => { - setUp(); - expect(screen.getAllByRole('columnheader')).toHaveLength(6); - }); - - it('should render table header cells without "order by" icon by default', () => { - setUp(); - expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument(); - }); - - it('should render table header cells with conditional order by icon', () => { - setUp(); - - const getThElementForSortableField = (orderableField: string) => screen.getAllByRole('columnheader').find( - ({ innerHTML }) => innerHTML.includes(SHORT_URLS_ORDERABLE_FIELDS[orderableField as ShortUrlsOrderableFields]), - ); - const sortableFields = Object.keys(SHORT_URLS_ORDERABLE_FIELDS).filter((sortableField) => sortableField !== 'title'); - - expect.assertions(sortableFields.length * 2); - sortableFields.forEach((sortableField) => { - const element = getThElementForSortableField(sortableField); - - expect(element).toBeDefined(); - element && fireEvent.click(element); - expect(orderByColumn).toHaveBeenCalled(); - }); - }); - - it('should render composed title column', () => { - setUp(); - - const { innerHTML } = screen.getAllByRole('columnheader')[2]; - - expect(innerHTML).toContain('Title'); - expect(innerHTML).toContain('Long URL'); - }); -}); diff --git a/shlink-web-component/test/short-urls/UseExistingIfFoundInfoIcon.test.tsx b/shlink-web-component/test/short-urls/UseExistingIfFoundInfoIcon.test.tsx deleted file mode 100644 index 43d2319a..00000000 --- a/shlink-web-component/test/short-urls/UseExistingIfFoundInfoIcon.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { screen } from '@testing-library/react'; -import { UseExistingIfFoundInfoIcon } from '../../src/short-urls/UseExistingIfFoundInfoIcon'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -describe('', () => { - it('shows modal when icon is clicked', async () => { - const { user } = renderWithEvents(); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - await user.click(screen.getByTitle('What does this mean?').firstElementChild as Element); - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/CreateShortUrlResult.test.tsx b/shlink-web-component/test/short-urls/helpers/CreateShortUrlResult.test.tsx deleted file mode 100644 index d06c4e92..00000000 --- a/shlink-web-component/test/short-urls/helpers/CreateShortUrlResult.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { CreateShortUrlResult as createResult } from '../../../src/short-urls/helpers/CreateShortUrlResult'; -import type { ShortUrlCreation } from '../../../src/short-urls/reducers/shortUrlCreation'; -import type { TimeoutToggle } from '../../../src/utils/helpers/hooks'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const copyToClipboard = vi.fn(); - const useTimeoutToggle = vi.fn(() => [false, copyToClipboard]) as TimeoutToggle; - const CreateShortUrlResult = createResult(useTimeoutToggle); - const setUp = (creation: ShortUrlCreation) => renderWithEvents( - {}} creation={creation} />, - ); - - it('renders an error when error is true', () => { - setUp({ error: true, saved: false, saving: false }); - expect(screen.getByText('An error occurred while creating the URL :(')).toBeInTheDocument(); - }); - - it.each([[true], [false]])('renders nothing when not saved yet', (saving) => { - const { container } = setUp({ error: false, saved: false, saving }); - expect(container.firstChild).toBeNull(); - }); - - it('renders a result message when result is provided', () => { - setUp( - { result: fromPartial({ shortUrl: 'https://s.test/abc123' }), saving: false, saved: true, error: false }, - ); - expect(screen.getByText(/The short URL is/)).toHaveTextContent('Great! The short URL is https://s.test/abc123'); - }); - - it('Invokes tooltip timeout when copy to clipboard button is clicked', async () => { - const { user } = setUp( - { result: fromPartial({ shortUrl: 'https://s.test/abc123' }), saving: false, saved: true, error: false }, - ); - - expect(copyToClipboard).not.toHaveBeenCalled(); - await user.click(screen.getByRole('button')); - expect(copyToClipboard).toHaveBeenCalledTimes(1); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/DeleteShortUrlModal.test.tsx b/shlink-web-component/test/short-urls/helpers/DeleteShortUrlModal.test.tsx deleted file mode 100644 index 448d46bb..00000000 --- a/shlink-web-component/test/short-urls/helpers/DeleteShortUrlModal.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { InvalidShortUrlDeletion, ShlinkShortUrl } from '../../../src/api-contract'; -import { ErrorTypeV2, ErrorTypeV3 } from '../../../src/api-contract'; -import { DeleteShortUrlModal } from '../../../src/short-urls/helpers/DeleteShortUrlModal'; -import type { ShortUrlDeletion } from '../../../src/short-urls/reducers/shortUrlDeletion'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; -import { TestModalWrapper } from '../../__helpers__/TestModalWrapper'; - -describe('', () => { - const shortUrl = fromPartial({ - tags: [], - shortCode: 'abc123', - longUrl: 'https://long-domain.com/foo/bar', - }); - const deleteShortUrl = vi.fn().mockResolvedValue(undefined); - const shortUrlDeleted = vi.fn(); - const setUp = (shortUrlDeletion: Partial) => renderWithEvents( - ( - - )} - />, - ); - - it('shows generic error when non-threshold error occurs', () => { - setUp({ - loading: false, - error: true, - shortCode: 'abc123', - errorData: fromPartial({ type: 'OTHER_ERROR' }), - }); - expect(screen.getByText('Something went wrong while deleting the URL :(').parentElement).not.toHaveClass( - 'bg-warning', - ); - }); - - it.each([ - [fromPartial({ type: ErrorTypeV3.INVALID_SHORT_URL_DELETION })], - [fromPartial({ type: ErrorTypeV2.INVALID_SHORT_URL_DELETION })], - ])('shows specific error when threshold error occurs', (errorData) => { - setUp({ loading: false, error: true, shortCode: 'abc123', errorData }); - expect(screen.getByText('Something went wrong while deleting the URL :(').parentElement).toHaveClass('bg-warning'); - }); - - it('disables submit button when loading', () => { - setUp({ - loading: true, - error: false, - shortCode: 'abc123', - }); - expect(screen.getByRole('button', { name: 'Deleting...' })).toHaveAttribute('disabled'); - }); - - it('enables submit button when proper short code is provided', async () => { - const { user } = setUp({ - loading: false, - error: false, - shortCode: 'abc123', - }); - const getDeleteBtn = () => screen.getByRole('button', { name: 'Delete' }); - - expect(getDeleteBtn()).toHaveAttribute('disabled'); - await user.type(screen.getByPlaceholderText('Insert delete'), 'delete'); - expect(getDeleteBtn()).not.toHaveAttribute('disabled'); - }); - - it('tries to delete short URL when form is submit', async () => { - const { user } = setUp({ - loading: false, - error: false, - deleted: true, - shortCode: 'abc123', - }); - - expect(deleteShortUrl).not.toHaveBeenCalled(); - await user.type(screen.getByPlaceholderText('Insert delete'), 'delete'); - await user.click(screen.getByRole('button', { name: 'Delete' })); - expect(deleteShortUrl).toHaveBeenCalledTimes(1); - await waitFor(() => expect(shortUrlDeleted).toHaveBeenCalledTimes(1)); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx b/shlink-web-component/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx deleted file mode 100644 index c4f21519..00000000 --- a/shlink-web-component/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { MemoryRouter } from 'react-router-dom'; -import type { ShlinkShortUrl } from '../../../src/api-contract'; -import { ExportShortUrlsBtn as createExportShortUrlsBtn } from '../../../src/short-urls/helpers/ExportShortUrlsBtn'; -import type { ReportExporter } from '../../../src/utils/services/ReportExporter'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const listShortUrls = vi.fn(); - const buildShlinkApiClient = vi.fn().mockReturnValue({ listShortUrls }); - const exportShortUrls = vi.fn(); - const reportExporter = fromPartial({ exportShortUrls }); - const ExportShortUrlsBtn = createExportShortUrlsBtn(buildShlinkApiClient, reportExporter); - const setUp = (amount?: number) => renderWithEvents( - - - , - ); - - it.each([ - [undefined, '0'], - [1, '1'], - [4578, '4,578'], - ])('renders expected amount', (amount, expectedAmount) => { - setUp(amount); - expect(screen.getByText(/Export/)).toHaveTextContent(`Export (${expectedAmount})`); - }); - - it.each([ - [10, 1], - [30, 2], - [39, 2], - [40, 2], - [41, 3], - [385, 20], - ])('loads proper amount of pages based on the amount of results', async (amount, expectedPageLoads) => { - listShortUrls.mockResolvedValue({ data: [] }); - const { user } = setUp(amount); - - await user.click(screen.getByRole('button')); - - expect(listShortUrls).toHaveBeenCalledTimes(expectedPageLoads); - expect(exportShortUrls).toHaveBeenCalled(); - }); - - it('maps short URLs for exporting', async () => { - listShortUrls.mockResolvedValue({ - data: [fromPartial({ - shortUrl: 'https://s.test/short-code', - tags: [], - })], - }); - const { user } = setUp(); - - await user.click(screen.getByRole('button')); - - expect(exportShortUrls).toHaveBeenCalledWith([expect.objectContaining({ - shortUrl: 'https://s.test/short-code', - domain: 's.test', - shortCode: 'short-code', - })]); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/QrCodeModal.test.tsx b/shlink-web-component/test/short-urls/helpers/QrCodeModal.test.tsx deleted file mode 100644 index 2e54ba47..00000000 --- a/shlink-web-component/test/short-urls/helpers/QrCodeModal.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { fireEvent, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { QrCodeModal as createQrCodeModal } from '../../../src/short-urls/helpers/QrCodeModal'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const saveImage = vi.fn().mockReturnValue(Promise.resolve()); - const QrCodeModal = createQrCodeModal(fromPartial({ saveImage })); - const shortUrl = 'https://s.test/abc123'; - const setUp = () => renderWithEvents( - {}} - />, - ); - - it('shows an external link to the URL in the header', () => { - setUp(); - const externalLink = screen.getByRole('heading').querySelector('a'); - - expect(externalLink).toBeInTheDocument(); - expect(externalLink).toHaveAttribute('href', shortUrl); - expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer'); - }); - - it.each([ - [10, '/qr-code?size=300&format=png&errorCorrection=L&margin=10'], - [0, '/qr-code?size=300&format=png&errorCorrection=L'], - ])('displays an image with the QR code of the URL', async (margin, expectedUrl) => { - const { container } = setUp(); - const marginControl = container.parentNode?.querySelectorAll('.form-control-range').item(1); - - if (marginControl) { - fireEvent.change(marginControl, { target: { value: `${margin}` } }); - } - - expect(screen.getByRole('img')).toHaveAttribute('src', `${shortUrl}${expectedUrl}`); - expect(screen.getByText(`${shortUrl}${expectedUrl}`)).toHaveAttribute('href', `${shortUrl}${expectedUrl}`); - }); - - it.each([ - [530, 0, 'lg'], - [200, 0, undefined], - [830, 0, 'xl'], - [430, 80, 'lg'], - [200, 50, undefined], - [720, 100, 'xl'], - ])('renders expected size', (size, margin, modalSize) => { - const { container } = setUp(); - const formControls = container.parentNode?.querySelectorAll('.form-control-range'); - const sizeInput = formControls?.[0]; - const marginInput = formControls?.[1]; - - sizeInput && fireEvent.change(sizeInput, { target: { value: `${size}` } }); - marginInput && fireEvent.change(marginInput, { target: { value: `${margin}` } }); - - expect(screen.getByText(`Size: ${size}px`)).toBeInTheDocument(); - expect(screen.getByText(`Margin: ${margin}px`)).toBeInTheDocument(); - modalSize && expect(screen.getByRole('document')).toHaveClass(`modal-${modalSize}`); - }); - - it('shows expected components based on server version', () => { - setUp(); - const dropdowns = screen.getAllByRole('button'); - - expect(dropdowns).toHaveLength(2 + 2); // Add two because of the close and download buttons - }); - - it('saves the QR code image when clicking the Download button', async () => { - const { user } = setUp(); - - expect(saveImage).not.toHaveBeenCalled(); - await user.click(screen.getByRole('button', { name: /^Download/ })); - expect(saveImage).toHaveBeenCalledTimes(1); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/ShortUrlDetailLink.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlDetailLink.test.tsx deleted file mode 100644 index 0ad7f412..00000000 --- a/shlink-web-component/test/short-urls/helpers/ShortUrlDetailLink.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { MemoryRouter } from 'react-router-dom'; -import type { ShlinkShortUrl } from '../../../src/api-contract'; -import type { LinkSuffix } from '../../../src/short-urls/helpers/ShortUrlDetailLink'; -import { ShortUrlDetailLink } from '../../../src/short-urls/helpers/ShortUrlDetailLink'; -import { RoutesPrefixProvider } from '../../../src/utils/routesPrefix'; - -describe('', () => { - it.each([ - [false, undefined], - [false, null], - [true, null], - [true, undefined], - [false, fromPartial({})], - [false, fromPartial({})], - ])('only renders a plain span when either server or short URL are not set', (asLink, shortUrl) => { - render( - - Something - , - ); - - expect(screen.queryByRole('link')).not.toBeInTheDocument(); - expect(screen.getByText('Something')).toBeInTheDocument(); - }); - - it.each([ - [ - '/server/1', - fromPartial({ shortCode: 'abc123' }), - 'visits' as LinkSuffix, - '/server/1/short-code/abc123/visits', - ], - [ - '/foobar', - fromPartial({ shortCode: 'def456', domain: 'example.com' }), - 'visits' as LinkSuffix, - '/foobar/short-code/def456/visits?domain=example.com', - ], - [ - '/server/1', - fromPartial({ shortCode: 'abc123' }), - 'edit' as LinkSuffix, - '/server/1/short-code/abc123/edit', - ], - [ - '/server/3', - fromPartial({ shortCode: 'def456', domain: 'example.com' }), - 'edit' as LinkSuffix, - '/server/3/short-code/def456/edit?domain=example.com', - ], - ])('renders link with expected query when', (routesPrefix, shortUrl, suffix, expectedLink) => { - render( - - - - Something - - - , - ); - expect(screen.getByRole('link')).toHaveProperty('href', expect.stringContaining(expectedLink)); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx deleted file mode 100644 index ec3276d0..00000000 --- a/shlink-web-component/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { ShortUrlFormCheckboxGroup } from '../../../src/short-urls/helpers/ShortUrlFormCheckboxGroup'; - -describe('', () => { - it.each([ - [undefined, '', 0], - ['This is the tooltip', 'me-2', 1], - ])('renders tooltip only when provided', (infoTooltip, expectedClassName, expectedAmountOfTooltips) => { - render(); - - expect(screen.getByRole('checkbox').parentNode).toHaveAttribute( - 'class', - expect.stringContaining(expectedClassName), - ); - expect(screen.queryAllByRole('img', { hidden: true })).toHaveLength(expectedAmountOfTooltips); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/ShortUrlStatus.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlStatus.test.tsx deleted file mode 100644 index 40ced0af..00000000 --- a/shlink-web-component/test/short-urls/helpers/ShortUrlStatus.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkShortUrl, ShlinkShortUrlMeta, ShlinkVisitsSummary } from '../../../src/api-contract'; -import { ShortUrlStatus } from '../../../src/short-urls/helpers/ShortUrlStatus'; - -describe('', () => { - const setUp = (shortUrl: ShlinkShortUrl) => ({ - user: userEvent.setup(), - ...render(), - }); - - it.each([ - [ - fromPartial({ validSince: '2099-01-01T10:30:15' }), - {}, - 'This short URL will start working on 2099-01-01 10:30.', - ], - [ - fromPartial({ validUntil: '2020-01-01T10:30:15' }), - {}, - 'This short URL cannot be visited since 2020-01-01 10:30.', - ], - [ - fromPartial({ maxVisits: 10 }), - fromPartial({ total: 10 }), - 'This short URL cannot be currently visited because it has reached the maximum amount of 10 visits.', - ], - [ - fromPartial({ maxVisits: 1 }), - fromPartial({ total: 1 }), - 'This short URL cannot be currently visited because it has reached the maximum amount of 1 visit.', - ], - [{}, {}, 'This short URL can be visited normally.'], - [fromPartial({ validUntil: '2099-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'], - [fromPartial({ validSince: '2020-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'], - [ - fromPartial({ maxVisits: 10 }), - fromPartial({ total: 1 }), - 'This short URL can be visited normally.', - ], - ])('shows expected tooltip', async (meta, visitsSummary, expectedTooltip) => { - const { user } = setUp(fromPartial({ meta, visitsSummary })); - - await user.hover(screen.getByRole('img', { hidden: true })); - await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent(expectedTooltip)); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/ShortUrlVisitsCount.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlVisitsCount.test.tsx deleted file mode 100644 index 2c0c5517..00000000 --- a/shlink-web-component/test/short-urls/helpers/ShortUrlVisitsCount.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkShortUrl } from '../../../src/api-contract'; -import { ShortUrlVisitsCount } from '../../../src/short-urls/helpers/ShortUrlVisitsCount'; - -describe('', () => { - const setUp = (visitsCount: number, shortUrl: ShlinkShortUrl) => ({ - user: userEvent.setup(), - ...render( - , - ), - }); - - it.each([undefined, {}])('just returns visits when no limits are provided', (meta) => { - const visitsCount = 45; - const { container } = setUp(visitsCount, fromPartial({ meta })); - - expect(container.firstChild).toHaveTextContent(`${visitsCount}`); - expect(container.querySelector('.short-urls-visits-count__max-visits-control')).not.toBeInTheDocument(); - }); - - it('displays the maximum amount of visits when present', () => { - const visitsCount = 45; - const maxVisits = 500; - const meta = { maxVisits }; - const { container } = setUp(visitsCount, fromPartial({ meta })); - - expect(container.firstChild).toHaveTextContent(`/ ${maxVisits}`); - }); - - it.each([ - [['This short URL will not accept more than 50 visits'], { maxVisits: 50 }], - [['This short URL will not accept more than 1 visit'], { maxVisits: 1 }], - [['This short URL will not accept visits before 2022-01-01 10:00'], { validSince: '2022-01-01T10:00:00' }], - [['This short URL will not accept visits after 2022-05-05 15:30'], { validUntil: '2022-05-05T15:30:30' }], - [[ - 'This short URL will not accept more than 100 visits', - 'This short URL will not accept visits after 2022-05-05 15:30', - ], { validUntil: '2022-05-05T15:30:30', maxVisits: 100 }], - [[ - 'This short URL will not accept more than 100 visits', - 'This short URL will not accept visits before 2023-01-01 10:00', - 'This short URL will not accept visits after 2023-05-05 15:30', - ], { validSince: '2023-01-01T10:00:00', validUntil: '2023-05-05T15:30:30', maxVisits: 100 }], - ])('displays proper amount of tooltip list items', async (expectedListItems, meta) => { - const { user } = setUp(100, fromPartial({ meta })); - - await user.hover(screen.getByRole('img', { hidden: true })); - await waitFor(() => expect(screen.getByRole('list'))); - - const items = screen.getAllByRole('listitem'); - expect(items).toHaveLength(expectedListItems.length); - expectedListItems.forEach((text, index) => expect(items[index]).toHaveTextContent(text)); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx deleted file mode 100644 index 5861b77a..00000000 --- a/shlink-web-component/test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { ShortUrlsFilterDropdown } from '../../../src/short-urls/helpers/ShortUrlsFilterDropdown'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const setUp = (supportsDisabledFiltering: boolean) => renderWithEvents( - , - ); - - it.each([ - [true, 3], - [false, 1], - ])('displays proper amount of menu items', async (supportsDisabledFiltering, expectedItems) => { - const { user } = setUp(supportsDisabledFiltering); - - await user.click(screen.getByRole('button', { name: 'Filters' })); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); - - expect(screen.getAllByRole('menuitem')).toHaveLength(expectedItems); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/ShortUrlsRow.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlsRow.test.tsx deleted file mode 100644 index 0331e2db..00000000 --- a/shlink-web-component/test/short-urls/helpers/ShortUrlsRow.test.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { addDays, formatISO, subDays } from 'date-fns'; -import { last } from 'ramda'; -import { MemoryRouter, useLocation } from 'react-router-dom'; -import type { Settings } from '../../../src'; -import type { ShlinkShortUrl, ShlinkShortUrlMeta } from '../../../src/api-contract'; -import { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow'; -import { now, parseDate } from '../../../src/utils/dates/helpers/date'; -import type { TimeoutToggle } from '../../../src/utils/helpers/hooks'; -import { SettingsProvider } from '../../../src/utils/settings'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; -import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock'; - -interface SetUpOptions { - title?: string | null; - tags?: string[]; - meta?: ShlinkShortUrlMeta; - settings?: Partial; -} - -vi.mock('react-router-dom', async () => ({ - ...(await vi.importActual('react-router-dom')), - useLocation: vi.fn().mockReturnValue({}), -})); - -describe('', () => { - const timeoutToggle = vi.fn(() => true); - const useTimeoutToggle = vi.fn(() => [false, timeoutToggle]) as TimeoutToggle; - const shortUrl: ShlinkShortUrl = { - shortCode: 'abc123', - shortUrl: 'https://s.test/abc123', - longUrl: 'https://foo.com/bar', - dateCreated: formatISO(parseDate('2018-05-23 18:30:41', 'yyyy-MM-dd HH:mm:ss')), - tags: [], - visitsCount: 45, - visitsSummary: { - total: 45, - nonBots: 40, - bots: 5, - }, - domain: null, - meta: { - validSince: null, - validUntil: null, - maxVisits: null, - }, - }; - const ShortUrlsRow = createShortUrlsRow(() => ShortUrlsRowMenu, colorGeneratorMock, useTimeoutToggle); - - const setUp = ({ title, tags = [], meta = {}, settings = {} }: SetUpOptions = {}, search = '') => { - (useLocation as any).mockReturnValue({ search }); - return renderWithEvents( - - - - - null} - /> - -
-
-
, - ); - }; - - it.each([ - [null, 7], - [undefined, 7], - ['The title', 8], - ])('renders expected amount of columns', (title, expectedAmount) => { - setUp({ title }); - expect(screen.getAllByRole('cell')).toHaveLength(expectedAmount); - }); - - it('renders date in first column', () => { - setUp(); - expect(screen.getAllByRole('cell')[0]).toHaveTextContent('2018-05-23 18:30'); - }); - - it.each([ - [1, shortUrl.shortUrl], - [2, shortUrl.longUrl], - ])('renders expected links on corresponding columns', (colIndex, expectedLink) => { - setUp(); - - const col = screen.getAllByRole('cell')[colIndex]; - const link = col.querySelector('a'); - - expect(link).toHaveAttribute('href', expectedLink); - }); - - it.each([ - ['My super cool title', 'My super cool title'], - [undefined, shortUrl.longUrl], - ])('renders title when short URL has it', (title, expectedContent) => { - setUp({ title }); - - const titleSharedCol = screen.getAllByRole('cell')[2]; - - expect(titleSharedCol.querySelector('a')).toHaveAttribute('href', shortUrl.longUrl); - expect(titleSharedCol).toHaveTextContent(expectedContent); - }); - - it.each([ - [[], ['No tags']], - [['nodejs', 'reactjs'], ['nodejs', 'reactjs']], - ])('renders list of tags in fourth row', (tags, expectedContents) => { - setUp({ tags }); - const cell = screen.getAllByRole('cell')[3]; - - expectedContents.forEach((content) => expect(cell).toHaveTextContent(content)); - }); - - it.each([ - [{}, '', shortUrl.visitsSummary?.total], - [fromPartial({ visits: { excludeBots: false } }), '', shortUrl.visitsSummary?.total], - [fromPartial({ visits: { excludeBots: true } }), '', shortUrl.visitsSummary?.nonBots], - [fromPartial({ visits: { excludeBots: false } }), 'excludeBots=true', shortUrl.visitsSummary?.nonBots], - [fromPartial({ visits: { excludeBots: true } }), 'excludeBots=true', shortUrl.visitsSummary?.nonBots], - [{}, 'excludeBots=true', shortUrl.visitsSummary?.nonBots], - [fromPartial({ visits: { excludeBots: true } }), 'excludeBots=false', shortUrl.visitsSummary?.total], - [fromPartial({ visits: { excludeBots: false } }), 'excludeBots=false', shortUrl.visitsSummary?.total], - [{}, 'excludeBots=false', shortUrl.visitsSummary?.total], - ])('renders visits count in fifth row', (settings, search, expectedAmount) => { - setUp({ settings }, search); - expect(screen.getAllByRole('cell')[4]).toHaveTextContent(`${expectedAmount}`); - }); - - it('updates state when copied to clipboard', async () => { - const { user } = setUp(); - - expect(timeoutToggle).not.toHaveBeenCalled(); - await user.click(screen.getAllByRole('img', { hidden: true })[0]); - expect(timeoutToggle).toHaveBeenCalledTimes(1); - }); - - it.each([ - [{ validUntil: formatISO(subDays(now(), 1)) }, ['fa-calendar-xmark', 'text-danger']], - [{ validSince: formatISO(addDays(now(), 1)) }, ['fa-calendar-xmark', 'text-warning']], - [{ maxVisits: 45 }, ['fa-link-slash', 'text-danger']], - [{ maxVisits: 45, validSince: formatISO(addDays(now(), 1)) }, ['fa-link-slash', 'text-danger']], - [ - { validSince: formatISO(addDays(now(), 1)), validUntil: formatISO(subDays(now(), 1)) }, - ['fa-calendar-xmark', 'text-danger'], - ], - [ - { validSince: formatISO(subDays(now(), 1)), validUntil: formatISO(addDays(now(), 1)) }, - ['fa-check', 'text-primary'], - ], - [{ maxVisits: 500 }, ['fa-check', 'text-primary']], - [{}, ['fa-check', 'text-primary']], - ])('displays expected status icon', (meta, expectedIconClasses) => { - setUp({ meta }); - const statusIcon = last(screen.getAllByRole('img', { hidden: true })); - - expect(statusIcon).toBeInTheDocument(); - expectedIconClasses.forEach((expectedClass) => expect(statusIcon).toHaveClass(expectedClass)); - expect(statusIcon).toMatchSnapshot(); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx deleted file mode 100644 index 413369c7..00000000 --- a/shlink-web-component/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { MemoryRouter } from 'react-router-dom'; -import type { ShlinkShortUrl } from '../../../src/api-contract'; -import { ShortUrlsRowMenu as createShortUrlsRowMenu } from '../../../src/short-urls/helpers/ShortUrlsRowMenu'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const ShortUrlsRowMenu = createShortUrlsRowMenu(() => DeleteShortUrlModal, () => QrCodeModal); - const shortUrl = fromPartial({ - shortCode: 'abc123', - shortUrl: 'https://s.test/abc123', - }); - const setUp = () => renderWithEvents( - - - , - ); - - it('renders modal windows', () => { - setUp(); - - expect(screen.getByText('DeleteShortUrlModal')).toBeInTheDocument(); - expect(screen.getByText('QrCodeModal')).toBeInTheDocument(); - }); - - it('renders correct amount of menu items', async () => { - const { user } = setUp(); - - await user.click(screen.getByRole('button')); - expect(screen.getAllByRole('menuitem')).toHaveLength(4); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/Tags.test.tsx b/shlink-web-component/test/short-urls/helpers/Tags.test.tsx deleted file mode 100644 index f2edd302..00000000 --- a/shlink-web-component/test/short-urls/helpers/Tags.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { Tags } from '../../../src/short-urls/helpers/Tags'; -import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock'; - -describe('', () => { - const setUp = (tags: string[]) => render(); - - it('returns no tags when the list is empty', () => { - setUp([]); - expect(screen.getByText('No tags')).toBeInTheDocument(); - }); - - it.each([ - [['foo', 'bar', 'baz']], - [['one', 'two', 'three', 'four', 'five']], - ])('returns expected tags based on provided list', (tags) => { - setUp(tags); - - expect(screen.queryByText('No tags')).not.toBeInTheDocument(); - tags.forEach((tag) => expect(screen.getByText(tag)).toBeInTheDocument()); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap b/shlink-web-component/test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap deleted file mode 100644 index 1d2989cd..00000000 --- a/shlink-web-component/test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap +++ /dev/null @@ -1,145 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > displays expected status icon 1`] = ` - -`; - -exports[` > displays expected status icon 2`] = ` - -`; - -exports[` > displays expected status icon 3`] = ` - -`; - -exports[` > displays expected status icon 4`] = ` - -`; - -exports[` > displays expected status icon 5`] = ` - -`; - -exports[` > displays expected status icon 6`] = ` - -`; - -exports[` > displays expected status icon 7`] = ` - -`; - -exports[` > displays expected status icon 8`] = ` - -`; diff --git a/shlink-web-component/test/short-urls/helpers/index.test.ts b/shlink-web-component/test/short-urls/helpers/index.test.ts deleted file mode 100644 index ff3c1344..00000000 --- a/shlink-web-component/test/short-urls/helpers/index.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkShortUrl } from '../../../src/api-contract'; -import { shortUrlDataFromShortUrl, urlDecodeShortCode, urlEncodeShortCode } from '../../../src/short-urls/helpers'; - -describe('helpers', () => { - describe('shortUrlDataFromShortUrl', () => { - it.each([ - [undefined, { validateUrls: true }, { longUrl: '', validateUrl: true }], - [undefined, undefined, { longUrl: '', validateUrl: false }], - [ - fromPartial({ meta: {} }), - { validateUrls: false }, - { - longUrl: undefined, - tags: undefined, - title: undefined, - domain: undefined, - validSince: undefined, - validUntil: undefined, - maxVisits: undefined, - validateUrl: false, - }, - ], - ])('returns expected data', (shortUrl, settings, expectedInitialState) => { - expect(shortUrlDataFromShortUrl(shortUrl, settings)).toEqual(expectedInitialState); - }); - }); - - describe('urlEncodeShortCode', () => { - it.each([ - ['foo', 'foo'], - ['foo/bar', 'foo__bar'], - ['foo/bar/baz', 'foo__bar__baz'], - ])('parses shortCode as expected', (shortCode, result) => { - expect(urlEncodeShortCode(shortCode)).toEqual(result); - }); - }); - - describe('urlDecodeShortCode', () => { - it.each([ - ['foo', 'foo'], - ['foo__bar', 'foo/bar'], - ['foo__bar__baz', 'foo/bar/baz'], - ])('parses shortCode as expected', (shortCode, result) => { - expect(urlDecodeShortCode(shortCode)).toEqual(result); - }); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx b/shlink-web-component/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx deleted file mode 100644 index 5c5f3927..00000000 --- a/shlink-web-component/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { screen } from '@testing-library/react'; -import { QrErrorCorrectionDropdown } from '../../../../src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown'; -import type { QrErrorCorrection } from '../../../../src/utils/helpers/qrCodes'; -import { renderWithEvents } from '../../../__helpers__/setUpTest'; - -describe('', () => { - const initialErrorCorrection: QrErrorCorrection = 'Q'; - const setErrorCorrection = vi.fn(); - const setUp = () => renderWithEvents( - , - ); - - it('renders initial state', async () => { - const { user } = setUp(); - const btn = screen.getByRole('button'); - - expect(btn).toHaveTextContent('Error correction (Q)'); - await user.click(btn); - const items = screen.getAllByRole('menuitem'); - - expect(items[0]).not.toHaveClass('active'); - expect(items[1]).not.toHaveClass('active'); - expect(items[2]).toHaveClass('active'); - expect(items[3]).not.toHaveClass('active'); - }); - - it('invokes callback when items are clicked', async () => { - const { user } = setUp(); - const clickItem = async (name: RegExp) => { - await user.click(screen.getByRole('button')); - await user.click(screen.getByRole('menuitem', { name })); - }; - - expect(setErrorCorrection).not.toHaveBeenCalled(); - - await clickItem(/ow/); - expect(setErrorCorrection).toHaveBeenCalledWith('L'); - - await clickItem(/edium/); - expect(setErrorCorrection).toHaveBeenCalledWith('M'); - - await clickItem(/uartile/); - expect(setErrorCorrection).toHaveBeenCalledWith('Q'); - - await clickItem(/igh/); - expect(setErrorCorrection).toHaveBeenCalledWith('H'); - }); -}); diff --git a/shlink-web-component/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx b/shlink-web-component/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx deleted file mode 100644 index 840f24ea..00000000 --- a/shlink-web-component/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { screen } from '@testing-library/react'; -import { QrFormatDropdown } from '../../../../src/short-urls/helpers/qr-codes/QrFormatDropdown'; -import type { QrCodeFormat } from '../../../../src/utils/helpers/qrCodes'; -import { renderWithEvents } from '../../../__helpers__/setUpTest'; - -describe('', () => { - const initialFormat: QrCodeFormat = 'svg'; - const setFormat = vi.fn(); - const setUp = () => renderWithEvents(); - - it('renders initial state', async () => { - const { user } = setUp(); - const btn = screen.getByRole('button'); - - expect(btn).toHaveTextContent('Format (svg'); - await user.click(btn); - const items = screen.getAllByRole('menuitem'); - - expect(items[0]).not.toHaveClass('active'); - expect(items[1]).toHaveClass('active'); - }); - - it('invokes callback when items are clicked', async () => { - const { user } = setUp(); - const clickItem = async (name: string) => { - await user.click(screen.getByRole('button')); - await user.click(screen.getByRole('menuitem', { name })); - }; - - expect(setFormat).not.toHaveBeenCalled(); - - await clickItem('PNG'); - expect(setFormat).toHaveBeenCalledWith('png'); - - await clickItem('SVG'); - expect(setFormat).toHaveBeenCalledWith('svg'); - }); -}); diff --git a/shlink-web-component/test/short-urls/reducers/shortUrlCreation.test.ts b/shlink-web-component/test/short-urls/reducers/shortUrlCreation.test.ts deleted file mode 100644 index c547f623..00000000 --- a/shlink-web-component/test/short-urls/reducers/shortUrlCreation.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient, ShlinkShortUrl } from '../../../src/api-contract'; -import { - createShortUrl as createShortUrlCreator, - shortUrlCreationReducerCreator, -} from '../../../src/short-urls/reducers/shortUrlCreation'; - -describe('shortUrlCreationReducer', () => { - const shortUrl = fromPartial({}); - const createShortUrlCall = vi.fn(); - const buildShlinkApiClient = () => fromPartial({ createShortUrl: createShortUrlCall }); - const createShortUrl = createShortUrlCreator(buildShlinkApiClient); - const { reducer, resetCreateShortUrl } = shortUrlCreationReducerCreator(createShortUrl); - - describe('reducer', () => { - it('returns loading on CREATE_SHORT_URL_START', () => { - expect(reducer(undefined, createShortUrl.pending('', fromPartial({})))).toEqual({ - saving: true, - saved: false, - error: false, - }); - }); - - it('returns error on CREATE_SHORT_URL_ERROR', () => { - expect(reducer(undefined, createShortUrl.rejected(null, '', fromPartial({})))).toEqual({ - saving: false, - saved: false, - error: true, - }); - }); - - it('returns result on CREATE_SHORT_URL', () => { - expect(reducer(undefined, createShortUrl.fulfilled(shortUrl, '', fromPartial({})))).toEqual({ - result: shortUrl, - saving: false, - saved: true, - error: false, - }); - }); - - it('returns default state on RESET_CREATE_SHORT_URL', () => { - expect(reducer(undefined, resetCreateShortUrl())).toEqual({ - saving: false, - saved: false, - error: false, - }); - }); - }); - - describe('createShortUrl', () => { - const dispatch = vi.fn(); - - it('calls API on success', async () => { - createShortUrlCall.mockResolvedValue(shortUrl); - await createShortUrl({ longUrl: 'foo' })(dispatch, vi.fn(), {}); - - expect(createShortUrlCall).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: shortUrl })); - }); - }); -}); diff --git a/shlink-web-component/test/short-urls/reducers/shortUrlDeletion.test.ts b/shlink-web-component/test/short-urls/reducers/shortUrlDeletion.test.ts deleted file mode 100644 index f31b11b0..00000000 --- a/shlink-web-component/test/short-urls/reducers/shortUrlDeletion.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ProblemDetailsError, ShlinkApiClient } from '../../../src/api-contract'; -import { - deleteShortUrl as deleteShortUrlCreator, - shortUrlDeletionReducerCreator, -} from '../../../src/short-urls/reducers/shortUrlDeletion'; - -describe('shortUrlDeletionReducer', () => { - const deleteShortUrlCall = vi.fn(); - const buildShlinkApiClient = () => fromPartial({ deleteShortUrl: deleteShortUrlCall }); - const deleteShortUrl = deleteShortUrlCreator(buildShlinkApiClient); - const { reducer, resetDeleteShortUrl } = shortUrlDeletionReducerCreator(deleteShortUrl); - - describe('reducer', () => { - it('returns loading on DELETE_SHORT_URL_START', () => - expect(reducer(undefined, deleteShortUrl.pending('', { shortCode: '' }))).toEqual({ - shortCode: '', - loading: true, - error: false, - deleted: false, - })); - - it('returns default on RESET_DELETE_SHORT_URL', () => - expect(reducer(undefined, resetDeleteShortUrl())).toEqual({ - shortCode: '', - loading: false, - error: false, - deleted: false, - })); - - it('returns shortCode on SHORT_URL_DELETED', () => - expect(reducer(undefined, deleteShortUrl.fulfilled({ shortCode: 'foo' }, '', { shortCode: 'foo' }))).toEqual({ - shortCode: 'foo', - loading: false, - error: false, - deleted: true, - })); - - it('returns errorData on DELETE_SHORT_URL_ERROR', () => { - const errorData = fromPartial( - { type: 'bar', detail: 'detail', title: 'title', status: 400 }, - ); - const error = errorData as unknown as Error; - - expect(reducer(undefined, deleteShortUrl.rejected(error, '', { shortCode: '' }))).toEqual({ - shortCode: '', - loading: false, - error: true, - deleted: false, - errorData, - }); - }); - }); - - describe('deleteShortUrl', () => { - const dispatch = vi.fn(); - const getState = vi.fn().mockReturnValue({ selectedServer: {} }); - - it.each( - [[undefined], [null], ['example.com']], - )('dispatches proper actions if API client request succeeds', async (domain) => { - const shortCode = 'abc123'; - - await deleteShortUrl({ shortCode, domain })(dispatch, getState, {}); - - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ - payload: { shortCode, domain }, - })); - - expect(deleteShortUrlCall).toHaveBeenCalledTimes(1); - expect(deleteShortUrlCall).toHaveBeenCalledWith(shortCode, domain); - }); - }); -}); diff --git a/shlink-web-component/test/short-urls/reducers/shortUrlDetail.test.ts b/shlink-web-component/test/short-urls/reducers/shortUrlDetail.test.ts deleted file mode 100644 index ce7afc60..00000000 --- a/shlink-web-component/test/short-urls/reducers/shortUrlDetail.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient, ShlinkShortUrl } from '../../../src/api-contract'; -import type { RootState } from '../../../src/container/store'; -import { shortUrlDetailReducerCreator } from '../../../src/short-urls/reducers/shortUrlDetail'; -import type { ShortUrlsList } from '../../../src/short-urls/reducers/shortUrlsList'; - -describe('shortUrlDetailReducer', () => { - const getShortUrlCall = vi.fn(); - const buildShlinkApiClient = () => fromPartial({ getShortUrl: getShortUrlCall }); - const { reducer, getShortUrlDetail } = shortUrlDetailReducerCreator(buildShlinkApiClient); - - describe('reducer', () => { - it('returns loading on GET_SHORT_URL_DETAIL_START', () => { - const { loading } = reducer({ loading: false, error: false }, getShortUrlDetail.pending('', { shortCode: '' })); - expect(loading).toEqual(true); - }); - - it('stops loading and returns error on GET_SHORT_URL_DETAIL_ERROR', () => { - const state = reducer({ loading: true, error: false }, getShortUrlDetail.rejected(null, '', { shortCode: '' })); - const { loading, error } = state; - - expect(loading).toEqual(false); - expect(error).toEqual(true); - }); - - it('return short URL on GET_SHORT_URL_DETAIL', () => { - const actionShortUrl = fromPartial({ longUrl: 'foo', shortCode: 'bar' }); - const state = reducer( - { loading: true, error: false }, - getShortUrlDetail.fulfilled(actionShortUrl, '', { shortCode: '' }), - ); - const { loading, error, shortUrl } = state; - - expect(loading).toEqual(false); - expect(error).toEqual(false); - expect(shortUrl).toEqual(actionShortUrl); - }); - }); - - describe('getShortUrlDetail', () => { - const dispatchMock = vi.fn(); - const buildGetState = (shortUrlsList?: ShortUrlsList) => () => fromPartial({ shortUrlsList }); - - it.each([ - [undefined], - [fromPartial({})], - [ - fromPartial({ - shortUrls: { data: [] }, - }), - ], - [ - fromPartial({ - shortUrls: { - data: [{ shortCode: 'this_will_not_match' }], - }, - }), - ], - ])('performs API call when short URL is not found in local state', async (shortUrlsList?: ShortUrlsList) => { - const resolvedShortUrl = fromPartial({ longUrl: 'foo', shortCode: 'abc123' }); - getShortUrlCall.mockResolvedValue(resolvedShortUrl); - - await getShortUrlDetail({ shortCode: 'abc123', domain: '' })(dispatchMock, buildGetState(shortUrlsList), {}); - - expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenLastCalledWith(expect.objectContaining({ payload: resolvedShortUrl })); - expect(getShortUrlCall).toHaveBeenCalledTimes(1); - }); - - it('avoids API calls when short URL is found in local state', async () => { - const foundShortUrl = fromPartial({ longUrl: 'foo', shortCode: 'abc123' }); - getShortUrlCall.mockResolvedValue(fromPartial({})); - - await getShortUrlDetail(foundShortUrl)( - dispatchMock, - buildGetState(fromPartial({ - shortUrls: { - data: [foundShortUrl], - }, - })), - {}, - ); - - expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenLastCalledWith(expect.objectContaining({ payload: foundShortUrl })); - expect(getShortUrlCall).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/shlink-web-component/test/short-urls/reducers/shortUrlEdition.test.ts b/shlink-web-component/test/short-urls/reducers/shortUrlEdition.test.ts deleted file mode 100644 index 96cfa681..00000000 --- a/shlink-web-component/test/short-urls/reducers/shortUrlEdition.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkShortUrl } from '../../../src/api-contract'; -import { - editShortUrl as editShortUrlCreator, - shortUrlEditionReducerCreator, -} from '../../../src/short-urls/reducers/shortUrlEdition'; - -describe('shortUrlEditionReducer', () => { - const longUrl = 'https://shlink.io'; - const shortCode = 'abc123'; - const shortUrl = fromPartial({ longUrl, shortCode }); - const updateShortUrl = vi.fn().mockResolvedValue(shortUrl); - const buildShlinkApiClient = vi.fn().mockReturnValue({ updateShortUrl }); - const editShortUrl = editShortUrlCreator(buildShlinkApiClient); - const { reducer } = shortUrlEditionReducerCreator(editShortUrl); - - describe('reducer', () => { - it('returns loading on EDIT_SHORT_URL_START', () => { - expect(reducer(undefined, editShortUrl.pending('', fromPartial({})))).toEqual({ - saving: true, - saved: false, - error: false, - }); - }); - - it('returns error on EDIT_SHORT_URL_ERROR', () => { - expect(reducer(undefined, editShortUrl.rejected(null, '', fromPartial({})))).toEqual({ - saving: false, - saved: false, - error: true, - }); - }); - - it('returns provided tags and shortCode on SHORT_URL_EDITED', () => { - expect(reducer(undefined, editShortUrl.fulfilled(shortUrl, '', fromPartial({})))).toEqual({ - shortUrl, - saving: false, - saved: true, - error: false, - }); - }); - }); - - describe('editShortUrl', () => { - const dispatch = vi.fn(); - - it.each([[undefined], [null], ['example.com']])('dispatches short URL on success', async (domain) => { - await editShortUrl({ shortCode, domain, data: { longUrl } })(dispatch, vi.fn(), {}); - - expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrl).toHaveBeenCalledTimes(1); - expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, { longUrl }); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: shortUrl })); - }); - }); -}); diff --git a/shlink-web-component/test/short-urls/reducers/shortUrlsList.test.ts b/shlink-web-component/test/short-urls/reducers/shortUrlsList.test.ts deleted file mode 100644 index a758ab50..00000000 --- a/shlink-web-component/test/short-urls/reducers/shortUrlsList.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient, ShlinkShortUrl, ShlinkShortUrlsResponse } from '../../../src/api-contract'; -import { createShortUrl as createShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlCreation'; -import { shortUrlDeleted } from '../../../src/short-urls/reducers/shortUrlDeletion'; -import { editShortUrl as editShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlEdition'; -import { - listShortUrls as listShortUrlsCreator, - shortUrlsListReducerCreator, -} from '../../../src/short-urls/reducers/shortUrlsList'; -import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; -import type { CreateVisit } from '../../../src/visits/types'; - -describe('shortUrlsListReducer', () => { - const shortCode = 'abc123'; - const listShortUrlsMock = vi.fn(); - const buildShlinkApiClient = () => fromPartial({ listShortUrls: listShortUrlsMock }); - const listShortUrls = listShortUrlsCreator(buildShlinkApiClient); - const editShortUrl = editShortUrlCreator(buildShlinkApiClient); - const createShortUrl = createShortUrlCreator(buildShlinkApiClient); - const { reducer } = shortUrlsListReducerCreator(listShortUrls, editShortUrl, createShortUrl); - - describe('reducer', () => { - it('returns loading on LIST_SHORT_URLS_START', () => - expect(reducer(undefined, listShortUrls.pending(''))).toEqual({ - loading: true, - error: false, - })); - - it('returns short URLs on LIST_SHORT_URLS', () => - expect(reducer(undefined, listShortUrls.fulfilled(fromPartial({ data: [] }), ''))).toEqual({ - shortUrls: { data: [] }, - loading: false, - error: false, - })); - - it('returns error on LIST_SHORT_URLS_ERROR', () => - expect(reducer(undefined, listShortUrls.rejected(null, ''))).toEqual({ - loading: false, - error: true, - })); - - it('removes matching URL and reduces total on SHORT_URL_DELETED', () => { - const state = { - shortUrls: fromPartial({ - data: [ - { shortCode }, - { shortCode, domain: 'example.com' }, - { shortCode: 'foo' }, - ], - pagination: { totalItems: 10 }, - }), - loading: false, - error: false, - }; - - expect(reducer(state, shortUrlDeleted(fromPartial({ shortCode })))).toEqual({ - shortUrls: { - data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }], - pagination: { totalItems: 9 }, - }, - loading: false, - error: false, - }); - }); - - const createNewShortUrlVisit = (visitsCount: number) => fromPartial({ - shortUrl: { shortCode: 'abc123', visitsCount }, - }); - - it.each([ - [[createNewShortUrlVisit(11)], 11], - [[createNewShortUrlVisit(30)], 30], - [[createNewShortUrlVisit(20), createNewShortUrlVisit(40)], 40], - [[], 10], - ])('updates visits count on CREATE_VISITS', (createdVisits, expectedCount) => { - const state = { - shortUrls: fromPartial({ - data: [ - { shortCode, domain: 'example.com', visitsCount: 5 }, - { shortCode, visitsCount: 10 }, - { shortCode: 'foo', visitsCount: 8 }, - ], - }), - loading: false, - error: false, - }; - - expect(reducer(state, createNewVisits(createdVisits))).toEqual({ - shortUrls: { - data: [ - { shortCode, domain: 'example.com', visitsCount: 5 }, - { shortCode, visitsCount: expectedCount }, - { shortCode: 'foo', visitsCount: 8 }, - ], - }, - loading: false, - error: false, - }); - }); - - it.each([ - [ - [ - fromPartial({ shortCode }), - fromPartial({ shortCode, domain: 'example.com' }), - fromPartial({ shortCode: 'foo' }), - ], - [{ shortCode: 'newOne' }, { shortCode }, { shortCode, domain: 'example.com' }, { shortCode: 'foo' }], - ], - [ - [ - fromPartial({ shortCode }), - fromPartial({ shortCode: 'code' }), - fromPartial({ shortCode: 'foo' }), - fromPartial({ shortCode: 'bar' }), - fromPartial({ shortCode: 'baz' }), - ], - [{ shortCode: 'newOne' }, { shortCode }, { shortCode: 'code' }, { shortCode: 'foo' }, { shortCode: 'bar' }], - ], - [ - [ - fromPartial({ shortCode }), - fromPartial({ shortCode: 'code' }), - fromPartial({ shortCode: 'foo' }), - fromPartial({ shortCode: 'bar' }), - fromPartial({ shortCode: 'baz1' }), - fromPartial({ shortCode: 'baz2' }), - fromPartial({ shortCode: 'baz3' }), - ], - [{ shortCode: 'newOne' }, { shortCode }, { shortCode: 'code' }, { shortCode: 'foo' }, { shortCode: 'bar' }], - ], - ])('prepends new short URL and increases total on CREATE_SHORT_URL', (data, expectedData) => { - const newShortUrl = fromPartial({ shortCode: 'newOne' }); - const state = { - shortUrls: fromPartial({ - data, - pagination: { totalItems: 15 }, - }), - loading: false, - error: false, - }; - - expect(reducer(state, createShortUrl.fulfilled(newShortUrl, '', fromPartial({})))).toEqual({ - shortUrls: { - data: expectedData, - pagination: { totalItems: 16 }, - }, - loading: false, - error: false, - }); - }); - - it.each([ - ((): [ShlinkShortUrl, ShlinkShortUrl[], ShlinkShortUrl[]] => { - const editedShortUrl = fromPartial({ shortCode: 'notMatching' }); - const list: ShlinkShortUrl[] = [fromPartial({ shortCode: 'foo' }), fromPartial({ shortCode: 'bar' })]; - - return [editedShortUrl, list, list]; - })(), - ((): [ShlinkShortUrl, ShlinkShortUrl[], ShlinkShortUrl[]] => { - const editedShortUrl = fromPartial({ shortCode: 'matching', longUrl: 'new_one' }); - const list: ShlinkShortUrl[] = [ - fromPartial({ shortCode: 'matching', longUrl: 'old_one' }), - fromPartial({ shortCode: 'bar' }), - ]; - const expectedList = [editedShortUrl, list[1]]; - - return [editedShortUrl, list, expectedList]; - })(), - ])('updates matching short URL on SHORT_URL_EDITED', (editedShortUrl, initialList, expectedList) => { - const state = { - shortUrls: fromPartial({ - data: initialList, - pagination: { totalItems: 15 }, - }), - loading: false, - error: false, - }; - - const result = reducer(state, editShortUrl.fulfilled(editedShortUrl, '', fromPartial({}))); - - expect(result.shortUrls?.data).toEqual(expectedList); - }); - }); - - describe('listShortUrls', () => { - const dispatch = vi.fn(); - const getState = vi.fn(); - - it('dispatches proper actions if API client request succeeds', async () => { - listShortUrlsMock.mockResolvedValue({}); - - await listShortUrls()(dispatch, getState, {}); - - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: {} })); - - expect(listShortUrlsMock).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/shlink-web-component/test/tags/TagsList.test.tsx b/shlink-web-component/test/tags/TagsList.test.tsx deleted file mode 100644 index 0f7271d5..00000000 --- a/shlink-web-component/test/tags/TagsList.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { identity } from 'ramda'; -import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; -import type { TagsList } from '../../src/tags/reducers/tagsList'; -import type { TagsListProps } from '../../src/tags/TagsList'; -import { TagsList as createTagsList } from '../../src/tags/TagsList'; -import { SettingsProvider } from '../../src/utils/settings'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -describe('', () => { - const filterTags = vi.fn(); - const TagsListComp = createTagsList(({ sortedTags }) => <>TagsTable ({sortedTags.map((t) => t.visits).join(',')})); - const setUp = (tagsList: Partial, excludeBots = false) => renderWithEvents( - - ({})} - {...fromPartial({ mercureInfo: {} })} - forceListTags={identity} - filterTags={filterTags} - tagsList={fromPartial(tagsList)} - /> - , - ); - - it('shows a loading message when tags are being loaded', () => { - setUp({ loading: true }); - - expect(screen.getByText('Loading...')).toBeInTheDocument(); - expect(screen.queryByText('Error loading tags :(')).not.toBeInTheDocument(); - }); - - it('shows an error when tags failed to be loaded', () => { - setUp({ error: true }); - - expect(screen.getByText('Error loading tags :(')).toBeInTheDocument(); - expect(screen.queryByText('Loading')).not.toBeInTheDocument(); - }); - - it('shows a message when the list of tags is empty', () => { - setUp({ filteredTags: [] }); - - expect(screen.getByText('No tags found')).toBeInTheDocument(); - expect(screen.queryByText('Error loading tags :(')).not.toBeInTheDocument(); - expect(screen.queryByText('Loading')).not.toBeInTheDocument(); - }); - - it('triggers tags filtering when search field changes', async () => { - const { user } = setUp({ filteredTags: [] }); - - expect(filterTags).not.toHaveBeenCalled(); - await user.type(screen.getByPlaceholderText('Search...'), 'Hello'); - await waitFor(() => expect(filterTags).toHaveBeenCalledTimes(1)); - }); - - it.each([ - [false, undefined, '25,25,25'], - [true, undefined, '25,25,25'], - [ - false, - { - total: 20, - nonBots: 15, - bots: 5, - }, - '20,20,20', - ], - [ - true, - { - total: 20, - nonBots: 15, - bots: 5, - }, - '15,15,15', - ], - ])('displays proper amount of visits', (excludeBots, visitsSummary, expectedAmounts) => { - setUp({ - filteredTags: ['foo', 'bar', 'baz'], - stats: { - foo: { - visitsSummary, - visitsCount: 25, - shortUrlsCount: 1, - }, - bar: { - visitsSummary, - visitsCount: 25, - shortUrlsCount: 1, - }, - baz: { - visitsSummary, - visitsCount: 25, - shortUrlsCount: 1, - }, - }, - }, excludeBots); - expect(screen.getByText(`TagsTable (${expectedAmounts})`)).toBeInTheDocument(); - }); -}); diff --git a/shlink-web-component/test/tags/TagsTable.test.tsx b/shlink-web-component/test/tags/TagsTable.test.tsx deleted file mode 100644 index 12894b98..00000000 --- a/shlink-web-component/test/tags/TagsTable.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { useLocation } from 'react-router-dom'; -import { TagsTable as createTagsTable } from '../../src/tags/TagsTable'; -import { rangeOf } from '../../src/utils/helpers'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -vi.mock('react-router-dom', async () => ({ - ...(await vi.importActual('react-router-dom')), - useLocation: vi.fn(), -})); - -describe('', () => { - const orderByColumn = vi.fn(); - const TagsTable = createTagsTable(({ tag }) => TagsTableRow [{tag.tag}]); - const tags = (amount: number) => rangeOf(amount, (i) => `tag_${i}`); - const setUp = (sortedTags: string[] = [], search = '') => { - (useLocation as any).mockReturnValue({ search }); - return renderWithEvents( - fromPartial({ tag }))} - currentOrder={{}} - orderByColumn={() => orderByColumn} - />, - ); - }; - - it('renders empty result if there are no tags', () => { - setUp(); - - expect(screen.queryByText(/^TagsTableRow/)).not.toBeInTheDocument(); - expect(screen.getByText('No results found')).toBeInTheDocument(); - }); - - it.each([ - [['foo', 'bar', 'baz'], 3], - [['foo'], 1], - [tags(19), 19], - [tags(20), 20], - [tags(30), 20], - [tags(100), 20], - ])('renders as many rows as there are in current page', (filteredTags, expectedRows) => { - setUp(filteredTags); - - expect(screen.getAllByText(/^TagsTableRow/)).toHaveLength(expectedRows); - expect(screen.queryByText('No results found')).not.toBeInTheDocument(); - }); - - it.each([ - [['foo', 'bar', 'baz'], 0], - [['foo'], 0], - [tags(19), 0], - [tags(20), 0], - [tags(30), 1], - [tags(100), 1], - ])('renders paginator if there are more than one page', (filteredTags, expectedPaginators) => { - const { container } = setUp(filteredTags); - expect(container.querySelectorAll('.sticky-card-paginator')).toHaveLength(expectedPaginators); - }); - - it.each([ - [1, 20, 0], - [2, 20, 20], - [3, 20, 40], - [4, 20, 60], - [5, 7, 80], - [6, 0, 0], - ])('renders page from query if present', (page, expectedRows, offset) => { - setUp(tags(87), `page=${page}`); - - const tagRows = screen.queryAllByText(/^TagsTableRow/); - - expect(tagRows).toHaveLength(expectedRows); - tagRows.forEach((row, index) => expect(row).toHaveTextContent(`[tag_${index + offset + 1}]`)); - }); - - it('allows changing current page in paginator', async () => { - const { user, container } = setUp(tags(100)); - - expect(container.querySelector('.active')).toHaveTextContent('1'); - await user.click(screen.getByText('5')); - expect(container.querySelector('.active')).toHaveTextContent('5'); - }); - - it('orders tags when column is clicked', async () => { - const { user } = setUp(tags(100)); - const headers = screen.getAllByRole('columnheader'); - - expect(orderByColumn).not.toHaveBeenCalled(); - await user.click(headers[0]); - await user.click(headers[2]); - await user.click(headers[1]); - expect(orderByColumn).toHaveBeenCalledTimes(3); - }); -}); diff --git a/shlink-web-component/test/tags/TagsTableRow.test.tsx b/shlink-web-component/test/tags/TagsTableRow.test.tsx deleted file mode 100644 index f0fb3927..00000000 --- a/shlink-web-component/test/tags/TagsTableRow.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { TagsTableRow as createTagsTableRow } from '../../src/tags/TagsTableRow'; -import { RoutesPrefixProvider } from '../../src/utils/routesPrefix'; -import { renderWithEvents } from '../__helpers__/setUpTest'; -import { colorGeneratorMock } from '../utils/services/__mocks__/ColorGenerator.mock'; - -describe('', () => { - const TagsTableRow = createTagsTableRow( - ({ isOpen }) => DeleteTagConfirmModal {isOpen ? 'OPEN' : 'CLOSED'}, - ({ isOpen }) => EditTagModal {isOpen ? 'OPEN' : 'CLOSED'}, - colorGeneratorMock, - ); - const setUp = (tagStats?: { visits?: number; shortUrls?: number }) => renderWithEvents( - - - - - - -
-
-
, - ); - - it.each([ - [undefined, '0', '0'], - [{ shortUrls: 10, visits: 3480 }, '10', '3,480'], - ])('shows expected tag stats', (stats, expectedShortUrls, expectedVisits) => { - setUp(stats); - - const [shortUrlsLink, visitsLink] = screen.getAllByRole('link'); - - expect(shortUrlsLink).toHaveTextContent(expectedShortUrls); - expect(shortUrlsLink).toHaveAttribute( - 'href', - `/server/abc123/list-short-urls/1?tags=${encodeURIComponent('foo&bar')}`, - ); - expect(visitsLink).toHaveTextContent(expectedVisits); - expect(visitsLink).toHaveAttribute('href', '/server/abc123/tag/foo&bar/visits'); - }); - - it('allows toggling dropdown menu', async () => { - const { user } = setUp(); - - expect(screen.queryByRole('menu')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button')); - expect(screen.queryByRole('menu')).toBeInTheDocument(); - }); - - it('allows toggling modals through dropdown items', async () => { - const { user } = setUp(); - const clickItemOnIndex = async (index: 0 | 1) => { - await user.click(screen.getByRole('button')); - await user.click(screen.getAllByRole('menuitem')[index]); - }; - - expect(screen.getByText(/^EditTagModal/)).toHaveTextContent('CLOSED'); - expect(screen.getByText(/^EditTagModal/)).not.toHaveTextContent('OPEN'); - await clickItemOnIndex(0); - expect(screen.getByText(/^EditTagModal/)).toHaveTextContent('OPEN'); - expect(screen.getByText(/^EditTagModal/)).not.toHaveTextContent('CLOSED'); - - expect(screen.getByText(/^DeleteTagConfirmModal/)).toHaveTextContent('CLOSED'); - expect(screen.getByText(/^DeleteTagConfirmModal/)).not.toHaveTextContent('OPEN'); - await clickItemOnIndex(1); - expect(screen.getByText(/^DeleteTagConfirmModal/)).toHaveTextContent('OPEN'); - expect(screen.getByText(/^DeleteTagConfirmModal/)).not.toHaveTextContent('CLOSED'); - }); -}); diff --git a/shlink-web-component/test/tags/helpers/DeleteTagConfirmModal.test.tsx b/shlink-web-component/test/tags/helpers/DeleteTagConfirmModal.test.tsx deleted file mode 100644 index 03dfd732..00000000 --- a/shlink-web-component/test/tags/helpers/DeleteTagConfirmModal.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { screen } from '@testing-library/react'; -import { DeleteTagConfirmModal } from '../../../src/tags/helpers/DeleteTagConfirmModal'; -import type { TagDeletion } from '../../../src/tags/reducers/tagDelete'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const tag = 'nodejs'; - const deleteTag = vi.fn(); - const toggle = vi.fn(); - const setUp = (tagDelete: TagDeletion) => renderWithEvents( - , - ); - - it('asks confirmation for provided tag to be deleted', () => { - setUp({ error: false, deleted: false, deleting: false }); - - const delBtn = screen.getByRole('button', { name: 'Delete tag' }); - - expect(screen.getByText(/^Are you sure you want to delete tag/)).toBeInTheDocument(); - expect(screen.queryByText('Something went wrong while deleting the tag :(')).not.toBeInTheDocument(); - expect(delBtn).toBeInTheDocument(); - expect(delBtn).not.toHaveClass('disabled'); - expect(delBtn).not.toHaveAttribute('disabled'); - }); - - it('shows error message when deletion failed', () => { - setUp({ error: true, deleted: false, deleting: false }); - expect(screen.getByText('Something went wrong while deleting the tag :(')).toBeInTheDocument(); - }); - - it('shows loading status while deleting', () => { - setUp({ error: false, deleted: false, deleting: true }); - - const delBtn = screen.getByRole('button', { name: 'Deleting tag...' }); - - expect(delBtn).toBeInTheDocument(); - expect(delBtn).toHaveClass('disabled'); - expect(delBtn).toHaveAttribute('disabled'); - }); - - it('hides tag modal when btn is clicked', async () => { - const { user } = setUp({ error: false, deleted: true, deleting: false }); - - await user.click(screen.getByRole('button', { name: 'Delete tag' })); - - expect(deleteTag).toHaveBeenCalledTimes(1); - expect(deleteTag).toHaveBeenCalledWith(tag); - expect(toggle).toHaveBeenCalledTimes(1); - }); - - it('does no further actions when modal is closed without deleting tag', async () => { - const { user } = setUp({ error: false, deleted: true, deleting: false }); - - await user.click(screen.getByLabelText('Close')); - - expect(deleteTag).not.toHaveBeenCalled(); - expect(toggle).toHaveBeenCalled(); - }); -}); diff --git a/shlink-web-component/test/tags/helpers/EditTagModal.test.tsx b/shlink-web-component/test/tags/helpers/EditTagModal.test.tsx deleted file mode 100644 index 91e57488..00000000 --- a/shlink-web-component/test/tags/helpers/EditTagModal.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { EditTagModal as createEditTagModal } from '../../../src/tags/helpers/EditTagModal'; -import type { TagEdition } from '../../../src/tags/reducers/tagEdit'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const EditTagModal = createEditTagModal(fromPartial({ getColorForKey: vi.fn(() => 'green') })); - const editTag = vi.fn().mockReturnValue(Promise.resolve()); - const toggle = vi.fn(); - const setUp = (tagEdit: Partial = {}) => { - const edition = fromPartial(tagEdit); - return renderWithEvents( - , - ); - }; - - it('allows modal to be toggled with different mechanisms', async () => { - const { user } = setUp(); - - expect(toggle).not.toHaveBeenCalled(); - - await user.click(screen.getByLabelText('Close')); - await user.click(screen.getByRole('button', { name: 'Cancel' })); - - expect(toggle).toHaveBeenCalledTimes(2); - expect(editTag).not.toHaveBeenCalled(); - }); - - it.each([ - [true, 'Saving...'], - [false, 'Save'], - ])('renders submit button in expected state', (editing, name) => { - setUp({ editing }); - expect(screen.getByRole('button', { name })).toBeInTheDocument(); - }); - - it.each([ - [true, 1], - [false, 0], - ])('displays error result in case of error', (error, expectedResultCount) => { - setUp({ error, errorData: fromPartial({}) }); - expect(screen.queryAllByText('Something went wrong while editing the tag :(')).toHaveLength(expectedResultCount); - }); - - it('updates tag value when text changes', async () => { - const { user } = setUp(); - const getInput = () => screen.getByPlaceholderText('Tag'); - - expect(getInput()).toHaveValue('foo'); - await user.clear(getInput()); - await user.type(getInput(), 'bar'); - expect(getInput()).toHaveValue('bar'); - }); - - it('invokes all functions on form submit', async () => { - const { user } = setUp(); - - expect(editTag).not.toHaveBeenCalled(); - expect(toggle).not.toHaveBeenCalled(); - - await user.click(screen.getByRole('button', { name: 'Save' })); - - expect(editTag).toHaveBeenCalled(); - expect(toggle).toHaveBeenCalled(); - }); - - it('changes color when changing on color picker', async () => { - const { user } = setUp(); - const colorBtn = screen.getByRole('img', { hidden: true }); - // const initialColor = colorBtn.parentElement?.style.backgroundColor; - - await user.click(colorBtn); - await waitFor(() => screen.getByRole('tooltip')); - await user.click(screen.getByLabelText('Hue')); - await user.click(screen.getByLabelText('Color')); - await user.click(colorBtn); - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); - - // I need to figure this one out - // await waitFor(() => expect(initialColor).not.toEqual(colorBtn.parentElement?.style.backgroundColor)); - }); -}); diff --git a/shlink-web-component/test/tags/helpers/Tag.test.tsx b/shlink-web-component/test/tags/helpers/Tag.test.tsx deleted file mode 100644 index 2700a51f..00000000 --- a/shlink-web-component/test/tags/helpers/Tag.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { MAIN_COLOR } from '@shlinkio/shlink-frontend-kit'; -import { screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ReactNode } from 'react'; -import { Tag } from '../../../src/tags/helpers/Tag'; -import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -const hexToRgb = (hex: string) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - if (!result) { - throw new Error((`Could not convert color ${hex} to RGB`)); - } - - return { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - }; -}; - -describe('', () => { - const onClick = vi.fn(); - const onClose = vi.fn(); - const isColorLightForKey = vi.fn(() => false); - const getColorForKey = vi.fn(() => MAIN_COLOR); - const colorGenerator = fromPartial({ getColorForKey, isColorLightForKey }); - const setUp = (text: string, clearable?: boolean, children?: ReactNode) => renderWithEvents( - - {children} - , - ); - - it.each([ - [true], - [false], - ])('includes an extra class when the color is light', (isLight) => { - isColorLightForKey.mockReturnValue(isLight); - - const { container } = setUp('foo'); - - if (isLight) { - expect(container.firstChild).toHaveClass('tag--light-bg'); - } else { - expect(container.firstChild).not.toHaveClass('tag--light-bg'); - } - }); - - it.each([ - [MAIN_COLOR], - ['#8A661C'], - ['#F7BE05'], - ['#5A02D8'], - ['#202786'], - ])('includes generated color as backgroundColor', (generatedColor) => { - getColorForKey.mockReturnValue(generatedColor); - - const { container } = setUp('foo'); - const { r, g, b } = hexToRgb(generatedColor); - - expect(container.firstChild).toHaveAttribute( - 'style', - expect.stringContaining(`background-color: rgb(${r}, ${g}, ${b})`), - ); - }); - - it('invokes expected callbacks when appropriate events are triggered', async () => { - const { container, user } = setUp('foo', true); - - expect(onClick).not.toHaveBeenCalled(); - expect(onClose).not.toHaveBeenCalled(); - - container.firstElementChild && await user.click(container.firstElementChild); - expect(onClick).toHaveBeenCalledTimes(1); - - await user.click(screen.getByLabelText(/^Remove/)); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it.each([ - [true, 1, 'auto'], - [false, 0, 'pointer'], - [undefined, 0, 'pointer'], - ])('includes a close component when the tag is clearable', (clearable, expectedCloseBtnAmount, expectedCursor) => { - const { container } = setUp('foo', clearable); - - expect(screen.queryAllByLabelText(/^Remove/)).toHaveLength(expectedCloseBtnAmount); - expect(container.firstChild).toHaveAttribute('style', expect.stringContaining(`cursor: ${expectedCursor}`)); - }); - - it.each([ - [undefined, 'foo'], - ['bar', 'bar'], - ])('falls back to text as children when no children are provided', (children, expectedChildren) => { - const { container } = setUp('foo', false, children); - expect(container.firstChild).toHaveTextContent(expectedChildren); - }); -}); diff --git a/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx b/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx deleted file mode 100644 index e4326133..00000000 --- a/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { TagsSelector as createTagsSelector } from '../../../src/tags/helpers/TagsSelector'; -import type { TagsList } from '../../../src/tags/reducers/tagsList'; -import type { TagFilteringMode } from '../../../src/utils/settings'; -import { SettingsProvider } from '../../../src/utils/settings'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; -import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock'; - -type SetUpOptions = { - allowNew?: boolean; - allTags?: string[]; - tagFilteringMode?: TagFilteringMode; -}; - -describe('', () => { - const onChange = vi.fn(); - const TagsSelector = createTagsSelector(colorGeneratorMock); - const tags = ['foo', 'bar']; - const setUp = ({ allowNew = true, allTags, tagFilteringMode }: SetUpOptions = {}) => renderWithEvents( - - ({ tags: allTags ?? [...tags, 'baz'] })} - listTags={vi.fn()} - onChange={onChange} - allowNew={allowNew} - /> - , - ); - - it('has an input for tags', () => { - setUp(); - expect(screen.getByPlaceholderText('Add tags to the URL')).toBeInTheDocument(); - }); - - it('contains expected tags', () => { - setUp(); - - expect(screen.getByText('foo')).toBeInTheDocument(); - expect(screen.getByText('bar')).toBeInTheDocument(); - }); - - it('contains expected suggestions', async () => { - const { container, user } = setUp(); - - expect(container.querySelector('.react-tags__listbox')).not.toBeInTheDocument(); - expect(screen.queryByText('baz')).not.toBeInTheDocument(); - - await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'ba'); - - expect(container.querySelector('.react-tags__listbox')).toBeInTheDocument(); - expect(screen.getByText('baz')).toBeInTheDocument(); - }); - - it('limits the amount of suggestions', async () => { - const { user } = setUp({ allTags: ['foo', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5', 'foo6', 'foo7'] }); - - await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'fo'); - - // First results are in the document - expect(screen.getByText('foo')).toBeInTheDocument(); - expect(screen.getByText('foo1')).toBeInTheDocument(); - expect(screen.getByText('foo2')).toBeInTheDocument(); - expect(screen.getByText('foo3')).toBeInTheDocument(); - expect(screen.getByText('foo4')).toBeInTheDocument(); - expect(screen.getByText('foo5')).toBeInTheDocument(); - // While the last ones are not - expect(screen.queryByText('foo6')).not.toBeInTheDocument(); - expect(screen.queryByText('foo7')).not.toBeInTheDocument(); - }); - - it.each([ - ['The-New-Tag', [...tags, 'the-new-tag']], - ['AnOTH er tag ', [...tags, 'anoth-er-tag']], - // ['foo', tags], TODO Test that existing tags are ignored - ])('invokes onChange when new tags are added', async (newTag, expectedTags) => { - const { user } = setUp(); - - expect(onChange).not.toHaveBeenCalled(); - await user.type(screen.getByPlaceholderText('Add tags to the URL'), newTag); - await user.type(screen.getByPlaceholderText('Add tags to the URL'), '{Enter}'); - expect(onChange).toHaveBeenCalledWith(expectedTags); - }); - - it('splits tags when several comma-separated ones are pasted', async () => { - const { user } = setUp(); - - expect(onChange).not.toHaveBeenCalled(); - await user.click(screen.getByPlaceholderText('Add tags to the URL')); - await user.paste('comma,separated,tags'); - await user.type(screen.getByPlaceholderText('Add tags to the URL'), '{Enter}'); - expect(onChange).toHaveBeenCalledWith([...tags, 'comma', 'separated', 'tags']); - }); - - it.each([ - ['foo', 'bar'], - ['bar', 'foo'], - ])('invokes onChange when tags are deleted', async (removedLabel, expected) => { - const { user } = setUp(); - - await user.click(screen.getByLabelText(`Remove ${removedLabel}`)); - expect(onChange).toHaveBeenCalledWith([expected]); - }); - - it('displays "Add tag" option for new tags', async () => { - const { user } = setUp(); - - expect(screen.queryByText(/^Add "/)).not.toBeInTheDocument(); - await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'new-tag'); - expect(screen.getByText(/^Add "/)).toBeInTheDocument(); - }); - - it('displays "Tag not found" for unknown tags when add is not allowed', async () => { - const { user } = setUp({ allowNew: false }); - - expect(screen.queryByText('Tag not found')).not.toBeInTheDocument(); - await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'not-found-tag'); - expect(screen.getByText('Tag not found')).toBeInTheDocument(); - }); - - it.each([ - ['startsWith' as TagFilteringMode, ['foo', 'foobar']], - ['includes' as TagFilteringMode, ['foo', 'barfoo', 'foobar']], - ])('filters suggestions with different algorithm based on filtering mode', async (tagFilteringMode, expectedTags) => { - const { user } = setUp({ tagFilteringMode, allTags: ['foo', 'barfoo', 'foobar'] }); - - await user.type(screen.getByPlaceholderText('Add tags to the URL'), ' Foo'); - - expectedTags.forEach((tag) => expect(screen.getByText(tag)).toBeInTheDocument()); - }); -}); diff --git a/shlink-web-component/test/tags/reducers/tagDelete.test.ts b/shlink-web-component/test/tags/reducers/tagDelete.test.ts deleted file mode 100644 index 59a4fb14..00000000 --- a/shlink-web-component/test/tags/reducers/tagDelete.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api-contract'; -import { tagDeleted, tagDeleteReducerCreator } from '../../../src/tags/reducers/tagDelete'; - -describe('tagDeleteReducer', () => { - const deleteTagsCall = vi.fn(); - const buildShlinkApiClient = () => fromPartial({ deleteTags: deleteTagsCall }); - const { reducer, deleteTag } = tagDeleteReducerCreator(buildShlinkApiClient); - - describe('reducer', () => { - it('returns loading on DELETE_TAG_START', () => { - expect(reducer(undefined, deleteTag.pending('', ''))).toEqual({ - deleting: true, - deleted: false, - error: false, - }); - }); - - it('returns error on DELETE_TAG_ERROR', () => { - expect(reducer(undefined, deleteTag.rejected(null, '', ''))).toEqual({ - deleting: false, - deleted: false, - error: true, - }); - }); - - it('returns tag names on DELETE_TAG', () => { - expect(reducer(undefined, deleteTag.fulfilled(undefined, '', ''))).toEqual({ - deleting: false, - deleted: true, - error: false, - }); - }); - }); - - describe('tagDeleted', () => { - it('returns action based on provided params', () => { - expect(tagDeleted('foo').payload).toEqual('foo'); - }); - }); - - describe('deleteTag', () => { - const dispatch = vi.fn(); - - it('calls API on success', async () => { - const tag = 'foo'; - deleteTagsCall.mockResolvedValue(undefined); - - await deleteTag(tag)(dispatch, vi.fn(), {}); - - expect(deleteTagsCall).toHaveBeenCalledTimes(1); - expect(deleteTagsCall).toHaveBeenNthCalledWith(1, [tag]); - - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: undefined })); - }); - }); -}); diff --git a/shlink-web-component/test/tags/reducers/tagEdit.test.ts b/shlink-web-component/test/tags/reducers/tagEdit.test.ts deleted file mode 100644 index cd61f9df..00000000 --- a/shlink-web-component/test/tags/reducers/tagEdit.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api-contract'; -import { editTag as editTagCreator, tagEdited, tagEditReducerCreator } from '../../../src/tags/reducers/tagEdit'; -import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator'; - -describe('tagEditReducer', () => { - const oldName = 'foo'; - const newName = 'bar'; - const color = '#ff0000'; - const editTagCall = vi.fn(); - const buildShlinkApiClient = () => fromPartial({ editTag: editTagCall }); - const colorGenerator = fromPartial({ setColorForKey: vi.fn() }); - const editTag = editTagCreator(buildShlinkApiClient, colorGenerator); - const { reducer } = tagEditReducerCreator(editTag); - - describe('reducer', () => { - it('returns loading on EDIT_TAG_START', () => { - expect(reducer(undefined, editTag.pending('', fromPartial({})))).toEqual({ - editing: true, - edited: false, - error: false, - }); - }); - - it('returns error on EDIT_TAG_ERROR', () => { - expect(reducer(undefined, editTag.rejected(null, '', fromPartial({})))).toEqual({ - editing: false, - edited: false, - error: true, - }); - }); - - it('returns tag names on EDIT_TAG', () => { - expect(reducer(undefined, editTag.fulfilled({ oldName, newName, color }, '', fromPartial({})))).toEqual({ - editing: false, - edited: true, - error: false, - oldName: 'foo', - newName: 'bar', - }); - }); - }); - - describe('tagEdited', () => { - it('returns action based on provided params', () => { - const payload = { oldName: 'foo', newName: 'bar', color: '#ff0000' }; - expect(tagEdited(payload).payload).toEqual(payload); - }); - }); - - describe('editTag', () => { - const dispatch = vi.fn(); - - it('calls API on success', async () => { - editTagCall.mockResolvedValue(undefined); - - await editTag({ oldName, newName, color })(dispatch, vi.fn(), {}); - - expect(editTagCall).toHaveBeenCalledTimes(1); - expect(editTagCall).toHaveBeenCalledWith(oldName, newName); - - expect(colorGenerator.setColorForKey).toHaveBeenCalledTimes(1); - expect(colorGenerator.setColorForKey).toHaveBeenCalledWith(newName, color); - - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ - payload: { oldName, newName, color }, - })); - }); - }); -}); diff --git a/shlink-web-component/test/tags/reducers/tagsList.test.ts b/shlink-web-component/test/tags/reducers/tagsList.test.ts deleted file mode 100644 index 9fedfd3b..00000000 --- a/shlink-web-component/test/tags/reducers/tagsList.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkShortUrl } from '../../../src/api-contract'; -import type { RootState } from '../../../src/container/store'; -import { createShortUrl as createShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlCreation'; -import { tagDeleted } from '../../../src/tags/reducers/tagDelete'; -import { tagEdited } from '../../../src/tags/reducers/tagEdit'; -import type { - TagsList } from '../../../src/tags/reducers/tagsList'; -import { - filterTags, - listTags as listTagsCreator, - tagsListReducerCreator, -} from '../../../src/tags/reducers/tagsList'; -import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; -import type { CreateVisit } from '../../../src/visits/types'; - -describe('tagsListReducer', () => { - const state = (props: Partial) => fromPartial(props); - const buildShlinkApiClient = vi.fn(); - const listTags = listTagsCreator(buildShlinkApiClient, true); - const createShortUrl = createShortUrlCreator(buildShlinkApiClient); - const { reducer } = tagsListReducerCreator(listTags, createShortUrl); - - describe('reducer', () => { - it('returns loading on LIST_TAGS_START', () => { - expect(reducer(undefined, listTags.pending(''))).toEqual(expect.objectContaining({ - loading: true, - error: false, - })); - }); - - it('returns error on LIST_TAGS_ERROR', () => { - expect(reducer(undefined, listTags.rejected(null, ''))).toEqual(expect.objectContaining({ - loading: false, - error: true, - })); - }); - - it('returns provided tags as filtered and regular tags on LIST_TAGS', () => { - const tags = ['foo', 'bar', 'baz']; - - expect(reducer(undefined, listTags.fulfilled(fromPartial({ tags }), ''))).toEqual({ - tags, - filteredTags: tags, - loading: false, - error: false, - }); - }); - - it('removes provided tag from filtered and regular tags on TAG_DELETED', () => { - const tags = ['foo', 'bar', 'baz']; - const tag = 'foo'; - const expectedTags = ['bar', 'baz']; - - expect(reducer( - state({ tags, filteredTags: tags }), - tagDeleted(tag), - )).toEqual({ - tags: expectedTags, - filteredTags: expectedTags, - }); - }); - - it('renames provided tag from filtered and regular tags on TAG_EDITED', () => { - const tags = ['foo', 'bar', 'baz']; - const oldName = 'bar'; - const newName = 'renamed'; - const expectedTags = ['foo', 'renamed', 'baz'].sort(); - - expect(reducer( - state({ - tags, - filteredTags: tags, - stats: { - [oldName]: { - shortUrlsCount: 35, - visitsCount: 35, - }, - }, - }), - tagEdited({ oldName, newName, color: '' }), - )).toEqual({ - tags: expectedTags, - filteredTags: expectedTags, - stats: { - [oldName]: { - shortUrlsCount: 35, - visitsCount: 35, - }, - [newName]: { - shortUrlsCount: 35, - visitsCount: 35, - }, - }, - }); - }); - - it('filters original list of tags by provided search term on FILTER_TAGS', () => { - const tags = ['foo', 'bar', 'baz', 'Foo2', 'fo']; - const payload = 'Fo'; - const filteredTags = ['foo', 'Foo2', 'fo']; - - expect(reducer(state({ tags }), filterTags(payload))).toEqual({ - tags, - filteredTags, - }); - }); - - it.each([ - [['foo', 'foo3', 'bar3', 'fo'], ['foo', 'bar', 'baz', 'foo2', 'fo', 'foo3', 'bar3']], - [['foo', 'bar'], ['foo', 'bar', 'baz', 'foo2', 'fo']], - [['new', 'tag'], ['foo', 'bar', 'baz', 'foo2', 'fo', 'new', 'tag']], - ])('appends new short URL\'s tags to the list of tags on CREATE_SHORT_URL', (shortUrlTags, expectedTags) => { - const tags = ['foo', 'bar', 'baz', 'foo2', 'fo']; - const payload = fromPartial({ tags: shortUrlTags }); - - expect(reducer(state({ tags }), createShortUrl.fulfilled(payload, '', fromPartial({})))).toEqual({ - tags: expectedTags, - }); - }); - - it('increases amounts when visits are created', () => { - const createdVisits: CreateVisit[] = [ - fromPartial({ - shortUrl: { tags: ['foo', 'bar'] }, - visit: { potentialBot: true }, - }), - fromPartial({ - shortUrl: { tags: ['foo', 'bar'] }, - visit: {}, - }), - fromPartial({ - shortUrl: { tags: ['bar'] }, - visit: {}, - }), - fromPartial({ - shortUrl: { tags: ['baz'] }, - visit: { potentialBot: true }, - }), - ]; - const tagStats = (total: number) => ({ - shortUrlsCount: 1, - visitsCount: total, - visitsSummary: { - total, - nonBots: total - 10, - bots: 10, - }, - }); - const stateBefore = state({ - stats: { - foo: tagStats(100), - bar: tagStats(200), - baz: tagStats(150), - }, - }); - - expect(reducer(stateBefore, createNewVisits(createdVisits))).toEqual(expect.objectContaining({ - stats: { - foo: { - shortUrlsCount: 1, - visitsCount: 100 + 2, - visitsSummary: { - total: 100 + 2, - nonBots: 90 + 1, - bots: 10 + 1, - }, - }, - bar: { - shortUrlsCount: 1, - visitsCount: 200 + 3, - visitsSummary: { - total: 200 + 3, - nonBots: 190 + 2, - bots: 10 + 1, - }, - }, - baz: { - shortUrlsCount: 1, - visitsCount: 150 + 1, - visitsSummary: { - total: 150 + 1, - nonBots: 140, - bots: 10 + 1, - }, - }, - }, - })); - }); - }); - - describe('filterTags', () => { - it('creates expected action', () => expect(filterTags('foo').payload).toEqual('foo')); - }); - - describe('listTags', () => { - const dispatch = vi.fn(); - const getState = vi.fn(() => fromPartial({})); - const listTagsMock = vi.fn(); - - const assertNoAction = async (tagsList: TagsList) => { - getState.mockReturnValue(fromPartial({ tagsList })); - - await listTagsCreator(buildShlinkApiClient, false)()(dispatch, getState, {}); - - expect(buildShlinkApiClient).not.toHaveBeenCalled(); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(getState).toHaveBeenCalledTimes(1); - }; - - it('does nothing when loading', async () => assertNoAction(state({ loading: true }))); - it( - 'does nothing when list is not empty', - async () => assertNoAction(state({ loading: false, tags: ['foo', 'bar'] })), - ); - - it('dispatches loaded lists when no error occurs', async () => { - const tags = ['foo', 'bar', 'baz']; - - listTagsMock.mockResolvedValue({ tags, stats: [] }); - buildShlinkApiClient.mockReturnValue({ tagsStats: listTagsMock }); - - await listTags()(dispatch, getState, {}); - - expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(getState).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ - payload: { tags, stats: {} }, - })); - }); - }); -}); diff --git a/shlink-web-component/test/utils/components/CopyToClipboardIcon.test.tsx b/shlink-web-component/test/utils/components/CopyToClipboardIcon.test.tsx deleted file mode 100644 index 01aee93b..00000000 --- a/shlink-web-component/test/utils/components/CopyToClipboardIcon.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { CopyToClipboardIcon } from '../../../src/utils/components/CopyToClipboardIcon'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const onCopy = vi.fn(); - const setUp = (text = 'foo') => renderWithEvents(); - - it('wraps expected components', () => { - const { container } = setUp(); - expect(container).toMatchSnapshot(); - }); - - it.each([ - ['text'], - ['bar'], - ['baz'], - ])('copies content to clipboard when clicked', async (text) => { - const { user, container } = setUp(text); - - expect(onCopy).not.toHaveBeenCalled(); - container.firstElementChild && await user.click(container.firstElementChild); - expect(onCopy).toHaveBeenCalledWith(text, false); - }); -}); diff --git a/shlink-web-component/test/utils/components/ExportBtn.test.tsx b/shlink-web-component/test/utils/components/ExportBtn.test.tsx deleted file mode 100644 index 39d0f5cd..00000000 --- a/shlink-web-component/test/utils/components/ExportBtn.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { ExportBtn } from '../../../src/utils/components/ExportBtn'; - -describe('', () => { - const setUp = (amount?: number, loading = false) => render(); - - it.each([ - [true, 'Exporting...'], - [false, 'Export (0)'], - ])('renders loading state when expected', async (loading, text) => { - setUp(undefined, loading); - const btn = await screen.findByRole('button'); - - expect(btn).toHaveTextContent(text); - if (loading) { - expect(btn).toHaveAttribute('disabled'); - } else { - expect(btn).not.toHaveAttribute('disabled'); - } - }); - - it.each([ - [undefined, '0'], - [10, '10'], - [10_000, '10,000'], - [10_000_000, '10,000,000'], - ])('renders expected amount', async (amount, expectedRenderedAmount) => { - setUp(amount); - expect(await screen.findByRole('button')).toHaveTextContent(`Export (${expectedRenderedAmount})`); - }); - - it('renders expected icon', () => { - setUp(); - expect(screen.getByRole('img', { hidden: true })).toMatchSnapshot(); - }); -}); diff --git a/shlink-web-component/test/utils/components/IconInput.test.tsx b/shlink-web-component/test/utils/components/IconInput.test.tsx deleted file mode 100644 index 96bee16d..00000000 --- a/shlink-web-component/test/utils/components/IconInput.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faAppleAlt, faCalendar, faTable } from '@fortawesome/free-solid-svg-icons'; -import { screen } from '@testing-library/react'; -import { IconInput } from '../../../src/utils/components/IconInput'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const setUp = (icon: IconProp, placeholder?: string) => renderWithEvents( - , - ); - - it.each([faCalendar, faAppleAlt, faTable])('displays provided icon', (icon) => { - const { container } = setUp(icon); - expect(container).toMatchSnapshot(); - }); - - it('focuses input on icon click', async () => { - const { user } = setUp(faCalendar, 'foo'); - - expect(screen.getByPlaceholderText('foo')).not.toHaveFocus(); - await user.click(screen.getByRole('img', { hidden: true })); - expect(screen.getByPlaceholderText('foo')).toHaveFocus(); - }); -}); diff --git a/shlink-web-component/test/utils/components/InfoTooltip.test.tsx b/shlink-web-component/test/utils/components/InfoTooltip.test.tsx deleted file mode 100644 index 13c97c8e..00000000 --- a/shlink-web-component/test/utils/components/InfoTooltip.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { Placement } from '@popperjs/core'; -import { screen, waitFor } from '@testing-library/react'; -import type { InfoTooltipProps } from '../../../src/utils/components/InfoTooltip'; -import { InfoTooltip } from '../../../src/utils/components/InfoTooltip'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const setUp = (props: Partial = {}) => renderWithEvents( - , - ); - - it.each([ - [undefined], - ['foo'], - ['bar'], - ])('renders expected className on span', (className) => { - const { container } = setUp({ className }); - - if (className) { - expect(container.firstChild).toHaveClass(className); - } else { - expect(container.firstChild).toHaveAttribute('class', ''); - } - }); - - it.each([ - [foo, 'foo'], - ['Foo', 'Foo'], - ['Hello', 'Hello'], - [['One', 'Two', ], 'OneTwo'], - ])('passes children down to the nested tooltip component', async (children, expectedContent) => { - const { container, user } = setUp({ children }); - - container.firstElementChild && await user.hover(container.firstElementChild); - await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument()); - expect(screen.getByRole('tooltip')).toHaveTextContent(expectedContent); - }); - - it.each([ - ['right' as Placement], - ['left' as Placement], - ['top' as Placement], - ['bottom' as Placement], - ])('places tooltip where requested', async (placement) => { - const { container, user } = setUp({ placement }); - - container.firstElementChild && await user.hover(container.firstElementChild); - await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument()); - expect(screen.getByRole('tooltip').parentNode).toHaveAttribute('data-popper-placement', placement); - }); -}); diff --git a/shlink-web-component/test/utils/components/PaginationDropdown.test.tsx b/shlink-web-component/test/utils/components/PaginationDropdown.test.tsx deleted file mode 100644 index 57a8be6b..00000000 --- a/shlink-web-component/test/utils/components/PaginationDropdown.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { screen } from '@testing-library/react'; -import { PaginationDropdown } from '../../../src/utils/components/PaginationDropdown'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const setValue = vi.fn(); - const setUp = async () => { - const result = renderWithEvents(); - const { user } = result; - - await user.click(screen.getByRole('button')); - - return result; - }; - - it('renders expected amount of items', async () => { - await setUp(); - expect(screen.getAllByRole('menuitem')).toHaveLength(5); - }); - - it.each([ - [0, 10], - [1, 50], - [2, 100], - [3, 200], - ])('sets expected value when an item is clicked', async (index, expectedValue) => { - const { user } = await setUp(); - - expect(setValue).not.toHaveBeenCalled(); - await user.click(screen.getAllByRole('menuitem')[index]); - expect(setValue).toHaveBeenCalledWith(expectedValue); - }); -}); diff --git a/shlink-web-component/test/utils/components/SimplePaginator.test.tsx b/shlink-web-component/test/utils/components/SimplePaginator.test.tsx deleted file mode 100644 index b3557603..00000000 --- a/shlink-web-component/test/utils/components/SimplePaginator.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { SimplePaginator } from '../../../src/utils/components/SimplePaginator'; -import { ELLIPSIS } from '../../../src/utils/helpers/pagination'; - -describe('', () => { - const setUp = (pagesCount: number, currentPage = 1) => render( - , - ); - - it.each([-3, -2, 0, 1])('renders empty when the amount of pages is smaller than 2', (pagesCount) => { - const { container } = setUp(pagesCount); - expect(container.firstChild).toBeNull(); - }); - - describe('ELLIPSIS are rendered where expected', () => { - const getItemsForPages = (pagesCount: number, currentPage: number) => { - setUp(pagesCount, currentPage); - - const items = screen.getAllByRole('link'); - const itemsWithEllipsis = items.filter((item) => item.innerHTML.includes(ELLIPSIS)); - - return { items, itemsWithEllipsis }; - }; - - it('renders first ELLIPSIS', () => { - const { items, itemsWithEllipsis } = getItemsForPages(9, 7); - - expect(items[1]).toHaveTextContent(ELLIPSIS); - expect(itemsWithEllipsis).toHaveLength(1); - }); - - it('renders last ELLIPSIS', () => { - const { items, itemsWithEllipsis } = getItemsForPages(9, 2); - - expect(items[items.length - 2]).toHaveTextContent(ELLIPSIS); - expect(itemsWithEllipsis).toHaveLength(1); - }); - - it('renders both ELLIPSIS', () => { - const { items, itemsWithEllipsis } = getItemsForPages(20, 9); - - expect(items[1]).toHaveTextContent(ELLIPSIS); - expect(items[items.length - 2]).toHaveTextContent(ELLIPSIS); - expect(itemsWithEllipsis).toHaveLength(2); - }); - }); -}); diff --git a/shlink-web-component/test/utils/components/__snapshots__/CopyToClipboardIcon.test.tsx.snap b/shlink-web-component/test/utils/components/__snapshots__/CopyToClipboardIcon.test.tsx.snap deleted file mode 100644 index a6d37ddf..00000000 --- a/shlink-web-component/test/utils/components/__snapshots__/CopyToClipboardIcon.test.tsx.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > wraps expected components 1`] = ` -
- -
-`; diff --git a/shlink-web-component/test/utils/components/__snapshots__/ExportBtn.test.tsx.snap b/shlink-web-component/test/utils/components/__snapshots__/ExportBtn.test.tsx.snap deleted file mode 100644 index 3688603f..00000000 --- a/shlink-web-component/test/utils/components/__snapshots__/ExportBtn.test.tsx.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > renders expected icon 1`] = ` - -`; diff --git a/shlink-web-component/test/utils/components/__snapshots__/IconInput.test.tsx.snap b/shlink-web-component/test/utils/components/__snapshots__/IconInput.test.tsx.snap deleted file mode 100644 index d76e8654..00000000 --- a/shlink-web-component/test/utils/components/__snapshots__/IconInput.test.tsx.snap +++ /dev/null @@ -1,85 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > displays provided icon 1`] = ` -
-
- - -
-
-`; - -exports[` > displays provided icon 2`] = ` -
-
- - -
-
-`; - -exports[` > displays provided icon 3`] = ` -
-
- - -
-
-`; diff --git a/shlink-web-component/test/utils/dates/DateInput.test.tsx b/shlink-web-component/test/utils/dates/DateInput.test.tsx deleted file mode 100644 index b37e3409..00000000 --- a/shlink-web-component/test/utils/dates/DateInput.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { parseISO } from 'date-fns'; -import type { DateInputProps } from '../../../src/utils/dates/DateInput'; -import { DateInput } from '../../../src/utils/dates/DateInput'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const setUp = (props: Partial = {}) => renderWithEvents( - (props)} />, - ); - - it('shows calendar icon when input is not clearable', () => { - setUp({ isClearable: false }); - expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); - }); - - it('shows calendar icon when input is clearable but selected value is nil', () => { - setUp({ isClearable: true, selected: null }); - expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); - }); - - it('does not show calendar icon when input is clearable', () => { - setUp({ isClearable: true, selected: new Date() }); - expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument(); - }); - - it('shows popper on element click', async () => { - const { user, container } = setUp({ placeholderText: 'foo' }); - - expect(container.querySelector('.react-datepicker')).not.toBeInTheDocument(); - await user.click(screen.getByPlaceholderText('foo')); - await waitFor(() => expect(container.querySelector('.react-datepicker')).toBeInTheDocument()); - }); - - it.each([ - [undefined, '2022-01-01'], - ['yyyy-MM-dd', '2022-01-01'], - ['yyyy-MM-dd HH:mm', '2022-01-01 15:18'], - ['HH:mm:ss', '15:18:36'], - ])('shows date in expected format', (dateFormat, expectedValue) => { - setUp({ placeholderText: 'foo', selected: parseISO('2022-01-01T15:18:36'), dateFormat }); - expect(screen.getByPlaceholderText('foo')).toHaveValue(expectedValue); - }); -}); diff --git a/shlink-web-component/test/utils/dates/DateIntervalDropdownItems.test.tsx b/shlink-web-component/test/utils/dates/DateIntervalDropdownItems.test.tsx deleted file mode 100644 index 79495f08..00000000 --- a/shlink-web-component/test/utils/dates/DateIntervalDropdownItems.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { DropdownBtn } from '@shlinkio/shlink-frontend-kit'; -import { screen, waitFor } from '@testing-library/react'; -import { DateIntervalDropdownItems } from '../../../src/utils/dates/DateIntervalDropdownItems'; -import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals'; -import { DATE_INTERVALS, rangeOrIntervalToString } from '../../../src/utils/dates/helpers/dateIntervals'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const onChange = vi.fn(); - const setUp = async () => { - const { user, ...renderResult } = renderWithEvents( - - - , - ); - - await user.click(screen.getByRole('button')); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); - - return { user, ...renderResult }; - }; - - it('renders expected amount of items', async () => { - await setUp(); - - expect(screen.getAllByRole('menuitem')).toHaveLength(DATE_INTERVALS.length + 1); - expect(screen.getByRole('menuitem', { name: 'Last 180 days' })).toHaveClass('active'); - }); - - it('sets expected item as active', async () => { - await setUp(); - const EXPECTED_ACTIVE_INDEX = 5; - - DATE_INTERVALS.forEach((interval, index) => { - const item = screen.getByRole('menuitem', { name: rangeOrIntervalToString(interval) }); - - if (index === EXPECTED_ACTIVE_INDEX) { - expect(item).toHaveClass('active'); - } else { - expect(item).not.toHaveClass('active'); - } - }); - }); - - it.each([ - [3, 'last7Days' as DateInterval], - [7, 'last365Days' as DateInterval], - [2, 'yesterday' as DateInterval], - ])('triggers onChange callback when selecting an element', async (index, expectedInterval) => { - const { user } = await setUp(); - - await user.click(screen.getAllByRole('menuitem')[index]); - - expect(onChange).toHaveBeenCalledWith(expectedInterval); - }); -}); diff --git a/shlink-web-component/test/utils/dates/DateRangeRow.test.tsx b/shlink-web-component/test/utils/dates/DateRangeRow.test.tsx deleted file mode 100644 index 85f05904..00000000 --- a/shlink-web-component/test/utils/dates/DateRangeRow.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { screen } from '@testing-library/react'; -import { DateRangeRow } from '../../../src/utils/dates/DateRangeRow'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const onEndDateChange = vi.fn(); - const onStartDateChange = vi.fn(); - const setUp = () => renderWithEvents( - , - ); - - it('renders two date inputs', () => { - setUp(); - expect(screen.getAllByRole('textbox')).toHaveLength(2); - }); - - it('invokes start date callback when change event is triggered on first input', async () => { - const { user } = setUp(); - - expect(onStartDateChange).not.toHaveBeenCalled(); - await user.type(screen.getByPlaceholderText('Since...'), '2020-05-05'); - expect(onStartDateChange).toHaveBeenCalled(); - }); - - it('invokes end date callback when change event is triggered on second input', async () => { - const { user } = setUp(); - - expect(onEndDateChange).not.toHaveBeenCalled(); - await user.type(screen.getByPlaceholderText('Until...'), '2022-05-05'); - expect(onEndDateChange).toHaveBeenCalled(); - }); -}); diff --git a/shlink-web-component/test/utils/dates/DateRangeSelector.test.tsx b/shlink-web-component/test/utils/dates/DateRangeSelector.test.tsx deleted file mode 100644 index 016a5496..00000000 --- a/shlink-web-component/test/utils/dates/DateRangeSelector.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import type { DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector'; -import { DateRangeSelector } from '../../../src/utils/dates/DateRangeSelector'; -import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; - -describe('', () => { - const onDatesChange = vi.fn(); - const setUp = async (props: Partial = {}) => { - const result = renderWithEvents( - (props)} - defaultText="Default text" - onDatesChange={onDatesChange} - />, - ); - - await result.user.click(screen.getByRole('button')); - await waitFor(() => screen.getByRole('menu')); - - return result; - }; - - it('renders proper amount of items', async () => { - const { container } = await setUp(); - - expect(screen.getAllByRole('menuitem')).toHaveLength(8); - expect(screen.getByRole('heading')).toHaveTextContent('Custom:'); - expect(container.querySelector('.dropdown-divider')).toBeInTheDocument(); - expect(container.querySelector('.dropdown-item-text')).toBeInTheDocument(); - }); - - it.each([ - [undefined, 0], - ['all' as DateInterval, 1], - ['today' as DateInterval, 1], - ['yesterday' as DateInterval, 1], - ['last7Days' as DateInterval, 1], - ['last30Days' as DateInterval, 1], - ['last90Days' as DateInterval, 1], - ['last180Days' as DateInterval, 1], - ['last365Days' as DateInterval, 1], - [{ startDate: new Date() }, 0], - ])('sets proper element as active based on provided date range', async (initialDateRange, expectedActiveItems) => { - const { container } = await setUp({ initialDateRange }); - expect(container.querySelectorAll('.active')).toHaveLength(expectedActiveItems); - }); - - it('triggers onDatesChange callback when selecting an element', async () => { - const { user } = await setUp(); - - await user.click(screen.getByPlaceholderText('Since...')); - await user.click(screen.getAllByRole('option')[0]); - - await user.click(screen.getByPlaceholderText('Until...')); - await user.click(screen.getAllByRole('option')[0]); - - await user.click(screen.getAllByRole('menuitem')[0]); - - expect(onDatesChange).toHaveBeenCalledTimes(3); - }); - - it('propagates default text to DateIntervalDropdownItems', async () => { - await setUp(); - expect(screen.getAllByText('Default text')).toHaveLength(2); - }); -}); diff --git a/shlink-web-component/test/utils/dates/Time.test.tsx b/shlink-web-component/test/utils/dates/Time.test.tsx deleted file mode 100644 index e3f0388f..00000000 --- a/shlink-web-component/test/utils/dates/Time.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { render } from '@testing-library/react'; -import { parseDate } from '../../../src/utils/dates/helpers/date'; -import type { TimeProps } from '../../../src/utils/dates/Time'; -import { Time } from '../../../src/utils/dates/Time'; - -describe('