From 580cc8108cf0e2f6a745a241c569a825297bfe40 Mon Sep 17 00:00:00 2001 From: Oleksii Kurinnyi Date: Mon, 4 Sep 2023 13:12:08 +0000 Subject: [PATCH 1/6] feat(backend): gitconfig: add route and API service Signed-off-by: Oleksii Kurinnyi --- .deps/dev.md | 6 +- .deps/prod.md | 9 +- packages/common/src/dto/api/index.ts | 13 ++ packages/dashboard-backend/package.json | 3 +- packages/dashboard-backend/src/app.ts | 3 + .../src/constants/schemas.ts | 26 +++ .../__tests__/index.spec.ts | 2 + .../src/devworkspaceClient/index.ts | 13 +- .../gitConfigApi/__tests__/index.spec.ts | 112 +++++++++++++ .../services/gitConfigApi/index.ts | 149 ++++++++++++++++++ .../services/gitConfigApi/multi-ini.d.ts | 60 +++++++ .../services/helpers/prepareCoreV1API.ts | 6 + .../src/devworkspaceClient/types/index.ts | 12 ++ .../src/models/restParams.ts | 2 +- .../routes/api/__tests__/gitConfig.spec.ts | 76 +++++++++ .../src/routes/api/gitConfig.ts | 51 ++++++ .../__mocks__/getDevWorkspaceClient.ts | 9 +- yarn.lock | 19 ++- 18 files changed, 556 insertions(+), 15 deletions(-) create mode 100644 packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts create mode 100644 packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/index.ts create mode 100644 packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/multi-ini.d.ts create mode 100644 packages/dashboard-backend/src/routes/api/__tests__/gitConfig.spec.ts create mode 100644 packages/dashboard-backend/src/routes/api/gitConfig.ts diff --git a/.deps/dev.md b/.deps/dev.md index 0a15567b9..991f1994d 100644 --- a/.deps/dev.md +++ b/.deps/dev.md @@ -436,7 +436,7 @@ | [`fs-constants@1.0.0`](https://github.com/mafintosh/fs-constants.git) | MIT | clearlydefined | | [`fs-extra@9.1.0`](https://github.com/jprichardson/node-fs-extra) | MIT | clearlydefined | | `fsevents@2.3.2` | | transitive dependency | -| [`function.prototype.name@1.1.5`](git://github.com/es-shims/Function.prototype.name.git) | MIT | clearlydefined | +| [`function.prototype.name@1.1.5`](git://github.com/es-shims/Function.prototype.name.git) | MIT | #10255 | | [`functions-have-names@1.2.3`](git+https://github.com/inspect-js/functions-have-names.git) | MIT | clearlydefined | | [`gauge@4.0.4`](https://github.com/npm/gauge.git) | ISC | clearlydefined | | [`gensync@1.0.0-beta.2`](https://github.com/loganfsmyth/gensync.git) | MIT | clearlydefined | @@ -799,7 +799,7 @@ | [`run-async@2.4.1`](https://github.com/SBoudrias/run-async.git) | MIT | clearlydefined | | [`run-parallel@1.2.0`](git://github.com/feross/run-parallel.git) | MIT | clearlydefined | | [`rxjs@7.8.1`](https://github.com/reactivex/rxjs.git) | Apache-2.0 | #5993 | -| [`safe-array-concat@1.0.0`](git+https://github.com/ljharb/safe-array-concat.git) | MIT | clearlydefined | +| [`safe-array-concat@1.0.0`](git+https://github.com/ljharb/safe-array-concat.git) | MIT | #10335 | | [`safe-regex-test@1.0.0`](git+https://github.com/ljharb/safe-regex-test.git) | MIT | clearlydefined | | [`saxes@6.0.0`](https://github.com/lddubeau/saxes.git) | ISC | clearlydefined | | [`schema-utils@4.2.0`](https://github.com/webpack/schema-utils.git) | MIT | #8986 | @@ -836,7 +836,7 @@ | [`string-width-cjs@4.2.3`](https://github.com/sindresorhus/string-width.git) | MIT | transitive dependency | | [`string-width@4.2.3`](https://github.com/sindresorhus/string-width.git) | MIT | clearlydefined | | [`string.prototype.matchall@4.0.8`](git+https://github.com/es-shims/String.prototype.matchAll.git) | MIT | #4571 | -| [`string.prototype.trim@1.2.7`](git://github.com/es-shims/String.prototype.trim.git) | MIT | clearlydefined | +| [`string.prototype.trim@1.2.7`](git://github.com/es-shims/String.prototype.trim.git) | MIT | #10361 | | [`string.prototype.trimend@1.0.6`](git://github.com/es-shims/String.prototype.trimEnd.git) | MIT | #4564 | | [`string.prototype.trimstart@1.0.6`](git://github.com/es-shims/String.prototype.trimStart.git) | MIT | #4647 | | [`strip-ansi-cjs@6.0.1`](https://github.com/chalk/strip-ansi.git) | MIT | transitive dependency | diff --git a/.deps/prod.md b/.deps/prod.md index 4af8b29f9..5e248e113 100644 --- a/.deps/prod.md +++ b/.deps/prod.md @@ -5,10 +5,10 @@ | [`@babel/runtime@7.22.10`](https://github.com/babel/babel.git) | MIT | #8730 | | [`@devfile/api@2.2.1-alpha-1667236163`](https://github.com/devfile/api.git) | Apache-2.0 | clearlydefined | | `@eclipse-che/api@7.72.0` | EPL-2.0 | ecd.che | -| [`@eclipse-che/che-devworkspace-generator@0.0.1-99986b8`](git+https://github.com/eclipse-che/che-devfile-registry.git) | EPL-2.0 | ecd.che | -| [`@eclipse-che/common@7.74.0-next`](https://github.com/eclipse-che/che-dashboard) | EPL-2.0 | ecd.che | -| [`@eclipse-che/dashboard-backend@7.74.0-next`](https://github.com/eclipse-che/che-dashboard) | EPL-2.0 | ecd.che | -| [`@eclipse-che/dashboard-frontend@7.74.0-next`](git://github.com/eclipse/che-dashboard.git) | EPL-2.0 | ecd.che | +| [`@eclipse-che/che-devworkspace-generator@7.75.0-next-50585f6`](git+https://github.com/eclipse-che/che-devfile-registry.git) | EPL-2.0 | ecd.che | +| [`@eclipse-che/common@7.75.0-next`](https://github.com/eclipse-che/che-dashboard) | EPL-2.0 | ecd.che | +| [`@eclipse-che/dashboard-backend@7.75.0-next`](https://github.com/eclipse-che/che-dashboard) | EPL-2.0 | ecd.che | +| [`@eclipse-che/dashboard-frontend@7.75.0-next`](git://github.com/eclipse/che-dashboard.git) | EPL-2.0 | ecd.che | | [`@eclipse-che/devfile-converter@0.0.1-ff55f9a`](git+https://github.com/che-incubator/devfile-converter.git) | EPL-2.0 | ecd.che | | [`@eclipse-che/workspace-client@0.0.1-1672830275`](https://github.com/eclipse/che-workspace-client) | EPL-2.0 | ecd.che | | [`@fastify/accept-negotiator@1.1.0`](git+https://github.com/fastify/accept-negotiator.git) | MIT | clearlydefined | @@ -256,6 +256,7 @@ | [`monaco-languages@1.10.0`](https://github.com/Microsoft/monaco-languages) | MIT | clearlydefined | | [`mri@1.1.4`](https://github.com/lukeed/mri.git) | MIT | clearlydefined | | [`ms@2.0.0`](https://github.com/zeit/ms.git) | MIT | clearlydefined | +| [`multi-ini@2.3.2`](git://github.com/evangelion1204/multi-ini.git) | MIT | clearlydefined | | [`nanoid@3.3.6`](https://github.com/ai/nanoid.git) | MIT | #7571 | | [`node-fetch@2.6.12`](https://github.com/bitinn/node-fetch.git) | MIT | #6954 | | [`oauth-sign@0.9.0`](https://github.com/mikeal/oauth-sign) | Apache-2.0 | clearlydefined | diff --git a/packages/common/src/dto/api/index.ts b/packages/common/src/dto/api/index.ts index bb2c9df96..5cc5af4ff 100644 --- a/packages/common/src/dto/api/index.ts +++ b/packages/common/src/dto/api/index.ts @@ -54,6 +54,19 @@ export interface IDockerConfig { resourceVersion?: string; } +export interface IGitConfig { + resourceVersion?: string; + gitconfig: { + user: { + name: string; + email: string; + }; + [section: string]: { + [key: string]: string; + }; + }; +} + export interface IWorkspacesDefaultPlugins { editor: string; plugins: string[]; diff --git a/packages/dashboard-backend/package.json b/packages/dashboard-backend/package.json index a04e285ae..d31e819d4 100644 --- a/packages/dashboard-backend/package.json +++ b/packages/dashboard-backend/package.json @@ -36,13 +36,14 @@ "@fastify/swagger": "^8.8.0", "@fastify/swagger-ui": "1.9.2", "@fastify/websocket": "^8.2.0", - "@kubernetes/client-node": "^0.18.0", + "@kubernetes/client-node": "^0.18.1", "args": "^5.0.3", "axios": "^0.21.4", "fastify": "^4.21.0", "fs-extra": "^11.1.1", "https": "^1.0.0", "js-yaml": "^4.0.0", + "multi-ini": "^2.3.2", "node-fetch": "^2.6.7", "querystring": "^0.2.1", "reflect-metadata": "^0.1.13", diff --git a/packages/dashboard-backend/src/app.ts b/packages/dashboard-backend/src/app.ts index d99f86fdd..ad4233a70 100644 --- a/packages/dashboard-backend/src/app.ts +++ b/packages/dashboard-backend/src/app.ts @@ -37,6 +37,7 @@ import { registerWebsocket } from './routes/api/websocket'; import { registerYamlResolverRoute } from './routes/api/yamlResolver'; import { registerFactoryAcceptanceRedirect } from './routes/factoryAcceptanceRedirect'; import { registerWorkspaceRedirect } from './routes/workspaceRedirect'; +import { registerGitConfigRoutes } from './routes/api/gitConfig'; import { registerGettingStartedSamplesRoutes } from './routes/api/gettingStartedSample'; export default async function buildApp(server: FastifyInstance): Promise { @@ -112,5 +113,7 @@ export default async function buildApp(server: FastifyInstance): Promise { registerPersonalAccessTokenRoutes(server); + registerGitConfigRoutes(server); + registerGettingStartedSamplesRoutes(isLocalRun(), server); } diff --git a/packages/dashboard-backend/src/constants/schemas.ts b/packages/dashboard-backend/src/constants/schemas.ts index 062a08d90..0cfe6d41d 100644 --- a/packages/dashboard-backend/src/constants/schemas.ts +++ b/packages/dashboard-backend/src/constants/schemas.ts @@ -127,6 +127,32 @@ export const dockerConfigSchema: JSONSchema7 = { required: ['dockerconfig'], }; +export const gitConfigSchema: JSONSchema7 = { + type: 'object', + properties: { + gitconfig: { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: { + type: 'string', + }, + email: { + type: 'string', + }, + }, + }, + }, + }, + resourceVersion: { + type: 'string', + }, + }, + required: ['gitconfig'], +}; + export const devfileVersionSchema: JSONSchema7 = { type: 'object', properties: { diff --git a/packages/dashboard-backend/src/devworkspaceClient/__tests__/index.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/__tests__/index.spec.ts index 55afd15b8..1800e4d05 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/__tests__/index.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/__tests__/index.spec.ts @@ -17,6 +17,7 @@ import { DevWorkspaceApiService } from '../services/devWorkspaceApi'; import { DevWorkspaceTemplateApiService } from '../services/devWorkspaceTemplateApi'; import { DockerConfigApiService } from '../services/dockerConfigApi'; import { EventApiService } from '../services/eventApi'; +import { GitConfigApiService } from '../services/gitConfigApi'; import { KubeConfigApiService } from '../services/kubeConfigApi'; import { LogsApiService } from '../services/logsApi'; import { PodApiService } from '../services/podApi'; @@ -50,6 +51,7 @@ describe('DevWorkspace client', () => { expect(client.podApi).toBeInstanceOf(PodApiService); expect(client.serverConfigApi).toBeInstanceOf(ServerConfigApiService); expect(client.userProfileApi).toBeInstanceOf(UserProfileApiService); + expect(client.gitConfigApi).toBeInstanceOf(GitConfigApiService); expect(client.gettingStartedSampleApi).toBeInstanceOf(GettingStartedSamplesApiService); }); }); diff --git a/packages/dashboard-backend/src/devworkspaceClient/index.ts b/packages/dashboard-backend/src/devworkspaceClient/index.ts index 344282f24..58b370e70 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/index.ts @@ -15,28 +15,31 @@ import { DevWorkspaceApiService } from './services/devWorkspaceApi'; import { DevWorkspaceTemplateApiService } from './services/devWorkspaceTemplateApi'; import { DockerConfigApiService } from './services/dockerConfigApi'; import { EventApiService } from './services/eventApi'; +import { GettingStartedSamplesApiService } from './services/gettingStartedSamplesApi'; +import { GitConfigApiService } from './services/gitConfigApi'; import { KubeConfigApiService } from './services/kubeConfigApi'; -import { PodmanApiService } from './services/podmanApi'; import { LogsApiService } from './services/logsApi'; import { PersonalAccessTokenService } from './services/personalAccessTokenApi'; import { PodApiService } from './services/podApi'; +import { PodmanApiService } from './services/podmanApi'; import { ServerConfigApiService } from './services/serverConfigApi'; import { UserProfileApiService } from './services/userProfileApi'; -import { IGettingStartedSampleApi, IPodmanApi } from './types/index'; import { IDevWorkspaceApi, IDevWorkspaceClient, IDevWorkspaceTemplateApi, IDockerConfigApi, IEventApi, + IGettingStartedSampleApi, + IGitConfigApi, IKubeConfigApi, ILogsApi, IPersonalAccessTokenApi, IPodApi, + IPodmanApi, IServerConfigApi, IUserProfileApi, } from './types'; -import { GettingStartedSamplesApiService } from './services/gettingStartedSamplesApi'; export * from './types'; @@ -91,6 +94,10 @@ export class DevWorkspaceClient implements IDevWorkspaceClient { return new PersonalAccessTokenService(this.kubeConfig); } + get gitConfigApi(): IGitConfigApi { + return new GitConfigApiService(this.kubeConfig); + } + get gettingStartedSampleApi(): IGettingStartedSampleApi { return new GettingStartedSamplesApiService(this.kubeConfig); } diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts new file mode 100644 index 000000000..634f484e2 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as mockClient from '@kubernetes/client-node'; +import { CoreV1Api, V1ConfigMap } from '@kubernetes/client-node'; +import { IncomingMessage } from 'http'; +import { GitConfigApiService } from '..'; +import { api } from '@eclipse-che/common'; + +jest.mock('../../helpers/retryableExec'); + +const namespace = 'user-che'; +const responseBody = { + data: { + gitconfig: `[user]\n\tname = User One\n\temail = user-1@che`, + }, +}; + +describe('Gitconfig API', () => { + let gitConfigApiService: GitConfigApiService; + + const stubCoreV1Api = { + readNamespacedConfigMap: () => { + return Promise.resolve({ + body: responseBody as V1ConfigMap, + response: {} as IncomingMessage, + }); + }, + patchNamespacedConfigMap: () => { + return Promise.resolve({ + body: responseBody as V1ConfigMap, + response: {} as IncomingMessage, + }); + }, + } as unknown as CoreV1Api; + const spyReadNamespacedConfigMap = jest.spyOn(stubCoreV1Api, 'readNamespacedConfigMap'); + const spyPatchNamespacedConfigMap = jest.spyOn(stubCoreV1Api, 'patchNamespacedConfigMap'); + + beforeEach(() => { + const { KubeConfig } = mockClient; + const kubeConfig = new KubeConfig(); + kubeConfig.makeApiClient = jest.fn().mockImplementation(() => stubCoreV1Api); + + gitConfigApiService = new GitConfigApiService(kubeConfig); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('reading gitconfig', () => { + it('should return gitconfig', async () => { + const resp = await gitConfigApiService.read(namespace); + + expect(resp.gitconfig).toStrictEqual( + expect.objectContaining({ + user: expect.objectContaining({ + name: 'User One', + email: 'user-1@che', + }), + }), + ); + + expect(spyReadNamespacedConfigMap).toHaveBeenCalledTimes(1); + expect(spyPatchNamespacedConfigMap).not.toHaveBeenCalled(); + }); + }); + + describe('patching gitconfig', () => { + it('should patch and return gitconfig', async () => { + const newGitConfig = { + gitconfig: { + user: { + email: 'user-2@che', + name: 'User Two', + }, + }, + } as api.IGitConfig; + await gitConfigApiService.patch(namespace, newGitConfig); + + expect(spyReadNamespacedConfigMap).toHaveBeenCalledTimes(1); + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledTimes(1); + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith( + 'workspace-userdata-gitconfig-configmap', + 'user-che', + { + data: { + gitconfig: `[user] +name="User Two" +email="user-2@che" +`, + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'content-type': 'application/strategic-merge-patch+json' } }, + ); + }); + }); +}); diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/index.ts b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/index.ts new file mode 100644 index 000000000..c9f4db723 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/index.ts @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import * as k8s from '@kubernetes/client-node'; +import * as ini from 'multi-ini'; +import { IGitConfigApi } from '../../types'; +import { createError } from '../helpers/createError'; +import { CoreV1API, prepareCoreV1API } from '../helpers/prepareCoreV1API'; + +const GITCONFIG_CONFIGMAP = 'workspace-userdata-gitconfig-configmap'; +const GITCONFIG_API_ERROR_LABEL = 'CORE_V1_API_ERROR'; + +export class GitConfigApiService implements IGitConfigApi { + private readonly coreV1API: CoreV1API; + + constructor(kc: k8s.KubeConfig) { + this.coreV1API = prepareCoreV1API(kc); + } + + /** + * @throws + * Reads `gitconfig` from the given `namespace`. + */ + public async read(namespace: string): Promise { + try { + const response = await this.coreV1API.readNamespacedConfigMap(GITCONFIG_CONFIGMAP, namespace); + + return this.toGitConfig(response.body); + } catch (error) { + const message = `Unable to read gitconfig in the namespace "${namespace}"`; + throw createError(error, GITCONFIG_API_ERROR_LABEL, message); + } + } + + /** + * @throws + * Updates `gitconfig` in the given `namespace` with `changedGitConfig`. + */ + public async patch(namespace: string, changedGitConfig: api.IGitConfig): Promise { + let gitConfig: api.IGitConfig; + try { + gitConfig = await this.read(namespace); + } catch (error) { + const message = `Unable to update gitconfig in the namespace "${namespace}"`; + throw createError(undefined, GITCONFIG_API_ERROR_LABEL, message); + } + + if ( + parseInt(gitConfig.resourceVersion || '0', 10) > + parseInt(changedGitConfig.resourceVersion || '0', 10) + ) { + const message = `Gitconfig was modified in namespace "${namespace}" by someone else.`; + throw createError(undefined, GITCONFIG_API_ERROR_LABEL, message); + } + + gitConfig.gitconfig.user.email = changedGitConfig.gitconfig.user.email; + gitConfig.gitconfig.user.name = changedGitConfig.gitconfig.user.name; + + try { + const gitconfigStr = this.fromGitConfig(gitConfig); + + const response = await this.coreV1API.patchNamespacedConfigMap( + GITCONFIG_CONFIGMAP, + namespace, + { + data: { + gitconfig: gitconfigStr, + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + { + headers: { + 'content-type': k8s.PatchUtils.PATCH_FORMAT_STRATEGIC_MERGE_PATCH, + }, + }, + ); + + return this.toGitConfig(response.body); + } catch (error) { + const message = `Unable to update gitconfig in the namespace "${namespace}"`; + throw createError(error, GITCONFIG_API_ERROR_LABEL, message); + } + } + + /** + * @throws + * Serializes `gitConfig` object. + */ + private fromGitConfig(gitConfig: api.IGitConfig): string { + const serializer = new ini.Serializer(); + const gitconfigStr = serializer.serialize(gitConfig.gitconfig); + return gitconfigStr; + } + + /** + * @throws + * Extracts `resourceVersion` and `data.gitconfig` from given `ConfigMap`. + */ + private toGitConfig(configMapBody: k8s.V1ConfigMap): api.IGitConfig { + const resourceVersion = configMapBody.metadata?.resourceVersion; + const gitconfigStr = configMapBody.data?.gitconfig; + + const parser = new ini.Parser(); + + if (typeof gitconfigStr !== 'string') { + throw new Error('Unexpected data type'); + } + + const gitconfigLines = gitconfigStr.split(/\r?\n/); + + const gitconfig = parser.parse(gitconfigLines); + if (!isGitConfig(gitconfig)) { + throw new Error('Gitconfig is empty.'); + } + + return { + resourceVersion, + gitconfig: { + user: { name: gitconfig.user.name, email: gitconfig.user.email }, + }, + }; + } +} + +type GitConfig = api.IGitConfig['gitconfig']; +/** + * Checks if given object is a valid `GitConfig`. + */ +function isGitConfig(gitConfig: unknown): gitConfig is GitConfig { + return ( + (gitConfig as GitConfig).user !== undefined && + (gitConfig as GitConfig).user.email !== undefined && + (gitConfig as GitConfig).user.name !== undefined + ); +} diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/multi-ini.d.ts b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/multi-ini.d.ts new file mode 100644 index 000000000..57766d2c4 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/multi-ini.d.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +declare module 'multi-ini' { + declare class Parser { + constructor(options?: Record); + options: unknown; + handlers: ((ctx: unknown, line: unknown) => unknown)[]; + parse(lines: unknown): Record; + isSection(line: unknown): unknown; + getSection(line: unknown): unknown; + getParentSection(line: unknown): unknown; + isInheritedSection(line: unknown): boolean; + isComment(line: unknown): unknown; + isSingleLine(line: unknown): boolean; + isMultiLine(line: unknown): boolean; + isMultiLineEnd(line: unknown): boolean; + isArray(line: unknown): unknown; + assignValue(element: unknown, keys: unknown, value: unknown): unknown; + applyFilter(value: unknown): unknown; + getKeyValue(line: unknown): { + key: unknown; + value: unknown; + status: number; + }; + getMultiKeyValue(line: unknown): { + key: unknown; + value: unknown; + }; + getMultiLineEndValue(line: unknown): { + value: unknown; + status: number; + }; + getArrayKey(line: unknown): unknown; + handleMultiLineStart(ctx: unknown, line: unknown): boolean; + handleMultiLineEnd(ctx: unknown, line: unknown): boolean; + handleMultiLineAppend(ctx: unknown, line: unknown): boolean; + handleComment(ctx: unknown, line: unknown): unknown; + handleSection(ctx: unknown, line: unknown): boolean; + handleSingleLine(ctx: unknown, line: unknown): boolean; + createSection(ctx: unknown, section: unknown): void; + } + + declare class Serializer { + constructor(options?: Record); + options: unknown; + needToBeQuoted(value: unknown): boolean; + serialize(content: unknown): string; + serializeContent(content: unknown, path: unknown): string; + } +} diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts index a67789259..2c9211e91 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts @@ -20,6 +20,8 @@ export type CoreV1API = Pick< | 'listNamespacedEvent' | 'listNamespacedPod' | 'listNamespacedSecret' + | 'patchNamespacedConfigMap' + | 'readNamespacedConfigMap' | 'readNamespacedPod' | 'readNamespacedSecret' | 'replaceNamespacedSecret' @@ -40,6 +42,10 @@ export function prepareCoreV1API(kc: k8s.KubeConfig): CoreV1API { retryableExec(() => coreV1API.listNamespacedPod(...args)), listNamespacedSecret: (...args: Parameters) => retryableExec(() => coreV1API.listNamespacedSecret(...args)), + patchNamespacedConfigMap: (...args: Parameters) => + retryableExec(() => coreV1API.patchNamespacedConfigMap(...args)), + readNamespacedConfigMap: (...args: Parameters) => + retryableExec(() => coreV1API.readNamespacedConfigMap(...args)), readNamespacedPod: (...args: Parameters) => retryableExec(() => coreV1API.readNamespacedPod(...args)), readNamespacedSecret: (...args: Parameters) => diff --git a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts index a00a8a8fe..231c2b4a0 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts @@ -36,6 +36,18 @@ export interface IDockerConfigApi { update(namespace: string, dockerCfg: api.IDockerConfig): Promise; } +export interface IGitConfigApi { + /** + * Read gitconfig from configmap in the specified namespace + */ + read(namespace: string): Promise; + + /** + * Replace gitconfig in configmap in the specified namespace + */ + patch(namespace: string, gitconfig: api.IGitConfig): Promise; +} + export interface IDevWorkspaceApi extends IWatcherService { /** * Get the DevWorkspace with given namespace in the specified namespace diff --git a/packages/dashboard-backend/src/models/restParams.ts b/packages/dashboard-backend/src/models/restParams.ts index d9dad3eaf..4b99bfbd8 100644 --- a/packages/dashboard-backend/src/models/restParams.ts +++ b/packages/dashboard-backend/src/models/restParams.ts @@ -34,7 +34,7 @@ export interface INamespacedPodParams extends INamespacedParams { } export interface ISchemaParams { - [key: string]: any; + [key: string]: unknown; } export interface ITemplateBodyParams { diff --git a/packages/dashboard-backend/src/routes/api/__tests__/gitConfig.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/gitConfig.spec.ts new file mode 100644 index 000000000..402896ab3 --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/__tests__/gitConfig.spec.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FastifyInstance } from 'fastify'; +import { baseApiPath } from '../../../constants/config'; +import { setup, teardown } from '../../../helpers/tests/appBuilder'; +import { api } from '@eclipse-che/common'; + +const mockRead = jest.fn().mockResolvedValue({}); +const mockPatch = jest.fn().mockResolvedValue({}); +jest.mock('../helpers/getDevWorkspaceClient.ts', () => ({ + getDevWorkspaceClient: () => ({ + gitConfigApi: { + read: mockRead, + patch: mockPatch, + }, + }), +})); +jest.mock('../helpers/getToken.ts'); + +describe('Gitconfig Routes', () => { + let app: FastifyInstance; + const namespace = 'user-che'; + + beforeAll(async () => { + app = await setup(); + }); + + afterEach(() => { + teardown(app); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('GET ${baseApiPath}/namespace/:namespace/gitconfig', async () => { + const res = await app.inject().get(`${baseApiPath}/namespace/${namespace}/gitconfig`); + + expect(res.statusCode).toEqual(200); + expect(res.json()).toEqual({}); + + expect(mockRead).toHaveBeenCalledTimes(1); + expect(mockPatch).not.toHaveBeenCalled(); + }); + + test('PATCH ${baseApiPath}/namespace/:namespace/gitconfig', async () => { + const res = await app + .inject() + .patch(`${baseApiPath}/namespace/${namespace}/gitconfig`) + .payload({ + gitconfig: { + user: { + name: 'user-che', + email: 'user@che', + }, + }, + resourceVersion: '123456789', + } as api.IGitConfig); + + expect(res.statusCode).toEqual(200); + expect(res.json()).toEqual({}); + + expect(mockRead).not.toHaveBeenCalled(); + expect(mockPatch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard-backend/src/routes/api/gitConfig.ts b/packages/dashboard-backend/src/routes/api/gitConfig.ts new file mode 100644 index 000000000..331e8b226 --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/gitConfig.ts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { baseApiPath } from '../../constants/config'; +import { gitConfigSchema, namespacedSchema } from '../../constants/schemas'; +import { restParams } from '../../models'; +import { getSchema } from '../../services/helpers'; +import { getDevWorkspaceClient } from './helpers/getDevWorkspaceClient'; +import { getToken } from './helpers/getToken'; + +const tags = ['Gitconfig']; + +export function registerGitConfigRoutes(instance: FastifyInstance) { + instance.register(async server => { + server.get( + `${baseApiPath}/namespace/:namespace/gitconfig`, + getSchema({ tags, params: namespacedSchema }), + async function (request: FastifyRequest) { + const { namespace } = request.params as restParams.INamespacedParams; + const token = getToken(request); + const { gitConfigApi } = getDevWorkspaceClient(token); + + return gitConfigApi.read(namespace); + }, + ); + + server.patch( + `${baseApiPath}/namespace/:namespace/gitconfig`, + getSchema({ tags, params: namespacedSchema, body: gitConfigSchema }), + async function (request: FastifyRequest) { + const { namespace } = request.params as restParams.INamespacedParams; + const gitconfig = request.body as api.IGitConfig; + const token = getToken(request); + const { gitConfigApi } = getDevWorkspaceClient(token); + + return gitConfigApi.patch(namespace, gitconfig); + }, + ); + }); +} diff --git a/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts b/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts index 3f622fd0c..3db691b79 100644 --- a/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts +++ b/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts @@ -25,6 +25,7 @@ import { IDevWorkspaceTemplateApi, IDockerConfigApi, IEventApi, + IGitConfigApi, IKubeConfigApi, ILogsApi, IPersonalAccessTokenApi, @@ -109,7 +110,9 @@ export const stubPodsList: api.IPodList = { export const stubPersonalAccessTokenList: api.PersonalAccessToken[] = []; -export function getDevWorkspaceClient(_args: Parameters): ReturnType { +export function getDevWorkspaceClient( + ..._args: Parameters +): ReturnType { return { serverConfigApi: { fetchCheCustomResource: () => ({}), @@ -176,5 +179,9 @@ export function getDevWorkspaceClient(_args: Parameters): ReturnT listInNamespace: _namespace => Promise.resolve(stubPersonalAccessTokenList), replace: (_namespace, _token) => Promise.resolve({} as api.PersonalAccessToken), } as IPersonalAccessTokenApi, + gitConfigApi: { + read: _namespace => Promise.resolve({} as api.IGitConfig), + patch: (_namespace, _gitconfig) => Promise.resolve({} as api.IGitConfig), + } as IGitConfigApi, } as DevWorkspaceClient; } diff --git a/yarn.lock b/yarn.lock index 2d8dd28dc..9a8c6938f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -276,6 +276,13 @@ core-js-pure "^3.30.2" regenerator-runtime "^0.14.0" +"@babel/runtime@^7.0.0": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.11.tgz#7a9ba3bbe406ad6f9e8dd4da2ece453eb23a77a4" + integrity sha512-ee7jVNlWN09+KftVOu9n7S8gQzD/Z6hN/I8VBRXW4P1+Xe7kJGXMwu8vds4aGIMHZnNbdpSWCfZZtinytpcAvA== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.21.0", "@babel/runtime@^7.9.2": version "7.22.10" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.10.tgz#ae3e9631fd947cb7e3610d3e9d8fef5f76696682" @@ -921,7 +928,7 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@kubernetes/client-node@^0.18.0", "@kubernetes/client-node@^0.18.1": +"@kubernetes/client-node@^0.18.1": version "0.18.1" resolved "https://registry.yarnpkg.com/@kubernetes/client-node/-/client-node-0.18.1.tgz#58d864c8f584efd0f8670f6c46bb8e9d5abd58f6" integrity sha512-F3JiK9iZnbh81O/da1tD0h8fQMi/MDttWc/JydyUVnjPEom55wVfnpl4zQ/sWD4uKB8FlxYRPiLwV2ZXB+xPKw== @@ -7502,7 +7509,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== -lodash@^4.15.0, lodash@^4.17.11, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4: +lodash@^4.0.0, lodash@^4.15.0, lodash@^4.17.11, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -8025,6 +8032,14 @@ ms@^2.0.0, ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +multi-ini@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/multi-ini/-/multi-ini-2.3.2.tgz#de71f5ec1620c66eebe072121b0a542bab8429c0" + integrity sha512-zuznIotGjtc8AXfWwX5/pfQI6JadxR/kN7zA1W8qqomk/7zKHMW54ik052dqV3bPzWLucysvPgJXEySsctUUWQ== + dependencies: + "@babel/runtime" "^7.0.0" + lodash "^4.0.0" + multimatch@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" From ca03f48664f5e9454e8bc1cfc828c90db24cc217 Mon Sep 17 00:00:00 2001 From: Oleksii Kurinnyi Date: Thu, 7 Sep 2023 16:38:48 +0300 Subject: [PATCH 2/6] feat(frontend): add GitConfig store Signed-off-by: Oleksii Kurinnyi --- .../devWorkspaceApi.ts | 15 -- .../dashboard-backend-client/gitConfigApi.ts | 31 ++++ .../store/GitConfig/__tests__/index.spec.ts | 158 ++++++++++++++++++ .../store/GitConfig/__tests__/reducer.spec.ts | 138 +++++++++++++++ .../GitConfig/__tests__/selectors.spec.ts | 88 ++++++++++ .../src/store/GitConfig/index.ts | 81 +++++++++ .../src/store/GitConfig/reducer.ts | 52 ++++++ .../src/store/GitConfig/selectors.ts | 26 +++ .../src/store/GitConfig/types.ts | 48 ++++++ .../src/store/__mocks__/storeBuilder.ts | 19 +++ .../dashboard-frontend/src/store/index.ts | 3 + 11 files changed, 644 insertions(+), 15 deletions(-) create mode 100644 packages/dashboard-frontend/src/services/dashboard-backend-client/gitConfigApi.ts create mode 100644 packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts create mode 100644 packages/dashboard-frontend/src/store/GitConfig/__tests__/reducer.spec.ts create mode 100644 packages/dashboard-frontend/src/store/GitConfig/__tests__/selectors.spec.ts create mode 100644 packages/dashboard-frontend/src/store/GitConfig/index.ts create mode 100644 packages/dashboard-frontend/src/store/GitConfig/reducer.ts create mode 100644 packages/dashboard-frontend/src/store/GitConfig/selectors.ts create mode 100644 packages/dashboard-frontend/src/store/GitConfig/types.ts diff --git a/packages/dashboard-frontend/src/services/dashboard-backend-client/devWorkspaceApi.ts b/packages/dashboard-frontend/src/services/dashboard-backend-client/devWorkspaceApi.ts index c33a370c8..8395e22f4 100644 --- a/packages/dashboard-frontend/src/services/dashboard-backend-client/devWorkspaceApi.ts +++ b/packages/dashboard-frontend/src/services/dashboard-backend-client/devWorkspaceApi.ts @@ -121,21 +121,6 @@ export async function putDockerConfig( } } -export async function addPersonalAccessToken( - namespace: string, - personalAccessToken: api.PersonalAccessToken, -): Promise { - try { - const response = await axios.post( - `${dashboardBackendPrefix}/namespace/${namespace}/personal-access-token`, - personalAccessToken, - ); - return response.data; - } catch (e) { - throw new Error(`Failed to add personal access token. ${helpers.errors.getMessage(e)}`); - } -} - export async function injectKubeConfig(namespace: string, devworkspaceId: string): Promise { try { await axios.post( diff --git a/packages/dashboard-frontend/src/services/dashboard-backend-client/gitConfigApi.ts b/packages/dashboard-frontend/src/services/dashboard-backend-client/gitConfigApi.ts new file mode 100644 index 000000000..3cc6570b5 --- /dev/null +++ b/packages/dashboard-frontend/src/services/dashboard-backend-client/gitConfigApi.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import axios from 'axios'; +import { dashboardBackendPrefix } from './const'; + +export async function fetchGitConfig(namespace: string): Promise { + const response = await axios.get(`${dashboardBackendPrefix}/namespace/${namespace}/gitconfig`); + return response.data; +} + +export async function patchGitConfig( + namespace: string, + gitconfig: api.IGitConfig, +): Promise { + const response = await axios.patch( + `${dashboardBackendPrefix}/namespace/${namespace}/gitconfig`, + gitconfig, + ); + return response.data; +} diff --git a/packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts new file mode 100644 index 000000000..db40ec0f4 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { MockStoreEnhanced } from 'redux-mock-store'; +import { ThunkDispatch } from 'redux-thunk'; +import * as TestStore from '..'; +import { AppState } from '../..'; +import { FakeStoreBuilder } from '../../__mocks__/storeBuilder'; +import { AUTHORIZED } from '../../sanityCheckMiddleware'; + +const mockFetchGitConfig = jest.fn().mockResolvedValue({ gitconfig: {} } as api.IGitConfig); +const mockPatchGitConfig = jest.fn().mockResolvedValue({ gitconfig: {} } as api.IGitConfig); +jest.mock('../../../services/dashboard-backend-client/gitConfigApi', () => { + return { + fetchGitConfig: (...args: unknown[]) => mockFetchGitConfig(...args), + patchGitConfig: (...args: unknown[]) => mockPatchGitConfig(...args), + }; +}); + +// mute the outputs +console.error = jest.fn(); + +describe('GitConfig store, actions', () => { + let store: MockStoreEnhanced>; + + beforeEach(() => { + store = new FakeStoreBuilder() + .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) + .build(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG when fetch the gitconfig', async () => { + await store.dispatch(TestStore.actionCreators.requestGitConfig()); + + const actions = store.getActions(); + + const expectedActions: TestStore.KnownAction[] = [ + { + type: TestStore.Type.REQUEST_GITCONFIG, + check: AUTHORIZED, + }, + { + type: TestStore.Type.RECEIVE_GITCONFIG, + config: { gitconfig: {} } as api.IGitConfig, + }, + ]; + + expect(actions).toEqual(expectedActions); + + expect(mockFetchGitConfig).toHaveBeenCalledTimes(1); + expect(mockPatchGitConfig).toHaveBeenCalledTimes(0); + }); + + it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG_ERROR when fetch the gitconfig with error', async () => { + mockFetchGitConfig.mockRejectedValueOnce(new Error('unexpected error')); + + try { + await store.dispatch(TestStore.actionCreators.requestGitConfig()); + } catch (e) { + // ignore + } + + const actions = store.getActions(); + + const expectedActions: TestStore.KnownAction[] = [ + { + type: TestStore.Type.REQUEST_GITCONFIG, + check: AUTHORIZED, + }, + { + type: TestStore.Type.RECEIVE_GITCONFIG_ERROR, + error: 'unexpected error', + }, + ]; + + expect(actions).toEqual(expectedActions); + + expect(mockFetchGitConfig).toHaveBeenCalledTimes(1); + expect(mockPatchGitConfig).toHaveBeenCalledTimes(0); + }); + + it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG when path the gitconfig', async () => { + await store.dispatch( + TestStore.actionCreators.updateGitConfig({ + name: 'testname', + email: 'test@email', + }), + ); + + const actions = store.getActions(); + + const expectedActions: TestStore.KnownAction[] = [ + { + type: TestStore.Type.REQUEST_GITCONFIG, + check: AUTHORIZED, + }, + { + type: TestStore.Type.RECEIVE_GITCONFIG, + config: { gitconfig: {} } as api.IGitConfig, + }, + ]; + + expect(actions).toEqual(expectedActions); + + expect(mockFetchGitConfig).toHaveBeenCalledTimes(0); + expect(mockPatchGitConfig).toHaveBeenCalledTimes(1); + expect(mockPatchGitConfig).toHaveBeenCalledWith('user-che', { + gitconfig: { user: { email: 'test@email', name: 'testname' } }, + }); + }); + + it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG_ERROR when path the gitconfig with error', async () => { + mockPatchGitConfig.mockRejectedValueOnce(new Error('unexpected error')); + + try { + await store.dispatch( + TestStore.actionCreators.updateGitConfig({ name: 'testname', email: 'testemail' }), + ); + } catch (e) { + // ignore + } + + const actions = store.getActions(); + + const expectedActions: TestStore.KnownAction[] = [ + { + type: TestStore.Type.REQUEST_GITCONFIG, + check: AUTHORIZED, + }, + { + type: TestStore.Type.RECEIVE_GITCONFIG_ERROR, + error: 'unexpected error', + }, + ]; + + expect(actions).toEqual(expectedActions); + + expect(mockFetchGitConfig).toHaveBeenCalledTimes(0); + expect(mockPatchGitConfig).toHaveBeenCalledTimes(1); + expect(mockPatchGitConfig).toHaveBeenCalledWith('user-che', { + gitconfig: { user: { name: 'testname', email: 'testemail' } }, + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/GitConfig/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/GitConfig/__tests__/reducer.spec.ts new file mode 100644 index 000000000..e9fbe9451 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitConfig/__tests__/reducer.spec.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { AnyAction } from 'redux'; +import * as TestStore from '..'; +import { AUTHORIZED } from '../../sanityCheckMiddleware'; +import * as unloadedState from '../reducer'; + +describe('GitConfig store, reducer', () => { + it('should return initial state', () => { + const incomingAction: TestStore.RequestGitConfigAction = { + type: TestStore.Type.REQUEST_GITCONFIG, + check: AUTHORIZED, + }; + const initialState = unloadedState.reducer(undefined, incomingAction); + + const expectedState: TestStore.State = { + isLoading: false, + config: undefined, + error: undefined, + }; + + expect(initialState).toEqual(expectedState); + }); + + it('should return state if action type is not matched', () => { + const initialState: TestStore.State = { + isLoading: false, + config: undefined, + error: undefined, + }; + const incomingAction = { + type: 'OTHER_ACTION', + isLoading: true, + registries: [], + resourceVersion: undefined, + } as AnyAction; + const newState = unloadedState.reducer(initialState, incomingAction); + + const expectedState: TestStore.State = { + isLoading: false, + config: undefined, + error: undefined, + }; + expect(newState).toEqual(expectedState); + }); + + it('should handle REQUEST_GITCONFIG', () => { + const initialState: TestStore.State = { + isLoading: false, + config: undefined, + error: undefined, + }; + const incomingAction: TestStore.RequestGitConfigAction = { + type: TestStore.Type.REQUEST_GITCONFIG, + check: AUTHORIZED, + }; + + const newState = unloadedState.reducer(initialState, incomingAction); + + const expectedState: TestStore.State = { + isLoading: true, + config: undefined, + error: undefined, + }; + + expect(newState).toEqual(expectedState); + }); + + it('should handle RECEIVE_GITCONFIG', () => { + const initialState: TestStore.State = { + isLoading: true, + config: undefined, + error: undefined, + }; + const incomingAction: TestStore.ReceiveGitConfigAction = { + type: TestStore.Type.RECEIVE_GITCONFIG, + config: { + gitconfig: { + user: { + email: 'user@che', + name: 'user-che', + }, + }, + resourceVersion: '345', + }, + }; + + const newState = unloadedState.reducer(initialState, incomingAction); + + const expectedState: TestStore.State = { + isLoading: false, + config: { + gitconfig: { + user: { + email: 'user@che', + name: 'user-che', + }, + }, + resourceVersion: '345', + }, + error: undefined, + }; + + expect(newState).toEqual(expectedState); + }); + + it('should handle RECEIVE_GITCONFIG_ERROR', () => { + const initialState: TestStore.State = { + isLoading: true, + config: undefined, + error: undefined, + }; + const incomingAction: TestStore.ReceiveGitConfigErrorAction = { + type: TestStore.Type.RECEIVE_GITCONFIG_ERROR, + error: 'unexpected error', + }; + + const newState = unloadedState.reducer(initialState, incomingAction); + + const expectedState: TestStore.State = { + isLoading: false, + config: undefined, + error: 'unexpected error', + }; + + expect(newState).toEqual(expectedState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/GitConfig/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/GitConfig/__tests__/selectors.spec.ts new file mode 100644 index 000000000..4afe099fd --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitConfig/__tests__/selectors.spec.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { MockStoreEnhanced } from 'redux-mock-store'; +import { ThunkDispatch } from 'redux-thunk'; +import * as TestStore from '..'; +import { AppState } from '../..'; +import { FakeStoreBuilder } from '../../__mocks__/storeBuilder'; +import { selectGitConfigError, selectGitConfigIsLoading, selectGitConfigUser } from '../selectors'; + +describe('GitConfig store, selectors', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return the error', () => { + const fakeStore = new FakeStoreBuilder() + .withGitConfig({ config: {} as api.IGitConfig, error: 'Something unexpected' }, false) + .build() as MockStoreEnhanced< + AppState, + ThunkDispatch + >; + const state = fakeStore.getState(); + + const selectedError = selectGitConfigError(state); + expect(selectedError).toEqual('Something unexpected'); + }); + + it('should return the gitconfig', () => { + const fakeStore = new FakeStoreBuilder() + .withGitConfig({ + config: { + gitconfig: { + user: { + name: 'user-che', + email: 'user@che', + }, + }, + }, + }) + .build() as MockStoreEnhanced< + AppState, + ThunkDispatch + >; + const state = fakeStore.getState(); + + const gitconfig = selectGitConfigUser(state); + expect(gitconfig).toEqual({ + name: 'user-che', + email: 'user@che', + }); + }); + + it('should return isLoading state', () => { + const fakeStore = new FakeStoreBuilder() + .withGitConfig( + { + config: { + gitconfig: { + user: { + name: 'user-che', + email: 'user@che', + }, + }, + }, + }, + true, + ) + .build() as MockStoreEnhanced< + AppState, + ThunkDispatch + >; + const state = fakeStore.getState(); + + const isLoading = selectGitConfigIsLoading(state); + expect(isLoading).toEqual(true); + }); +}); diff --git a/packages/dashboard-frontend/src/store/GitConfig/index.ts b/packages/dashboard-frontend/src/store/GitConfig/index.ts new file mode 100644 index 000000000..30d920072 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitConfig/index.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; +import { AppThunk } from '..'; +import { + fetchGitConfig, + patchGitConfig, +} from '../../services/dashboard-backend-client/gitConfigApi'; +import { selectDefaultNamespace } from '../InfrastructureNamespaces/selectors'; +import { AUTHORIZED } from '../sanityCheckMiddleware'; +import { GitConfigUser, KnownAction, Type } from './types'; +export * from './reducer'; +export * from './types'; + +export type ActionCreators = { + requestGitConfig: () => AppThunk>; + updateGitConfig: (gitconfig: GitConfigUser) => AppThunk>; +}; + +export const actionCreators: ActionCreators = { + requestGitConfig: + (): AppThunk> => + async (dispatch, getState): Promise => { + await dispatch({ type: Type.REQUEST_GITCONFIG, check: AUTHORIZED }); + + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + try { + const config = await fetchGitConfig(namespace); + dispatch({ + type: Type.RECEIVE_GITCONFIG, + config, + }); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch({ + type: Type.RECEIVE_GITCONFIG_ERROR, + error: errorMessage, + }); + throw e; + } + }, + + updateGitConfig: + (changedGitConfig: GitConfigUser): AppThunk> => + async (dispatch, getState): Promise => { + await dispatch({ type: Type.REQUEST_GITCONFIG, check: AUTHORIZED }); + + const namespace = selectDefaultNamespace(getState()).name; + const { gitConfig } = getState(); + const gitconfig = Object.assign(gitConfig.config || {}, { + gitconfig: { + user: changedGitConfig, + }, + } as api.IGitConfig); + try { + const updated = await patchGitConfig(namespace, gitconfig); + dispatch({ + type: Type.RECEIVE_GITCONFIG, + config: updated, + }); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch({ + type: Type.RECEIVE_GITCONFIG_ERROR, + error: errorMessage, + }); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/GitConfig/reducer.ts b/packages/dashboard-frontend/src/store/GitConfig/reducer.ts new file mode 100644 index 000000000..8368157a3 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitConfig/reducer.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { Action, Reducer } from 'redux'; +import { createObject } from '../helpers'; +import { KnownAction, State, Type } from './types'; + +const unloadedState: State = { + isLoading: false, + config: undefined, + error: undefined, +}; + +export const reducer: Reducer = ( + state: State | undefined, + incomingAction: Action, +): State => { + if (state === undefined) { + return unloadedState; + } + + const action = incomingAction as KnownAction; + switch (action.type) { + case Type.REQUEST_GITCONFIG: + return createObject(state, { + isLoading: true, + error: undefined, + }); + case Type.RECEIVE_GITCONFIG: + return createObject(state, { + isLoading: false, + error: undefined, + config: action.config, + }); + case Type.RECEIVE_GITCONFIG_ERROR: + return createObject(state, { + isLoading: false, + error: action.error, + }); + default: + return state; + } +}; diff --git a/packages/dashboard-frontend/src/store/GitConfig/selectors.ts b/packages/dashboard-frontend/src/store/GitConfig/selectors.ts new file mode 100644 index 000000000..76ca7c6fb --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitConfig/selectors.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createSelector } from 'reselect'; +import { AppState } from '..'; +import { State } from './types'; + +const selectState = (state: AppState) => state.gitConfig; + +export const selectGitConfigIsLoading = createSelector(selectState, state => state.isLoading); + +export const selectGitConfigUser = createSelector( + selectState, + (state: State) => state.config?.gitconfig.user, +); + +export const selectGitConfigError = createSelector(selectState, state => state.error); diff --git a/packages/dashboard-frontend/src/store/GitConfig/types.ts b/packages/dashboard-frontend/src/store/GitConfig/types.ts new file mode 100644 index 000000000..52ae32a25 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitConfig/types.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { Action } from 'redux'; +import { SanityCheckAction } from '../sanityCheckMiddleware'; + +export type GitConfigUser = api.IGitConfig['gitconfig']['user']; + +export interface State { + isLoading: boolean; + config?: api.IGitConfig; + error: string | undefined; +} + +export enum Type { + REQUEST_GITCONFIG = 'REQUEST_GITCONFIG', + RECEIVE_GITCONFIG = 'RECEIVE_GITCONFIG', + RECEIVE_GITCONFIG_ERROR = 'RECEIVE_GITCONFIG_ERROR', +} + +export interface RequestGitConfigAction extends Action, SanityCheckAction { + type: Type.REQUEST_GITCONFIG; +} + +export interface ReceiveGitConfigAction extends Action { + type: Type.RECEIVE_GITCONFIG; + config: api.IGitConfig; +} + +export interface ReceiveGitConfigErrorAction extends Action { + type: Type.RECEIVE_GITCONFIG_ERROR; + error: string; +} + +export type KnownAction = + | RequestGitConfigAction + | ReceiveGitConfigAction + | ReceiveGitConfigErrorAction; diff --git a/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts b/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts index 1936f6947..611cb08b8 100644 --- a/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts +++ b/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts @@ -160,6 +160,11 @@ export class FakeStoreBuilder { isLoading: false, tokens: [], }, + gitConfig: { + config: undefined, + isLoading: false, + error: undefined, + }, }; constructor(store?: MockStoreEnhanced>) { @@ -434,6 +439,20 @@ export class FakeStoreBuilder { return this; } + public withGitConfig( + options: { + config?: api.IGitConfig; + error?: string; + }, + isLoading = false, + ) { + this.state.gitConfig.config = options.config; + this.state.gitConfig.error = options.error; + + this.state.gitConfig.isLoading = isLoading; + return this; + } + public build(): MockStoreEnhanced> { const middlewares = [mockThunk]; const mockStore = createMockStore(middlewares); diff --git a/packages/dashboard-frontend/src/store/index.ts b/packages/dashboard-frontend/src/store/index.ts index cb0be0cff..5b9da7d72 100644 --- a/packages/dashboard-frontend/src/store/index.ts +++ b/packages/dashboard-frontend/src/store/index.ts @@ -23,6 +23,7 @@ import * as DwPluginsStore from './Plugins/devWorkspacePlugins'; import * as DwServerConfigStore from './ServerConfig'; import * as EventsStore from './Events'; import * as FactoryResolverStore from './FactoryResolver'; +import * as GitConfigStore from './GitConfig'; import * as GitOauthConfigStore from './GitOauthConfig'; import * as InfrastructureNamespacesStore from './InfrastructureNamespaces'; import * as LogsStore from './Pods/Logs'; @@ -47,6 +48,7 @@ export interface AppState { dwServerConfig: DwServerConfigStore.State; events: EventsStore.State; factoryResolver: FactoryResolverStore.State; + gitConfig: GitConfigStore.State; gitOauthConfig: GitOauthConfigStore.State; infrastructureNamespaces: InfrastructureNamespacesStore.State; logs: LogsStore.State; @@ -71,6 +73,7 @@ export const reducers = { dwServerConfig: DwServerConfigStore.reducer, events: EventsStore.reducer, factoryResolver: FactoryResolverStore.reducer, + gitConfig: GitConfigStore.reducer, gitOauthConfig: GitOauthConfigStore.reducer, infrastructureNamespaces: InfrastructureNamespacesStore.reducer, logs: LogsStore.reducer, From ee301dccd4dfd75cf910a0e7c360b8444058fcb8 Mon Sep 17 00:00:00 2001 From: Oleksii Kurinnyi Date: Tue, 19 Sep 2023 18:26:25 +0300 Subject: [PATCH 3/6] feat(frontend): add Gitconfig page Signed-off-by: Oleksii Kurinnyi --- .../InputGroupExtended/__mocks__/index.tsx | 29 +++ .../__snapshots__/index.spec.tsx.snap | 49 +++++ .../__tests__/index.spec.tsx | 159 ++++++++++++++ .../InputGroupExtended/index.module.css | 27 +++ .../components/InputGroupExtended/index.tsx | 106 ++++++++++ .../__snapshots__/index.spec.tsx.snap | 39 ++++ .../EmptyState/__tests__/index.spec.tsx | 32 +++ .../GitConfig/EmptyState/index.tsx | 28 +++ .../SectionUser/Email/__mocks__/index.tsx | 22 ++ .../__snapshots__/index.spec.tsx.snap | 82 ++++++++ .../Email/__tests__/index.spec.tsx | 92 ++++++++ .../GitConfig/SectionUser/Email/index.tsx | 120 +++++++++++ .../SectionUser/Name/__mocks__/index.tsx | 22 ++ .../__snapshots__/index.spec.tsx.snap | 83 ++++++++ .../SectionUser/Name/__tests__/index.spec.tsx | 83 ++++++++ .../GitConfig/SectionUser/Name/index.tsx | 109 ++++++++++ .../GitConfig/SectionUser/__mocks__/index.tsx | 29 +++ .../__snapshots__/index.spec.tsx.snap | 50 +++++ .../SectionUser/__tests__/index.spec.tsx | 79 +++++++ .../GitConfig/SectionUser/index.tsx | 57 +++++ .../GitConfig/__mocks__/index.tsx | 19 ++ .../__snapshots__/index.spec.tsx.snap | 81 ++++++++ .../GitConfig/__tests__/index.spec.tsx | 196 ++++++++++++++++++ .../pages/UserPreferences/GitConfig/index.tsx | 109 ++++++++++ .../UserPreferences/__tests__/index.spec.tsx | 22 +- .../src/pages/UserPreferences/index.tsx | 12 +- .../src/services/helpers/types.ts | 6 +- 27 files changed, 1732 insertions(+), 10 deletions(-) create mode 100644 packages/dashboard-frontend/src/components/InputGroupExtended/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/components/InputGroupExtended/__tests__/__snapshots__/index.spec.tsx.snap create mode 100644 packages/dashboard-frontend/src/components/InputGroupExtended/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/components/InputGroupExtended/index.module.css create mode 100644 packages/dashboard-frontend/src/components/InputGroupExtended/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/__tests__/__snapshots__/index.spec.tsx.snap create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__tests__/__snapshots__/index.spec.tsx.snap create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__tests__/__snapshots__/index.spec.tsx.snap create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__tests__/__snapshots__/index.spec.tsx.snap create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/__snapshots__/index.spec.tsx.snap create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/index.tsx diff --git a/packages/dashboard-frontend/src/components/InputGroupExtended/__mocks__/index.tsx b/packages/dashboard-frontend/src/components/InputGroupExtended/__mocks__/index.tsx new file mode 100644 index 000000000..58a18e181 --- /dev/null +++ b/packages/dashboard-frontend/src/components/InputGroupExtended/__mocks__/index.tsx @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as React from 'react'; +import { Props, State } from '..'; +import { Button } from '@patternfly/react-core'; + +export class InputGroupExtended extends React.PureComponent { + render(): React.ReactElement { + const { children, onCancel, onSave, validated } = this.props; + return ( +
+ {children} + {validated} +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/components/InputGroupExtended/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/InputGroupExtended/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..022f50d2a --- /dev/null +++ b/packages/dashboard-frontend/src/components/InputGroupExtended/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InputGroupExtended snapshots editable 1`] = ` + + value + + +`; + +exports[`InputGroupExtended snapshots readonly 1`] = ` + + value + +`; diff --git a/packages/dashboard-frontend/src/components/InputGroupExtended/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/InputGroupExtended/__tests__/index.spec.tsx new file mode 100644 index 000000000..cecf6c2d8 --- /dev/null +++ b/packages/dashboard-frontend/src/components/InputGroupExtended/__tests__/index.spec.tsx @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ValidatedOptions } from '@patternfly/react-core'; +import { StateMock } from '@react-mock/state'; +import * as React from 'react'; +import { InputGroupExtended, Props, State } from '..'; +import getComponentRenderer, { screen } from '../../../services/__mocks__/getComponentRenderer'; + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const mockOnCancel = jest.fn(); +const mockOnSave = jest.fn(); + +describe('InputGroupExtended', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('snapshots', () => { + test('readonly', () => { + const snapshot = createSnapshot({ + readonly: true, + value: 'value', + onCancel: mockOnCancel, + onSave: mockOnSave, + }); + + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('editable', () => { + const snapshot = createSnapshot({ + readonly: false, + value: 'value', + onCancel: mockOnCancel, + onSave: mockOnSave, + }); + + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + }); + + it('should switch in edit mode', () => { + renderComponent({ + readonly: false, + value: 'value', + onCancel: mockOnCancel, + onSave: mockOnSave, + }); + + const editButton = screen.queryByTestId('button-edit'); + expect(editButton).not.toBeNull(); + + editButton!.click(); + + expect(screen.queryByTestId('button-edit')).toBeNull(); + + expect(screen.queryByTestId('text-input')).not.toBeNull(); + expect(screen.queryByTestId('button-save')).not.toBeNull(); + expect(screen.queryByTestId('button-cancel')).not.toBeNull(); + }); + + it('should switch out of edit mode', () => { + renderComponent( + { + readonly: false, + value: 'value', + onCancel: mockOnCancel, + onSave: mockOnSave, + }, + { + isEditMode: true, + }, + ); + + const cancelButton = screen.queryByTestId('button-cancel'); + expect(cancelButton).not.toBeNull(); + expect(cancelButton).toBeEnabled(); + + cancelButton!.click(); + + expect(mockOnCancel).toBeCalled(); + expect(screen.queryByTestId('button-edit')).not.toBeNull(); + + expect(screen.queryByTestId('text-input')).toBeNull(); + expect(screen.queryByTestId('button-save')).toBeNull(); + expect(screen.queryByTestId('button-cancel')).toBeNull(); + }); + + it('should handle save', () => { + renderComponent( + { + readonly: false, + value: 'value', + onCancel: mockOnCancel, + onSave: mockOnSave, + validated: ValidatedOptions.success, + }, + { + isEditMode: true, + }, + ); + + const saveButton = screen.queryByTestId('button-save'); + expect(saveButton).not.toBeNull(); + expect(saveButton).toBeEnabled(); + + saveButton!.click(); + + expect(mockOnSave).toBeCalled(); + expect(screen.queryByTestId('button-edit')).not.toBeNull(); + + expect(screen.queryByTestId('text-input')).toBeNull(); + expect(screen.queryByTestId('button-save')).toBeNull(); + expect(screen.queryByTestId('button-cancel')).toBeNull(); + }); + + it('should disable save button if not validated', () => { + renderComponent( + { + readonly: false, + value: 'value', + onCancel: mockOnCancel, + onSave: mockOnSave, + validated: ValidatedOptions.error, + }, + { + isEditMode: true, + }, + ); + + const saveButton = screen.queryByTestId('button-save'); + expect(saveButton).not.toBeNull(); + expect(saveButton).toBeDisabled(); + }); +}); + +function getComponent(props: Props, localState?: Partial): React.ReactElement { + const component = ( + +
+ + ); + if (localState) { + return {component}; + } else { + return component; + } +} diff --git a/packages/dashboard-frontend/src/components/InputGroupExtended/index.module.css b/packages/dashboard-frontend/src/components/InputGroupExtended/index.module.css new file mode 100644 index 000000000..ef4b3f6b9 --- /dev/null +++ b/packages/dashboard-frontend/src/components/InputGroupExtended/index.module.css @@ -0,0 +1,27 @@ +/* +* Copyright (c) 2018-2023 Red Hat, Inc. +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* Red Hat, Inc. - initial API and implementation +*/ + +.nameInput { + max-width: 450px; +} + +.editable { + position: relative; + top: 2px; + color: var(--pf-global--link--Color); +} + +.readonly { + display: inline-block; + padding-top: 8px; +} + diff --git a/packages/dashboard-frontend/src/components/InputGroupExtended/index.tsx b/packages/dashboard-frontend/src/components/InputGroupExtended/index.tsx new file mode 100644 index 000000000..a67a73e2f --- /dev/null +++ b/packages/dashboard-frontend/src/components/InputGroupExtended/index.tsx @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { Button, Form, InputGroup, ValidatedOptions } from '@patternfly/react-core'; +import { CheckIcon, PencilAltIcon, TimesIcon } from '@patternfly/react-icons'; +import * as React from 'react'; + +import styles from './index.module.css'; + +export type Props = React.PropsWithChildren & { + readonly: boolean; + validated?: ValidatedOptions; + value: string; + onCancel: () => void; + onSave: () => void; +}; +export type State = { + isEditMode: boolean; +}; + +export class InputGroupExtended extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + isEditMode: false, + }; + } + + private handleEdit(): void { + this.setState({ isEditMode: true }); + } + + private handleSave(): void { + this.setState({ isEditMode: false }); + this.props.onSave(); + } + + private handleCancel(): void { + this.setState({ isEditMode: false }); + this.props.onCancel(); + } + + private handleSubmit(e: React.FormEvent): void { + e.preventDefault(); + + if (this.canSave()) { + this.handleSave(); + } + } + + private canSave(): boolean { + const { validated } = this.props; + return validated === ValidatedOptions.success; + } + + public render(): React.ReactElement { + const { children, readonly, value } = this.props; + const { isEditMode } = this.state; + + if (readonly) { + return {value}; + } + + if (isEditMode === false) { + return ( + + {value} + + + ); + } + + const isSaveButtonDisabled = this.canSave() === false; + + return ( +
this.handleSubmit(e)}> + + {children} + + + +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..89f8b61b4 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EmptyState snapshot 1`] = ` +
+
+ +

+ No gitconfig found +

+
+
+`; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/__tests__/index.spec.tsx new file mode 100644 index 000000000..f902a277d --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/__tests__/index.spec.tsx @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; +import { GitConfigEmptyState } from '..'; +import getComponentRenderer from '../../../../../services/__mocks__/getComponentRenderer'; + +const { createSnapshot } = getComponentRenderer(getComponent); + +describe('EmptyState', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot(); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); +}); + +function getComponent(): React.ReactElement { + return ; +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/index.tsx new file mode 100644 index 000000000..f12dd44b9 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/EmptyState/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { EmptyState, EmptyStateIcon, EmptyStateVariant, Title } from '@patternfly/react-core'; +import { CogIcon } from '@patternfly/react-icons'; +import React from 'react'; + +export class GitConfigEmptyState extends React.PureComponent { + public render(): React.ReactElement { + return ( + + + + No gitconfig found + + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__mocks__/index.tsx new file mode 100644 index 000000000..c474d0e72 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__mocks__/index.tsx @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as React from 'react'; +import { Props } from '..'; + +export class GitConfigUserEmail extends React.PureComponent { + public render(): React.ReactElement { + const { onChange } = this.props; + + return ; + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..17580d5b0 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GitConfigUserEmail snapshot 1`] = ` +
+
+ + +
+
+
+ + +
+ +
+
+`; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__tests__/index.spec.tsx new file mode 100644 index 000000000..39f7ca7a9 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/__tests__/index.spec.tsx @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as React from 'react'; +import { GitConfigUserEmail } from '..'; +import getComponentRenderer, { + screen, +} from '../../../../../../services/__mocks__/getComponentRenderer'; +import { ValidatedOptions } from '@patternfly/react-core'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../../../../../../components/InputGroupExtended'); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const mockOnChange = jest.fn(); + +describe('GitConfigUserEmail', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot('user@che'); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + it('should fail validation if value is empty', () => { + renderComponent('user@che.org'); + + const textInput = screen.getByRole('textbox'); + userEvent.clear(textInput); + + expect(screen.getByTestId('validated')).toHaveTextContent(ValidatedOptions.error); + }); + + it('should fail validation if value is too long', () => { + renderComponent('user@che.org'); + + const textInput = screen.getByRole('textbox'); + userEvent.type(textInput, 'a'.repeat(129)); + + expect(screen.getByTestId('validated')).toHaveTextContent(ValidatedOptions.error); + }); + + it('should fail validation if is not a valid email', () => { + renderComponent('user@che.org'); + + const textInput = screen.getByRole('textbox'); + userEvent.type(textInput, '@test'); + + expect(screen.getByTestId('validated')).toHaveTextContent(ValidatedOptions.error); + }); + + it('should handle save', () => { + renderComponent('user@che.org'); + + const textInput = screen.getByRole('textbox'); + userEvent.type(textInput, 'a'); + + const buttonSave = screen.getByTestId('button-save'); + userEvent.click(buttonSave); + + expect(mockOnChange).toBeCalledWith('user@che.orga'); + }); + + it('should handle cancel', () => { + renderComponent('user@che.org'); + + const textInput = screen.getByRole('textbox'); + userEvent.type(textInput, 'a'); + + const buttonCancel = screen.getByTestId('button-cancel'); + userEvent.click(buttonCancel); + + expect(textInput).toHaveValue('user@che.org'); + expect(mockOnChange).not.toBeCalled(); + }); +}); + +function getComponent(value: string): React.ReactElement { + return ; +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/index.tsx new file mode 100644 index 000000000..046d75e71 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Email/index.tsx @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FormGroup, TextInput, ValidatedOptions } from '@patternfly/react-core'; +import React from 'react'; +import { InputGroupExtended } from '../../../../../components/InputGroupExtended'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; + +const ERROR_REQUIRED_VALUE = 'A value is required.'; +const MAX_LENGTH = 128; +const ERROR_MAX_LENGTH = `The value is too long. The maximum length is ${MAX_LENGTH} characters.`; +const REGEX = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; +const ERROR_INVALID_EMAIL = 'The value is not a valid email address.'; + +export type Props = { + value: string; + onChange: (value: string) => void; +}; +export type State = { + errorMessage?: string; + value: string; + validated: ValidatedOptions | undefined; +}; + +export class GitConfigUserEmail extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + value: props.value, + validated: undefined, + }; + } + + private handleChange(value: string): void { + this.validate(value); + this.setState({ value }); + } + + private handleSave(): void { + const { value } = this.state; + this.props.onChange(value); + } + + private handleCancel(): void { + const { value } = this.props; + this.setState({ + value, + validated: undefined, + }); + } + + private validate(value: string): void { + if (value.length === 0) { + this.setState({ + errorMessage: ERROR_REQUIRED_VALUE, + validated: ValidatedOptions.error, + }); + return; + } + if (value.length > MAX_LENGTH) { + this.setState({ + errorMessage: ERROR_MAX_LENGTH, + validated: ValidatedOptions.error, + }); + return; + } + if (!REGEX.test(value)) { + this.setState({ + errorMessage: ERROR_INVALID_EMAIL, + validated: ValidatedOptions.error, + }); + return; + } + this.setState({ + errorMessage: undefined, + validated: ValidatedOptions.success, + }); + } + + public render(): React.ReactElement { + const { errorMessage, value, validated } = this.state; + + const fieldId = 'gitconfig-user-email'; + + return ( + } + > + this.handleSave()} + onCancel={() => this.handleCancel()} + > + this.handleChange(value)} + /> + + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__mocks__/index.tsx new file mode 100644 index 000000000..c7de94855 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__mocks__/index.tsx @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as React from 'react'; +import { Props } from '..'; + +export class GitConfigUserName extends React.PureComponent { + public render(): React.ReactElement { + const { onChange } = this.props; + + return ; + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..613ca80c5 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GitConfigUserName snapshot 1`] = ` +
+
+ + +
+
+
+ + +
+ +
+
+`; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__tests__/index.spec.tsx new file mode 100644 index 000000000..0e1cfb14d --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/__tests__/index.spec.tsx @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as React from 'react'; +import { GitConfigUserName } from '..'; +import getComponentRenderer, { + screen, +} from '../../../../../../services/__mocks__/getComponentRenderer'; +import { ValidatedOptions } from '@patternfly/react-core'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../../../../../../components/InputGroupExtended'); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const mockOnChange = jest.fn(); + +describe('GitConfigUserName', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot('user one'); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + it('should fail validation if value is empty', () => { + renderComponent('user one'); + + const textInput = screen.getByRole('textbox'); + userEvent.clear(textInput); + + expect(screen.getByTestId('validated')).toHaveTextContent(ValidatedOptions.error); + }); + + it('should fail validation if value is too long', () => { + renderComponent('user one'); + + const textInput = screen.getByRole('textbox'); + userEvent.type(textInput, 'a'.repeat(129)); + + expect(screen.getByTestId('validated')).toHaveTextContent(ValidatedOptions.error); + }); + + it('should handle save', () => { + renderComponent('user one'); + + const textInput = screen.getByRole('textbox'); + userEvent.type(textInput, ' two'); + + const buttonSave = screen.getByTestId('button-save'); + userEvent.click(buttonSave); + + expect(mockOnChange).toBeCalledWith('user one two'); + }); + + it('should handle cancel', () => { + renderComponent('user one'); + + const textInput = screen.getByRole('textbox'); + userEvent.type(textInput, ' two'); + + const buttonCancel = screen.getByTestId('button-cancel'); + userEvent.click(buttonCancel); + + expect(textInput).toHaveValue('user one'); + expect(mockOnChange).not.toBeCalled(); + }); +}); + +function getComponent(value: string): React.ReactElement { + return ; +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/index.tsx new file mode 100644 index 000000000..33b3e2109 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/Name/index.tsx @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FormGroup, TextInput, ValidatedOptions } from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { InputGroupExtended } from '../../../../../components/InputGroupExtended'; + +const ERROR_REQUIRED_VALUE = 'A value is required.'; +const MAX_LENGTH = 128; +const ERROR_MAX_LENGTH = `The value is too long. The maximum length is ${MAX_LENGTH} characters.`; + +export type Props = { + value: string; + onChange: (value: string) => void; +}; +export type State = { + errorMessage?: string; + value: string; + validated: ValidatedOptions | undefined; +}; + +export class GitConfigUserName extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + value: props.value, + validated: undefined, + }; + } + + private handleChange(value: string): void { + this.validate(value); + this.setState({ value }); + } + + private handleSave(): void { + const { value } = this.state; + this.props.onChange(value); + } + + private handleCancel(): void { + const { value } = this.props; + this.setState({ value }); + } + + private validate(value: string): void { + if (value.length === 0) { + this.setState({ + errorMessage: ERROR_REQUIRED_VALUE, + validated: ValidatedOptions.error, + }); + return; + } + if (value.length > MAX_LENGTH) { + this.setState({ + errorMessage: ERROR_MAX_LENGTH, + validated: ValidatedOptions.error, + }); + return; + } + this.setState({ + errorMessage: undefined, + validated: ValidatedOptions.success, + }); + } + + public render(): React.ReactElement { + const { errorMessage, value, validated } = this.state; + + const fieldId = 'gitconfig-user-name'; + + return ( + } + > + this.handleSave()} + onCancel={() => this.handleCancel()} + > + this.handleChange(value)} + onSubmit={() => this.handleSave()} + /> + + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__mocks__/index.tsx new file mode 100644 index 000000000..76c7cb23e --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__mocks__/index.tsx @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as React from 'react'; +import { Props } from '..'; + +export class GitConfigSectionUser extends React.PureComponent { + render(): React.ReactElement { + const { config, onChange } = this.props; + return ( +
+
{config.email || ''}
+
{config.name || ''}
+ +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..bc5f36aee --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GitConfigSectionUser snapshot 1`] = ` +
+
+
+
+
+
+ [user] +
+ + +
+
+
+
+
+`; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__tests__/index.spec.tsx new file mode 100644 index 000000000..111f974f3 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/__tests__/index.spec.tsx @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as React from 'react'; +import { GitConfigSectionUser, Props } from '..'; +import getComponentRenderer, { + screen, +} from '../../../../../services/__mocks__/getComponentRenderer'; + +jest.mock('../Email'); +jest.mock('../Name'); + +const mockOnChange = jest.fn(); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +describe('GitConfigSectionUser', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot({ + config: { + name: 'user', + email: 'user@che', + }, + onChange: mockOnChange, + }); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + it('should handle name change', () => { + renderComponent({ + config: { + name: 'user', + email: 'user@che', + }, + onChange: mockOnChange, + }); + + screen.getByText('Change Name').click(); + + expect(mockOnChange).toHaveBeenCalledWith({ + name: 'new user', + email: 'user@che', + }); + }); + + it('should handle email change', () => { + renderComponent({ + config: { + name: 'user', + email: 'user@che', + }, + onChange: mockOnChange, + }); + + screen.getByText('Change Email').click(); + + expect(mockOnChange).toHaveBeenCalledWith({ + name: 'user', + email: 'new-user@che', + }); + }); +}); + +function getComponent(props: Props): React.ReactElement { + return ; +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/index.tsx new file mode 100644 index 000000000..93c25cf5b --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/SectionUser/index.tsx @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { Form, FormSection, Panel, PanelMain, PanelMainBody } from '@patternfly/react-core'; +import * as React from 'react'; +import { GitConfigUser } from '../../../../store/GitConfig'; +import { GitConfigUserName } from './Name'; +import { GitConfigUserEmail } from './Email'; + +export type Props = { + config: GitConfigUser; + onChange: (gitConfigUser: GitConfigUser) => void; +}; + +export class GitConfigSectionUser extends React.PureComponent { + private handleChange(partialConfig: Partial): void { + const { config, onChange } = this.props; + + onChange({ + ...config, + ...partialConfig, + }); + } + + public render(): React.ReactElement { + const { config } = this.props; + return ( + + + +
e.preventDefault()}> + + this.handleChange({ name })} + /> + this.handleChange({ email })} + /> + +
+
+
+
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__mocks__/index.tsx new file mode 100644 index 000000000..1cd5916bb --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__mocks__/index.tsx @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +export default class GitConfig extends React.PureComponent { + render() { + return
GitConfig
; + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..9d8068ccb --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GitConfig snapshot with gitconfig 1`] = ` +[ + + + , +
+
+
+ user@che +
+
+ user +
+ +
+
, +] +`; + +exports[`GitConfig snapshot with no gitconfig 1`] = ` +[ + + + , +
+
+
+ +

+ No gitconfig found +

+
+
+
, +] +`; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/index.spec.tsx new file mode 100644 index 000000000..98d35147b --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/index.spec.tsx @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { AlertVariant } from '@patternfly/react-core'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { Provider } from 'react-redux'; +import { Action, Store } from 'redux'; +import GitConfig from '..'; +import { container } from '../../../../inversify.config'; +import getComponentRenderer, { + screen, + waitFor, +} from '../../../../services/__mocks__/getComponentRenderer'; +import { AppAlerts } from '../../../../services/alerts/appAlerts'; +import { AlertItem } from '../../../../services/helpers/types'; +import { AppThunk } from '../../../../store'; +import { ActionCreators } from '../../../../store/GitConfig'; +import { FakeStoreBuilder } from '../../../../store/__mocks__/storeBuilder'; +import { mockShowAlert } from '../../../WorkspaceDetails/__mocks__'; + +jest.mock('../SectionUser'); + +// mute output +console.error = jest.fn(); + +const mockRequestGitConfig = jest.fn(); +const mockUpdateGitConfig = jest.fn(); +jest.mock('../../../../store/GitConfig', () => ({ + actionCreators: { + requestGitConfig: + (...args): AppThunk> => + async (): Promise => + mockRequestGitConfig(...args), + updateGitConfig: + (...args): AppThunk> => + async (): Promise => + mockUpdateGitConfig(...args), + } as ActionCreators, +})); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +let store: Store; +let storeEmpty: Store; + +describe('GitConfig', () => { + beforeEach(() => { + store = new FakeStoreBuilder() + .withGitConfig({ + config: { + gitconfig: { + user: { + name: 'user', + email: 'user@che', + }, + }, + }, + }) + .build(); + storeEmpty = new FakeStoreBuilder().build(); + + class MockAppAlerts extends AppAlerts { + showAlert(alert: AlertItem): void { + mockShowAlert(alert); + } + } + + container.snapshot(); + container.rebind(AppAlerts).to(MockAppAlerts).inSingletonScope(); + }); + + afterEach(() => { + jest.clearAllMocks(); + container.restore(); + }); + + describe('snapshot', () => { + test('with no gitconfig', () => { + const snapshot = createSnapshot(storeEmpty); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('with gitconfig', () => { + const snapshot = createSnapshot(store); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + }); + + describe('empty state', () => { + it('should render empty state when there is not gitconfig', () => { + renderComponent(storeEmpty); + + expect(screen.queryByRole('heading', { name: 'No gitconfig found' })).not.toBeNull(); + }); + + it('should request gitconfig', () => { + renderComponent(storeEmpty); + + expect(mockRequestGitConfig).toHaveBeenCalled(); + }); + }); + + describe('while loading', () => { + it('should not request gitconfig', () => { + const store = new FakeStoreBuilder() + .withGitConfig( + { + config: undefined, + }, + true, + ) + .build(); + renderComponent(store); + + expect(mockRequestGitConfig).not.toHaveBeenCalled(); + }); + }); + + describe('with data', () => { + it('should update the git config and show a success notification', async () => { + renderComponent(store); + + const changeEmailButton = screen.getByRole('button', { name: 'Change Email' }); + userEvent.click(changeEmailButton); + + // mock should be called + expect(mockUpdateGitConfig).toHaveBeenCalled(); + + // success alert should be shown + await waitFor(() => + expect(mockShowAlert).toHaveBeenCalledWith({ + key: 'gitconfig-success', + title: 'Gitconfig saved successfully.', + variant: AlertVariant.success, + } as AlertItem), + ); + }); + + it('should try to update the gitconfig and show alert notification', async () => { + const { reRenderComponent } = renderComponent(store); + + mockUpdateGitConfig.mockRejectedValueOnce(new Error('update gitconfig error')); + + const changeEmailButton = screen.getByRole('button', { name: 'Change Email' }); + userEvent.click(changeEmailButton); + + // mock should be called + expect(mockUpdateGitConfig).toHaveBeenCalled(); + + // error alert should not be shown + expect(mockShowAlert).not.toHaveBeenCalled(); + + const nextStore = new FakeStoreBuilder() + .withGitConfig({ + config: { + gitconfig: { + user: { + name: 'user', + email: 'user@che', + }, + }, + }, + error: 'update gitconfig error', + }) + .build(); + reRenderComponent(nextStore); + + // error alert should be shown + await waitFor(() => + expect(mockShowAlert).toHaveBeenCalledWith({ + key: 'gitconfig-error', + title: 'update gitconfig error', + variant: AlertVariant.danger, + } as AlertItem), + ); + }); + }); +}); + +function getComponent(store: Store): React.ReactElement { + return ( + + + + ); +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/index.tsx new file mode 100644 index 000000000..8902584e4 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/index.tsx @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { helpers } from '@eclipse-che/common'; +import { AlertVariant, PageSection } from '@patternfly/react-core'; +import React from 'react'; +import { ConnectedProps, connect } from 'react-redux'; +import ProgressIndicator from '../../../components/Progress'; +import { lazyInject } from '../../../inversify.config'; +import { AppAlerts } from '../../../services/alerts/appAlerts'; +import { AppState } from '../../../store'; +import * as GitConfigStore from '../../../store/GitConfig'; +import { + selectGitConfigError, + selectGitConfigIsLoading, + selectGitConfigUser, +} from '../../../store/GitConfig/selectors'; +import { GitConfigEmptyState } from './EmptyState'; +import { GitConfigSectionUser } from './SectionUser'; + +export type Props = MappedProps; + +class GitConfig extends React.PureComponent { + @lazyInject(AppAlerts) + private readonly appAlerts: AppAlerts; + + public async componentDidMount(): Promise { + const { gitConfigIsLoading, requestGitConfig } = this.props; + + if (gitConfigIsLoading === true) { + return; + } + + try { + await requestGitConfig(); + } catch (error) { + console.error(error); + } + } + + public componentDidUpdate(prevProps: Props): void { + const { gitConfigError } = this.props; + if (gitConfigError && gitConfigError !== prevProps.gitConfigError) { + this.appAlerts.showAlert({ + key: 'gitconfig-error', + title: helpers.errors.getMessage(gitConfigError), + variant: AlertVariant.danger, + }); + } + } + + private async handleChangeConfigUser(gitConfigUser: GitConfigStore.GitConfigUser): Promise { + try { + await this.props.updateGitConfig(gitConfigUser); + this.appAlerts.showAlert({ + key: 'gitconfig-success', + title: 'Gitconfig saved successfully.', + variant: AlertVariant.success, + }); + } catch (error) { + console.error('Failed to update gitconfig', error); + } + } + + public render(): React.ReactElement { + const { gitConfigIsLoading, gitConfigUser } = this.props; + + const isEmpty = gitConfigUser === undefined; + + return ( + + + + {isEmpty ? ( + + ) : ( + this.handleChangeConfigUser(gitConfigUser)} + /> + )} + + + ); + } +} + +const mapStateToProps = (state: AppState) => ({ + gitConfigUser: selectGitConfigUser(state), + gitConfigIsLoading: selectGitConfigIsLoading(state), + gitConfigError: selectGitConfigError(state), +}); + +const connector = connect(mapStateToProps, GitConfigStore.actionCreators, null, { + // forwardRef is mandatory for using `@react-mock/state` in unit tests + forwardRef: true, +}); + +type MappedProps = ConnectedProps; +export default connector(GitConfig); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx index f36f53816..e03d90509 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx @@ -22,6 +22,7 @@ import { FakeStoreBuilder } from '../../../store/__mocks__/storeBuilder'; jest.mock('../GitServicesTab'); jest.mock('../ContainerRegistriesTab'); jest.mock('../PersonalAccessTokens'); +jest.mock('../GitConfig'); const { renderComponent } = getComponentRenderer(getComponent); @@ -91,8 +92,8 @@ describe('UserPreferences', () => { it('should activate the Container Registries tab', () => { renderComponent(); - const devfileTab = screen.getByRole('tab', { name: 'Container Registries' }); - userEvent.click(devfileTab); + const tab = screen.getByRole('tab', { name: 'Container Registries' }); + userEvent.click(tab); expect(screen.queryByRole('tabpanel', { name: 'Container Registries' })).toBeTruthy(); }); @@ -100,8 +101,8 @@ describe('UserPreferences', () => { it('should activate the Git Services tab', () => { renderComponent(); - const devfileTab = screen.getByRole('tab', { name: 'Git Services' }); - userEvent.click(devfileTab); + const tab = screen.getByRole('tab', { name: 'Git Services' }); + userEvent.click(tab); expect(screen.queryByRole('tabpanel', { name: 'Git Services' })).toBeTruthy(); }); @@ -109,10 +110,19 @@ describe('UserPreferences', () => { it('should activate the Personal Access Tokens tab', () => { renderComponent(); - const devfileTab = screen.getByRole('tab', { name: 'Personal Access Tokens' }); - userEvent.click(devfileTab); + const tab = screen.getByRole('tab', { name: 'Personal Access Tokens' }); + userEvent.click(tab); expect(screen.queryByRole('tabpanel', { name: 'Personal Access Tokens' })).toBeTruthy(); }); + + it('should activate the Gitconfig tab', () => { + renderComponent(); + + const tab = screen.getByRole('tab', { name: 'Gitconfig' }); + userEvent.click(tab); + + expect(screen.queryByRole('tabpanel', { name: 'Gitconfig' })).toBeTruthy(); + }); }); }); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx index 57ff05586..7415726d8 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx @@ -13,20 +13,22 @@ import { PageSection, PageSectionVariants, Tab, Tabs, Title } from '@patternfly/react-core'; import { History } from 'history'; import React from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { ConnectedProps, connect } from 'react-redux'; +import { ROUTE } from '../../Routes/routes'; import Head from '../../components/Head'; import { UserPreferencesTab } from '../../services/helpers/types'; -import { ROUTE } from '../../Routes/routes'; import { AppState } from '../../store'; -import { selectIsLoading } from '../../store/GitOauthConfig/selectors'; import { actionCreators } from '../../store/GitOauthConfig'; +import { selectIsLoading } from '../../store/GitOauthConfig/selectors'; import ContainerRegistries from './ContainerRegistriesTab'; +import GitConfig from './GitConfig'; import GitServicesTab from './GitServicesTab'; import PersonalAccessTokens from './PersonalAccessTokens'; const CONTAINER_REGISTRIES_TAB: UserPreferencesTab = 'container-registries'; const GIT_SERVICES_TAB: UserPreferencesTab = 'git-services'; const PERSONAL_ACCESS_TOKENS_TAB: UserPreferencesTab = 'personal-access-tokens'; +const GITCONFIG_TAB: UserPreferencesTab = 'gitconfig'; export type Props = { history: History; @@ -56,6 +58,7 @@ export class UserPreferences extends React.PureComponent { if ( pathname === ROUTE.USER_PREFERENCES && (tab === CONTAINER_REGISTRIES_TAB || + tab === GITCONFIG_TAB || tab === GIT_SERVICES_TAB || tab === PERSONAL_ACCESS_TOKENS_TAB) ) { @@ -103,6 +106,9 @@ export class UserPreferences extends React.PureComponent { + + + ); diff --git a/packages/dashboard-frontend/src/services/helpers/types.ts b/packages/dashboard-frontend/src/services/helpers/types.ts index 31ba6370b..80a2b4af6 100644 --- a/packages/dashboard-frontend/src/services/helpers/types.ts +++ b/packages/dashboard-frontend/src/services/helpers/types.ts @@ -104,4 +104,8 @@ export enum WorkspaceAction { WORKSPACE_DETAILS = 'Workspace Details', } -export type UserPreferencesTab = 'container-registries' | 'git-services' | 'personal-access-tokens'; +export type UserPreferencesTab = + | 'container-registries' + | 'git-services' + | 'personal-access-tokens' + | 'gitconfig'; From f28762f0b1214a7f395b2223cee5ed82ccf5d340 Mon Sep 17 00:00:00 2001 From: Oleksii Kurinnyi Date: Tue, 19 Sep 2023 18:27:21 +0300 Subject: [PATCH 4/6] fix: skaffold push image to registry Signed-off-by: Oleksii Kurinnyi --- skaffold.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skaffold.yaml b/skaffold.yaml index 4cf5301a6..209a34a2e 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -3,6 +3,8 @@ kind: Config metadata: name: che-dashboard build: + local: + push: true tagPolicy: sha256: {} artifacts: @@ -10,7 +12,7 @@ build: context: . custom: buildCommand: | - scripts/container_tool.sh build --tag=$IMAGE -f build/dockerfiles/skaffold.Dockerfile . && scripts/container_tool.sh push $IMAGE + scripts/container_tool.sh build --tag=$IMAGE -f build/dockerfiles/skaffold.Dockerfile . && scripts/container_tool.sh push $IMAGE dependencies: paths: - packages/*/lib/* From 77968c43265a68955974d0b30f1ce85a7ed05f6f Mon Sep 17 00:00:00 2001 From: Oleksii Kurinnyi Date: Wed, 20 Sep 2023 14:31:26 +0300 Subject: [PATCH 5/6] chore: code cleanup --- .../services/gitConfigApi => typings}/multi-ini.d.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/dashboard-backend/src/{devworkspaceClient/services/gitConfigApi => typings}/multi-ini.d.ts (100%) diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/multi-ini.d.ts b/packages/dashboard-backend/src/typings/multi-ini.d.ts similarity index 100% rename from packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/multi-ini.d.ts rename to packages/dashboard-backend/src/typings/multi-ini.d.ts From 4103fb5f1d17028753bf4fb13468547dc23fe690 Mon Sep 17 00:00:00 2001 From: Oleksii Kurinnyi Date: Thu, 21 Sep 2023 13:33:33 +0300 Subject: [PATCH 6/6] fix: do not show error if gitconfig not found Signed-off-by: Oleksii Kurinnyi --- .../gitConfigApi/__tests__/index.spec.ts | 113 ++++++++++++++++++ .../services/gitConfigApi/index.ts | 2 +- .../store/GitConfig/__tests__/index.spec.ts | 38 +++++- .../src/store/GitConfig/index.ts | 10 +- .../src/store/GitConfig/types.ts | 2 +- 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts index 634f484e2..b53f4eb76 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts @@ -73,6 +73,24 @@ describe('Gitconfig API', () => { expect(spyReadNamespacedConfigMap).toHaveBeenCalledTimes(1); expect(spyPatchNamespacedConfigMap).not.toHaveBeenCalled(); }); + + it('should throw', async () => { + spyReadNamespacedConfigMap.mockRejectedValueOnce('404 not found'); + + try { + await gitConfigApiService.read(namespace); + + // should not reach here + expect(false).toBeTruthy(); + } catch (e) { + expect((e as Error).message).toBe( + 'Unable to read gitconfig in the namespace "user-che": 404 not found', + ); + } + + expect(spyReadNamespacedConfigMap).toHaveBeenCalledTimes(1); + expect(spyPatchNamespacedConfigMap).not.toHaveBeenCalled(); + }); }); describe('patching gitconfig', () => { @@ -108,5 +126,100 @@ email="user-2@che" { headers: { 'content-type': 'application/strategic-merge-patch+json' } }, ); }); + + it('should throw when can`t read the ConfigMap', async () => { + spyReadNamespacedConfigMap.mockRejectedValueOnce('404 not found'); + + const newGitConfig = { + gitconfig: { + user: { + email: 'user-2@che', + name: 'User Two', + }, + }, + } as api.IGitConfig; + + try { + await gitConfigApiService.patch(namespace, newGitConfig); + + // should not reach here + expect(false).toBeTruthy(); + } catch (e) { + expect((e as Error).message).toBe( + 'Unable to update gitconfig in the namespace "user-che".', + ); + } + + expect(spyReadNamespacedConfigMap).toHaveBeenCalledTimes(1); + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledTimes(0); + }); + + it('should throw when failed to patch the ConfigMap', async () => { + spyPatchNamespacedConfigMap.mockRejectedValueOnce('some error'); + + const newGitConfig = { + gitconfig: { + user: { + email: 'user-2@che', + name: 'User Two', + }, + }, + } as api.IGitConfig; + + try { + await gitConfigApiService.patch(namespace, newGitConfig); + + // should not reach here + expect(false).toBeTruthy(); + } catch (e) { + expect((e as Error).message).toBe( + 'Unable to update gitconfig in the namespace "user-che": some error', + ); + } + + expect(spyReadNamespacedConfigMap).toHaveBeenCalledTimes(1); + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledTimes(1); + }); + + it('should throw when conflict detected', async () => { + spyReadNamespacedConfigMap.mockResolvedValueOnce({ + body: { + metadata: { + resourceVersion: '2', + }, + data: { + gitconfig: `[user] +name="User Two" +email="user-2@che" +`, + }, + } as V1ConfigMap, + response: {} as IncomingMessage, + }); + + const newGitConfig = { + gitconfig: { + user: { + email: 'user-2@che', + name: 'User Two', + }, + }, + resourceVersion: '1', + } as api.IGitConfig; + + try { + await gitConfigApiService.patch(namespace, newGitConfig); + + // should not reach here + expect(false).toBeTruthy(); + } catch (e) { + expect((e as Error).message).toBe( + 'Conflict detected. The gitconfig was modified in the namespace "user-che".', + ); + } + + expect(spyReadNamespacedConfigMap).toHaveBeenCalledTimes(1); + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledTimes(0); + }); }); }); diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/index.ts b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/index.ts index c9f4db723..9dfc16ce7 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/index.ts @@ -59,7 +59,7 @@ export class GitConfigApiService implements IGitConfigApi { parseInt(gitConfig.resourceVersion || '0', 10) > parseInt(changedGitConfig.resourceVersion || '0', 10) ) { - const message = `Gitconfig was modified in namespace "${namespace}" by someone else.`; + const message = `Conflict detected. The gitconfig was modified in the namespace "${namespace}"`; throw createError(undefined, GITCONFIG_API_ERROR_LABEL, message); } diff --git a/packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts index db40ec0f4..a7c5020dd 100644 --- a/packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts +++ b/packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts @@ -65,7 +65,43 @@ describe('GitConfig store, actions', () => { expect(mockPatchGitConfig).toHaveBeenCalledTimes(0); }); - it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG_ERROR when fetch the gitconfig with error', async () => { + it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG when got 404', async () => { + mockFetchGitConfig.mockRejectedValueOnce({ + response: { + status: 404, + statusText: 'Not Found', + headers: {}, + config: {}, + data: {}, + }, + }); + + try { + await store.dispatch(TestStore.actionCreators.requestGitConfig()); + } catch (e) { + // ignore + } + + const actions = store.getActions(); + + const expectedActions: TestStore.KnownAction[] = [ + { + type: TestStore.Type.REQUEST_GITCONFIG, + check: AUTHORIZED, + }, + { + type: TestStore.Type.RECEIVE_GITCONFIG, + config: undefined, + }, + ]; + + expect(actions).toEqual(expectedActions); + + expect(mockFetchGitConfig).toHaveBeenCalledTimes(1); + expect(mockPatchGitConfig).toHaveBeenCalledTimes(0); + }); + + it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG_ERROR when fetch the gitconfig with error other than 404', async () => { mockFetchGitConfig.mockRejectedValueOnce(new Error('unexpected error')); try { diff --git a/packages/dashboard-frontend/src/store/GitConfig/index.ts b/packages/dashboard-frontend/src/store/GitConfig/index.ts index 30d920072..77576d279 100644 --- a/packages/dashboard-frontend/src/store/GitConfig/index.ts +++ b/packages/dashboard-frontend/src/store/GitConfig/index.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -import { api, helpers } from '@eclipse-che/common'; +import common, { api, helpers } from '@eclipse-che/common'; import { AppThunk } from '..'; import { fetchGitConfig, @@ -42,6 +42,14 @@ export const actionCreators: ActionCreators = { config, }); } catch (e) { + if (common.helpers.errors.includesAxiosResponse(e) && e.response.status === 404) { + dispatch({ + type: Type.RECEIVE_GITCONFIG, + config: undefined, + }); + return; + } + const errorMessage = helpers.errors.getMessage(e); dispatch({ type: Type.RECEIVE_GITCONFIG_ERROR, diff --git a/packages/dashboard-frontend/src/store/GitConfig/types.ts b/packages/dashboard-frontend/src/store/GitConfig/types.ts index 52ae32a25..3b6213b0d 100644 --- a/packages/dashboard-frontend/src/store/GitConfig/types.ts +++ b/packages/dashboard-frontend/src/store/GitConfig/types.ts @@ -34,7 +34,7 @@ export interface RequestGitConfigAction extends Action, SanityCheckAction { export interface ReceiveGitConfigAction extends Action { type: Type.RECEIVE_GITCONFIG; - config: api.IGitConfig; + config: api.IGitConfig | undefined; } export interface ReceiveGitConfigErrorAction extends Action {