From 3a8d031ed515b6125cd2329fb2e26404304c6d2f Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Wed, 24 Feb 2021 08:17:40 +0100 Subject: [PATCH] Introduce stale while revalidate pattern (#1555) This Improves the frontend performance with stale while revalidate pattern. There are noticeable performance problems in the frontend that needed addressing. While implementing the stale-while-revalidate pattern to display cached responses while re-fetching up-to-date data in the background, in the same vein we used the opportunity to remove legacy code involving redux as much as possible, cleaned up many components and converted them to functional react components. Co-authored-by: Sebastian Sdorra Co-authored-by: Eduard Heimbuch --- gradle/changelog/stale_while_revalidate.yaml | 2 + scm-plugins/scm-git-plugin/package.json | 2 +- scm-plugins/scm-hg-plugin/package.json | 2 +- scm-plugins/scm-legacy-plugin/package.json | 2 +- scm-plugins/scm-svn-plugin/package.json | 2 +- scm-ui/ui-api/package.json | 43 + scm-ui/ui-api/src/ApiProvider.test.tsx | 60 + scm-ui/ui-api/src/ApiProvider.tsx | 81 + scm-ui/ui-api/src/LegacyContext.test.tsx | 46 + .../src/LegacyContext.tsx} | 44 +- scm-ui/ui-api/src/admin.test.ts | 55 + .../convertUser.ts => ui-api/src/admin.ts} | 29 +- .../src/apiclient.test.ts | 0 .../src/apiclient.ts | 33 +- scm-ui/ui-api/src/base.test.ts | 237 + scm-ui/ui-api/src/base.ts | 100 + scm-ui/ui-api/src/branches.test.ts | 219 + scm-ui/ui-api/src/branches.ts | 104 + scm-ui/ui-api/src/changesets.test.ts | 179 + scm-ui/ui-api/src/changesets.ts | 77 + scm-ui/ui-api/src/config.test.ts | 113 + scm-ui/ui-api/src/config.ts | 56 + .../src/errors.test.ts | 0 .../{ui-components => ui-api}/src/errors.ts | 2 + scm-ui/ui-api/src/groups.test.ts | 241 + scm-ui/ui-api/src/groups.ts | 141 + scm-ui/ui-api/src/index.ts | 48 + scm-ui/ui-api/src/keys.ts | 50 + scm-ui/ui-api/src/links.test.ts | 65 + scm-ui/ui-api/src/links.ts | 38 + scm-ui/ui-api/src/login.test.ts | 239 + scm-ui/ui-api/src/login.ts | 118 + scm-ui/ui-api/src/namespaces.test.ts | 97 + scm-ui/ui-api/src/namespaces.ts | 45 + scm-ui/ui-api/src/permissions.test.ts | 347 + scm-ui/ui-api/src/permissions.ts | 158 + scm-ui/ui-api/src/plugins.test.ts | 317 + scm-ui/ui-api/src/plugins.ts | 249 + scm-ui/ui-api/src/repositories.test.ts | 517 + scm-ui/ui-api/src/repositories.ts | 229 + scm-ui/ui-api/src/repository-roles.test.ts | 228 + scm-ui/ui-api/src/repository-roles.ts | 118 + scm-ui/ui-api/src/reset.ts | 38 + scm-ui/ui-api/src/sources.test.ts | 235 + scm-ui/ui-api/src/sources.ts | 131 + scm-ui/ui-api/src/tags.test.ts | 266 + scm-ui/ui-api/src/tags.ts | 119 + .../src/tests/createInfiniteCachingClient.ts} | 24 +- scm-ui/ui-api/src/tests/createWrapper.tsx | 37 + scm-ui/ui-api/src/tests/indexLinks.ts | 43 + .../src/urls.test.ts | 0 scm-ui/{ui-components => ui-api}/src/urls.ts | 3 +- scm-ui/ui-api/src/users.test.ts | 310 + scm-ui/ui-api/src/users.ts | 198 + scm-ui/ui-api/src/utils.ts | 29 + scm-ui/ui-api/tsconfig.json | 6 + scm-ui/ui-components/.storybook/.babelrc | 3 + scm-ui/ui-components/.storybook/config.js | 12 +- .../.storybook/webpack.config.js | 79 - .../.storybook/withApiProvider.js | 46 + scm-ui/ui-components/package.json | 14 +- .../src/BackendErrorNotification.tsx | 2 +- scm-ui/ui-components/src/Breadcrumb.tsx | 15 +- scm-ui/ui-components/src/ErrorBoundary.tsx | 156 +- .../ui-components/src/ErrorNotification.tsx | 65 +- scm-ui/ui-components/src/ErrorPage.tsx | 2 +- scm-ui/ui-components/src/Image.tsx | 4 +- .../src/MarkdownCodeRenderer.tsx | 18 +- .../src/MarkdownHeadingRenderer.tsx | 4 +- .../src/MarkdownLinkRenderer.tsx | 4 +- .../ui-components/src/OverviewPageActions.tsx | 3 +- .../ui-components/src/SyntaxHighlighter.tsx | 4 +- .../src/UserGroupAutocomplete.tsx | 2 +- .../src/__resources__/changesets.tsx | 4 +- .../src/__snapshots__/storyshots.test.ts.snap | 198416 ++++++++++----- scm-ui/ui-components/src/index.ts | 31 +- scm-ui/ui-components/src/layout/Subtitle.tsx | 4 +- .../ui-components/src/modals/ConfirmAlert.tsx | 3 +- .../src/modals/CreateTagModal.tsx | 3 + .../src/navigation/PrimaryNavigation.tsx | 4 +- .../ui-components/src/repos/CommitAuthor.tsx | 26 +- .../ui-components/src/repos/LoadingDiff.tsx | 3 +- .../src/repos/changesets/ChangesetDiff.tsx | 6 +- scm-ui/ui-extensions/package.json | 9 +- scm-ui/ui-plugins/package.json | 6 +- scm-ui/ui-tests/package.json | 4 +- scm-ui/ui-types/package.json | 2 +- .../types.ts => ui-types/src/Admin.ts} | 8 +- scm-ui/ui-types/src/Branches.ts | 13 +- scm-ui/ui-types/src/Changesets.ts | 22 +- scm-ui/ui-types/src/Config.ts | 5 +- scm-ui/ui-types/src/Group.ts | 26 +- scm-ui/ui-types/src/LoginInfo.ts | 35 + scm-ui/ui-types/src/Me.ts | 5 +- scm-ui/ui-types/src/NamespaceStrategies.ts | 2 + scm-ui/ui-types/src/Plugin.ts | 31 +- scm-ui/ui-types/src/Repositories.ts | 32 +- scm-ui/ui-types/src/RepositoryPermissions.ts | 10 +- scm-ui/ui-types/src/RepositoryRole.ts | 15 +- scm-ui/ui-types/src/RepositoryTypes.ts | 10 +- .../src/RepositoryVerbs.ts} | 11 +- scm-ui/ui-types/src/Tags.ts | 11 +- scm-ui/ui-types/src/User.ts | 12 +- scm-ui/ui-types/src/hal.ts | 20 +- scm-ui/ui-types/src/index.ts | 33 +- scm-ui/ui-webapp/package.json | 3 +- scm-ui/ui-webapp/public/index.mustache | 1 + .../ui-webapp/public/locales/de/commons.json | 6 + .../ui-webapp/public/locales/en/commons.json | 6 + scm-ui/ui-webapp/src/LegacyReduxProvider.tsx | 104 + .../ui-webapp/src/ReduxAwareApiProvider.tsx | 48 + .../src/admin/components/form/ConfigForm.tsx | 1 + .../ui-webapp/src/admin/containers/Admin.tsx | 257 +- .../src/admin/containers/AdminDetails.tsx | 222 +- .../src/admin/containers/GlobalConfig.tsx | 204 +- .../src/admin/modules/config.test.ts | 337 - scm-ui/ui-webapp/src/admin/modules/config.ts | 193 - .../admin/modules/namespaceStrategies.test.ts | 224 - .../src/admin/modules/namespaceStrategies.ts | 121 - .../components/CancelPendingActionModal.tsx | 53 +- .../components/ExecutePendingAction.tsx | 78 - .../components/ExecutePendingActionModal.tsx | 51 +- .../components/ExecutePendingModal.tsx | 135 - .../plugins/components/PluginActionModal.tsx | 56 +- .../admin/plugins/components/PluginEntry.tsx | 196 +- .../plugins/components/PluginGroupEntry.tsx | 20 +- .../admin/plugins/components/PluginList.tsx | 29 +- .../admin/plugins/components/PluginModal.tsx | 286 +- .../components/SuccessNotification.tsx | 4 +- .../components/UpdateAllActionModal.tsx | 54 +- .../plugins/containers/PluginsOverview.tsx | 286 +- .../src/admin/plugins/modules/plugins.test.ts | 363 - .../src/admin/plugins/modules/plugins.ts | 277 - .../roles/containers/CreateRepositoryRole.tsx | 85 +- .../roles/containers/DeleteRepositoryRole.tsx | 43 +- .../roles/containers/EditRepositoryRole.tsx | 84 +- .../roles/containers/RepositoryRoleForm.tsx | 176 +- .../roles/containers/RepositoryRoles.tsx | 141 +- .../roles/containers/SingleRepositoryRole.tsx | 120 +- .../src/admin/roles/modules/roles.test.ts | 697 - .../src/admin/roles/modules/roles.ts | 553 - scm-ui/ui-webapp/src/components/InfoBox.tsx | 4 +- scm-ui/ui-webapp/src/components/LoginForm.tsx | 8 +- scm-ui/ui-webapp/src/components/LoginInfo.tsx | 9 +- scm-ui/ui-webapp/src/containers/App.tsx | 113 +- .../src/containers/ChangeUserPassword.tsx | 2 +- scm-ui/ui-webapp/src/containers/Index.tsx | 109 +- scm-ui/ui-webapp/src/containers/Login.tsx | 88 +- scm-ui/ui-webapp/src/containers/Logout.tsx | 63 +- .../ui-webapp/src/containers/PluginLoader.tsx | 11 +- scm-ui/ui-webapp/src/containers/Profile.tsx | 198 +- scm-ui/ui-webapp/src/containers/loadBundle.ts | 7 +- scm-ui/ui-webapp/src/createReduxStore.ts | 106 - .../src/groups/containers/CreateGroup.tsx | 91 +- .../src/groups/containers/DeleteGroup.tsx | 56 +- .../src/groups/containers/EditGroup.tsx | 97 +- .../src/groups/containers/Groups.tsx | 147 +- .../src/groups/containers/SingleGroup.tsx | 165 +- .../src/groups/modules/groups.test.ts | 738 - scm-ui/ui-webapp/src/groups/modules/groups.ts | 494 - scm-ui/ui-webapp/src/index.tsx | 25 +- scm-ui/ui-webapp/src/modules/auth.test.ts | 491 - scm-ui/ui-webapp/src/modules/auth.ts | 308 - scm-ui/ui-webapp/src/modules/failure.test.ts | 183 - scm-ui/ui-webapp/src/modules/failure.ts | 104 - .../src/modules/indexResource.test.ts | 529 - scm-ui/ui-webapp/src/modules/indexResource.ts | 275 - scm-ui/ui-webapp/src/modules/pending.test.ts | 193 - scm-ui/ui-webapp/src/modules/pending.ts | 102 - .../permissions/components/SetPermissions.tsx | 206 +- .../repos/branches/components/BranchForm.tsx | 14 +- .../repos/branches/components/BranchTable.tsx | 88 +- .../repos/branches/containers/BranchRoot.tsx | 106 +- .../branches/containers/BranchesOverview.tsx | 141 +- .../branches/containers/CreateBranch.tsx | 157 +- .../repos/branches/modules/branches.test.ts | 519 - .../src/repos/branches/modules/branches.ts | 371 - .../codeSection/components/CodeActionBar.tsx | 4 +- .../components/CodeViewSwitcher.tsx | 31 +- .../codeSection/containers/CodeOverview.tsx | 167 +- .../components/NamespaceAndNameFields.tsx | 37 +- .../changesets/ChangesetDetails.tsx | 39 +- .../components/changesets/CreateTagModal.tsx | 110 + .../repos/components/form/RepositoryForm.tsx | 5 +- .../src/repos/containers/ArchiveRepo.tsx | 51 +- .../src/repos/containers/ChangesetView.tsx | 107 +- .../src/repos/containers/Changesets.tsx | 135 +- .../src/repos/containers/ChangesetsRoot.tsx | 88 +- .../src/repos/containers/CreateRepository.tsx | 171 +- .../src/repos/containers/DeleteRepo.tsx | 58 +- .../src/repos/containers/EditRepo.tsx | 116 +- .../src/repos/containers/ImportRepository.tsx | 59 +- .../src/repos/containers/Overview.tsx | 262 +- .../src/repos/containers/RenameRepository.tsx | 5 +- .../repos/containers/RepositoryDangerZone.tsx | 5 +- .../src/repos/containers/RepositoryRoot.tsx | 539 +- .../src/repos/containers/UnarchiveRepo.tsx | 58 +- .../src/repos/modules/changesets.test.ts | 701 - .../ui-webapp/src/repos/modules/changesets.ts | 350 - .../ui-webapp/src/repos/modules/repos.test.ts | 853 - scm-ui/ui-webapp/src/repos/modules/repos.ts | 665 - .../src/repos/modules/repositoryTypes.test.ts | 221 - .../src/repos/modules/repositoryTypes.ts | 113 - .../namespaces/containers/NamespaceRoot.tsx | 139 +- .../{buttons => }/DeletePermissionButton.tsx | 56 +- .../components/PermissionsTable.tsx | 35 +- .../permissions/components/RoleSelector.tsx | 59 +- .../buttons/DeletePermissionButton.test.tsx | 111 - .../containers/AdvancedPermissionsDialog.tsx | 135 +- .../containers/CreatePermissionForm.tsx | 362 +- .../permissions/containers/Permissions.tsx | 246 +- .../containers/SinglePermission.tsx | 301 +- .../permissions/modules/permissions.test.ts | 659 - .../repos/permissions/modules/permissions.ts | 597 - .../permissions/utils/findVerbsForRole.ts | 37 + .../permissionValidation.test.ts | 0 .../permissionValidation.ts | 11 +- .../src/repos/sources/components/FileTree.tsx | 264 +- .../components/TruncatedNotification.tsx | 55 + .../src/repos/sources/containers/Content.tsx | 17 +- .../sources/containers/SourceExtensions.tsx | 112 +- .../src/repos/sources/containers/Sources.tsx | 238 +- .../src/repos/sources/modules/sources.test.ts | 336 - .../src/repos/sources/modules/sources.ts | 281 - .../src/repos/tags/components/TagTable.tsx | 82 +- .../src/repos/tags/container/DeleteTag.tsx | 63 +- .../src/repos/tags/container/TagRoot.tsx | 53 +- .../src/repos/tags/container/TagsOverview.tsx | 47 +- scm-ui/ui-webapp/src/tokenExpired.ts | 47 + .../src/users/components/UserConverter.tsx | 60 +- .../users/components/apiKeys/AddApiKey.tsx | 82 +- .../src/users/containers/CreateUser.tsx | 84 +- .../src/users/containers/DeleteUser.tsx | 60 +- .../src/users/containers/EditUser.tsx | 88 +- .../src/users/containers/SingleUser.tsx | 187 +- .../ui-webapp/src/users/containers/Users.tsx | 147 +- .../ui-webapp/src/users/modules/users.test.ts | 713 - scm-ui/ui-webapp/src/users/modules/users.ts | 489 - .../{modules => utils}/changePassword.test.ts | 0 .../src/{modules => utils}/changePassword.ts | 0 .../sonia/scm/admin/ReleaseFeedParser.java | 3 +- .../v2/resources/BaseFileObjectDtoMapper.java | 2 +- yarn.lock | 2019 +- 243 files changed, 150259 insertions(+), 80227 deletions(-) create mode 100644 gradle/changelog/stale_while_revalidate.yaml create mode 100644 scm-ui/ui-api/package.json create mode 100644 scm-ui/ui-api/src/ApiProvider.test.tsx create mode 100644 scm-ui/ui-api/src/ApiProvider.tsx create mode 100644 scm-ui/ui-api/src/LegacyContext.test.tsx rename scm-ui/{ui-webapp/src/admin/plugins/components/waitForRestart.ts => ui-api/src/LegacyContext.tsx} (62%) create mode 100644 scm-ui/ui-api/src/admin.test.ts rename scm-ui/{ui-webapp/src/users/components/convertUser.ts => ui-api/src/admin.ts} (73%) rename scm-ui/{ui-components => ui-api}/src/apiclient.test.ts (100%) rename scm-ui/{ui-components => ui-api}/src/apiclient.ts (90%) create mode 100644 scm-ui/ui-api/src/base.test.ts create mode 100644 scm-ui/ui-api/src/base.ts create mode 100644 scm-ui/ui-api/src/branches.test.ts create mode 100644 scm-ui/ui-api/src/branches.ts create mode 100644 scm-ui/ui-api/src/changesets.test.ts create mode 100644 scm-ui/ui-api/src/changesets.ts create mode 100644 scm-ui/ui-api/src/config.test.ts create mode 100644 scm-ui/ui-api/src/config.ts rename scm-ui/{ui-components => ui-api}/src/errors.test.ts (100%) rename scm-ui/{ui-components => ui-api}/src/errors.ts (99%) create mode 100644 scm-ui/ui-api/src/groups.test.ts create mode 100644 scm-ui/ui-api/src/groups.ts create mode 100644 scm-ui/ui-api/src/index.ts create mode 100644 scm-ui/ui-api/src/keys.ts create mode 100644 scm-ui/ui-api/src/links.test.ts create mode 100644 scm-ui/ui-api/src/links.ts create mode 100644 scm-ui/ui-api/src/login.test.ts create mode 100644 scm-ui/ui-api/src/login.ts create mode 100644 scm-ui/ui-api/src/namespaces.test.ts create mode 100644 scm-ui/ui-api/src/namespaces.ts create mode 100644 scm-ui/ui-api/src/permissions.test.ts create mode 100644 scm-ui/ui-api/src/permissions.ts create mode 100644 scm-ui/ui-api/src/plugins.test.ts create mode 100644 scm-ui/ui-api/src/plugins.ts create mode 100644 scm-ui/ui-api/src/repositories.test.ts create mode 100644 scm-ui/ui-api/src/repositories.ts create mode 100644 scm-ui/ui-api/src/repository-roles.test.ts create mode 100644 scm-ui/ui-api/src/repository-roles.ts create mode 100644 scm-ui/ui-api/src/reset.ts create mode 100644 scm-ui/ui-api/src/sources.test.ts create mode 100644 scm-ui/ui-api/src/sources.ts create mode 100644 scm-ui/ui-api/src/tags.test.ts create mode 100644 scm-ui/ui-api/src/tags.ts rename scm-ui/{ui-components/.storybook/withRedux.js => ui-api/src/tests/createInfiniteCachingClient.ts} (78%) create mode 100644 scm-ui/ui-api/src/tests/createWrapper.tsx create mode 100644 scm-ui/ui-api/src/tests/indexLinks.ts rename scm-ui/{ui-components => ui-api}/src/urls.test.ts (100%) rename scm-ui/{ui-components => ui-api}/src/urls.ts (96%) create mode 100644 scm-ui/ui-api/src/users.test.ts create mode 100644 scm-ui/ui-api/src/users.ts create mode 100644 scm-ui/ui-api/src/utils.ts create mode 100644 scm-ui/ui-api/tsconfig.json create mode 100644 scm-ui/ui-components/.storybook/.babelrc delete mode 100644 scm-ui/ui-components/.storybook/webpack.config.js create mode 100644 scm-ui/ui-components/.storybook/withApiProvider.js rename scm-ui/{ui-webapp/src/modules/types.ts => ui-types/src/Admin.ts} (87%) create mode 100644 scm-ui/ui-types/src/LoginInfo.ts rename scm-ui/{ui-webapp/src/components/InfoItem.ts => ui-types/src/RepositoryVerbs.ts} (88%) create mode 100644 scm-ui/ui-webapp/src/LegacyReduxProvider.tsx create mode 100644 scm-ui/ui-webapp/src/ReduxAwareApiProvider.tsx delete mode 100644 scm-ui/ui-webapp/src/admin/modules/config.test.ts delete mode 100644 scm-ui/ui-webapp/src/admin/modules/config.ts delete mode 100644 scm-ui/ui-webapp/src/admin/modules/namespaceStrategies.test.ts delete mode 100644 scm-ui/ui-webapp/src/admin/modules/namespaceStrategies.ts delete mode 100644 scm-ui/ui-webapp/src/admin/plugins/components/ExecutePendingAction.tsx delete mode 100644 scm-ui/ui-webapp/src/admin/plugins/components/ExecutePendingModal.tsx delete mode 100644 scm-ui/ui-webapp/src/admin/plugins/modules/plugins.test.ts delete mode 100644 scm-ui/ui-webapp/src/admin/plugins/modules/plugins.ts delete mode 100644 scm-ui/ui-webapp/src/admin/roles/modules/roles.test.ts delete mode 100644 scm-ui/ui-webapp/src/admin/roles/modules/roles.ts delete mode 100644 scm-ui/ui-webapp/src/createReduxStore.ts delete mode 100644 scm-ui/ui-webapp/src/groups/modules/groups.test.ts delete mode 100644 scm-ui/ui-webapp/src/groups/modules/groups.ts delete mode 100644 scm-ui/ui-webapp/src/modules/auth.test.ts delete mode 100644 scm-ui/ui-webapp/src/modules/auth.ts delete mode 100644 scm-ui/ui-webapp/src/modules/failure.test.ts delete mode 100644 scm-ui/ui-webapp/src/modules/failure.ts delete mode 100644 scm-ui/ui-webapp/src/modules/indexResource.test.ts delete mode 100644 scm-ui/ui-webapp/src/modules/indexResource.ts delete mode 100644 scm-ui/ui-webapp/src/modules/pending.test.ts delete mode 100644 scm-ui/ui-webapp/src/modules/pending.ts delete mode 100644 scm-ui/ui-webapp/src/repos/branches/modules/branches.test.ts delete mode 100644 scm-ui/ui-webapp/src/repos/branches/modules/branches.ts create mode 100644 scm-ui/ui-webapp/src/repos/components/changesets/CreateTagModal.tsx delete mode 100644 scm-ui/ui-webapp/src/repos/modules/changesets.test.ts delete mode 100644 scm-ui/ui-webapp/src/repos/modules/changesets.ts delete mode 100644 scm-ui/ui-webapp/src/repos/modules/repos.test.ts delete mode 100644 scm-ui/ui-webapp/src/repos/modules/repos.ts delete mode 100644 scm-ui/ui-webapp/src/repos/modules/repositoryTypes.test.ts delete mode 100644 scm-ui/ui-webapp/src/repos/modules/repositoryTypes.ts rename scm-ui/ui-webapp/src/repos/permissions/components/{buttons => }/DeletePermissionButton.tsx (69%) delete mode 100644 scm-ui/ui-webapp/src/repos/permissions/components/buttons/DeletePermissionButton.test.tsx delete mode 100644 scm-ui/ui-webapp/src/repos/permissions/modules/permissions.test.ts delete mode 100644 scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts create mode 100644 scm-ui/ui-webapp/src/repos/permissions/utils/findVerbsForRole.ts rename scm-ui/ui-webapp/src/repos/permissions/{components => utils}/permissionValidation.test.ts (100%) rename scm-ui/ui-webapp/src/repos/permissions/{components => utils}/permissionValidation.ts (88%) create mode 100644 scm-ui/ui-webapp/src/repos/sources/components/TruncatedNotification.tsx delete mode 100644 scm-ui/ui-webapp/src/repos/sources/modules/sources.test.ts delete mode 100644 scm-ui/ui-webapp/src/repos/sources/modules/sources.ts create mode 100644 scm-ui/ui-webapp/src/tokenExpired.ts delete mode 100644 scm-ui/ui-webapp/src/users/modules/users.test.ts delete mode 100644 scm-ui/ui-webapp/src/users/modules/users.ts rename scm-ui/ui-webapp/src/{modules => utils}/changePassword.test.ts (100%) rename scm-ui/ui-webapp/src/{modules => utils}/changePassword.ts (100%) diff --git a/gradle/changelog/stale_while_revalidate.yaml b/gradle/changelog/stale_while_revalidate.yaml new file mode 100644 index 0000000000..5dee4cc4f9 --- /dev/null +++ b/gradle/changelog/stale_while_revalidate.yaml @@ -0,0 +1,2 @@ +- type: changed + description: improve frontend performance with stale while revalidate pattern ([#1555](https://github.com/scm-manager/scm-manager/pull/1555)) diff --git a/scm-plugins/scm-git-plugin/package.json b/scm-plugins/scm-git-plugin/package.json index a6cd68739f..f9facf852a 100644 --- a/scm-plugins/scm-git-plugin/package.json +++ b/scm-plugins/scm-git-plugin/package.json @@ -16,7 +16,7 @@ "devDependencies": { "@scm-manager/babel-preset": "^2.11.1", "@scm-manager/eslint-config": "^2.11.1", - "@scm-manager/jest-preset": "^2.12.3", + "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/plugin-scripts": "^1.0.1", "@scm-manager/prettier-config": "^2.11.1" }, diff --git a/scm-plugins/scm-hg-plugin/package.json b/scm-plugins/scm-hg-plugin/package.json index d320ca5f8e..a820fc2cfc 100644 --- a/scm-plugins/scm-hg-plugin/package.json +++ b/scm-plugins/scm-hg-plugin/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@scm-manager/babel-preset": "^2.11.1", "@scm-manager/eslint-config": "^2.11.1", - "@scm-manager/jest-preset": "^2.12.3", + "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/plugin-scripts": "^1.0.1", "@scm-manager/prettier-config": "^2.11.1" }, diff --git a/scm-plugins/scm-legacy-plugin/package.json b/scm-plugins/scm-legacy-plugin/package.json index c64d2f54bf..e46052ca44 100644 --- a/scm-plugins/scm-legacy-plugin/package.json +++ b/scm-plugins/scm-legacy-plugin/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@scm-manager/babel-preset": "^2.11.1", "@scm-manager/eslint-config": "^2.11.1", - "@scm-manager/jest-preset": "^2.12.3", + "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/plugin-scripts": "^1.0.1", "@scm-manager/prettier-config": "^2.11.1" }, diff --git a/scm-plugins/scm-svn-plugin/package.json b/scm-plugins/scm-svn-plugin/package.json index a7039805f8..580ae31528 100644 --- a/scm-plugins/scm-svn-plugin/package.json +++ b/scm-plugins/scm-svn-plugin/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@scm-manager/babel-preset": "^2.11.1", "@scm-manager/eslint-config": "^2.11.1", - "@scm-manager/jest-preset": "^2.12.3", + "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/plugin-scripts": "^1.0.1", "@scm-manager/prettier-config": "^2.11.1" }, diff --git a/scm-ui/ui-api/package.json b/scm-ui/ui-api/package.json new file mode 100644 index 0000000000..cf53d8ac3e --- /dev/null +++ b/scm-ui/ui-api/package.json @@ -0,0 +1,43 @@ +{ + "name": "@scm-manager/ui-api", + "version": "2.13.1-SNAPSHOT", + "description": "React hook api for the SCM-Manager backend", + "main": "src/index.ts", + "files": [ + "dist", + "src" + ], + "repository": "https://github.com/scm-manager/scm-manager", + "author": "SCM Team ", + "license": "MIT", + "scripts": { + "test": "jest src/", + "typecheck": "tsc" + }, + "devDependencies": { + "@scm-manager/babel-preset": "^2.11.2", + "@scm-manager/eslint-config": "^2.10.1", + "@scm-manager/jest-preset": "^2.12.7", + "@scm-manager/prettier-config": "^2.10.1", + "@scm-manager/tsconfig": "^2.11.2", + "@testing-library/react-hooks": "^5.0.3", + "react-test-renderer": "^17.0.1" + }, + "dependencies": { + "@scm-manager/ui-types": "^2.13.1-SNAPSHOT", + "fetch-mock-jest": "^1.5.1", + "react": "^16.8.6", + "react-query": "^3.5.16", + "query-string": "5" + }, + "babel": { + "presets": [ + "@scm-manager/babel-preset" + ] + }, + "jest": { + "preset": "@scm-manager/jest-preset" + }, + "prettier": "@scm-manager/prettier-config", + "private": true +} diff --git a/scm-ui/ui-api/src/ApiProvider.test.tsx b/scm-ui/ui-api/src/ApiProvider.test.tsx new file mode 100644 index 0000000000..e944885260 --- /dev/null +++ b/scm-ui/ui-api/src/ApiProvider.test.tsx @@ -0,0 +1,60 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { LegacyContext, useLegacyContext } from "./LegacyContext"; +import { FC } from "react"; +import { renderHook } from "@testing-library/react-hooks"; +import * as React from "react"; +import ApiProvider from "./ApiProvider"; +import { useQueryClient } from "react-query"; + +describe("ApiProvider tests", () => { + const createWrapper = (context?: LegacyContext): FC => { + return ({ children }) => {children}; + }; + + it("should register QueryClient", () => { + const { result } = renderHook(() => useQueryClient(), { + wrapper: createWrapper() + }); + expect(result.current).toBeDefined(); + }); + + it("should pass legacy context QueryClient", () => { + let msg: string; + const onIndexFetched = () => { + msg = "hello"; + }; + + const { result } = renderHook(() => useLegacyContext(), { + wrapper: createWrapper({ onIndexFetched }) + }); + + if (result.current?.onIndexFetched) { + result.current.onIndexFetched({ version: "a.b.c", _links: {} }); + } + + expect(msg!).toEqual("hello"); + }); +}); diff --git a/scm-ui/ui-api/src/ApiProvider.tsx b/scm-ui/ui-api/src/ApiProvider.tsx new file mode 100644 index 0000000000..fe18a73166 --- /dev/null +++ b/scm-ui/ui-api/src/ApiProvider.tsx @@ -0,0 +1,81 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import React, { FC, useEffect } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { ReactQueryDevtools } from "react-query/devtools"; +import { LegacyContext, LegacyContextProvider } from "./LegacyContext"; +import { IndexResources, Me } from "@scm-manager/ui-types"; +import { reset } from "./reset"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + // refetch on focus can reset form inputs + refetchOnWindowFocus: false + } + } +}); + +type Props = LegacyContext & { + index?: IndexResources; + me?: Me; +}; + +const ApiProvider: FC = ({ children, index, me, onMeFetched, onIndexFetched }) => { + useEffect(() => { + if (index) { + queryClient.setQueryData("index", index); + if (onIndexFetched) { + onIndexFetched(index); + } + } + }, [index, onIndexFetched]); + useEffect(() => { + if (me) { + queryClient.setQueryData("me", me); + if (onMeFetched) { + onMeFetched(me); + } + } + }, [me, onMeFetched]); + return ( + + + {children} + + + + ); +}; + +export { Props as ApiProviderProps }; + +export const clearCache = () => { + // we do a safe reset instead of clearing the whole cache + // this should avoid missing link errors for index + return reset(queryClient); +}; + +export default ApiProvider; diff --git a/scm-ui/ui-api/src/LegacyContext.test.tsx b/scm-ui/ui-api/src/LegacyContext.test.tsx new file mode 100644 index 0000000000..1498a9a60c --- /dev/null +++ b/scm-ui/ui-api/src/LegacyContext.test.tsx @@ -0,0 +1,46 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { LegacyContext, LegacyContextProvider, useLegacyContext } from "./LegacyContext"; +import { FC } from "react"; +import * as React from "react"; +import { renderHook } from "@testing-library/react-hooks"; + +describe("LegacyContext tests", () => { + const createWrapper = (context?: LegacyContext): FC => { + return ({ children }) => {children}; + }; + + it("should return provided context", () => { + const { result } = renderHook(() => useLegacyContext(), { + wrapper: createWrapper() + }); + expect(result.current).toBeDefined(); + }); + + it("should fail without providers", () => { + const { result } = renderHook(() => useLegacyContext()); + expect(result.error).toBeDefined(); + }); +}); diff --git a/scm-ui/ui-webapp/src/admin/plugins/components/waitForRestart.ts b/scm-ui/ui-api/src/LegacyContext.tsx similarity index 62% rename from scm-ui/ui-webapp/src/admin/plugins/components/waitForRestart.ts rename to scm-ui/ui-api/src/LegacyContext.tsx index 32699882a3..aacd07e0b2 100644 --- a/scm-ui/ui-webapp/src/admin/plugins/components/waitForRestart.ts +++ b/scm-ui/ui-api/src/LegacyContext.tsx @@ -22,32 +22,24 @@ * SOFTWARE. */ -import { apiClient } from "@scm-manager/ui-components"; +import { IndexResources, Me } from "@scm-manager/ui-types"; +import React, { createContext, FC, useContext } from "react"; -const waitForRestart = () => { - const endTime = Number(new Date()) + 60000; - let started = false; - - const executor = (resolve, reject) => { - // we need some initial delay - if (!started) { - started = true; - setTimeout(executor, 1000, resolve, reject); - } else { - apiClient - .get("") - .then(resolve) - .catch(() => { - if (Number(new Date()) < endTime) { - setTimeout(executor, 500, resolve, reject); - } else { - reject(new Error("timeout reached")); - } - }); - } - }; - - return new Promise(executor); +export type LegacyContext = { + onIndexFetched?: (index: IndexResources) => void; + onMeFetched?: (me: Me) => void; }; -export default waitForRestart; +const Context = createContext(undefined); + +export const useLegacyContext = () => { + const context = useContext(Context); + if (!context) { + throw new Error("useLegacyContext can't be used outside of ApiProvider"); + } + return context; +}; + +export const LegacyContextProvider: FC = ({ onIndexFetched, onMeFetched, children }) => ( + {children} +); diff --git a/scm-ui/ui-api/src/admin.test.ts b/scm-ui/ui-api/src/admin.test.ts new file mode 100644 index 0000000000..35b85bbb26 --- /dev/null +++ b/scm-ui/ui-api/src/admin.test.ts @@ -0,0 +1,55 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import fetchMock from "fetch-mock-jest"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import { useUpdateInfo } from "./admin"; +import { UpdateInfo } from "@scm-manager/ui-types"; +import { setIndexLink } from "./tests/indexLinks"; + +describe("Test admin hooks", () => { + describe("useUpdateInfo tests", () => { + it("should get update info", async () => { + const updateInfo: UpdateInfo = { + latestVersion: "x.y.z", + link: "http://heartofgold@hitchhiker.com/x.y.z" + }; + fetchMock.getOnce("/api/v2/updateInfo", updateInfo); + + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "updateInfo", "/updateInfo"); + + const { result, waitFor } = renderHook(() => useUpdateInfo(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + + expect(result.current.data).toEqual(updateInfo); + }); + }); +}); diff --git a/scm-ui/ui-webapp/src/users/components/convertUser.ts b/scm-ui/ui-api/src/admin.ts similarity index 73% rename from scm-ui/ui-webapp/src/users/components/convertUser.ts rename to scm-ui/ui-api/src/admin.ts index a37c8879c9..4a8cbbb7f0 100644 --- a/scm-ui/ui-webapp/src/users/components/convertUser.ts +++ b/scm-ui/ui-api/src/admin.ts @@ -22,25 +22,14 @@ * SOFTWARE. */ +import { ApiResult, useRequiredIndexLink } from "./base"; +import { UpdateInfo } from "@scm-manager/ui-types"; +import { useQuery } from "react-query"; import { apiClient } from "@scm-manager/ui-components"; -import { CONTENT_TYPE_USER } from "../modules/users"; -export function convertToInternal(url: string, newPassword: string) { - return apiClient - .put( - url, - { - newPassword - }, - CONTENT_TYPE_USER - ) - .then(response => { - return response; - }); -} - -export function convertToExternal(url: string) { - return apiClient.put(url, {}, CONTENT_TYPE_USER).then(response => { - return response; - }); -} +export const useUpdateInfo = (): ApiResult => { + const indexLink = useRequiredIndexLink("updateInfo"); + return useQuery("updateInfo", () => + apiClient.get(indexLink).then(response => (response.status === 204 ? null : response.json())) + ); +}; diff --git a/scm-ui/ui-components/src/apiclient.test.ts b/scm-ui/ui-api/src/apiclient.test.ts similarity index 100% rename from scm-ui/ui-components/src/apiclient.test.ts rename to scm-ui/ui-api/src/apiclient.test.ts diff --git a/scm-ui/ui-components/src/apiclient.ts b/scm-ui/ui-api/src/apiclient.ts similarity index 90% rename from scm-ui/ui-components/src/apiclient.ts rename to scm-ui/ui-api/src/apiclient.ts index 5378bbc944..b332ce22d7 100644 --- a/scm-ui/ui-components/src/apiclient.ts +++ b/scm-ui/ui-api/src/apiclient.ts @@ -23,14 +23,7 @@ */ import { contextPath } from "./urls"; -import { - createBackendError, - ForbiddenError, - isBackendError, - UnauthorizedError, - BackendErrorContent, - TOKEN_EXPIRED_ERROR_CODE -} from "./errors"; +import { BackendErrorContent, createBackendError, ForbiddenError, isBackendError, UnauthorizedError } from "./errors"; type SubscriptionEvent = { type: string; @@ -157,11 +150,14 @@ export function createUrlWithIdentifiers(url: string): string { type ErrorListener = (error: Error) => void; +type RequestListener = (url: string, options?: RequestInit) => void; + class ApiClient { errorListeners: ErrorListener[] = []; + requestListeners: RequestListener[] = []; get = (url: string): Promise => { - return fetch(createUrl(url), applyFetchOptions({})) + return this.request(url, applyFetchOptions({})) .then(handleFailure) .catch(this.notifyAndRethrow); }; @@ -204,7 +200,7 @@ class ApiClient { method: "HEAD" }; options = applyFetchOptions(options); - return fetch(createUrl(url), options) + return this.request(url, options) .then(handleFailure) .catch(this.notifyAndRethrow); }; @@ -214,7 +210,7 @@ class ApiClient { method: "DELETE" }; options = applyFetchOptions(options); - return fetch(createUrl(url), options) + return this.request(url, options) .then(handleFailure) .catch(this.notifyAndRethrow); }; @@ -260,7 +256,7 @@ class ApiClient { options.headers["Content-Type"] = contentType; } - return fetch(createUrl(url), options) + return this.request(url, options) .then(handleFailure) .catch(this.notifyAndRethrow); }; @@ -294,10 +290,23 @@ class ApiClient { return () => es.close(); } + onRequest = (requestListener: RequestListener) => { + this.requestListeners.push(requestListener); + }; + onError = (errorListener: ErrorListener) => { this.errorListeners.push(errorListener); }; + private request = (url: string, options: RequestInit) => { + this.notifyRequestListeners(url, options); + return fetch(createUrl(url), options); + }; + + private notifyRequestListeners = (url: string, options: RequestInit) => { + this.requestListeners.forEach(requestListener => requestListener(url, options)); + }; + private notifyAndRethrow = (error: Error): never => { this.errorListeners.forEach(errorListener => errorListener(error)); throw error; diff --git a/scm-ui/ui-api/src/base.test.ts b/scm-ui/ui-api/src/base.test.ts new file mode 100644 index 0000000000..1b9a7d14c6 --- /dev/null +++ b/scm-ui/ui-api/src/base.test.ts @@ -0,0 +1,237 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import fetchMock from "fetch-mock-jest"; +import { useIndex, useIndexJsonResource, useIndexLink, useIndexLinks, useRequiredIndexLink, useVersion } from "./base"; +import { renderHook } from "@testing-library/react-hooks"; +import { LegacyContext } from "./LegacyContext"; +import { IndexResources, Link } from "@scm-manager/ui-types"; +import createWrapper from "./tests/createWrapper"; +import { QueryClient } from "react-query"; + +describe("Test base api hooks", () => { + describe("useIndex tests", () => { + fetchMock.get("/api/v2/", { + version: "x.y.z", + _links: {} + }); + + it("should return index", async () => { + const { result, waitFor } = renderHook(() => useIndex(), { wrapper: createWrapper() }); + await waitFor(() => { + return !!result.current.data; + }); + expect(result.current?.data?.version).toEqual("x.y.z"); + }); + + it("should call onIndexFetched of LegacyContext", async () => { + let index: IndexResources; + const context: LegacyContext = { + onIndexFetched: fetchedIndex => { + index = fetchedIndex; + } + }; + const { result, waitFor } = renderHook(() => useIndex(), { wrapper: createWrapper(context) }); + await waitFor(() => { + return !!result.current.data; + }); + expect(index!.version).toEqual("x.y.z"); + }); + }); + + describe("useIndexLink tests", () => { + it("should throw an error if index is not available", () => { + const { result } = renderHook(() => useIndexLink("spaceships"), { wrapper: createWrapper() }); + expect(result.error).toBeDefined(); + }); + + it("should return undefined for unknown link", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData("index", { + version: "x.y.z", + _links: {} + }); + const { result } = renderHook(() => useIndexLink("spaceships"), { + wrapper: createWrapper(undefined, queryClient) + }); + expect(result.current).toBeUndefined(); + }); + + it("should return undefined for link array", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData("index", { + version: "x.y.z", + _links: { + spaceships: [ + { + name: "heartOfGold", + href: "/spaceships/heartOfGold" + }, + { + name: "razorCrest", + href: "/spaceships/razorCrest" + } + ] + } + }); + const { result } = renderHook(() => useIndexLink("spaceships"), { + wrapper: createWrapper(undefined, queryClient) + }); + expect(result.current).toBeUndefined(); + }); + + it("should return link", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData("index", { + version: "x.y.z", + _links: { + spaceships: { + href: "/api/spaceships" + } + } + }); + const { result } = renderHook(() => useIndexLink("spaceships"), { + wrapper: createWrapper(undefined, queryClient) + }); + expect(result.current).toBe("/api/spaceships"); + }); + }); + + describe("useIndexLinks tests", () => { + it("should throw an error if index is not available", async () => { + const { result } = renderHook(() => useIndexLinks(), { wrapper: createWrapper() }); + expect(result.error).toBeDefined(); + }); + + it("should return links", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData("index", { + version: "x.y.z", + _links: { + spaceships: { + href: "/api/spaceships" + } + } + }); + const { result } = renderHook(() => useIndexLinks(), { + wrapper: createWrapper(undefined, queryClient) + }); + expect((result.current!.spaceships as Link).href).toBe("/api/spaceships"); + }); + }); + + describe("useVersion tests", () => { + it("should throw an error if version is not available", async () => { + const { result } = renderHook(() => useVersion(), { wrapper: createWrapper() }); + expect(result.error).toBeDefined(); + }); + + it("should return version", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData("index", { + version: "x.y.z" + }); + const { result } = renderHook(() => useVersion(), { + wrapper: createWrapper(undefined, queryClient) + }); + expect(result.current).toBe("x.y.z"); + }); + }); + + describe("useRequiredIndexLink tests", () => { + it("should throw error for undefined link", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData("index", { + version: "x.y.z", + _links: {} + }); + const { result } = renderHook(() => useRequiredIndexLink("spaceships"), { + wrapper: createWrapper(undefined, queryClient) + }); + expect(result.error).toBeDefined(); + }); + + it("should return link", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData("index", { + version: "x.y.z", + _links: { + spaceships: { + href: "/api/spaceships" + } + } + }); + const { result } = renderHook(() => useRequiredIndexLink("spaceships"), { + wrapper: createWrapper(undefined, queryClient) + }); + expect(result.current).toBe("/api/spaceships"); + }); + }); + + describe("useIndexJsonResource tests", () => { + it("should return json resource from link", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData("index", { + version: "x.y.z", + _links: { + spaceships: { + href: "/spaceships" + } + } + }); + + const spaceship = { + name: "heartOfGold" + }; + + fetchMock.get("/api/v2/spaceships", spaceship); + + const { result, waitFor } = renderHook(() => useIndexJsonResource("spaceships"), { + wrapper: createWrapper(undefined, queryClient) + }); + + await waitFor(() => { + return !!result.current.data; + }); + + expect(result.current.data!.name).toBe("heartOfGold"); + }); + }); + + it("should return nothing if link is not available", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData("index", { + version: "x.y.z", + _links: {} + }); + + const { result } = renderHook(() => useIndexJsonResource<{}>("spaceships"), { + wrapper: createWrapper(undefined, queryClient) + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeFalsy(); + expect(result.current.data).toBeFalsy(); + }); +}); diff --git a/scm-ui/ui-api/src/base.ts b/scm-ui/ui-api/src/base.ts new file mode 100644 index 0000000000..3f9c93df56 --- /dev/null +++ b/scm-ui/ui-api/src/base.ts @@ -0,0 +1,100 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { IndexResources, Link } from "@scm-manager/ui-types"; +import { useQuery } from "react-query"; +import { apiClient } from "./apiclient"; +import { useLegacyContext } from "./LegacyContext"; +import { MissingLinkError, UnauthorizedError } from "./errors"; + +export type ApiResult = { + isLoading: boolean; + error: Error | null; + data?: T; +}; + +export const useIndex = (): ApiResult => { + const legacy = useLegacyContext(); + return useQuery("index", () => apiClient.get("/").then(response => response.json()), { + onSuccess: index => { + // ensure legacy code is notified + if (legacy.onIndexFetched) { + legacy.onIndexFetched(index); + } + }, + refetchOnMount: false, + retry: (failureCount, error) => { + // The index resource returns a 401 if the access token expired. + // This only happens once because the error response automatically invalidates the cookie. + // In this event, we have to try the request once again. + return error instanceof UnauthorizedError && failureCount === 0; + } + }); +}; + +export const useIndexLink = (name: string): string | undefined => { + const { data } = useIndex(); + if (!data) { + throw new Error("could not find index data"); + } + const linkObject = data._links[name] as Link; + if (linkObject && linkObject.href) { + return linkObject.href; + } +}; + +export const useIndexLinks = () => { + const { data } = useIndex(); + if (!data) { + throw new Error("could not find index data"); + } + return data._links; +}; + +export const useRequiredIndexLink = (name: string): string => { + const link = useIndexLink(name); + if (!link) { + throw new MissingLinkError(`Could not find link ${name} in index resource`); + } + return link; +}; + +export const useVersion = (): string => { + const { data } = useIndex(); + if (!data) { + throw new Error("could not find index data"); + } + const { version } = data; + if (!version) { + throw new Error("could not find version in index data"); + } + return version; +}; + +export const useIndexJsonResource = (name: string): ApiResult => { + const link = useIndexLink(name); + return useQuery(name, () => apiClient.get(link!).then(response => response.json()), { + enabled: !!link + }); +}; diff --git a/scm-ui/ui-api/src/branches.test.ts b/scm-ui/ui-api/src/branches.test.ts new file mode 100644 index 0000000000..cf200b5e16 --- /dev/null +++ b/scm-ui/ui-api/src/branches.test.ts @@ -0,0 +1,219 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { Branch, BranchCollection, Repository } from "@scm-manager/ui-types"; +import fetchMock from "fetch-mock-jest"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { useBranch, useBranches, useCreateBranch, useDeleteBranch } from "./branches"; +import { act } from "react-test-renderer"; + +describe("Test branches hooks", () => { + const repository: Repository = { + namespace: "hitchhiker", + name: "heart-of-gold", + type: "hg", + _links: { + branches: { + href: "/hog/branches" + } + } + }; + + const develop: Branch = { + name: "develop", + revision: "42", + _links: { + delete: { + href: "/hog/branches/develop" + } + } + }; + + const branches: BranchCollection = { + _embedded: { + branches: [develop] + }, + _links: {} + }; + + const queryClient = createInfiniteCachingClient(); + + beforeEach(() => { + queryClient.clear(); + }); + + afterEach(() => { + fetchMock.reset(); + }); + + describe("useBranches tests", () => { + const fetchBrances = async () => { + fetchMock.getOnce("/api/v2/hog/branches", branches); + + const { result, waitFor } = renderHook(() => useBranches(repository), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + + return result.current.data; + }; + + it("should return branches", async () => { + const branches = await fetchBrances(); + expect(branches).toEqual(branches); + }); + + it("should add branches to cache", async () => { + await fetchBrances(); + + const data = queryClient.getQueryData([ + "repository", + "hitchhiker", + "heart-of-gold", + "branches" + ]); + expect(data).toEqual(branches); + }); + }); + + describe("useBranch tests", () => { + const fetchBranch = async () => { + fetchMock.getOnce("/api/v2/hog/branches/develop", develop); + + const { result, waitFor } = renderHook(() => useBranch(repository, "develop"), { + wrapper: createWrapper(undefined, queryClient) + }); + + expect(result.error).toBeUndefined(); + + await waitFor(() => { + return !!result.current.data; + }); + + return result.current.data; + }; + + it("should return branch", async () => { + const branch = await fetchBranch(); + expect(branch).toEqual(develop); + }); + }); + + describe("useCreateBranch tests", () => { + const createBranch = async () => { + fetchMock.postOnce("/api/v2/hog/branches", { + status: 201, + headers: { + Location: "/hog/branches/develop" + } + }); + + fetchMock.getOnce("/api/v2/hog/branches/develop", develop); + + const { result, waitForNextUpdate } = renderHook(() => useCreateBranch(repository), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create({ name: "develop", parent: "main" }); + return waitForNextUpdate(); + }); + + return result.current; + }; + + it("should create branch", async () => { + const { branch } = await createBranch(); + expect(branch).toEqual(develop); + }); + + it("should cache created branch", async () => { + await createBranch(); + + const branch = queryClient.getQueryData([ + "repository", + "hitchhiker", + "heart-of-gold", + "branch", + "develop" + ]); + expect(branch).toEqual(develop); + }); + + it("should invalidate cached branches list", async () => { + queryClient.setQueryData(["repository", "hitchhiker", "heart-of-gold", "branches"], branches); + await createBranch(); + + const queryState = queryClient.getQueryState(["repository", "hitchhiker", "heart-of-gold", "branches"]); + expect(queryState!.isInvalidated).toBe(true); + }); + }); + + describe("useDeleteBranch tests", () => { + const deleteBranch = async () => { + fetchMock.deleteOnce("/api/v2/hog/branches/develop", { + status: 204 + }); + + const { result, waitForNextUpdate } = renderHook(() => useDeleteBranch(repository), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { remove } = result.current; + remove(develop); + return waitForNextUpdate(); + }); + + return result.current; + }; + + it("should delete branch", async () => { + const { isDeleted } = await deleteBranch(); + expect(isDeleted).toBe(true); + }); + + it("should invalidate branch", async () => { + queryClient.setQueryData(["repository", "hitchhiker", "heart-of-gold", "branch", "develop"], develop); + await deleteBranch(); + + const queryState = queryClient.getQueryState(["repository", "hitchhiker", "heart-of-gold", "branch", "develop"]); + expect(queryState!.isInvalidated).toBe(true); + }); + + it("should invalidate cached branches list", async () => { + queryClient.setQueryData(["repository", "hitchhiker", "heart-of-gold", "branches"], branches); + await deleteBranch(); + + const queryState = queryClient.getQueryState(["repository", "hitchhiker", "heart-of-gold", "branches"]); + expect(queryState!.isInvalidated).toBe(true); + }); + }); +}); diff --git a/scm-ui/ui-api/src/branches.ts b/scm-ui/ui-api/src/branches.ts new file mode 100644 index 0000000000..41b9c5493c --- /dev/null +++ b/scm-ui/ui-api/src/branches.ts @@ -0,0 +1,104 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { Branch, BranchCollection, BranchCreation, Link, Repository } from "@scm-manager/ui-types"; +import { requiredLink } from "./links"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { ApiResult } from "./base"; +import { branchQueryKey, repoQueryKey } from "./keys"; +import { apiClient } from "./apiclient"; +import { concat } from "./urls"; + +export const useBranches = (repository: Repository): ApiResult => { + const link = requiredLink(repository, "branches"); + return useQuery( + repoQueryKey(repository, "branches"), + () => apiClient.get(link).then(response => response.json()) + // we do not populate the cache for a single branch, + // because we have no pagination for branches and if we have a lot of them + // the population slows us down + ); +}; + +export const useBranch = (repository: Repository, name: string): ApiResult => { + const link = requiredLink(repository, "branches"); + return useQuery(branchQueryKey(repository, name), () => + apiClient.get(concat(link, name)).then(response => response.json()) + ); +}; + +const createBranch = (link: string) => { + return (branch: BranchCreation) => { + return apiClient + .post(link, branch, "application/vnd.scmm-branchRequest+json;v=2") + .then(response => { + const location = response.headers.get("Location"); + if (!location) { + throw new Error("Server does not return required Location header"); + } + return apiClient.get(location); + }) + .then(response => response.json()); + }; +}; + +export const useCreateBranch = (repository: Repository) => { + const queryClient = useQueryClient(); + const link = requiredLink(repository, "branches"); + const { mutate, isLoading, error, data } = useMutation(createBranch(link), { + onSuccess: async branch => { + queryClient.setQueryData(branchQueryKey(repository, branch), branch); + await queryClient.invalidateQueries(repoQueryKey(repository, "branches")); + } + }); + return { + create: (branch: BranchCreation) => mutate(branch), + isLoading, + error, + branch: data + }; +}; + +export const useDeleteBranch = (repository: Repository) => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + branch => { + const deleteUrl = (branch._links.delete as Link).href; + return apiClient.delete(deleteUrl); + }, + { + onSuccess: async (_, branch) => { + await queryClient.invalidateQueries(branchQueryKey(repository, branch)); + await queryClient.invalidateQueries(repoQueryKey(repository, "branches")); + } + } + ); + return { + remove: (branch: Branch) => mutate(branch), + isLoading, + error, + isDeleted: !!data + }; +}; diff --git a/scm-ui/ui-api/src/changesets.test.ts b/scm-ui/ui-api/src/changesets.test.ts new file mode 100644 index 0000000000..7a50aa9d19 --- /dev/null +++ b/scm-ui/ui-api/src/changesets.test.ts @@ -0,0 +1,179 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { Branch, Changeset, ChangesetCollection, Repository } from "@scm-manager/ui-types"; +import fetchMock from "fetch-mock-jest"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import { useChangeset, useChangesets } from "./changesets"; + +describe("Test changeset hooks", () => { + const repository: Repository = { + namespace: "hitchhiker", + name: "heart-of-gold", + type: "hg", + _links: { + changesets: { + href: "/r/c" + } + } + }; + + const develop: Branch = { + name: "develop", + revision: "42", + _links: { + history: { + href: "/r/b/c" + } + } + }; + + const changeset: Changeset = { + id: "42", + description: "Awesome change", + date: new Date(), + author: { + name: "Arthur Dent" + }, + _embedded: {}, + _links: {} + }; + + const changesets: ChangesetCollection = { + page: 1, + pageTotal: 1, + _embedded: { + changesets: [changeset] + }, + _links: {} + }; + + const expectChangesetCollection = (result?: ChangesetCollection) => { + expect(result?._embedded.changesets[0].id).toBe(changesets._embedded.changesets[0].id); + }; + + afterEach(() => { + fetchMock.reset(); + }); + + describe("useChangesets tests", () => { + it("should return changesets", async () => { + fetchMock.getOnce("/api/v2/r/c", changesets); + + const queryClient = createInfiniteCachingClient(); + + const { result, waitFor } = renderHook(() => useChangesets(repository), { + wrapper: createWrapper(undefined, queryClient) + }); + + await waitFor(() => { + return !!result.current.data; + }); + + expectChangesetCollection(result.current.data); + }); + + it("should return changesets for page", async () => { + fetchMock.getOnce("/api/v2/r/c", changesets, { + query: { + page: 42 + } + }); + + const queryClient = createInfiniteCachingClient(); + + const { result, waitFor } = renderHook(() => useChangesets(repository, { page: 42 }), { + wrapper: createWrapper(undefined, queryClient) + }); + + await waitFor(() => { + return !!result.current.data; + }); + + expectChangesetCollection(result.current.data); + }); + + it("should use link from branch", async () => { + fetchMock.getOnce("/api/v2/r/b/c", changesets); + + const queryClient = createInfiniteCachingClient(); + + const { result, waitFor } = renderHook(() => useChangesets(repository, { branch: develop }), { + wrapper: createWrapper(undefined, queryClient) + }); + + await waitFor(() => { + return !!result.current.data; + }); + + expectChangesetCollection(result.current.data); + }); + + it("should populate changeset cache", async () => { + fetchMock.getOnce("/api/v2/r/c", changesets); + + const queryClient = createInfiniteCachingClient(); + + const { result, waitFor } = renderHook(() => useChangesets(repository), { + wrapper: createWrapper(undefined, queryClient) + }); + + await waitFor(() => { + return !!result.current.data; + }); + + const changeset: Changeset | undefined = queryClient.getQueryData([ + "repository", + "hitchhiker", + "heart-of-gold", + "changeset", + "42" + ]); + + expect(changeset?.id).toBe("42"); + }); + }); + + describe("useChangeset tests", () => { + it("should return changes", async () => { + fetchMock.get("/api/v2/r/c/42", changeset); + + const queryClient = createInfiniteCachingClient(); + + const { result, waitFor } = renderHook(() => useChangeset(repository, "42"), { + wrapper: createWrapper(undefined, queryClient) + }); + + await waitFor(() => { + return !!result.current.data; + }); + + const c = result.current.data; + expect(c?.description).toBe("Awesome change"); + }); + }); +}); diff --git a/scm-ui/ui-api/src/changesets.ts b/scm-ui/ui-api/src/changesets.ts new file mode 100644 index 0000000000..63fbb12934 --- /dev/null +++ b/scm-ui/ui-api/src/changesets.ts @@ -0,0 +1,77 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { Branch, Changeset, ChangesetCollection, NamespaceAndName, Repository } from "@scm-manager/ui-types"; +import { useQuery, useQueryClient } from "react-query"; +import { requiredLink } from "./links"; +import { apiClient } from "./apiclient"; +import { ApiResult } from "./base"; +import { branchQueryKey, repoQueryKey } from "./keys"; +import { concat } from "./urls"; + +type UseChangesetsRequest = { + branch?: Branch; + page?: string | number; +}; + +const changesetQueryKey = (repository: NamespaceAndName, id: string) => { + return repoQueryKey(repository, "changeset", id); +}; + +export const useChangesets = ( + repository: Repository, + request?: UseChangesetsRequest +): ApiResult => { + const queryClient = useQueryClient(); + + let link: string; + let branch = "_"; + if (request?.branch) { + link = requiredLink(request.branch, "history"); + branch = request.branch.name; + } else { + link = requiredLink(repository, "changesets"); + } + + if (request?.page) { + link = `${link}?page=${request.page}`; + } + + const key = branchQueryKey(repository, branch, "changesets", request?.page || 0); + return useQuery(key, () => apiClient.get(link).then(response => response.json()), { + onSuccess: changesetCollection => { + changesetCollection._embedded.changesets.forEach(changeset => { + queryClient.setQueryData(changesetQueryKey(repository, changeset.id), changeset); + }); + } + }); +}; + +export const useChangeset = (repository: Repository, id: string): ApiResult => { + const changesetsLink = requiredLink(repository, "changesets"); + return useQuery(changesetQueryKey(repository, id), () => + apiClient.get(concat(changesetsLink, id)).then(response => response.json()) + ); +}; diff --git a/scm-ui/ui-api/src/config.test.ts b/scm-ui/ui-api/src/config.test.ts new file mode 100644 index 0000000000..0e08bc3e2d --- /dev/null +++ b/scm-ui/ui-api/src/config.test.ts @@ -0,0 +1,113 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Config } from "@scm-manager/ui-types"; +import fetchMock from "fetch-mock-jest"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { setIndexLink } from "./tests/indexLinks"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import { useConfig, useUpdateConfig } from "./config"; +import { act } from "react-test-renderer"; + +describe("Test config hooks", () => { + const config: Config = { + anonymousAccessEnabled: false, + anonymousMode: "OFF", + baseUrl: "", + dateFormat: "", + disableGroupingGrid: false, + enableProxy: false, + enabledUserConverter: false, + enabledXsrfProtection: false, + forceBaseUrl: false, + loginAttemptLimit: 0, + loginAttemptLimitTimeout: 0, + loginInfoUrl: "", + mailDomainName: "", + namespaceStrategy: "", + pluginUrl: "", + proxyExcludes: [], + proxyPassword: null, + proxyPort: 0, + proxyServer: "", + proxyUser: null, + realmDescription: "", + releaseFeedUrl: "", + skipFailedAuthenticators: false, + _links: { + update: { + href: "/config" + } + } + }; + + afterEach(() => { + fetchMock.reset(); + }); + + describe("useConfig tests", () => { + it("should return config", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "config", "/config"); + fetchMock.get("/api/v2/config", config); + const { result, waitFor } = renderHook(() => useConfig(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(config); + }); + }); + + describe("useUpdateConfig tests", () => { + it("should update config", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "config", "/config"); + + const newConfig = { + ...config, + baseUrl: "/hog" + }; + + fetchMock.putOnce("/api/v2/config", { + status: 200 + }); + + const { result, waitForNextUpdate } = renderHook(() => useUpdateConfig(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { update } = result.current; + update(newConfig); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + expect(result.current.isUpdated).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(queryClient.getQueryData(["config"])).toBeUndefined(); + }); + }); +}); diff --git a/scm-ui/ui-api/src/config.ts b/scm-ui/ui-api/src/config.ts new file mode 100644 index 0000000000..78bdd81239 --- /dev/null +++ b/scm-ui/ui-api/src/config.ts @@ -0,0 +1,56 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ApiResult, useIndexLink } from "./base"; +import { Config } from "@scm-manager/ui-types"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { apiClient } from "@scm-manager/ui-components"; +import { requiredLink } from "./links"; + +export const useConfig = (): ApiResult => { + const indexLink = useIndexLink("config"); + return useQuery("config", () => apiClient.get(indexLink!).then(response => response.json()), { + enabled: !!indexLink + }); +}; + +export const useUpdateConfig = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data, reset } = useMutation( + config => { + const updateUrl = requiredLink(config, "update"); + return apiClient.put(updateUrl, config, "application/vnd.scmm-config+json;v=2"); + }, + { + onSuccess: () => queryClient.invalidateQueries("config") + } + ); + return { + update: (config: Config) => mutate(config), + isLoading, + error, + isUpdated: !!data, + reset + }; +}; diff --git a/scm-ui/ui-components/src/errors.test.ts b/scm-ui/ui-api/src/errors.test.ts similarity index 100% rename from scm-ui/ui-components/src/errors.test.ts rename to scm-ui/ui-api/src/errors.test.ts diff --git a/scm-ui/ui-components/src/errors.ts b/scm-ui/ui-api/src/errors.ts similarity index 99% rename from scm-ui/ui-components/src/errors.ts rename to scm-ui/ui-api/src/errors.ts index 41e44f0702..a7e293ac85 100644 --- a/scm-ui/ui-components/src/errors.ts +++ b/scm-ui/ui-api/src/errors.ts @@ -26,11 +26,13 @@ type Context = { type: string; id: string; }[]; + export type Violation = { path?: string; message: string; key?: string; }; + export type AdditionalMessage = { key?: string; message?: string; diff --git a/scm-ui/ui-api/src/groups.test.ts b/scm-ui/ui-api/src/groups.test.ts new file mode 100644 index 0000000000..b8eba35f26 --- /dev/null +++ b/scm-ui/ui-api/src/groups.test.ts @@ -0,0 +1,241 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Group } from "@scm-manager/ui-types"; +import fetchMock from "fetch-mock-jest"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { setIndexLink } from "./tests/indexLinks"; +import { renderHook } from "@testing-library/react-hooks"; +import { useCreateGroup, useDeleteGroup, useGroup, useGroups, useUpdateGroup } from "./groups"; +import createWrapper from "./tests/createWrapper"; +import { act } from "react-test-renderer"; + +describe("Test group hooks", () => { + const jedis: Group = { + name: "jedis", + description: "May the force be with you", + external: false, + members: [], + type: "xml", + _links: { + delete: { + href: "/groups/jedis" + }, + update: { + href: "/groups/jedis" + } + }, + _embedded: { + members: [] + } + }; + + const jedisCollection = { + _embedded: { + groups: [jedis] + } + }; + + afterEach(() => { + fetchMock.reset(); + }); + + describe("useGroups tests", () => { + it("should return groups", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "groups", "/groups"); + fetchMock.get("/api/v2/groups", jedisCollection); + const { result, waitFor } = renderHook(() => useGroups(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(jedisCollection); + }); + + it("should return paged groups", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "groups", "/groups"); + fetchMock.get("/api/v2/groups", jedisCollection, { + query: { + page: "42" + } + }); + const { result, waitFor } = renderHook(() => useGroups({ page: 42 }), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(jedisCollection); + }); + + it("should return searched groups", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "groups", "/groups"); + fetchMock.get("/api/v2/groups", jedisCollection, { + query: { + q: "jedis" + } + }); + const { result, waitFor } = renderHook(() => useGroups({ search: "jedis" }), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(jedisCollection); + }); + + it("should update group cache", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "groups", "/groups"); + fetchMock.get("/api/v2/groups", jedisCollection); + const { result, waitFor } = renderHook(() => useGroups(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(queryClient.getQueryData(["group", "jedis"])).toEqual(jedis); + }); + }); + + describe("useGroup tests", () => { + it("should return group", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "groups", "/groups"); + fetchMock.get("/api/v2/groups/jedis", jedis); + const { result, waitFor } = renderHook(() => useGroup("jedis"), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(jedis); + }); + }); + + describe("useCreateGroup tests", () => { + it("should create group", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "groups", "/groups"); + + fetchMock.postOnce("/api/v2/groups", { + status: 201, + headers: { + Location: "/groups/jedis" + } + }); + + fetchMock.getOnce("/api/v2/groups/jedis", jedis); + + const { result, waitForNextUpdate } = renderHook(() => useCreateGroup(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create(jedis); + return waitForNextUpdate(); + }); + + expect(result.current.group).toEqual(jedis); + }); + + it("should fail without location header", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "groups", "/groups"); + + fetchMock.postOnce("/api/v2/groups", { + status: 201 + }); + + fetchMock.getOnce("/api/v2/groups/jedis", jedis); + + const { result, waitForNextUpdate } = renderHook(() => useCreateGroup(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create(jedis); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeDefined(); + }); + }); + + describe("useDeleteGroup tests", () => { + it("should delete group", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "groups", "/groups"); + + fetchMock.deleteOnce("/api/v2/groups/jedis", { + status: 200 + }); + + const { result, waitForNextUpdate } = renderHook(() => useDeleteGroup(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { remove } = result.current; + remove(jedis); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + expect(result.current.isDeleted).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(queryClient.getQueryData(["group", "jedis"])).toBeUndefined(); + }); + }); + + describe("useUpdateGroup tests", () => { + it("should update group", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "groups", "/groups"); + + const newJedis = { + ...jedis, + description: "may the 4th be with you" + }; + + fetchMock.putOnce("/api/v2/groups/jedis", { + status: 200 + }); + + fetchMock.getOnce("/api/v2/groups/jedis", newJedis); + + const { result, waitForNextUpdate } = renderHook(() => useUpdateGroup(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { update } = result.current; + update(newJedis); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + expect(result.current.isUpdated).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(queryClient.getQueryData(["group", "jedis"])).toBeUndefined(); + expect(queryClient.getQueryData(["groups"])).toBeUndefined(); + }); + }); +}); diff --git a/scm-ui/ui-api/src/groups.ts b/scm-ui/ui-api/src/groups.ts new file mode 100644 index 0000000000..515b6e4a54 --- /dev/null +++ b/scm-ui/ui-api/src/groups.ts @@ -0,0 +1,141 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ApiResult, useRequiredIndexLink } from "./base"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Group, GroupCollection, GroupCreation, Link } from "@scm-manager/ui-types"; +import { apiClient } from "./apiclient"; +import { createQueryString } from "./utils"; +import { concat } from "./urls"; + +export type UseGroupsRequest = { + page?: number | string; + search?: string; +}; + +export const useGroups = (request?: UseGroupsRequest): ApiResult => { + const queryClient = useQueryClient(); + const indexLink = useRequiredIndexLink("groups"); + + const queryParams: Record = {}; + if (request?.search) { + queryParams.q = request.search; + } + if (request?.page) { + queryParams.page = request.page.toString(); + } + + return useQuery( + ["groups", request?.search || "", request?.page || 0], + () => apiClient.get(`${indexLink}?${createQueryString(queryParams)}`).then(response => response.json()), + { + onSuccess: (groups: GroupCollection) => { + groups._embedded.groups.forEach((group: Group) => queryClient.setQueryData(["group", group.name], group)); + } + } + ); +}; + +export const useGroup = (name: string): ApiResult => { + const indexLink = useRequiredIndexLink("groups"); + return useQuery(["group", name], () => + apiClient.get(concat(indexLink, name)).then(response => response.json()) + ); +}; + +const createGroup = (link: string) => { + return (group: GroupCreation) => { + return apiClient + .post(link, group, "application/vnd.scmm-group+json;v=2") + .then(response => { + const location = response.headers.get("Location"); + if (!location) { + throw new Error("Server does not return required Location header"); + } + return apiClient.get(location); + }) + .then(response => response.json()); + }; +}; + +export const useCreateGroup = () => { + const queryClient = useQueryClient(); + const link = useRequiredIndexLink("groups"); + const { mutate, data, isLoading, error } = useMutation(createGroup(link), { + onSuccess: group => { + queryClient.setQueryData(["group", group.name], group); + return queryClient.invalidateQueries(["groups"]); + } + }); + return { + create: (group: GroupCreation) => mutate(group), + isLoading, + error, + group: data + }; +}; + +export const useUpdateGroup = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + group => { + const updateUrl = (group._links.update as Link).href; + return apiClient.put(updateUrl, group, "application/vnd.scmm-group+json;v=2"); + }, + { + onSuccess: async (_, group) => { + await queryClient.invalidateQueries(["group", group.name]); + await queryClient.invalidateQueries(["groups"]); + } + } + ); + return { + update: (group: Group) => mutate(group), + isLoading, + error, + isUpdated: !!data + }; +}; + +export const useDeleteGroup = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + group => { + const deleteUrl = (group._links.delete as Link).href; + return apiClient.delete(deleteUrl); + }, + { + onSuccess: async (_, name) => { + await queryClient.invalidateQueries(["group", name]); + await queryClient.invalidateQueries(["groups"]); + } + } + ); + return { + remove: (group: Group) => mutate(group), + isLoading, + error, + isDeleted: !!data + }; +}; diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts new file mode 100644 index 0000000000..5429990ba4 --- /dev/null +++ b/scm-ui/ui-api/src/index.ts @@ -0,0 +1,48 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as urls from "./urls"; +export { urls }; + +export * from "./errors"; +export * from "./apiclient"; + +export * from "./base"; +export * from "./login"; +export * from "./groups"; +export * from "./users"; +export * from "./repositories"; +export * from "./namespaces"; +export * from "./branches"; +export * from "./changesets"; +export * from "./tags"; +export * from "./config"; +export * from "./admin"; +export * from "./plugins"; +export * from "./repository-roles"; +export * from "./permissions"; +export * from "./sources"; + +export { default as ApiProvider } from "./ApiProvider"; +export * from "./ApiProvider"; diff --git a/scm-ui/ui-api/src/keys.ts b/scm-ui/ui-api/src/keys.ts new file mode 100644 index 0000000000..7690379ce9 --- /dev/null +++ b/scm-ui/ui-api/src/keys.ts @@ -0,0 +1,50 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { Branch, NamespaceAndName } from "@scm-manager/ui-types"; + +export const repoQueryKey = (repository: NamespaceAndName, ...values: unknown[]) => { + return ["repository", repository.namespace, repository.name, ...values]; +}; + +const isBranch = (branch: string | Branch): branch is Branch => { + return (branch as Branch).name !== undefined; +}; + +export const branchQueryKey = ( + repository: NamespaceAndName, + branch: string | Branch | undefined, + ...values: unknown[] +) => { + let branchName; + if (!branch) { + branchName = "_"; + } else if (isBranch(branch)) { + branchName = branch.name; + } else { + branchName = branch; + } + return [...repoQueryKey(repository), "branch", branchName, ...values]; +}; diff --git a/scm-ui/ui-api/src/links.test.ts b/scm-ui/ui-api/src/links.test.ts new file mode 100644 index 0000000000..d1628cc7ed --- /dev/null +++ b/scm-ui/ui-api/src/links.test.ts @@ -0,0 +1,65 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { requiredLink } from "./links"; + +describe("requireLink tests", () => { + it("should return required link", () => { + const link = requiredLink( + { + _links: { + spaceship: { + href: "/v2/ship" + } + } + }, + "spaceship" + ); + expect(link).toBe("/v2/ship"); + }); + + it("should throw error, if link is missing", () => { + const object = { _links: {} }; + expect(() => requiredLink(object, "spaceship")).toThrowError(); + }); + + it("should throw error, if link is array", () => { + const object = { + _links: { + spaceship: [ + { + name: "one", + href: "/v2/one" + }, + { + name: "two", + href: "/v2/two" + } + ] + } + }; + expect(() => requiredLink(object, "spaceship")).toThrowError(); + }); +}); diff --git a/scm-ui/ui-api/src/links.ts b/scm-ui/ui-api/src/links.ts new file mode 100644 index 0000000000..970edcbdd7 --- /dev/null +++ b/scm-ui/ui-api/src/links.ts @@ -0,0 +1,38 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { HalRepresentation } from "@scm-manager/ui-types"; +import { MissingLinkError } from "./errors"; + +export const requiredLink = (object: HalRepresentation, name: string) => { + const link = object._links[name]; + if (!link) { + throw new MissingLinkError(`could not find link with name ${name}`); + } + if (Array.isArray(link)) { + throw new Error(`could not return href, link ${name} is a multi link`); + } + return link.href; +}; diff --git a/scm-ui/ui-api/src/login.test.ts b/scm-ui/ui-api/src/login.test.ts new file mode 100644 index 0000000000..3c83ea1f27 --- /dev/null +++ b/scm-ui/ui-api/src/login.test.ts @@ -0,0 +1,239 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import fetchMock from "fetch-mock-jest"; +import { renderHook } from "@testing-library/react-hooks"; +import { Me } from "@scm-manager/ui-types"; +import createWrapper from "./tests/createWrapper"; +import { useLogin, useLogout, useMe, useRequiredMe, useSubject } from "./login"; +import { setEmptyIndex, setIndexLink } from "./tests/indexLinks"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { LegacyContext } from "./LegacyContext"; +import { act } from "react-test-renderer"; + +describe("Test login hooks", () => { + const tricia: Me = { + name: "tricia", + displayName: "Tricia", + groups: [], + _links: {} + }; + + describe("useMe tests", () => { + fetchMock.get("/api/v2/me", { + name: "tricia", + displayName: "Tricia", + groups: [], + _links: {} + }); + + it("should return me", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "me", "/me"); + + const { result, waitFor } = renderHook(() => useMe(), { wrapper: createWrapper(undefined, queryClient) }); + await waitFor(() => { + return !!result.current.data; + }); + expect(result.current?.data?.name).toEqual("tricia"); + }); + + it("should call onMeFetched of LegacyContext", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "me", "/me"); + + let me: Me; + const context: LegacyContext = { + onMeFetched: fetchedMe => { + me = fetchedMe; + } + }; + + const { result, waitFor } = renderHook(() => useMe(), { wrapper: createWrapper(context, queryClient) }); + await waitFor(() => { + return !!result.current.data; + }); + expect(me!.name).toEqual("tricia"); + }); + + it("should return nothing without me link", () => { + const queryClient = createInfiniteCachingClient(); + setEmptyIndex(queryClient); + + const { result } = renderHook(() => useMe(), { wrapper: createWrapper(undefined, queryClient) }); + + expect(result.current.isLoading).toBe(false); + expect(result.current?.data).toBeFalsy(); + expect(result.current?.error).toBeFalsy(); + }); + }); + + describe("useRequiredMe tests", () => { + it("should return me", async () => { + const queryClient = createInfiniteCachingClient(); + queryClient.setQueryData("me", tricia); + setIndexLink(queryClient, "me", "/me"); + const { result, waitFor } = renderHook(() => useRequiredMe(), { wrapper: createWrapper(undefined, queryClient) }); + await waitFor(() => { + return !!result.current; + }); + expect(result.current?.name).toBe("tricia"); + }); + + it("should throw an error if me is not available", () => { + const queryClient = createInfiniteCachingClient(); + setEmptyIndex(queryClient); + + const { result } = renderHook(() => useRequiredMe(), { wrapper: createWrapper(undefined, queryClient) }); + + expect(result.error).toBeDefined(); + }); + }); + + describe("useSubject tests", () => { + it("should return authenticated subject", () => { + const queryClient = createInfiniteCachingClient(); + setEmptyIndex(queryClient); + queryClient.setQueryData("me", tricia); + const { result } = renderHook(() => useSubject(), { wrapper: createWrapper(undefined, queryClient) }); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.isAnonymous).toBe(false); + expect(result.current.me).toEqual(tricia); + }); + + it("should return anonymous subject", () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "login", "/login"); + queryClient.setQueryData("me", { + name: "_anonymous", + displayName: "Anonymous", + groups: [], + _links: {} + }); + const { result } = renderHook(() => useSubject(), { wrapper: createWrapper(undefined, queryClient) }); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.isAnonymous).toBe(true); + }); + + it("should return unauthenticated subject", () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "login", "/login"); + const { result } = renderHook(() => useSubject(), { wrapper: createWrapper(undefined, queryClient) }); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.isAnonymous).toBe(false); + }); + }); + + describe("useLogin tests", () => { + it("should login", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "login", "/login"); + + fetchMock.post("/api/v2/login", "", { + body: { + cookie: true, + grant_type: "password", + username: "tricia", + password: "hitchhikersSecret!" + } + }); + + // required because we invalidate the whole cache and react-query refetches the index + fetchMock.get("/api/v2/", { + version: "x.y.z", + _links: { + login: { + href: "/second/login" + } + } + }); + + const { result, waitForNextUpdate } = renderHook(() => useLogin(), { + wrapper: createWrapper(undefined, queryClient) + }); + const { login } = result.current; + expect(login).toBeDefined(); + + await act(() => { + if (login) { + login("tricia", "hitchhikersSecret!"); + } + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + }); + + it("should not return login, if authenticated", () => { + const queryClient = createInfiniteCachingClient(); + setEmptyIndex(queryClient); + queryClient.setQueryData("me", tricia); + + const { result } = renderHook(() => useLogin(), { + wrapper: createWrapper(undefined, queryClient) + }); + + expect(result.current.login).toBeUndefined(); + }); + }); + + describe("useLogout tests", () => { + it("should call logout", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "logout", "/logout"); + + fetchMock.deleteOnce("/api/v2/logout", ""); + + const { result, waitForNextUpdate } = renderHook(() => useLogout(), { + wrapper: createWrapper(undefined, queryClient) + }); + const { logout } = result.current; + expect(logout).toBeDefined(); + + await act(() => { + if (logout) { + logout(); + } + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + }); + + it("should not return logout without link", () => { + const queryClient = createInfiniteCachingClient(); + setEmptyIndex(queryClient); + + const { result } = renderHook(() => useLogout(), { + wrapper: createWrapper(undefined, queryClient) + }); + + const { logout } = result.current; + expect(logout).toBeUndefined(); + }); + }); +}); diff --git a/scm-ui/ui-api/src/login.ts b/scm-ui/ui-api/src/login.ts new file mode 100644 index 0000000000..eba8c112c6 --- /dev/null +++ b/scm-ui/ui-api/src/login.ts @@ -0,0 +1,118 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Me } from "@scm-manager/ui-types"; +import { useMutation, useQuery } from "react-query"; +import { apiClient } from "./apiclient"; +import { ApiResult, useIndexLink } from "./base"; +import { useLegacyContext } from "./LegacyContext"; +import { useReset } from "./reset"; + +export const useMe = (): ApiResult => { + const legacy = useLegacyContext(); + const link = useIndexLink("me"); + return useQuery("me", () => apiClient.get(link!).then(response => response.json()), { + enabled: !!link, + onSuccess: me => { + if (legacy.onMeFetched) { + legacy.onMeFetched(me); + } + } + }); +}; + +export const useRequiredMe = () => { + const { data } = useMe(); + if (!data) { + throw new Error("Could not find 'me' in cache"); + } + return data; +}; + +export const useSubject = () => { + const link = useIndexLink("login"); + const { isLoading, error, data: me } = useMe(); + const isAnonymous = me?.name === "_anonymous"; + const isAuthenticated = !isAnonymous && !!me && !link; + return { + isAuthenticated, + isAnonymous, + isLoading, + error, + me + }; +}; + +type Credentials = { + username: string; + password: string; + cookie: boolean; + grant_type: string; +}; + +export const useLogin = () => { + const link = useIndexLink("login"); + const reset = useReset(); + const { mutate, isLoading, error } = useMutation( + credentials => apiClient.post(link!, credentials), + { + onSuccess: reset + } + ); + + const login = (username: string, password: string) => { + // grant_type is specified by the oauth standard with the underscore + // so we stick with it, even if eslint does not like it. + // eslint-disable-next-line @typescript-eslint/camelcase + mutate({ cookie: true, grant_type: "password", username, password }); + }; + + return { + login: link ? login : undefined, + isLoading, + error + }; +}; + +export const useLogout = () => { + const link = useIndexLink("logout"); + const reset = useReset(); + + const { mutate, isLoading, error, data } = useMutation( + () => apiClient.delete(link!).then(() => true), + { + onSuccess: reset + } + ); + + const logout = () => { + mutate({}); + }; + + return { + logout: link && !data ? logout : undefined, + isLoading, + error + }; +}; diff --git a/scm-ui/ui-api/src/namespaces.test.ts b/scm-ui/ui-api/src/namespaces.test.ts new file mode 100644 index 0000000000..36a3d8a027 --- /dev/null +++ b/scm-ui/ui-api/src/namespaces.test.ts @@ -0,0 +1,97 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { setIndexLink } from "./tests/indexLinks"; +import fetchMock from "fetch-mock-jest"; +import { renderHook } from "@testing-library/react-hooks"; +import { useNamespace, useNamespaces, useNamespaceStrategies } from "./namespaces"; +import createWrapper from "./tests/createWrapper"; + +describe("Test namespace hooks", () => { + describe("useNamespaces test", () => { + it("should return namespaces", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "namespaces", "/namespaces"); + fetchMock.get("/api/v2/namespaces", { + _embedded: { + namespaces: [ + { + namespace: "spaceships", + _links: {} + } + ] + } + }); + + const { result, waitFor } = renderHook(() => useNamespaces(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + expect(result.current?.data?._embedded.namespaces[0].namespace).toBe("spaceships"); + }); + }); + + describe("useNamespaceStrategies tests", () => { + it("should return namespaces strategies", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "namespaceStrategies", "/ns"); + fetchMock.get("/api/v2/ns", { + current: "awesome", + available: [], + _links: {} + }); + + const { result, waitFor } = renderHook(() => useNamespaceStrategies(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + expect(result.current?.data?.current).toEqual("awesome"); + }); + }); + + describe("useNamespace tests", () => { + it("should return namespace", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "namespaces", "/ns"); + fetchMock.get("/api/v2/ns/awesome", { + namespace: "awesome", + _links: {} + }); + + const { result, waitFor } = renderHook(() => useNamespace("awesome"), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + expect(result.current?.data?.namespace).toEqual("awesome"); + }); + }); +}); diff --git a/scm-ui/ui-api/src/namespaces.ts b/scm-ui/ui-api/src/namespaces.ts new file mode 100644 index 0000000000..341afdca4a --- /dev/null +++ b/scm-ui/ui-api/src/namespaces.ts @@ -0,0 +1,45 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { ApiResult, useIndexJsonResource, useRequiredIndexLink } from "./base"; +import { Namespace, NamespaceCollection, NamespaceStrategies } from "@scm-manager/ui-types"; +import { useQuery } from "react-query"; +import { apiClient } from "./apiclient"; +import { concat } from "./urls"; + +export const useNamespaces = () => { + return useIndexJsonResource("namespaces"); +}; + +export const useNamespace = (name: string): ApiResult => { + const namespacesLink = useRequiredIndexLink("namespaces"); + return useQuery(["namespace", name], () => + apiClient.get(concat(namespacesLink, name)).then(response => response.json()) + ); +}; + +export const useNamespaceStrategies = () => { + return useIndexJsonResource("namespaceStrategies"); +}; diff --git a/scm-ui/ui-api/src/permissions.test.ts b/scm-ui/ui-api/src/permissions.test.ts new file mode 100644 index 0000000000..c37d214dc4 --- /dev/null +++ b/scm-ui/ui-api/src/permissions.test.ts @@ -0,0 +1,347 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { setIndexLink } from "./tests/indexLinks"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { + Namespace, + Permission, + PermissionCollection, + Repository, + RepositoryRole, + RepositoryRoleCollection, + RepositoryVerbs +} from "@scm-manager/ui-types"; +import fetchMock from "fetch-mock-jest"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import { + useAvailablePermissions, + useCreatePermission, + useDeletePermission, + usePermissions, + useRepositoryVerbs, + useUpdatePermission +} from "./permissions"; +import { act } from "react-test-renderer"; + +describe("permission hooks test", () => { + const readRole: RepositoryRole = { + name: "READ", + verbs: ["read", "pull"], + _links: {} + }; + + const roleCollection: RepositoryRoleCollection = { + _embedded: { + repositoryRoles: [readRole] + }, + _links: {}, + page: 1, + pageTotal: 1 + }; + + const verbCollection: RepositoryVerbs = { + verbs: ["read", "pull"], + _links: {} + }; + + const readPermission: Permission = { + name: "trillian", + role: "READ", + verbs: [], + groupPermission: false, + _links: { + update: { + href: "/p/trillian" + } + } + }; + + const writePermission: Permission = { + name: "dent", + role: "WRITE", + verbs: [], + groupPermission: false, + _links: { + delete: { + href: "/p/dent" + } + } + }; + + const permissionsRead: PermissionCollection = { + _embedded: { + permissions: [readPermission] + }, + _links: {} + }; + + const permissionsWrite: PermissionCollection = { + _embedded: { + permissions: [writePermission] + }, + _links: {} + }; + + const namespace: Namespace = { + namespace: "spaceships", + _links: { + permissions: { + href: "/ns/spaceships/permissions" + } + } + }; + + const repository: Repository = { + namespace: "spaceships", + name: "heart-of-gold", + type: "git", + _links: { + permissions: { + href: "/r/heart-of-gold/permissions" + } + } + }; + + const queryClient = createInfiniteCachingClient(); + + beforeEach(() => { + queryClient.clear(); + fetchMock.reset(); + }); + + describe("useRepositoryVerbs tests", () => { + it("should return available verbs", async () => { + setIndexLink(queryClient, "repositoryVerbs", "/verbs"); + fetchMock.get("/api/v2/verbs", verbCollection); + + const { result, waitFor } = renderHook(() => useRepositoryVerbs(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + expect(result.current.data).toEqual(verbCollection); + }); + }); + + describe("useAvailablePermissions tests", () => { + it("should return available roles and verbs", async () => { + queryClient.setQueryData("index", { + version: "x.y.z", + _links: { + repositoryRoles: { + href: "/roles" + }, + repositoryVerbs: { + href: "/verbs" + } + } + }); + fetchMock.get("/api/v2/roles", roleCollection); + fetchMock.get("/api/v2/verbs", verbCollection); + + const { result, waitFor } = renderHook(() => useAvailablePermissions(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + expect(result.current.data?.repositoryRoles).toEqual(roleCollection._embedded.repositoryRoles); + expect(result.current.data?.repositoryVerbs).toEqual(verbCollection.verbs); + }); + }); + + describe("usePermissions tests", () => { + const fetchPermissions = async (namespaceOrRepository: Namespace | Repository) => { + const { result, waitFor } = renderHook(() => usePermissions(namespaceOrRepository), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + return result.current.data; + }; + + it("should return permissions from namespace", async () => { + fetchMock.getOnce("/api/v2/ns/spaceships/permissions", permissionsRead); + const data = await fetchPermissions(namespace); + expect(data).toEqual(permissionsRead); + }); + + it("should cache permissions for namespace", async () => { + fetchMock.getOnce("/api/v2/ns/spaceships/permissions", permissionsRead); + await fetchPermissions(namespace); + const data = queryClient.getQueryData(["namespace", "spaceships", "permissions"]); + expect(data).toEqual(permissionsRead); + }); + + it("should return permissions from repository", async () => { + fetchMock.getOnce("/api/v2/r/heart-of-gold/permissions", permissionsWrite); + const data = await fetchPermissions(repository); + expect(data).toEqual(permissionsWrite); + }); + + it("should cache permissions for repository", async () => { + fetchMock.getOnce("/api/v2/r/heart-of-gold/permissions", permissionsWrite); + await fetchPermissions(repository); + const data = queryClient.getQueryData(["repository", "spaceships", "heart-of-gold", "permissions"]); + expect(data).toEqual(permissionsWrite); + }); + }); + + describe("useCreatePermission tests", () => { + const createAndFetch = async () => { + fetchMock.postOnce("/api/v2/ns/spaceships/permissions", { + status: 201, + headers: { + Location: "/ns/spaceships/permissions/42" + } + }); + + fetchMock.getOnce("/api/v2/ns/spaceships/permissions/42", readPermission); + + const { result, waitForNextUpdate } = renderHook(() => useCreatePermission(namespace), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create(readPermission); + return waitForNextUpdate(); + }); + return result.current; + }; + + it("should create permission", async () => { + const data = await createAndFetch(); + expect(data.permission).toEqual(readPermission); + }); + + it("should fail without location header", async () => { + fetchMock.postOnce("/api/v2/ns/spaceships/permissions", { + status: 201 + }); + + const { result, waitForNextUpdate } = renderHook(() => useCreatePermission(namespace), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create(readPermission); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeDefined(); + }); + + it("should invalidate namespace cache", async () => { + const key = ["namespace", "spaceships", "permissions"]; + queryClient.setQueryData(key, permissionsRead); + await createAndFetch(); + + const state = queryClient.getQueryState(key); + expect(state?.isInvalidated).toBe(true); + }); + }); + + describe("useDeletePermission tests", () => { + const deletePermission = async () => { + fetchMock.deleteOnce("/api/v2/p/dent", { + status: 204 + }); + + const { result, waitForNextUpdate } = renderHook(() => useDeletePermission(repository), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { remove } = result.current; + remove(writePermission); + return waitForNextUpdate(); + }); + + return result.current; + }; + + const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => { + queryClient.setQueryData(queryKey, data); + await deletePermission(); + + const queryState = queryClient.getQueryState(queryKey); + expect(queryState?.isInvalidated).toBe(true); + }; + + it("should delete permission", async () => { + const { isDeleted } = await deletePermission(); + + expect(isDeleted).toBe(true); + }); + + it("should invalidate permission cache", async () => { + await shouldInvalidateQuery(["repository", "spaceships", "heart-of-gold", "permissions"], permissionsWrite); + }); + }); + + describe("useUpdatePermission tests", () => { + const updatePermission = async () => { + fetchMock.putOnce("/api/v2/p/trillian", { + status: 204 + }); + + const { result, waitForNextUpdate } = renderHook(() => useUpdatePermission(repository), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { update } = result.current; + update(readPermission); + return waitForNextUpdate(); + }); + + return result.current; + }; + + const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => { + queryClient.setQueryData(queryKey, data); + await updatePermission(); + + const queryState = queryClient.getQueryState(queryKey); + expect(queryState?.isInvalidated).toBe(true); + }; + + it("should update permission", async () => { + const { isUpdated } = await updatePermission(); + + expect(isUpdated).toBe(true); + }); + + it("should invalidate permission cache", async () => { + await shouldInvalidateQuery(["repository", "spaceships", "heart-of-gold", "permissions"], permissionsRead); + }); + }); +}); diff --git a/scm-ui/ui-api/src/permissions.ts b/scm-ui/ui-api/src/permissions.ts new file mode 100644 index 0000000000..e48fe83a67 --- /dev/null +++ b/scm-ui/ui-api/src/permissions.ts @@ -0,0 +1,158 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { ApiResult, useIndexJsonResource } from "./base"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { + Namespace, + Permission, + PermissionCollection, + PermissionCreateEntry, + Repository, + RepositoryVerbs +} from "@scm-manager/ui-types"; +import { apiClient } from "./apiclient"; +import { requiredLink } from "./links"; +import { repoQueryKey } from "./keys"; +import { useRepositoryRoles } from "./repository-roles"; + +export const useRepositoryVerbs = (): ApiResult => { + return useIndexJsonResource("repositoryVerbs"); +}; + +export const useAvailablePermissions = () => { + const roles = useRepositoryRoles(); + const verbs = useRepositoryVerbs(); + let data; + if (roles.data && verbs.data) { + data = { + repositoryVerbs: verbs.data.verbs, + repositoryRoles: roles.data._embedded.repositoryRoles + }; + } + + return { + isLoading: roles.isLoading || verbs.isLoading, + error: roles.error || verbs.error, + data + }; +}; + +const isRepository = (namespaceOrRepository: Namespace | Repository): namespaceOrRepository is Repository => { + return (namespaceOrRepository as Repository).name !== undefined; +}; + +const createQueryKey = (namespaceOrRepository: Namespace | Repository) => { + if (isRepository(namespaceOrRepository)) { + return repoQueryKey(namespaceOrRepository, "permissions"); + } else { + return ["namespace", namespaceOrRepository.namespace, "permissions"]; + } +}; + +export const usePermissions = (namespaceOrRepository: Namespace | Repository): ApiResult => { + const link = requiredLink(namespaceOrRepository, "permissions"); + const queryKey = createQueryKey(namespaceOrRepository); + return useQuery(queryKey, () => apiClient.get(link).then(response => response.json())); +}; + +const createPermission = (link: string) => { + return (permission: PermissionCreateEntry) => { + return apiClient + .post(link, permission, "application/vnd.scmm-repositoryPermission+json") + .then(response => { + const location = response.headers.get("Location"); + if (!location) { + throw new Error("Server does not return required Location header"); + } + return apiClient.get(location); + }) + .then(response => response.json()); + }; +}; + +export const useCreatePermission = (namespaceOrRepository: Namespace | Repository) => { + const queryClient = useQueryClient(); + const link = requiredLink(namespaceOrRepository, "permissions"); + const { isLoading, error, mutate, data } = useMutation( + createPermission(link), + { + onSuccess: () => { + const queryKey = createQueryKey(namespaceOrRepository); + return queryClient.invalidateQueries(queryKey); + } + } + ); + return { + isLoading, + error, + create: (permission: PermissionCreateEntry) => mutate(permission), + permission: data + }; +}; + +export const useUpdatePermission = (namespaceOrRepository: Namespace | Repository) => { + const queryClient = useQueryClient(); + const { isLoading, error, mutate, data } = useMutation( + permission => { + const link = requiredLink(permission, "update"); + return apiClient.put(link, permission, "application/vnd.scmm-repositoryPermission+json"); + }, + { + onSuccess: () => { + const queryKey = createQueryKey(namespaceOrRepository); + return queryClient.invalidateQueries(queryKey); + } + } + ); + return { + isLoading, + error, + update: (permission: Permission) => mutate(permission), + isUpdated: !!data + }; +}; + +export const useDeletePermission = (namespaceOrRepository: Namespace | Repository) => { + const queryClient = useQueryClient(); + const { isLoading, error, mutate, data } = useMutation( + permission => { + const link = requiredLink(permission, "delete"); + return apiClient.delete(link); + }, + { + onSuccess: () => { + const queryKey = createQueryKey(namespaceOrRepository); + return queryClient.invalidateQueries(queryKey); + } + } + ); + return { + isLoading, + error, + remove: (permission: Permission) => mutate(permission), + isDeleted: !!data + }; +}; diff --git a/scm-ui/ui-api/src/plugins.test.ts b/scm-ui/ui-api/src/plugins.test.ts new file mode 100644 index 0000000000..da13183e82 --- /dev/null +++ b/scm-ui/ui-api/src/plugins.test.ts @@ -0,0 +1,317 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { PendingPlugins, Plugin, PluginCollection } from "@scm-manager/ui-types"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { setIndexLink } from "./tests/indexLinks"; +import fetchMock from "fetch-mock-jest"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import { + useAvailablePlugins, + useInstalledPlugins, + useInstallPlugin, + usePendingPlugins, + useUninstallPlugin, + useUpdatePlugins +} from "./plugins"; +import { act } from "react-test-renderer"; + +describe("Test plugin hooks", () => { + const availablePlugin: Plugin = { + author: "Douglas Adams", + category: "all", + displayName: "Heart of Gold", + version: "x.y.z", + name: "heart-of-gold-plugin", + pending: false, + dependencies: [], + optionalDependencies: [], + _links: { + install: { href: "/plugins/available/heart-of-gold-plugin/install" }, + installWithRestart: { + href: "/plugins/available/heart-of-gold-plugin/install?restart=true" + } + } + }; + + const installedPlugin: Plugin = { + author: "Douglas Adams", + category: "all", + displayName: "Heart of Gold", + version: "x.y.z", + name: "heart-of-gold-plugin", + pending: false, + markedForUninstall: false, + dependencies: [], + optionalDependencies: [], + _links: { + self: { + href: "/plugins/installed/heart-of-gold-plugin" + }, + update: { + href: "/plugins/available/heart-of-gold-plugin/install" + }, + updateWithRestart: { + href: "/plugins/available/heart-of-gold-plugin/install?restart=true" + }, + uninstall: { + href: "/plugins/installed/heart-of-gold-plugin/uninstall" + }, + uninstallWithRestart: { + href: "/plugins/installed/heart-of-gold-plugin/uninstall?restart=true" + } + } + }; + + const installedCorePlugin: Plugin = { + author: "Douglas Adams", + category: "all", + displayName: "Heart of Gold", + version: "x.y.z", + name: "heart-of-gold-core-plugin", + pending: false, + markedForUninstall: false, + dependencies: [], + optionalDependencies: [], + _links: { + self: { + href: "/plugins/installed/heart-of-gold-core-plugin" + } + } + }; + + const createPluginCollection = (plugins: Plugin[]): PluginCollection => ({ + _links: { + update: { + href: "/plugins/update" + } + }, + _embedded: { + plugins + } + }); + + const createPendingPlugins = ( + newPlugins: Plugin[] = [], + updatePlugins: Plugin[] = [], + uninstallPlugins: Plugin[] = [] + ): PendingPlugins => ({ + _links: {}, + _embedded: { + new: newPlugins, + update: updatePlugins, + uninstall: uninstallPlugins + } + }); + + afterEach(() => fetchMock.reset()); + + describe("useAvailablePlugins tests", () => { + it("should return availablePlugins", async () => { + const availablePlugins = createPluginCollection([availablePlugin]); + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "availablePlugins", "/availablePlugins"); + fetchMock.get("/api/v2/availablePlugins", availablePlugins); + const { result, waitFor } = renderHook(() => useAvailablePlugins(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(availablePlugins); + }); + }); + + describe("useInstalledPlugins tests", () => { + it("should return installedPlugins", async () => { + const installedPlugins = createPluginCollection([installedPlugin, installedCorePlugin]); + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "installedPlugins", "/installedPlugins"); + fetchMock.get("/api/v2/installedPlugins", installedPlugins); + const { result, waitFor } = renderHook(() => useInstalledPlugins(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(installedPlugins); + }); + }); + + describe("usePendingPlugins tests", () => { + it("should return pendingPlugins", async () => { + const pendingPlugins = createPendingPlugins([availablePlugin]); + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "pendingPlugins", "/pendingPlugins"); + fetchMock.get("/api/v2/pendingPlugins", pendingPlugins); + const { result, waitFor } = renderHook(() => usePendingPlugins(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(pendingPlugins); + }); + }); + + describe("useInstallPlugin tests", () => { + it("should use restart parameter", async () => { + const queryClient = createInfiniteCachingClient(); + queryClient.setQueryData(["plugins", "available"], createPluginCollection([availablePlugin])); + queryClient.setQueryData(["plugins", "installed"], createPluginCollection([])); + queryClient.setQueryData(["plugins", "pending"], createPendingPlugins()); + fetchMock.post("/api/v2/plugins/available/heart-of-gold-plugin/install?restart=true", installedPlugin); + fetchMock.get("/api/v2/", "Restarted"); + const { result, waitFor, waitForNextUpdate } = renderHook(() => useInstallPlugin(), { + wrapper: createWrapper(undefined, queryClient) + }); + await act(() => { + const { install } = result.current; + install(availablePlugin, { restart: true, initialDelay: 5, timeout: 5 }); + return waitForNextUpdate(); + }); + await waitFor(() => result.current.isInstalled); + expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true); + }); + + it("should invalidate query keys", async () => { + const queryClient = createInfiniteCachingClient(); + queryClient.setQueryData(["plugins", "available"], createPluginCollection([availablePlugin])); + queryClient.setQueryData(["plugins", "installed"], createPluginCollection([])); + queryClient.setQueryData(["plugins", "pending"], createPendingPlugins()); + fetchMock.post("/api/v2/plugins/available/heart-of-gold-plugin/install", installedPlugin); + const { result, waitForNextUpdate } = renderHook(() => useInstallPlugin(), { + wrapper: createWrapper(undefined, queryClient) + }); + await act(() => { + const { install } = result.current; + install(availablePlugin); + return waitForNextUpdate(); + }); + expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true); + }); + }); + + describe("useUninstallPlugin tests", () => { + it("should use restart parameter", async () => { + const queryClient = createInfiniteCachingClient(); + queryClient.setQueryData(["plugins", "available"], createPluginCollection([])); + queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin])); + queryClient.setQueryData(["plugins", "pending"], createPendingPlugins()); + fetchMock.post("/api/v2/plugins/installed/heart-of-gold-plugin/uninstall?restart=true", availablePlugin); + fetchMock.get("/api/v2/", "Restarted"); + const { result, waitForNextUpdate, waitFor } = renderHook(() => useUninstallPlugin(), { + wrapper: createWrapper(undefined, queryClient) + }); + await act(() => { + const { uninstall } = result.current; + uninstall(installedPlugin, { restart: true, initialDelay: 5, timeout: 5 }); + return waitForNextUpdate(); + }); + await waitFor(() => result.current.isUninstalled); + expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true); + }); + + it("should invalidate query keys", async () => { + const queryClient = createInfiniteCachingClient(); + queryClient.setQueryData(["plugins", "available"], createPluginCollection([])); + queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin])); + queryClient.setQueryData(["plugins", "pending"], createPendingPlugins()); + fetchMock.post("/api/v2/plugins/installed/heart-of-gold-plugin/uninstall", availablePlugin); + const { result, waitForNextUpdate } = renderHook(() => useUninstallPlugin(), { + wrapper: createWrapper(undefined, queryClient) + }); + await act(() => { + const { uninstall } = result.current; + uninstall(installedPlugin); + return waitForNextUpdate(); + }); + expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true); + }); + }); + + describe("useUpdatePlugins tests", () => { + it("should use restart parameter", async () => { + const queryClient = createInfiniteCachingClient(); + queryClient.setQueryData(["plugins", "available"], createPluginCollection([])); + queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin])); + queryClient.setQueryData(["plugins", "pending"], createPendingPlugins()); + fetchMock.post("/api/v2/plugins/available/heart-of-gold-plugin/install?restart=true", installedPlugin); + fetchMock.get("/api/v2/", "Restarted"); + const { result, waitForNextUpdate, waitFor } = renderHook(() => useUpdatePlugins(), { + wrapper: createWrapper(undefined, queryClient) + }); + await act(() => { + const { update } = result.current; + update(installedPlugin, { restart: true, timeout: 5, initialDelay: 5 }); + return waitForNextUpdate(); + }); + await waitFor(() => result.current.isUpdated); + expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true); + }); + it("should update collection", async () => { + const queryClient = createInfiniteCachingClient(); + queryClient.setQueryData(["plugins", "available"], createPluginCollection([])); + queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin])); + queryClient.setQueryData(["plugins", "pending"], createPendingPlugins()); + fetchMock.post("/api/v2/plugins/update", installedPlugin); + const { result, waitForNextUpdate } = renderHook(() => useUpdatePlugins(), { + wrapper: createWrapper(undefined, queryClient) + }); + await act(() => { + const { update } = result.current; + update(createPluginCollection([installedPlugin, installedCorePlugin])); + return waitForNextUpdate(); + }); + expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true); + }); + it("should ignore restart parameter collection", async () => { + const queryClient = createInfiniteCachingClient(); + queryClient.setQueryData(["plugins", "available"], createPluginCollection([])); + queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin])); + queryClient.setQueryData(["plugins", "pending"], createPendingPlugins()); + fetchMock.post("/api/v2/plugins/update", installedPlugin); + const { result, waitForNextUpdate } = renderHook(() => useUpdatePlugins(), { + wrapper: createWrapper(undefined, queryClient) + }); + await act(() => { + const { update } = result.current; + update(createPluginCollection([installedPlugin, installedCorePlugin]), { restart: true }); + return waitForNextUpdate(); + }); + expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true); + }); + it("should invalidate query keys", async () => { + const queryClient = createInfiniteCachingClient(); + queryClient.setQueryData(["plugins", "available"], createPluginCollection([])); + queryClient.setQueryData(["plugins", "installed"], createPluginCollection([installedPlugin])); + queryClient.setQueryData(["plugins", "pending"], createPendingPlugins()); + fetchMock.post("/api/v2/plugins/available/heart-of-gold-plugin/install", installedPlugin); + const { result, waitForNextUpdate } = renderHook(() => useUpdatePlugins(), { + wrapper: createWrapper(undefined, queryClient) + }); + await act(() => { + const { update } = result.current; + update(installedPlugin); + return waitForNextUpdate(); + }); + expect(queryClient.getQueryState("plugins")!.isInvalidated).toBe(true); + }); + }); +}); diff --git a/scm-ui/ui-api/src/plugins.ts b/scm-ui/ui-api/src/plugins.ts new file mode 100644 index 0000000000..634b5dcedb --- /dev/null +++ b/scm-ui/ui-api/src/plugins.ts @@ -0,0 +1,249 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ApiResult, useIndexLink, useRequiredIndexLink } from "./base"; +import { isPluginCollection, PendingPlugins, Plugin, PluginCollection } from "@scm-manager/ui-types"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { apiClient } from "@scm-manager/ui-components"; +import { requiredLink } from "./links"; + +type WaitForRestartOptions = { + initialDelay?: number; + timeout?: number; +}; + +const waitForRestartAfter = ( + promise: Promise, + { initialDelay = 1000, timeout = 500 }: WaitForRestartOptions = {} +): Promise => { + const endTime = Number(new Date()) + 60000; + let started = false; + + const executor = (data: T) => (resolve: (result: T) => void, reject: (error: Error) => void) => { + // we need some initial delay + if (!started) { + started = true; + setTimeout(executor(data), initialDelay, resolve, reject); + } else { + apiClient + .get("") + .then(() => resolve(data)) + .catch(() => { + if (Number(new Date()) < endTime) { + setTimeout(executor(data), timeout, resolve, reject); + } else { + reject(new Error("timeout reached")); + } + }); + } + }; + + return promise.then(data => new Promise(executor(data))); +}; + +export type UseAvailablePluginsOptions = { + enabled?: boolean; +}; + +export const useAvailablePlugins = ({ enabled }: UseAvailablePluginsOptions = {}): ApiResult => { + const indexLink = useRequiredIndexLink("availablePlugins"); + return useQuery( + ["plugins", "available"], + () => apiClient.get(indexLink).then(response => response.json()), + { + enabled, + retry: 3 + } + ); +}; + +export type UseInstalledPluginsOptions = { + enabled?: boolean; +}; + +export const useInstalledPlugins = ({ enabled }: UseInstalledPluginsOptions = {}): ApiResult => { + const indexLink = useRequiredIndexLink("installedPlugins"); + return useQuery( + ["plugins", "installed"], + () => apiClient.get(indexLink).then(response => response.json()), + { + enabled, + retry: 3 + } + ); +}; + +export const usePendingPlugins = (): ApiResult => { + const indexLink = useIndexLink("pendingPlugins"); + return useQuery( + ["plugins", "pending"], + () => apiClient.get(indexLink!).then(response => response.json()), + { + enabled: !!indexLink, + retry: 3 + } + ); +}; + +const linkWithRestart = (link: string, restart?: boolean) => { + if (restart) { + return link + "WithRestart"; + } + return link; +}; + +type RestartOptions = WaitForRestartOptions & { + restart?: boolean; +}; + +type PluginActionOptions = { + plugin: Plugin; + restartOptions: RestartOptions; +}; + +export const useInstallPlugin = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + ({ plugin, restartOptions: { restart, ...waitForRestartOptions } }) => { + const promise = apiClient.post(requiredLink(plugin, linkWithRestart("install", restart))); + if (restart) { + return waitForRestartAfter(promise, waitForRestartOptions); + } + return promise; + }, + { + onSuccess: () => queryClient.invalidateQueries("plugins") + } + ); + return { + install: (plugin: Plugin, restartOptions: RestartOptions = {}) => + mutate({ + plugin, + restartOptions + }), + isLoading, + error, + data, + isInstalled: !!data + }; +}; + +export const useUninstallPlugin = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + ({ plugin, restartOptions: { restart, ...waitForRestartOptions } }) => { + const promise = apiClient.post(requiredLink(plugin, linkWithRestart("uninstall", restart))); + if (restart) { + return waitForRestartAfter(promise, waitForRestartOptions); + } + return promise; + }, + { + onSuccess: () => queryClient.invalidateQueries("plugins") + } + ); + return { + uninstall: (plugin: Plugin, restartOptions: RestartOptions = {}) => + mutate({ + plugin, + restartOptions + }), + isLoading, + error, + isUninstalled: !!data + }; +}; + +type UpdatePluginsOptions = { + plugins: Plugin | PluginCollection; + restartOptions: RestartOptions; +}; + +export const useUpdatePlugins = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + ({ plugins, restartOptions: { restart, ...waitForRestartOptions } }) => { + const isCollection = isPluginCollection(plugins); + const promise = apiClient.post( + requiredLink(plugins, isCollection ? "update" : linkWithRestart("update", restart)) + ); + if (restart && !isCollection) { + return waitForRestartAfter(promise, waitForRestartOptions); + } + return promise; + }, + { + onSuccess: () => queryClient.invalidateQueries("plugins") + } + ); + return { + update: (plugin: Plugin | PluginCollection, restartOptions: RestartOptions = {}) => + mutate({ + plugins: plugin, + restartOptions + }), + isLoading, + error, + isUpdated: !!data + }; +}; + +type ExecutePendingPlugins = { + pending: PendingPlugins; + restartOptions: WaitForRestartOptions; +}; + +export const useExecutePendingPlugins = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + ({ pending, restartOptions }) => + waitForRestartAfter(apiClient.post(requiredLink(pending, "execute")), restartOptions), + { + onSuccess: () => queryClient.invalidateQueries("plugins") + } + ); + return { + update: (pending: PendingPlugins, restartOptions: WaitForRestartOptions = {}) => + mutate({ pending, restartOptions }), + isLoading, + error, + isExecuted: !!data + }; +}; + +export const useCancelPendingPlugins = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + pending => apiClient.post(requiredLink(pending, "cancel")), + { + onSuccess: () => queryClient.invalidateQueries("plugins") + } + ); + return { + update: (pending: PendingPlugins) => mutate(pending), + isLoading, + error, + isCancelled: !!data + }; +}; diff --git a/scm-ui/ui-api/src/repositories.test.ts b/scm-ui/ui-api/src/repositories.test.ts new file mode 100644 index 0000000000..c262f5586c --- /dev/null +++ b/scm-ui/ui-api/src/repositories.test.ts @@ -0,0 +1,517 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import fetchMock from "fetch-mock-jest"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import { setIndexLink } from "./tests/indexLinks"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { + useArchiveRepository, + useCreateRepository, + useDeleteRepository, + UseDeleteRepositoryOptions, + useRepositories, + UseRepositoriesRequest, + useRepository, + useRepositoryTypes, + useUnarchiveRepository, + useUpdateRepository +} from "./repositories"; +import { Repository } from "@scm-manager/ui-types"; +import { QueryClient } from "react-query"; +import { act } from "react-test-renderer"; + +describe("Test repository hooks", () => { + const heartOfGold: Repository = { + namespace: "spaceships", + name: "heartOfGold", + type: "git", + _links: { + delete: { + href: "/r/spaceships/heartOfGold" + }, + update: { + href: "/r/spaceships/heartOfGold" + }, + archive: { + href: "/r/spaceships/heartOfGold/archive" + }, + unarchive: { + href: "/r/spaceships/heartOfGold/unarchive" + } + } + }; + + const repositoryCollection = { + _embedded: { + repositories: [heartOfGold] + }, + _links: {} + }; + + afterEach(() => { + fetchMock.reset(); + }); + + describe("useRepositories tests", () => { + const expectCollection = async (queryClient: QueryClient, request?: UseRepositoriesRequest) => { + const { result, waitFor } = renderHook(() => useRepositories(request), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + expect(result.current.data).toEqual(repositoryCollection); + }; + + it("should return repositories", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositories", "/repos"); + fetchMock.get("/api/v2/repos", repositoryCollection, { + query: { + sortBy: "namespaceAndName" + } + }); + + await expectCollection(queryClient); + }); + + it("should return repositories with page", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositories", "/repos"); + fetchMock.get("/api/v2/repos", repositoryCollection, { + query: { + sortBy: "namespaceAndName", + page: "42" + } + }); + + await expectCollection(queryClient, { + page: 42 + }); + }); + + it("should use repository from namespace", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositories", "/repos"); + fetchMock.get("/api/v2/spaceships", repositoryCollection, { + query: { + sortBy: "namespaceAndName" + } + }); + + await expectCollection(queryClient, { + namespace: { + namespace: "spaceships", + _links: { + repositories: { + href: "/spaceships" + } + } + } + }); + }); + + it("should append search query", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositories", "/repos"); + fetchMock.get("/api/v2/repos", repositoryCollection, { + query: { + sortBy: "namespaceAndName", + q: "heart" + } + }); + + await expectCollection(queryClient, { + search: "heart" + }); + }); + + it("should update repository cache", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositories", "/repos"); + fetchMock.get("/api/v2/repos", repositoryCollection, { + query: { + sortBy: "namespaceAndName" + } + }); + + await expectCollection(queryClient); + + const repository = queryClient.getQueryData(["repository", "spaceships", "heartOfGold"]); + expect(repository).toEqual(heartOfGold); + }); + + it("should return nothing if disabled", () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositories", "/repos"); + const { result } = renderHook(() => useRepositories({ disabled: true }), { + wrapper: createWrapper(undefined, queryClient) + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeFalsy(); + expect(result.current.error).toBeFalsy(); + }); + }); + + describe("useCreateRepository tests", () => { + it("should create repository", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositories", "/r"); + + fetchMock.postOnce("/api/v2/r", { + status: 201, + headers: { + Location: "/r/spaceships/heartOfGold" + } + }); + + fetchMock.getOnce("/api/v2/r/spaceships/heartOfGold", heartOfGold); + + const { result, waitForNextUpdate } = renderHook(() => useCreateRepository(), { + wrapper: createWrapper(undefined, queryClient) + }); + + const repository = { + ...heartOfGold, + contextEntries: [] + }; + + await act(() => { + const { create } = result.current; + create(repository, false); + return waitForNextUpdate(); + }); + + expect(result.current.repository).toEqual(heartOfGold); + }); + + it("should append initialize param", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositories", "/r"); + + fetchMock.postOnce("/api/v2/r?initialize=true", { + status: 201, + headers: { + Location: "/r/spaceships/heartOfGold" + } + }); + + fetchMock.getOnce("/api/v2/r/spaceships/heartOfGold", heartOfGold); + + const { result, waitForNextUpdate } = renderHook(() => useCreateRepository(), { + wrapper: createWrapper(undefined, queryClient) + }); + + const repository = { + ...heartOfGold, + contextEntries: [] + }; + + await act(() => { + const { create } = result.current; + create(repository, true); + return waitForNextUpdate(); + }); + + expect(result.current.repository).toEqual(heartOfGold); + }); + + it("should fail without location header", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositories", "/r"); + + fetchMock.postOnce("/api/v2/r", { + status: 201 + }); + + const { result, waitForNextUpdate } = renderHook(() => useCreateRepository(), { + wrapper: createWrapper(undefined, queryClient) + }); + + const repository = { + ...heartOfGold, + contextEntries: [] + }; + + await act(() => { + const { create } = result.current; + create(repository, false); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeDefined(); + }); + }); + + describe("useRepository tests", () => { + it("should return repository", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositories", "/r"); + fetchMock.get("/api/v2/r/spaceships/heartOfGold", heartOfGold); + + const { result, waitFor } = renderHook(() => useRepository("spaceships", "heartOfGold"), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + expect(result.current?.data?.type).toEqual("git"); + }); + }); + + describe("useRepositoryTypes tests", () => { + it("should return repository types", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositoryTypes", "/rt"); + fetchMock.get("/api/v2/rt", { + _embedded: { + repositoryTypes: [ + { + name: "git", + displayName: "Git", + _links: {} + } + ] + }, + _links: {} + }); + + const { result, waitFor } = renderHook(() => useRepositoryTypes(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + expect(result.current.data).toBeDefined(); + if (result.current?.data) { + expect(result.current?.data._embedded.repositoryTypes[0].name).toEqual("git"); + } + }); + }); + + describe("useDeleteRepository tests", () => { + const queryClient = createInfiniteCachingClient(); + + beforeEach(() => { + queryClient.clear(); + }); + + const deleteRepository = async (options?: UseDeleteRepositoryOptions) => { + fetchMock.deleteOnce("/api/v2/r/spaceships/heartOfGold", { + status: 204 + }); + + const { result, waitForNextUpdate } = renderHook(() => useDeleteRepository(options), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { remove } = result.current; + remove(heartOfGold); + return waitForNextUpdate(); + }); + + return result.current; + }; + + const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => { + queryClient.setQueryData(queryKey, data); + await deleteRepository(); + + const queryState = queryClient.getQueryState(queryKey); + expect(queryState!.isInvalidated).toBe(true); + }; + + it("should delete repository", async () => { + const { isDeleted } = await deleteRepository(); + + expect(isDeleted).toBe(true); + }); + + it("should invalidate repository cache", async () => { + await shouldInvalidateQuery(["repository", "spaceships", "heartOfGold"], heartOfGold); + }); + + it("should invalidate repository collection cache", async () => { + await shouldInvalidateQuery(["repositories"], repositoryCollection); + }); + + it("should call onSuccess callback", async () => { + let repo; + await deleteRepository({ + onSuccess: repository => { + repo = repository; + } + }); + expect(repo).toEqual(heartOfGold); + }); + }); + + describe("useUpdateRepository tests", () => { + const queryClient = createInfiniteCachingClient(); + + beforeEach(() => { + queryClient.clear(); + }); + + const updateRepository = async () => { + fetchMock.putOnce("/api/v2/r/spaceships/heartOfGold", { + status: 204 + }); + + const { result, waitForNextUpdate } = renderHook(() => useUpdateRepository(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { update } = result.current; + update(heartOfGold); + return waitForNextUpdate(); + }); + + return result.current; + }; + + const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => { + queryClient.setQueryData(queryKey, data); + await updateRepository(); + + const queryState = queryClient.getQueryState(queryKey); + expect(queryState!.isInvalidated).toBe(true); + }; + + it("should update repository", async () => { + const { isUpdated } = await updateRepository(); + + expect(isUpdated).toBe(true); + }); + + it("should invalidate repository cache", async () => { + await shouldInvalidateQuery(["repository", "spaceships", "heartOfGold"], heartOfGold); + }); + + it("should invalidate repository collection cache", async () => { + await shouldInvalidateQuery(["repositories"], repositoryCollection); + }); + }); + + describe("useArchiveRepository tests", () => { + const queryClient = createInfiniteCachingClient(); + + beforeEach(() => { + queryClient.clear(); + }); + + const archiveRepository = async () => { + fetchMock.postOnce("/api/v2/r/spaceships/heartOfGold/archive", { + status: 204 + }); + + const { result, waitForNextUpdate } = renderHook(() => useArchiveRepository(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { archive } = result.current; + archive(heartOfGold); + return waitForNextUpdate(); + }); + + return result.current; + }; + + const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => { + queryClient.setQueryData(queryKey, data); + await archiveRepository(); + + const queryState = queryClient.getQueryState(queryKey); + expect(queryState!.isInvalidated).toBe(true); + }; + + it("should archive repository", async () => { + const { isArchived } = await archiveRepository(); + + expect(isArchived).toBe(true); + }); + + it("should invalidate repository cache", async () => { + await shouldInvalidateQuery(["repository", "spaceships", "heartOfGold"], heartOfGold); + }); + + it("should invalidate repository collection cache", async () => { + await shouldInvalidateQuery(["repositories"], repositoryCollection); + }); + }); + + describe("useUnarchiveRepository tests", () => { + const queryClient = createInfiniteCachingClient(); + + beforeEach(() => { + queryClient.clear(); + }); + + const unarchiveRepository = async () => { + fetchMock.postOnce("/api/v2/r/spaceships/heartOfGold/unarchive", { + status: 204 + }); + + const { result, waitForNextUpdate } = renderHook(() => useUnarchiveRepository(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { unarchive } = result.current; + unarchive(heartOfGold); + return waitForNextUpdate(); + }); + + return result.current; + }; + + const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => { + queryClient.setQueryData(queryKey, data); + await unarchiveRepository(); + + const queryState = queryClient.getQueryState(queryKey); + expect(queryState!.isInvalidated).toBe(true); + }; + + it("should unarchive repository", async () => { + const { isUnarchived } = await unarchiveRepository(); + + expect(isUnarchived).toBe(true); + }); + + it("should invalidate repository cache", async () => { + await shouldInvalidateQuery(["repository", "spaceships", "heartOfGold"], heartOfGold); + }); + + it("should invalidate repository collection cache", async () => { + await shouldInvalidateQuery(["repositories"], repositoryCollection); + }); + }); +}); diff --git a/scm-ui/ui-api/src/repositories.ts b/scm-ui/ui-api/src/repositories.ts new file mode 100644 index 0000000000..e5160cc481 --- /dev/null +++ b/scm-ui/ui-api/src/repositories.ts @@ -0,0 +1,229 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + Link, + Namespace, + Repository, + RepositoryCollection, + RepositoryCreation, + RepositoryTypeCollection, +} from "@scm-manager/ui-types"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { apiClient } from "./apiclient"; +import { ApiResult, useIndexJsonResource, useRequiredIndexLink } from "./base"; +import { createQueryString } from "./utils"; +import { requiredLink } from "./links"; +import { repoQueryKey } from "./keys"; +import { concat } from "./urls"; + +export type UseRepositoriesRequest = { + namespace?: Namespace; + search?: string; + page?: number | string; + disabled?: boolean; +}; + +export const useRepositories = (request?: UseRepositoriesRequest): ApiResult => { + const queryClient = useQueryClient(); + const indexLink = useRequiredIndexLink("repositories"); + const namespaceLink = (request?.namespace?._links.repositories as Link)?.href; + const link = namespaceLink || indexLink; + + const queryParams: Record = { + sortBy: "namespaceAndName" + }; + if (request?.search) { + queryParams.q = request.search; + } + if (request?.page) { + queryParams.page = request.page.toString(); + } + return useQuery( + ["repositories", request?.namespace?.namespace, request?.search || "", request?.page || 0], + () => apiClient.get(`${link}?${createQueryString(queryParams)}`).then(response => response.json()), + { + enabled: !request?.disabled, + onSuccess: (repositories: RepositoryCollection) => { + // prepare single repository cache + repositories._embedded.repositories.forEach((repository: Repository) => { + queryClient.setQueryData(["repository", repository.namespace, repository.name], repository); + }); + } + } + ); +}; + +type CreateRepositoryRequest = { + repository: RepositoryCreation; + initialize: boolean; +}; + +const createRepository = (link: string) => { + return (request: CreateRepositoryRequest) => { + let createLink = link; + if (request.initialize) { + createLink += "?initialize=true"; + } + return apiClient + .post(createLink, request.repository, "application/vnd.scmm-repository+json;v=2") + .then(response => { + const location = response.headers.get("Location"); + if (!location) { + throw new Error("Server does not return required Location header"); + } + return apiClient.get(location); + }) + .then(response => response.json()); + }; +}; + +export const useCreateRepository = () => { + const queryClient = useQueryClient(); + // not really the index link, + // but a post to the collection is create by convention + const link = useRequiredIndexLink("repositories"); + const { mutate, data, isLoading, error } = useMutation( + createRepository(link), + { + onSuccess: repository => { + queryClient.setQueryData(["repository", repository.namespace, repository.name], repository); + return queryClient.invalidateQueries(["repositories"]); + } + } + ); + return { + create: (repository: RepositoryCreation, initialize: boolean) => { + mutate({ repository, initialize }); + }, + isLoading, + error, + repository: data + }; +}; + +// TODO increase staleTime, infinite? +export const useRepositoryTypes = () => useIndexJsonResource("repositoryTypes"); + +export const useRepository = (namespace: string, name: string): ApiResult => { + const link = useRequiredIndexLink("repositories"); + return useQuery(["repository", namespace, name], () => + apiClient.get(concat(link, namespace, name)).then(response => response.json()) + ); +}; + +export type UseDeleteRepositoryOptions = { + onSuccess: (repository: Repository) => void; +}; + +export const useDeleteRepository = (options?: UseDeleteRepositoryOptions) => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + repository => { + const link = requiredLink(repository, "delete"); + return apiClient.delete(link); + }, + { + onSuccess: async (_, repository) => { + if (options?.onSuccess) { + options.onSuccess(repository); + } + await queryClient.invalidateQueries(repoQueryKey(repository)); + await queryClient.invalidateQueries(["repositories"]); + } + } + ); + return { + remove: (repository: Repository) => mutate(repository), + isLoading, + error, + isDeleted: !!data + }; +}; + +export const useUpdateRepository = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + repository => { + const link = requiredLink(repository, "update"); + return apiClient.put(link, repository, "application/vnd.scmm-repository+json;v=2"); + }, + { + onSuccess: async (_, repository) => { + await queryClient.invalidateQueries(repoQueryKey(repository)); + await queryClient.invalidateQueries(["repositories"]); + } + } + ); + return { + update: (repository: Repository) => mutate(repository), + isLoading, + error, + isUpdated: !!data + }; +}; + +export const useArchiveRepository = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + repository => { + const link = requiredLink(repository, "archive"); + return apiClient.post(link); + }, + { + onSuccess: async (_, repository) => { + await queryClient.invalidateQueries(repoQueryKey(repository)); + await queryClient.invalidateQueries(["repositories"]); + } + } + ); + return { + archive: (repository: Repository) => mutate(repository), + isLoading, + error, + isArchived: !!data + }; +}; + +export const useUnarchiveRepository = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + repository => { + const link = requiredLink(repository, "unarchive"); + return apiClient.post(link); + }, + { + onSuccess: async (_, repository) => { + await queryClient.invalidateQueries(repoQueryKey(repository)); + await queryClient.invalidateQueries(["repositories"]); + } + } + ); + return { + unarchive: (repository: Repository) => mutate(repository), + isLoading, + error, + isUnarchived: !!data + }; +}; diff --git a/scm-ui/ui-api/src/repository-roles.test.ts b/scm-ui/ui-api/src/repository-roles.test.ts new file mode 100644 index 0000000000..10a05474ea --- /dev/null +++ b/scm-ui/ui-api/src/repository-roles.test.ts @@ -0,0 +1,228 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RepositoryRole, RepositoryRoleCollection } from "@scm-manager/ui-types"; +import fetchMock from "fetch-mock-jest"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { setIndexLink } from "./tests/indexLinks"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import { act } from "react-test-renderer"; +import { + useCreateRepositoryRole, + useDeleteRepositoryRole, + useRepositoryRole, useRepositoryRoles, + useUpdateRepositoryRole +} from "./repository-roles"; + +describe("Test repository-roles hooks", () => { + const roleName = "theroleingstones"; + const role: RepositoryRole = { + name: roleName, + verbs: ["rocking"], + _links: { + delete: { + href: "/repositoryRoles/theroleingstones" + }, + update: { + href: "/repositoryRoles/theroleingstones" + } + } + }; + + const roleCollection: RepositoryRoleCollection = { + page: 0, + pageTotal: 0, + _links: {}, + _embedded: { + repositoryRoles: [role] + } + }; + + afterEach(() => { + fetchMock.reset(); + }); + + describe("useRepositoryRoles tests", () => { + it("should return repositoryRoles", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles"); + fetchMock.get("/api/v2/repositoryRoles", roleCollection); + const { result, waitFor } = renderHook(() => useRepositoryRoles(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(roleCollection); + }); + + it("should return paged repositoryRoles", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles"); + fetchMock.get("/api/v2/repositoryRoles", roleCollection, { + query: { + page: "42" + } + }); + const { result, waitFor } = renderHook(() => useRepositoryRoles({ page: 42 }), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(roleCollection); + }); + + it("should update repositoryRole cache", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles"); + fetchMock.get("/api/v2/repositoryRoles", roleCollection); + const { result, waitFor } = renderHook(() => useRepositoryRoles(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(queryClient.getQueryData(["repositoryRole", roleName])).toEqual(role); + }); + }); + + describe("useRepositoryRole tests", () => { + it("should return repositoryRole", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles"); + fetchMock.get("/api/v2/repositoryRoles/" + roleName, role); + const { result, waitFor } = renderHook(() => useRepositoryRole(roleName), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(role); + }); + }); + + describe("useCreateRepositoryRole tests", () => { + it("should create repositoryRole", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles"); + + fetchMock.postOnce("/api/v2/repositoryRoles", { + status: 201, + headers: { + Location: "/repositoryRoles/" + roleName + } + }); + + fetchMock.getOnce("/api/v2/repositoryRoles/" + roleName, role); + + const { result, waitForNextUpdate } = renderHook(() => useCreateRepositoryRole(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create(role); + return waitForNextUpdate(); + }); + + expect(result.current.repositoryRole).toEqual(role); + }); + + it("should fail without location header", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles"); + + fetchMock.postOnce("/api/v2/repositoryRoles", { + status: 201 + }); + + fetchMock.getOnce("/api/v2/repositoryRoles/" + roleName, role); + + const { result, waitForNextUpdate } = renderHook(() => useCreateRepositoryRole(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create(role); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeDefined(); + }); + }); + + describe("useDeleteRepositoryRole tests", () => { + it("should delete repositoryRole", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles"); + + fetchMock.deleteOnce("/api/v2/repositoryRoles/" + roleName, { + status: 200 + }); + + const { result, waitForNextUpdate } = renderHook(() => useDeleteRepositoryRole(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { remove } = result.current; + remove(role); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + expect(result.current.isDeleted).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(queryClient.getQueryData(["repositoryRole", roleName])).toBeUndefined(); + }); + }); + + describe("useUpdateRepositoryRole tests", () => { + it("should update repositoryRole", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "repositoryRoles", "/repositoryRoles"); + + const newRole: RepositoryRole = { + ...role, + name: "newname" + }; + + fetchMock.putOnce("/api/v2/repositoryRoles/" + roleName, { + status: 200 + }); + + const { result, waitForNextUpdate } = renderHook(() => useUpdateRepositoryRole(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { update } = result.current; + update(newRole); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + expect(result.current.isUpdated).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(queryClient.getQueryData(["repositoryRole", roleName])).toBeUndefined(); + expect(queryClient.getQueryData(["repositoryRole", "newname"])).toBeUndefined(); + expect(queryClient.getQueryData(["repositoryRoles"])).toBeUndefined(); + }); + }); +}); diff --git a/scm-ui/ui-api/src/repository-roles.ts b/scm-ui/ui-api/src/repository-roles.ts new file mode 100644 index 0000000000..ddc5ec48f6 --- /dev/null +++ b/scm-ui/ui-api/src/repository-roles.ts @@ -0,0 +1,118 @@ +import { ApiResult, useRequiredIndexLink } from "./base"; +import { RepositoryRole, RepositoryRoleCollection, RepositoryRoleCreation } from "@scm-manager/ui-types"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { apiClient, urls } from "@scm-manager/ui-components"; +import { createQueryString } from "./utils"; +import { requiredLink } from "./links"; + +export type UseRepositoryRolesRequest = { + page?: number | string; +}; + +export const useRepositoryRoles = (request?: UseRepositoryRolesRequest): ApiResult => { + const queryClient = useQueryClient(); + const indexLink = useRequiredIndexLink("repositoryRoles"); + + const queryParams: Record = {}; + if (request?.page) { + queryParams.page = request.page.toString(); + } + + return useQuery( + ["repositoryRoles", request?.page || 0], + () => apiClient.get(`${indexLink}?${createQueryString(queryParams)}`).then(response => response.json()), + { + onSuccess: (repositoryRoles: RepositoryRoleCollection) => { + repositoryRoles._embedded.repositoryRoles.forEach((repositoryRole: RepositoryRole) => + queryClient.setQueryData(["repositoryRole", repositoryRole.name], repositoryRole) + ); + } + } + ); +}; + +export const useRepositoryRole = (name: string): ApiResult => { + const indexLink = useRequiredIndexLink("repositoryRoles"); + return useQuery(["repositoryRole", name], () => + apiClient.get(urls.concat(indexLink, name)).then(response => response.json()) + ); +}; + +const createRepositoryRole = (link: string) => { + return (repositoryRole: RepositoryRoleCreation) => { + return apiClient + .post(link, repositoryRole, "application/vnd.scmm-repositoryRole+json;v=2") + .then(response => { + const location = response.headers.get("Location"); + if (!location) { + throw new Error("Server does not return required Location header"); + } + return apiClient.get(location); + }) + .then(response => response.json()); + }; +}; + +export const useCreateRepositoryRole = () => { + const queryClient = useQueryClient(); + const link = useRequiredIndexLink("repositoryRoles"); + const { mutate, data, isLoading, error } = useMutation( + createRepositoryRole(link), + { + onSuccess: repositoryRole => { + queryClient.setQueryData(["repositoryRole", repositoryRole.name], repositoryRole); + return queryClient.invalidateQueries(["repositoryRoles"]); + } + } + ); + return { + create: (repositoryRole: RepositoryRoleCreation) => mutate(repositoryRole), + isLoading, + error, + repositoryRole: data + }; +}; + +export const useUpdateRepositoryRole = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + repositoryRole => { + const updateUrl = requiredLink(repositoryRole, "update"); + return apiClient.put(updateUrl, repositoryRole, "application/vnd.scmm-repositoryRole+json;v=2"); + }, + { + onSuccess: async (_, repositoryRole) => { + await queryClient.invalidateQueries(["repositoryRole", repositoryRole.name]); + await queryClient.invalidateQueries(["repositoryRoles"]); + } + } + ); + return { + update: (repositoryRole: RepositoryRole) => mutate(repositoryRole), + isLoading, + error, + isUpdated: !!data + }; +}; + +export const useDeleteRepositoryRole = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + repositoryRole => { + const deleteUrl = requiredLink(repositoryRole, "delete"); + return apiClient.delete(deleteUrl); + }, + { + onSuccess: async (_, name) => { + await queryClient.invalidateQueries(["repositoryRole", name]); + await queryClient.invalidateQueries(["repositoryRoles"]); + } + } + ); + return { + remove: (repositoryRole: RepositoryRole) => mutate(repositoryRole), + isLoading, + error, + isDeleted: !!data + }; +}; diff --git a/scm-ui/ui-api/src/reset.ts b/scm-ui/ui-api/src/reset.ts new file mode 100644 index 0000000000..abb5a05ac1 --- /dev/null +++ b/scm-ui/ui-api/src/reset.ts @@ -0,0 +1,38 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { QueryClient, useQueryClient } from "react-query"; + +export const reset = (queryClient: QueryClient) => { + queryClient.removeQueries({ + predicate: ({ queryKey }) => queryKey !== "index" + }); + return queryClient.invalidateQueries("index"); +}; + +export const useReset = () => { + const queryClient = useQueryClient(); + return () => reset(queryClient); +}; diff --git a/scm-ui/ui-api/src/sources.test.ts b/scm-ui/ui-api/src/sources.test.ts new file mode 100644 index 0000000000..4a08387983 --- /dev/null +++ b/scm-ui/ui-api/src/sources.test.ts @@ -0,0 +1,235 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { File, Repository } from "@scm-manager/ui-types"; +import { useSources } from "./sources"; +import fetchMock from "fetch-mock"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { act, renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; + +describe("Test sources hooks", () => { + const puzzle42: Repository = { + namespace: "puzzles", + name: "42", + type: "git", + _links: { + sources: { + href: "/src" + } + } + }; + + const readmeMd: File = { + name: "README.md", + path: "README.md", + directory: false, + revision: "abc", + length: 21, + description: "Awesome readme", + _links: {}, + _embedded: { + children: [] + } + }; + + const rootDirectory: File = { + name: "", + path: "", + directory: true, + revision: "abc", + _links: {}, + _embedded: { + children: [readmeMd] + } + }; + + const sepecialMd: File = { + name: "special.md", + path: "main/special.md", + directory: false, + revision: "abc", + length: 42, + description: "Awesome special file", + _links: {}, + _embedded: { + children: [] + } + }; + + const sepecialMdPartial: File = { + ...sepecialMd, + partialResult: true, + computationAborted: false + }; + + const sepecialMdComputationAborted: File = { + ...sepecialMd, + partialResult: true, + computationAborted: true + }; + + const mainDirectoryTruncated: File = { + name: "main", + path: "main", + directory: true, + revision: "abc", + truncated: true, + _links: { + proceed: { + href: "src/2" + } + }, + _embedded: { + children: [] + } + }; + + const mainDirectory: File = { + ...mainDirectoryTruncated, + truncated: false, + _embedded: { + children: [sepecialMd] + } + }; + + beforeEach(() => { + fetchMock.reset(); + }); + + const firstChild = (directory?: File) => { + if (directory?._embedded.children && directory._embedded.children.length > 0) { + return directory._embedded.children[0]; + } + }; + + describe("useSources tests", () => { + it("should return root directory", async () => { + const queryClient = createInfiniteCachingClient(); + fetchMock.getOnce("/api/v2/src", rootDirectory); + const { result, waitFor } = renderHook(() => useSources(puzzle42), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(rootDirectory); + }); + + it("should return file from url with revision and path", async () => { + const queryClient = createInfiniteCachingClient(); + fetchMock.getOnce("/api/v2/src/abc/README.md", readmeMd); + const { result, waitFor } = renderHook(() => useSources(puzzle42, { revision: "abc", path: "README.md" }), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(readmeMd); + }); + + it("should fetch next page", async () => { + const queryClient = createInfiniteCachingClient(); + fetchMock.getOnce("/api/v2/src", mainDirectoryTruncated); + fetchMock.getOnce("/api/v2/src/2", mainDirectory); + const { result, waitFor, waitForNextUpdate } = renderHook(() => useSources(puzzle42), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + + expect(result.current.data).toEqual(mainDirectoryTruncated); + + await act(() => { + const { fetchNextPage } = result.current; + fetchNextPage(); + return waitForNextUpdate(); + }); + await waitFor(() => !result.current.isFetchingNextPage); + + expect(result.current.data).toEqual(mainDirectory); + }); + + it("should refetch if partial files exists", async () => { + const queryClient = createInfiniteCachingClient(); + fetchMock.get( + "/api/v2/src", + { + ...mainDirectory, + _embedded: { + children: [sepecialMdPartial] + } + }, + { + repeat: 1 + } + ); + fetchMock.get( + "/api/v2/src", + { + ...mainDirectory, + _embedded: { + children: [sepecialMd] + } + }, + { + repeat: 1, + overwriteRoutes: false + } + ); + + const { result, waitFor } = renderHook(() => useSources(puzzle42, { refetchPartialInterval: 100 }), { + wrapper: createWrapper(undefined, queryClient) + }); + + await waitFor(() => !!firstChild(result.current.data)); + expect(firstChild(result.current.data)?.partialResult).toBe(true); + + await waitFor(() => !firstChild(result.current.data)?.partialResult); + expect(firstChild(result.current.data)?.partialResult).toBeFalsy(); + }); + + it("should not refetch if computation is aborted", async () => { + const queryClient = createInfiniteCachingClient(); + fetchMock.getOnce("/api/v2/src/abc/main/special.md", sepecialMdComputationAborted, { repeat: 1 }); + // should never be called + fetchMock.getOnce("/api/v2/src/abc/main/special.md", sepecialMd, { + repeat: 1, + overwriteRoutes: false + }); + const { result, waitFor } = renderHook( + () => + useSources(puzzle42, { + revision: "abc", + path: "main/special.md", + refetchPartialInterval: 100 + }), + { + wrapper: createWrapper(undefined, queryClient) + } + ); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(sepecialMdComputationAborted); + + await new Promise(r => setTimeout(r, 200)); + expect(result.current.data).toEqual(sepecialMdComputationAborted); + }); + }); +}); diff --git a/scm-ui/ui-api/src/sources.ts b/scm-ui/ui-api/src/sources.ts new file mode 100644 index 0000000000..49fe5fb82b --- /dev/null +++ b/scm-ui/ui-api/src/sources.ts @@ -0,0 +1,131 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { File, Link, Repository } from "@scm-manager/ui-types"; +import { requiredLink } from "./links"; +import { apiClient, urls } from "@scm-manager/ui-components"; +import { useInfiniteQuery } from "react-query"; +import { repoQueryKey } from "./keys"; +import { useEffect } from "react"; + +export type UseSourcesOptions = { + revision?: string; + path?: string; + refetchPartialInterval?: number; + enabled?: boolean; +}; + +const UseSourcesDefaultOptions: UseSourcesOptions = { + enabled: true, + refetchPartialInterval: 3000 +}; + +export const useSources = (repository: Repository, opts: UseSourcesOptions = UseSourcesDefaultOptions) => { + const options = { + ...UseSourcesDefaultOptions, + ...opts + }; + const link = createSourcesLink(repository, options); + const { isLoading, error, data, isFetchingNextPage, fetchNextPage, refetch } = useInfiniteQuery( + repoQueryKey(repository, "sources", options.revision || "", options.path || ""), + ({ pageParam }) => { + return apiClient.get(pageParam || link).then(response => response.json()); + }, + { + enabled: options.enabled, + getNextPageParam: lastPage => { + return (lastPage._links.proceed as Link)?.href; + } + } + ); + + const file = merge(data?.pages); + useEffect(() => { + const intervalId = setInterval(() => { + if (isPartial(file)) { + refetch({ + throwOnError: true + }); + } + }, options.refetchPartialInterval); + return () => clearInterval(intervalId); + }, [options.refetchPartialInterval, file]); + + return { + isLoading, + error, + data: file, + isFetchingNextPage, + fetchNextPage: () => { + // wrapped because we do not want to leak react-query types in our api + fetchNextPage(); + } + }; +}; + +const createSourcesLink = (repository: Repository, options: UseSourcesOptions) => { + let link = requiredLink(repository, "sources"); + if (options.revision) { + link = urls.concat(link, encodeURIComponent(options.revision)); + + if (options.path) { + link = urls.concat(link, options.path); + } + } + return link; +}; + +const merge = (files?: File[]): File | undefined => { + if (!files || files.length === 0) { + return; + } + const children = []; + for (const page of files) { + children.push(...(page._embedded?.children || [])); + } + const lastPage = files[files.length - 1]; + return { + ...lastPage, + _embedded: { + ...lastPage._embedded, + children + } + }; +}; + +const isFilePartial = (f: File) => { + return f.partialResult && !f.computationAborted; +}; + +const isPartial = (file?: File) => { + if (!file) { + return false; + } + if (isFilePartial(file)) { + return true; + } + + return file._embedded?.children?.some(isFilePartial); +}; diff --git a/scm-ui/ui-api/src/tags.test.ts b/scm-ui/ui-api/src/tags.test.ts new file mode 100644 index 0000000000..df1a56ad46 --- /dev/null +++ b/scm-ui/ui-api/src/tags.test.ts @@ -0,0 +1,266 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { Changeset, Repository, Tag, TagCollection } from "@scm-manager/ui-types"; +import fetchMock from "fetch-mock-jest"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { useCreateTag, useDeleteTag, useTag, useTags } from "./tags"; +import { act } from "react-test-renderer"; + +describe("Test Tag hooks", () => { + const repository: Repository = { + namespace: "hitchhiker", + name: "heart-of-gold", + type: "git", + _links: { + tags: { + href: "/hog/tags" + } + } + }; + + const changeset: Changeset = { + id: "42", + description: "Awesome change", + date: new Date(), + author: { + name: "Arthur Dent" + }, + _embedded: {}, + _links: { + tag: { + href: "/hog/tag" + } + } + }; + + const tagOneDotZero = { + name: "1.0", + revision: "42", + signatures: [], + _links: { + "delete": { + href: "/hog/tags/1.0" + } + } + }; + + const tags: TagCollection = { + _embedded: { + tags: [tagOneDotZero] + }, + _links: {} + }; + + const queryClient = createInfiniteCachingClient(); + + beforeEach(() => queryClient.clear()); + + afterEach(() => { + fetchMock.reset(); + }); + + describe("useTags tests", () => { + const fetchTags = async () => { + fetchMock.getOnce("/api/v2/hog/tags", tags); + + const { result, waitFor } = renderHook(() => useTags(repository), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + + return result.current; + }; + + it("should return tags", async () => { + const { data } = await fetchTags(); + expect(data).toEqual(tags); + }); + + it("should cache tag collection", async () => { + await fetchTags(); + + const cachedTags = queryClient.getQueryData(["repository", "hitchhiker", "heart-of-gold", "tags"]); + expect(cachedTags).toEqual(tags); + }); + }); + + describe("useTag tests", () => { + const fetchTag = async () => { + fetchMock.getOnce("/api/v2/hog/tags/1.0", tagOneDotZero); + + const { result, waitFor } = renderHook(() => useTag(repository, "1.0"), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => { + return !!result.current.data; + }); + + return result.current; + }; + + it("should return tag", async () => { + const { data } = await fetchTag(); + expect(data).toEqual(tagOneDotZero); + }); + + it("should cache tag", async () => { + await fetchTag(); + + const cachedTag = queryClient.getQueryData(["repository", "hitchhiker", "heart-of-gold", "tag", "1.0"]); + expect(cachedTag).toEqual(tagOneDotZero); + }); + }); + + describe("useCreateTags tests", () => { + const createTag = async () => { + fetchMock.postOnce("/api/v2/hog/tag", { + status: 201, + headers: { + Location: "/hog/tags/1.0" + } + }); + + fetchMock.getOnce("/api/v2/hog/tags/1.0", tagOneDotZero); + + const { result, waitForNextUpdate } = renderHook(() => useCreateTag(repository, changeset), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create("1.0"); + return waitForNextUpdate(); + }); + + return result.current; + }; + + const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => { + queryClient.setQueryData(queryKey, data); + await createTag(); + + const queryState = queryClient.getQueryState(queryKey); + expect(queryState!.isInvalidated).toBe(true); + }; + + it("should create tag", async () => { + const { tag } = await createTag(); + + expect(tag).toEqual(tagOneDotZero); + }); + + it("should cache tag", async () => { + await createTag(); + + const cachedTag = queryClient.getQueryData(["repository", "hitchhiker", "heart-of-gold", "tag", "1.0"]); + expect(cachedTag).toEqual(tagOneDotZero); + }); + + it("should invalidate tag collection cache", async () => { + await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "tags"], tags); + }); + + it("should invalidate changeset cache", async () => { + await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "changeset", "42"], changeset); + }); + + it("should invalidate changeset collection cache", async () => { + await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "changesets"], [changeset]); + }); + + it("should fail without location header", async () => { + fetchMock.postOnce("/api/v2/hog/tag", { + status: 201 + }); + + const { result, waitForNextUpdate } = renderHook(() => useCreateTag(repository, changeset), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create("awesome-42"); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeDefined(); + }); + }); + + describe("useDeleteTags tests", () => { + const deleteTag = async () => { + fetchMock.deleteOnce("/api/v2/hog/tags/1.0", { + status: 204 + }); + + const { result, waitForNextUpdate } = renderHook(() => useDeleteTag(repository), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { remove } = result.current; + remove(tagOneDotZero); + return waitForNextUpdate(); + }); + + return result.current; + }; + + const shouldInvalidateQuery = async (queryKey: string[], data: unknown) => { + queryClient.setQueryData(queryKey, data); + await deleteTag(); + + const queryState = queryClient.getQueryState(queryKey); + expect(queryState!.isInvalidated).toBe(true); + }; + + it("should delete tag", async () => { + const { isDeleted } = await deleteTag(); + + expect(isDeleted).toBe(true); + }); + + it("should invalidate tag cache", async () => { + await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "tag", "1.0"], tagOneDotZero); + }); + + it("should invalidate tag collection cache", async () => { + await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "tags"], tags); + }); + + it("should invalidate changeset cache", async () => { + await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "changeset", "42"], changeset); + }); + + it("should invalidate changeset collection cache", async () => { + await shouldInvalidateQuery(["repository", "hitchhiker", "heart-of-gold", "changesets"], [changeset]); + }); + }); +}); diff --git a/scm-ui/ui-api/src/tags.ts b/scm-ui/ui-api/src/tags.ts new file mode 100644 index 0000000000..441360f224 --- /dev/null +++ b/scm-ui/ui-api/src/tags.ts @@ -0,0 +1,119 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { Changeset, Link, NamespaceAndName, Repository, Tag, TagCollection } from "@scm-manager/ui-types"; +import { requiredLink } from "./links"; +import { QueryClient, useMutation, useQuery, useQueryClient } from "react-query"; +import { ApiResult } from "./base"; +import { repoQueryKey } from "./keys"; +import { apiClient } from "./apiclient"; +import { concat } from "./urls"; + +const tagQueryKey = (repository: NamespaceAndName, tag: string) => { + return repoQueryKey(repository, "tag", tag); +}; + +export const useTags = (repository: Repository): ApiResult => { + const link = requiredLink(repository, "tags"); + return useQuery( + repoQueryKey(repository, "tags"), + () => apiClient.get(link).then(response => response.json()) + // we do not populate the cache for a single tag, + // because we have no pagination for tags and if we have a lot of them + // the population slows us down + ); +}; + +export const useTag = (repository: Repository, name: string): ApiResult => { + const link = requiredLink(repository, "tags"); + return useQuery(tagQueryKey(repository, name), () => + apiClient.get(concat(link, name)).then(response => response.json()) + ); +}; + +const invalidateCacheForTag = (queryClient: QueryClient, repository: NamespaceAndName, tag: Tag) => { + return Promise.all([ + queryClient.invalidateQueries(repoQueryKey(repository, "tags")), + queryClient.invalidateQueries(tagQueryKey(repository, tag.name)), + queryClient.invalidateQueries(repoQueryKey(repository, "changesets")), + queryClient.invalidateQueries(repoQueryKey(repository, "changeset", tag.revision)) + ]); +}; + +const createTag = (changeset: Changeset, link: string) => { + return (name: string) => { + return apiClient + .post(link, { + name, + revision: changeset.id + }) + .then(response => { + const location = response.headers.get("Location"); + if (!location) { + throw new Error("Server does not return required Location header"); + } + return apiClient.get(location); + }) + .then(response => response.json()); + }; +}; + +export const useCreateTag = (repository: Repository, changeset: Changeset) => { + const queryClient = useQueryClient(); + const link = requiredLink(changeset, "tag"); + const { isLoading, error, mutate, data } = useMutation(createTag(changeset, link), { + onSuccess: async tag => { + queryClient.setQueryData(tagQueryKey(repository, tag.name), tag); + await invalidateCacheForTag(queryClient, repository, tag); + } + }); + return { + isLoading, + error, + create: (name: string) => mutate(name), + tag: data + }; +}; + +export const useDeleteTag = (repository: Repository) => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + tag => { + const deleteUrl = (tag._links.delete as Link).href; + return apiClient.delete(deleteUrl); + }, + { + onSuccess: async (_, tag) => { + await invalidateCacheForTag(queryClient, repository, tag); + } + } + ); + return { + remove: (tag: Tag) => mutate(tag), + isLoading, + error, + isDeleted: !!data + }; +}; diff --git a/scm-ui/ui-components/.storybook/withRedux.js b/scm-ui/ui-api/src/tests/createInfiniteCachingClient.ts similarity index 78% rename from scm-ui/ui-components/.storybook/withRedux.js rename to scm-ui/ui-api/src/tests/createInfiniteCachingClient.ts index 0abc2149ca..88f56b9d38 100644 --- a/scm-ui/ui-components/.storybook/withRedux.js +++ b/scm-ui/ui-api/src/tests/createInfiniteCachingClient.ts @@ -22,20 +22,16 @@ * SOFTWARE. */ -import React from "react"; -import {createStore} from "redux"; -import { Provider } from 'react-redux' +import { QueryClient } from "react-query"; -const reducer = (state, action) => { - return state; +const createInfiniteCachingClient = () => { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity + } + } + }); }; -const withRedux = (storyFn) => { - return React.createElement(Provider, { - store: createStore(reducer, {}), - children: storyFn() - }); -} - - -export default withRedux; +export default createInfiniteCachingClient; diff --git a/scm-ui/ui-api/src/tests/createWrapper.tsx b/scm-ui/ui-api/src/tests/createWrapper.tsx new file mode 100644 index 0000000000..40a8434f23 --- /dev/null +++ b/scm-ui/ui-api/src/tests/createWrapper.tsx @@ -0,0 +1,37 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { LegacyContext, LegacyContextProvider } from "../LegacyContext"; +import { QueryClient, QueryClientProvider } from "react-query"; + +const createWrapper = (context?: LegacyContext, queryClient?: QueryClient): FC => { + return ({ children }) => ( + + {children} + + ); +}; + +export default createWrapper; diff --git a/scm-ui/ui-api/src/tests/indexLinks.ts b/scm-ui/ui-api/src/tests/indexLinks.ts new file mode 100644 index 0000000000..b55b874fbd --- /dev/null +++ b/scm-ui/ui-api/src/tests/indexLinks.ts @@ -0,0 +1,43 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { QueryClient} from "react-query"; + +export const setIndexLink = (queryClient: QueryClient, name: string, href: string) => { + queryClient.setQueryData("index", { + version: "x.y.z", + _links: { + [name]: { + href: href + } + } + }); +}; + +export const setEmptyIndex = (queryClient: QueryClient) => { + queryClient.setQueryData("index", { + version: "x.y.z", + _links: {} + }); +}; diff --git a/scm-ui/ui-components/src/urls.test.ts b/scm-ui/ui-api/src/urls.test.ts similarity index 100% rename from scm-ui/ui-components/src/urls.test.ts rename to scm-ui/ui-api/src/urls.test.ts diff --git a/scm-ui/ui-components/src/urls.ts b/scm-ui/ui-api/src/urls.ts similarity index 96% rename from scm-ui/ui-components/src/urls.ts rename to scm-ui/ui-api/src/urls.ts index cae061b225..50e564d848 100644 --- a/scm-ui/ui-components/src/urls.ts +++ b/scm-ui/ui-api/src/urls.ts @@ -23,7 +23,6 @@ */ import queryString from "query-string"; -import { RouteComponentProps } from "react-router-dom"; //@ts-ignore export const contextPath = window.ctxPath || ""; @@ -93,7 +92,7 @@ export function matchedUrlFromMatch(match: any) { return stripEndingSlash(match.url); } -export function matchedUrl(props: RouteComponentProps) { +export function matchedUrl(props: any) { const match = props.match; return matchedUrlFromMatch(match); } diff --git a/scm-ui/ui-api/src/users.test.ts b/scm-ui/ui-api/src/users.test.ts new file mode 100644 index 0000000000..c3b34da2af --- /dev/null +++ b/scm-ui/ui-api/src/users.test.ts @@ -0,0 +1,310 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { User, UserCollection } from "@scm-manager/ui-types"; +import fetchMock from "fetch-mock-jest"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; +import { setIndexLink } from "./tests/indexLinks"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import { act } from "react-test-renderer"; +import { + useConvertToExternal, + useConvertToInternal, + useCreateUser, + useDeleteUser, + useUpdateUser, + useUser, + useUsers +} from "./users"; + +describe("Test user hooks", () => { + const yoda: User = { + active: false, + displayName: "", + external: false, + password: "", + name: "yoda", + _links: { + delete: { + href: "/users/yoda" + }, + update: { + href: "/users/yoda" + }, + convertToInternal: { + href: "/users/yoda/convertToInternal" + }, + convertToExternal: { + href: "/users/yoda/convertToExternal" + } + }, + _embedded: { + members: [] + } + }; + + const userCollection: UserCollection = { + _links: {}, + page: 0, + pageTotal: 0, + _embedded: { + users: [yoda] + } + }; + + afterEach(() => { + fetchMock.reset(); + }); + + describe("useUsers tests", () => { + it("should return users", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "users", "/users"); + fetchMock.get("/api/v2/users", userCollection); + const { result, waitFor } = renderHook(() => useUsers(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(userCollection); + }); + + it("should return paged users", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "users", "/users"); + fetchMock.get("/api/v2/users", userCollection, { + query: { + page: "42" + } + }); + const { result, waitFor } = renderHook(() => useUsers({ page: 42 }), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(userCollection); + }); + + it("should return searched users", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "users", "/users"); + fetchMock.get("/api/v2/users", userCollection, { + query: { + q: "yoda" + } + }); + const { result, waitFor } = renderHook(() => useUsers({ search: "yoda" }), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(userCollection); + }); + + it("should update user cache", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "users", "/users"); + fetchMock.get("/api/v2/users", userCollection); + const { result, waitFor } = renderHook(() => useUsers(), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(queryClient.getQueryData(["user", "yoda"])).toEqual(yoda); + }); + }); + + describe("useUser tests", () => { + it("should return user", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "users", "/users"); + fetchMock.get("/api/v2/users/yoda", yoda); + const { result, waitFor } = renderHook(() => useUser("yoda"), { + wrapper: createWrapper(undefined, queryClient) + }); + await waitFor(() => !!result.current.data); + expect(result.current.data).toEqual(yoda); + }); + }); + + describe("useCreateUser tests", () => { + it("should create user", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "users", "/users"); + + fetchMock.postOnce("/api/v2/users", { + status: 201, + headers: { + Location: "/users/yoda" + } + }); + + fetchMock.getOnce("/api/v2/users/yoda", yoda); + + const { result, waitForNextUpdate } = renderHook(() => useCreateUser(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create(yoda); + return waitForNextUpdate(); + }); + + expect(result.current.user).toEqual(yoda); + }); + + it("should fail without location header", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "users", "/users"); + + fetchMock.postOnce("/api/v2/users", { + status: 201 + }); + + fetchMock.getOnce("/api/v2/users/yoda", yoda); + + const { result, waitForNextUpdate } = renderHook(() => useCreateUser(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { create } = result.current; + create(yoda); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeDefined(); + }); + }); + + describe("useDeleteUser tests", () => { + it("should delete user", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "users", "/users"); + + fetchMock.deleteOnce("/api/v2/users/yoda", { + status: 200 + }); + + const { result, waitForNextUpdate } = renderHook(() => useDeleteUser(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { remove } = result.current; + remove(yoda); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + expect(result.current.isDeleted).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(queryClient.getQueryData(["user", "yoda"])).toBeUndefined(); + }); + }); + + describe("useUpdateUser tests", () => { + it("should update user", async () => { + const queryClient = createInfiniteCachingClient(); + setIndexLink(queryClient, "users", "/users"); + + const newJedis = { + ...yoda, + description: "may the 4th be with you" + }; + + fetchMock.putOnce("/api/v2/users/yoda", { + status: 200 + }); + + fetchMock.getOnce("/api/v2/users/yoda", newJedis); + + const { result, waitForNextUpdate } = renderHook(() => useUpdateUser(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { update } = result.current; + update(newJedis); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + expect(result.current.isUpdated).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(queryClient.getQueryData(["user", "yoda"])).toBeUndefined(); + expect(queryClient.getQueryData(["users"])).toBeUndefined(); + }); + }); + + describe("useConvertToInternal tests", () => { + it("should convert user", async () => { + const queryClient = createInfiniteCachingClient(); + + fetchMock.putOnce("/api/v2/users/yoda/convertToInternal", { + status: 200 + }); + + const { result, waitForNextUpdate } = renderHook(() => useConvertToInternal(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { convertToInternal } = result.current; + convertToInternal(yoda, "thisisaverystrongpassword"); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + expect(result.current.isConverted).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(queryClient.getQueryData(["user", "yoda"])).toBeUndefined(); + expect(queryClient.getQueryData(["users"])).toBeUndefined(); + }); + }); + + describe("useConvertToExternal tests", () => { + it("should convert user", async () => { + const queryClient = createInfiniteCachingClient(); + + fetchMock.putOnce("/api/v2/users/yoda/convertToExternal", { + status: 200 + }); + + const { result, waitForNextUpdate } = renderHook(() => useConvertToExternal(), { + wrapper: createWrapper(undefined, queryClient) + }); + + await act(() => { + const { convertToExternal } = result.current; + convertToExternal(yoda); + return waitForNextUpdate(); + }); + + expect(result.current.error).toBeFalsy(); + expect(result.current.isConverted).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(queryClient.getQueryData(["user", "yoda"])).toBeUndefined(); + expect(queryClient.getQueryData(["users"])).toBeUndefined(); + }); + }); +}); diff --git a/scm-ui/ui-api/src/users.ts b/scm-ui/ui-api/src/users.ts new file mode 100644 index 0000000000..a8f1dfdb6e --- /dev/null +++ b/scm-ui/ui-api/src/users.ts @@ -0,0 +1,198 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ApiResult, useRequiredIndexLink } from "./base"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Link, User, UserCollection, UserCreation } from "@scm-manager/ui-types"; +import { apiClient } from "./apiclient"; +import { createQueryString } from "./utils"; +import { concat } from "./urls"; + +export type UseUsersRequest = { + page?: number | string; + search?: string; +}; + +export const useUsers = (request?: UseUsersRequest): ApiResult => { + const queryClient = useQueryClient(); + const indexLink = useRequiredIndexLink("users"); + + const queryParams: Record = {}; + if (request?.search) { + queryParams.q = request.search; + } + if (request?.page) { + queryParams.page = request.page.toString(); + } + + return useQuery( + ["users", request?.search || "", request?.page || 0], + () => apiClient.get(`${indexLink}?${createQueryString(queryParams)}`).then(response => response.json()), + { + onSuccess: (users: UserCollection) => { + users._embedded.users.forEach((user: User) => queryClient.setQueryData(["user", user.name], user)); + } + } + ); +}; + +export const useUser = (name: string): ApiResult => { + const indexLink = useRequiredIndexLink("users"); + return useQuery(["user", name], () => + apiClient.get(concat(indexLink, name)).then(response => response.json()) + ); +}; + +const createUser = (link: string) => { + return (user: UserCreation) => { + return apiClient + .post(link, user, "application/vnd.scmm-user+json;v=2") + .then(response => { + const location = response.headers.get("Location"); + if (!location) { + throw new Error("Server does not return required Location header"); + } + return apiClient.get(location); + }) + .then(response => response.json()); + }; +}; + +export const useCreateUser = () => { + const queryClient = useQueryClient(); + const link = useRequiredIndexLink("users"); + const { mutate, data, isLoading, error } = useMutation(createUser(link), { + onSuccess: user => { + queryClient.setQueryData(["user", user.name], user); + return queryClient.invalidateQueries(["users"]); + } + }); + return { + create: (user: UserCreation) => mutate(user), + isLoading, + error, + user: data + }; +}; + +export const useUpdateUser = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + user => { + const updateUrl = (user._links.update as Link).href; + return apiClient.put(updateUrl, user, "application/vnd.scmm-user+json;v=2"); + }, + { + onSuccess: async (_, user) => { + await queryClient.invalidateQueries(["user", user.name]); + await queryClient.invalidateQueries(["users"]); + } + } + ); + return { + update: (user: User) => mutate(user), + isLoading, + error, + isUpdated: !!data + }; +}; + +export const useDeleteUser = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + user => { + const deleteUrl = (user._links.delete as Link).href; + return apiClient.delete(deleteUrl); + }, + { + onSuccess: async (_, name) => { + await queryClient.invalidateQueries(["user", name]); + await queryClient.invalidateQueries(["users"]); + } + } + ); + return { + remove: (user: User) => mutate(user), + isLoading, + error, + isDeleted: !!data + }; +}; + +const convertToInternal = (url: string, newPassword: string) => { + return apiClient.put( + url, + { + newPassword + }, + "application/vnd.scmm-user+json;v=2" + ); +}; + +const convertToExternal = (url: string) => { + return apiClient.put(url, {}, "application/vnd.scmm-user+json;v=2"); +}; + +export type ConvertToInternalRequest = { + user: User; + password: string; +}; + +export const useConvertToInternal = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + ({ user, password }) => convertToInternal((user._links.convertToInternal as Link).href, password), + { + onSuccess: async (_, { user }) => { + await queryClient.invalidateQueries(["user", user.name]); + await queryClient.invalidateQueries(["users"]); + } + } + ); + return { + convertToInternal: (user: User, password: string) => mutate({ user, password }), + isLoading, + error, + isConverted: !!data + }; +}; + +export const useConvertToExternal = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + user => convertToExternal((user._links.convertToExternal as Link).href), + { + onSuccess: async (_, user) => { + await queryClient.invalidateQueries(["user", user.name]); + await queryClient.invalidateQueries(["users"]); + } + } + ); + return { + convertToExternal: (user: User) => mutate(user), + isLoading, + error, + isConverted: !!data + }; +}; diff --git a/scm-ui/ui-api/src/utils.ts b/scm-ui/ui-api/src/utils.ts new file mode 100644 index 0000000000..711c140ca0 --- /dev/null +++ b/scm-ui/ui-api/src/utils.ts @@ -0,0 +1,29 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export const createQueryString = (params: Record) => { + return Object.keys(params) + .map(k => encodeURIComponent(k) + "=" + encodeURIComponent(params[k])) + .join("&"); +}; diff --git a/scm-ui/ui-api/tsconfig.json b/scm-ui/ui-api/tsconfig.json new file mode 100644 index 0000000000..9aa573a38d --- /dev/null +++ b/scm-ui/ui-api/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@scm-manager/tsconfig", + "exclude": [ + "./scripts" + ] +} diff --git a/scm-ui/ui-components/.storybook/.babelrc b/scm-ui/ui-components/.storybook/.babelrc new file mode 100644 index 0000000000..a138b1182a --- /dev/null +++ b/scm-ui/ui-components/.storybook/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@scm-manager/babel-preset"] +} diff --git a/scm-ui/ui-components/.storybook/config.js b/scm-ui/ui-components/.storybook/config.js index af032d4054..bb24933bb1 100644 --- a/scm-ui/ui-components/.storybook/config.js +++ b/scm-ui/ui-components/.storybook/config.js @@ -25,12 +25,9 @@ import i18next from "i18next"; import { initReactI18next } from "react-i18next"; import { addDecorator, configure } from "@storybook/react"; import { withI18next } from "storybook-addon-i18next"; - import "!style-loader!css-loader!sass-loader!../../ui-styles/src/scm.scss"; import React from "react"; -import { MemoryRouter } from "react-router-dom"; -import withRedux from "./withRedux"; - +import withApiProvider from "./withApiProvider"; let i18n = i18next; @@ -38,11 +35,10 @@ let i18n = i18next; // and not for storyshots if (!process.env.JEST_WORKER_ID) { const Backend = require("i18next-fetch-backend"); - i18n = i18n.use(Backend.default) + i18n = i18n.use(Backend.default); } -i18n -.use(initReactI18next).init({ +i18n.use(initReactI18next).init({ whitelist: ["en", "de", "es"], lng: "en", fallbackLng: "en", @@ -71,6 +67,6 @@ addDecorator( }) ); -addDecorator(withRedux); +addDecorator(withApiProvider); configure(require.context("../src", true, /\.stories\.tsx?$/), module); diff --git a/scm-ui/ui-components/.storybook/webpack.config.js b/scm-ui/ui-components/.storybook/webpack.config.js deleted file mode 100644 index 9b7c1b6d26..0000000000 --- a/scm-ui/ui-components/.storybook/webpack.config.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2020-present Cloudogu GmbH and Contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -const WorkerPlugin = require("worker-plugin"); - -module.exports = { - module: { - rules: [ - { - parser: { - system: false, - systemjs: false - } - }, - { - test: /\.(js|ts|jsx|tsx)$/, - exclude: /node_modules/, - use: [ - { - loader: "babel-loader", - options: { - cacheDirectory: true, - presets: ["@scm-manager/babel-preset"] - } - } - ] - }, - { - test: /\.(css|scss|sass)$/i, - use: [ - // Creates `style` nodes from JS strings - "style-loader", - { - loader: "css-loader", - options: { - // Run `postcss-loader` on each CSS `@import`, do not forget that `sass-loader` compile non CSS `@import`'s into a single file - // If you need run `sass-loader` and `postcss-loader` on each CSS `@import` please set it to `2` - importLoaders: 1, - // Automatically enable css modules for files satisfying `/\.module\.\w+$/i` RegExp. - modules: { auto: true } - } - }, - // Compiles Sass to CSS - "sass-loader" - ] - }, - { - test: /\.(png|svg|jpg|gif|woff2?|eot|ttf)$/, - use: ["file-loader"] - } - ] - }, - resolve: { - extensions: [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".json"] - }, - plugins: [ - new WorkerPlugin() - ] -}; diff --git a/scm-ui/ui-components/.storybook/withApiProvider.js b/scm-ui/ui-components/.storybook/withApiProvider.js new file mode 100644 index 0000000000..1ae78e15f9 --- /dev/null +++ b/scm-ui/ui-components/.storybook/withApiProvider.js @@ -0,0 +1,46 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import * as React from "react"; +import { ApiProvider } from "@scm-manager/ui-api"; + +const withApiProvider = (storyFn) => { + return React.createElement(ApiProvider, { + index: { + version: "x.y.z", + _links: {} + }, + me: { + name: "trillian", + displayName: "Trillian McMillan", + mail: "trillian@hitchhiker.com", + groups: [], + _links: {} + }, + children: storyFn() + }); +} + +export default withApiProvider; diff --git a/scm-ui/ui-components/package.json b/scm-ui/ui-components/package.json index d78518e1e7..3465b5a3bb 100644 --- a/scm-ui/ui-components/package.json +++ b/scm-ui/ui-components/package.json @@ -18,15 +18,15 @@ "update-storyshots": "jest --testPathPattern=\"storyshots.test.ts\" --collectCoverage=false -u" }, "devDependencies": { - "@scm-manager/babel-preset": "^2.10.1", + "@scm-manager/babel-preset": "^2.11.2", "@scm-manager/eslint-config": "^2.12.0", - "@scm-manager/jest-preset": "^2.12.4", + "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/prettier-config": "^2.10.1", - "@scm-manager/tsconfig": "^2.10.1", + "@scm-manager/tsconfig": "^2.11.2", "@scm-manager/ui-tests": "^2.13.1-SNAPSHOT", - "@storybook/addon-actions": "^6.0.28", - "@storybook/addon-storyshots": "^6.0.28", - "@storybook/react": "^6.0.28", + "@storybook/addon-actions": "^6.1.17", + "@storybook/addon-storyshots": "^6.1.17", + "@storybook/react": "^6.1.17", "@types/classnames": "^2.2.9", "@types/css": "^0.0.31", "@types/enzyme": "^3.10.3", @@ -51,12 +51,12 @@ "react-test-renderer": "^16.10.2", "storybook-addon-i18next": "^1.3.0", "to-camel-case": "^1.0.0", - "typescript": "^3.7.2", "worker-plugin": "^3.2.0" }, "dependencies": { "@scm-manager/ui-extensions": "^2.13.1-SNAPSHOT", "@scm-manager/ui-types": "^2.13.1-SNAPSHOT", + "@scm-manager/ui-api": "^2.13.1-SNAPSHOT", "classnames": "^2.2.6", "date-fns": "^2.4.1", "gitdiff-parser": "^0.1.2", diff --git a/scm-ui/ui-components/src/BackendErrorNotification.tsx b/scm-ui/ui-components/src/BackendErrorNotification.tsx index a680304e73..aae7633c49 100644 --- a/scm-ui/ui-components/src/BackendErrorNotification.tsx +++ b/scm-ui/ui-components/src/BackendErrorNotification.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ import React from "react"; -import { BackendError } from "./errors"; +import { BackendError } from "@scm-manager/ui-api"; import Notification from "./Notification"; import { WithTranslation, withTranslation } from "react-i18next"; diff --git a/scm-ui/ui-components/src/Breadcrumb.tsx b/scm-ui/ui-components/src/Breadcrumb.tsx index 12f99f1539..986fab0089 100644 --- a/scm-ui/ui-components/src/Breadcrumb.tsx +++ b/scm-ui/ui-components/src/Breadcrumb.tsx @@ -27,16 +27,16 @@ import { useHistory, useLocation, Link } from "react-router-dom"; import classNames from "classnames"; import styled from "styled-components"; import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; -import { Branch, Repository } from "@scm-manager/ui-types"; +import { Branch, Repository, File } from "@scm-manager/ui-types"; import Icon from "./Icon"; import Tooltip from "./Tooltip"; import copyToClipboard from "./CopyToClipboard"; -import { withContextPath } from "./urls"; +import { urls } from "@scm-manager/ui-api"; type Props = { repository: Repository; - branch: Branch; - defaultBranch: Branch; + branch?: Branch; + defaultBranch?: Branch; revision: string; path: string; baseUrl: string; @@ -53,6 +53,7 @@ const PermaLinkWrapper = styled.div` color: #dbdbdb; opacity: 0.75; } + &:hover i { color: #b5b5b5; opacity: 1; @@ -108,7 +109,7 @@ const Breadcrumb: FC = ({ repository, branch, defaultBranch, revision, pa } return (
  • - {pathFragment} + {pathFragment}
  • ); }); @@ -120,7 +121,7 @@ const Breadcrumb: FC = ({ repository, branch, defaultBranch, revision, pa history.push(location.pathname); setCopying(true); copyToClipboard( - window.location.protocol + "//" + window.location.host + withContextPath(permalink || location.pathname) + window.location.protocol + "//" + window.location.host + urls.withContextPath(permalink || location.pathname) ).finally(() => setCopying(false)); }; @@ -161,7 +162,7 @@ const Breadcrumb: FC = ({ repository, branch, defaultBranch, revision, pa branch: branch ? branch : defaultBranch, path, sources, - repository, + repository }} renderAll={true} /> diff --git a/scm-ui/ui-components/src/ErrorBoundary.tsx b/scm-ui/ui-components/src/ErrorBoundary.tsx index 3d0144b95b..616e315498 100644 --- a/scm-ui/ui-components/src/ErrorBoundary.tsx +++ b/scm-ui/ui-components/src/ErrorBoundary.tsx @@ -21,31 +21,97 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { ComponentType, ReactNode } from "react"; +import React, { FC, ReactNode, useEffect } from "react"; import ErrorNotification from "./ErrorNotification"; -import { MissingLinkError } from "./errors"; -import { withContextPath } from "./urls"; -import { withRouter, RouteComponentProps } from "react-router-dom"; +import { MissingLinkError, urls, useIndexLink } from "@scm-manager/ui-api"; +import { RouteComponentProps, useLocation, withRouter } from "react-router-dom"; import ErrorPage from "./ErrorPage"; -import { WithTranslation, withTranslation } from "react-i18next"; -import { compose } from "redux"; -import { connect } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { Subtitle, Title } from "./layout"; +import Icon from "./Icon"; +import styled from "styled-components"; -type ExportedProps = { - fallback?: React.ComponentType; - children: ReactNode; - loginLink?: string; +type State = { + error?: Error; + errorInfo?: ErrorInfo; }; -type Props = WithTranslation & RouteComponentProps & ExportedProps; +type ExportedProps = { + fallback?: React.ComponentType; + children: ReactNode; +}; + +type Props = RouteComponentProps & ExportedProps; type ErrorInfo = { componentStack: string; }; -type State = { - error?: Error; - errorInfo?: ErrorInfo; +type ErrorDisplayProps = { + fallback?: React.ComponentType; + error: Error; + errorInfo: ErrorInfo; +}; + +const RedirectIconContainer = styled.div` + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 256px; +`; + +const RedirectPage = () => { + const [t] = useTranslation("commons"); + // we use an icon instead of loading spinner, + // because a redirect is synchron and a spinner does not spin on a synchron action + return ( +
    +
    + {t("errorBoundary.redirect.title")} + {t("errorBoundary.redirect.subtitle")} + + + +
    +
    + ); +}; + +const ErrorDisplay: FC = ({ error, errorInfo, fallback: FallbackComponent }) => { + const loginLink = useIndexLink("login"); + const [t] = useTranslation("commons"); + const location = useLocation(); + const isMissingLink = error instanceof MissingLinkError; + useEffect(() => { + if (isMissingLink && loginLink) { + window.location.assign(urls.withContextPath("/login?from=" + location.pathname)); + } + }, [isMissingLink, loginLink, location.pathname]); + + if (isMissingLink) { + if (loginLink) { + // we can render a loading screen, + // because the effect hook above should redirect + return ; + } else { + // missing link error without login link means we have no permissions + // and we should render an error + return ( + + ); + } + } + + if (!FallbackComponent) { + return ; + } + + const fallbackProps = { + error, + errorInfo + }; + + return ; }; class ErrorBoundary extends React.Component { @@ -62,62 +128,20 @@ class ErrorBoundary extends React.Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - this.setState( - { - error, - errorInfo - }, - () => this.redirectToLogin(error) - ); + this.setState({ + error, + errorInfo + }); } - redirectToLogin = (error: Error) => { - const { loginLink, location } = this.props; - if (error instanceof MissingLinkError) { - if (loginLink) { - window.location.assign(withContextPath("/login?from=" + location.pathname)); - } - } - }; - - renderError = () => { - const { t } = this.props; - const { error } = this.state; - - let FallbackComponent = this.props.fallback; - - if (error instanceof MissingLinkError) { - return ( - - ); - } - - if (!FallbackComponent) { - FallbackComponent = ErrorNotification; - } - - return ; - }; - render() { - const { error } = this.state; - if (error) { - return this.renderError(); + const { fallback } = this.props; + const { error, errorInfo } = this.state; + if (error && errorInfo) { + return ; } return this.props.children; } } -const mapStateToProps = (state: any) => { - const loginLink = state.indexResources?.links?.login?.href; - - return { - loginLink - }; -}; - -export default compose>( - withRouter, - withTranslation("commons"), - connect(mapStateToProps) -)(ErrorBoundary); +export default withRouter(ErrorBoundary); diff --git a/scm-ui/ui-components/src/ErrorNotification.tsx b/scm-ui/ui-components/src/ErrorNotification.tsx index 9bb685175b..61df6cf868 100644 --- a/scm-ui/ui-components/src/ErrorNotification.tsx +++ b/scm-ui/ui-components/src/ErrorNotification.tsx @@ -22,15 +22,14 @@ * SOFTWARE. */ import React, { FC } from "react"; -import { useTranslation, WithTranslation, withTranslation } from "react-i18next"; -import { BackendError, ForbiddenError, UnauthorizedError } from "./errors"; +import { useTranslation } from "react-i18next"; +import { BackendError, ForbiddenError, UnauthorizedError, urls } from "@scm-manager/ui-api"; import Notification from "./Notification"; import BackendErrorNotification from "./BackendErrorNotification"; import { useLocation } from "react-router-dom"; -import { withContextPath } from "./urls"; -type Props = WithTranslation & { - error?: Error; +type Props = { + error?: Error | null; }; const LoginLink: FC = () => { @@ -38,37 +37,35 @@ const LoginLink: FC = () => { const location = useLocation(); const from = encodeURIComponent(location.hash ? location.pathname + location.hash : location.pathname); - return {t("errorNotification.loginLink")}; + return {t("errorNotification.loginLink")}; }; -class ErrorNotification extends React.Component { - render() { - const { t, error } = this.props; - if (error) { - if (error instanceof BackendError) { - return ; - } else if (error instanceof UnauthorizedError) { - return ( - - {t("errorNotification.prefix")}: {t("errorNotification.timeout")} - - ); - } else if (error instanceof ForbiddenError) { - return ( - - {t("errorNotification.prefix")}: {t("errorNotification.forbidden")} - - ); - } else { - return ( - - {t("errorNotification.prefix")}: {error.message} - - ); - } +const ErrorNotification: FC = ({ error }) => { + const [t] = useTranslation("commons"); + if (error) { + if (error instanceof BackendError) { + return ; + } else if (error instanceof UnauthorizedError) { + return ( + + {t("errorNotification.prefix")}: {t("errorNotification.timeout")} + + ); + } else if (error instanceof ForbiddenError) { + return ( + + {t("errorNotification.prefix")}: {t("errorNotification.forbidden")} + + ); + } else { + return ( + + {t("errorNotification.prefix")}: {error.message} + + ); } - return null; } -} + return null; +}; -export default withTranslation("commons")(ErrorNotification); +export default ErrorNotification; diff --git a/scm-ui/ui-components/src/ErrorPage.tsx b/scm-ui/ui-components/src/ErrorPage.tsx index 7a2ac769c2..ae8f4749a0 100644 --- a/scm-ui/ui-components/src/ErrorPage.tsx +++ b/scm-ui/ui-components/src/ErrorPage.tsx @@ -23,7 +23,7 @@ */ import React from "react"; import ErrorNotification from "./ErrorNotification"; -import { BackendError, ForbiddenError } from "./errors"; +import { BackendError, ForbiddenError } from "@scm-manager/ui-api"; type Props = { error: Error; diff --git a/scm-ui/ui-components/src/Image.tsx b/scm-ui/ui-components/src/Image.tsx index 56f32cde7d..2e55624fc7 100644 --- a/scm-ui/ui-components/src/Image.tsx +++ b/scm-ui/ui-components/src/Image.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ import React from "react"; -import { withContextPath } from "./urls"; +import { urls } from "@scm-manager/ui-api"; type Props = { src: string; @@ -37,7 +37,7 @@ class Image extends React.Component { if (src.startsWith("http")) { return src; } - return withContextPath(src); + return urls.withContextPath(src); }; render() { diff --git a/scm-ui/ui-components/src/MarkdownCodeRenderer.tsx b/scm-ui/ui-components/src/MarkdownCodeRenderer.tsx index d08cfcb2d2..d5c19079be 100644 --- a/scm-ui/ui-components/src/MarkdownCodeRenderer.tsx +++ b/scm-ui/ui-components/src/MarkdownCodeRenderer.tsx @@ -25,17 +25,17 @@ import React, { FC } from "react"; import SyntaxHighlighter from "./SyntaxHighlighter"; import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions"; -import { connect } from "react-redux"; +import { useIndexLinks } from "@scm-manager/ui-api"; type Props = { language?: string; value: string; - indexLinks: { [key: string]: any }; }; -const MarkdownCodeRenderer: FC = (props) => { +const MarkdownCodeRenderer: FC = props => { const binder = useBinder(); - const { language, indexLinks } = props; + const indexLinks = useIndexLinks(); + const { language } = props; const extensionKey = `markdown-renderer.code.${language}`; if (binder.hasExtension(extensionKey, props)) { return ; @@ -43,12 +43,4 @@ const MarkdownCodeRenderer: FC = (props) => { return ; }; -const mapStateToProps = (state: any) => { - const indexLinks = state.indexResources.links; - - return { - indexLinks, - }; -}; - -export default connect(mapStateToProps)(MarkdownCodeRenderer); +export default MarkdownCodeRenderer; diff --git a/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx b/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx index 8423098e3b..a2e1a67707 100644 --- a/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx +++ b/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx @@ -23,7 +23,7 @@ */ import React, { ReactNode } from "react"; import { withRouter, RouteComponentProps } from "react-router-dom"; -import { withContextPath } from "./urls"; +import { urls } from "@scm-manager/ui-api"; /** * Adds anchor links to markdown headings. @@ -54,7 +54,7 @@ function MarkdownHeadingRenderer(props: Props) { const heading = children.reduce(flatten, ""); const anchorId = headingToAnchorId(heading); const headingElement = React.createElement("h" + props.level, {}, props.children); - const href = withContextPath(props.location.pathname + "#" + anchorId); + const href = urls.withContextPath(props.location.pathname + "#" + anchorId); return ( diff --git a/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx b/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx index fc38bd388b..b88da10601 100644 --- a/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx +++ b/scm-ui/ui-components/src/MarkdownLinkRenderer.tsx @@ -24,7 +24,7 @@ import React, { FC } from "react"; import { Link, useLocation } from "react-router-dom"; import ExternalLink from "./navigation/ExternalLink"; -import { withContextPath } from "./urls"; +import { urls } from "@scm-manager/ui-api"; const externalLinkRegex = new RegExp("^http(s)?://"); export const isExternalLink = (link: string) => { @@ -116,7 +116,7 @@ const MarkdownLinkRenderer: FC = ({ href, base, children }) => { } else if (isLinkWithProtocol(href)) { return {children}; } else if (isAnchorLink(href)) { - return {children}; + return {children}; } else { const localLink = createLocalLink(base, location.pathname, href); return {children}; diff --git a/scm-ui/ui-components/src/OverviewPageActions.tsx b/scm-ui/ui-components/src/OverviewPageActions.tsx index da9a2437cd..abfc12c890 100644 --- a/scm-ui/ui-components/src/OverviewPageActions.tsx +++ b/scm-ui/ui-components/src/OverviewPageActions.tsx @@ -30,7 +30,7 @@ import { FilterInput } from "./forms"; type Props = { showCreateButton: boolean; currentGroup: string; - groups: string[]; + groups?: string[]; link: string; groupSelected: (namespace: string) => void; label?: string; @@ -51,6 +51,7 @@ const OverviewPageActions: FC = ({ const history = useHistory(); const location = useLocation(); const [filterValue, setFilterValue] = useState(urls.getQueryStringFromLocation(location)); + const groupSelector = groups && (
    = ({ language = defaultLanguage, showLineNumb window.location.protocol + "//" + window.location.host + - withContextPath((permalink || location.pathname) + "#line-" + lineNumber); + urls.withContextPath((permalink || location.pathname) + "#line-" + lineNumber); const defaultRenderer = createSyntaxHighlighterRenderer(createLinePermaLink, showLineNumbers); diff --git a/scm-ui/ui-components/src/UserGroupAutocomplete.tsx b/scm-ui/ui-components/src/UserGroupAutocomplete.tsx index 50fd4e4b49..c4a755d792 100644 --- a/scm-ui/ui-components/src/UserGroupAutocomplete.tsx +++ b/scm-ui/ui-components/src/UserGroupAutocomplete.tsx @@ -24,7 +24,7 @@ import React from "react"; import { SelectValue, AutocompleteObject } from "@scm-manager/ui-types"; import Autocomplete from "./Autocomplete"; -import { apiClient } from "./apiclient"; +import { apiClient } from "@scm-manager/ui-api"; export type AutocompleteProps = { autocompleteLink?: string; diff --git a/scm-ui/ui-components/src/__resources__/changesets.tsx b/scm-ui/ui-components/src/__resources__/changesets.tsx index 2743378ad4..4608f3cd84 100644 --- a/scm-ui/ui-components/src/__resources__/changesets.tsx +++ b/scm-ui/ui-components/src/__resources__/changesets.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { Changeset, PagedCollection } from "@scm-manager/ui-types"; +import {Changeset, ChangesetCollection, PagedCollection} from "@scm-manager/ui-types"; const one: Changeset = { id: "a88567ef1e9528a700555cad8c4576b72fc7c6dd", @@ -266,7 +266,7 @@ const five: Changeset = { } }; -const changesets: PagedCollection = { +const changesets: ChangesetCollection = { page: 0, pageTotal: 1, _links: { diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 7437e6b518..41f36697c9 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -1,58 +1,35 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Storyshots Annotate Default 1`] = ` -
    +Array [
    -
    -      
    -        
    - Arthur Dent +
    + Arthur Dent +
    + +
    + +
    + +
    +
    + 1
    - -
    - -
    -
    - 1 -
    - -
    - - - package - - main + + package + + + main + + + +
    +
    +
    +
    +
    + 2 +
    + +
    + + - +
    -
    -
    -
    - 2 -
    - -
    - +
    + Tricia Marie McMillan +
    + +
    + +
    + +
    +
    + 3 +
    + +
    + + + + + + import + + + + + + "fmt" + + + + + + +
    +
    +
    +
    +
    + 4 +
    + +
    + + + + +
    +
    +
    +
    +
    + Arthur Dent +
    + +
    + +
    + +
    +
    + 5 +
    + +
    + + + + + + func + + + + + + main + + + ( + + + ) + + + + + + { + + + + + + +
    +
    +
    +
    +
    + Ford Prefect +
    + +
    + +
    + +
    +
    + 6 +
    + +
    + + + fmt + + + . + + + Println + + + ( + + + "Hello World" + + + ) + + + + + + +
    +
    +
    +
    +
    + Arthur Dent +
    + +
    + +
    + +
    +
    + 7 +
    + +
    + + + + + + } + + + + + + +
    +
    +
    +
    +
    + 8 +
    + +
    + + + + +
    +
    + + + + +
    +
    +
    +
    , +