diff --git a/.codecov.yml b/.codecov.yml deleted file mode 100644 index 6e19a8d7..00000000 --- a/.codecov.yml +++ /dev/null @@ -1,20 +0,0 @@ -coverage: - precision: 2 - round: down - range: 80..100 - - status: - project: - default: - target: "80%" - patch: - default: off - changes: - default: off - -comment: - layout: "header, reach, diff, flags, files, footer" - behavior: default - require_changes: no - require_base: no - require_head: yes diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..38758c9e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,59 @@ +# dockerignore exclusives +Containerfile +.dockerignore + +# dependencies +node_modules/ + +# coverage +lib-cov +coverage +.nyc_output + +# optional +.npm +.eslintcache +.node_repl_history +*.tgz + +# build & production +build +dist/ +public/assets + +.css +templates/client +styles/*.css +src/styles/css +src/styles/.css +public/locales/*.json +!public/locales/locales.json +!public/locales/en.json +!public/icons/ + +# misc +.qpc +.container +!.env +.env*.local +pids +*.pid +*.seed +*.pid.lock +pr.txt +stats.json +storybook-static +.DS_Store +.idea + +# logs +dependency-update-log.txt +logs +*.log +npm-debug.log* +api-debug* + +.vscode + +# local files +var/ diff --git a/.env b/.env index 31468359..8d98a9e2 100644 --- a/.env +++ b/.env @@ -24,6 +24,7 @@ REACT_APP_CONFIG_SERVICE_LOCALES_PATH=/locales/{{lng}}.json REACT_APP_CONFIG_SERVICE_LOCALES_EXPIRE=604800000 REACT_APP_CREDENTIALS_SERVICE=/api/v1/credentials/ +REACT_APP_CREDENTIALS_SERVICE_BULK_DELETE=/api/v1/credentials/bulk_delete/ REACT_APP_FACTS_SERVICE=/api/v1/facts/ REACT_APP_REPORTS_SERVICE=/api/v1/reports/ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..a79bcb5a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,52 @@ +name: Build Container Image + +on: + push: + branches: [ v2-alpha ] + tags: + - '*' + pull_request: + branches: [ v2-alpha ] + +env: + STABLE_TAG: ${{ github.event_name == 'push' && github.ref_name || format('pr-{0}', github.event.pull_request.number) }} + # We had a problem with GitHub setting quay expiration label also during + # merge to main, so we just set meaningless value as a workaround. + EXPIRATION_LABEL: ${{ github.event_name == 'push' && 'quipucords.source=github' || 'quay.expires-after=5d' }} + IMAGE_NAME: ${{ vars.IMAGE_NAME || 'quipucords/quipucords-ui' }} + REGISTRY: ${{ vars.REGISTRY || 'quay.io' }} + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # fetches all commits/tags + + - name: Build image + id: build-image + uses: redhat-actions/buildah-build@v2 + with: + image: ${{ env.IMAGE_NAME }} + tags: ${{ env.STABLE_TAG }} ${{ env.STABLE_TAG == 'main' && 'latest' || '' }} + containerfiles: | + ./Containerfile + labels: | + ${{ env.EXPIRATION_LABEL }} + quipucords.backend.git_sha=${{ github.sha }} + extra-args: | + --ulimit nofile=4096:4096 + + - name: Push To quay.io + # Forks that do not set secrets and override the variables may fail this step. + uses: redhat-actions/push-to-registry@v2 + with: + image: ${{ env.IMAGE_NAME }} + tags: ${{ steps.build-image.outputs.tags }} + registry: ${{ env.REGISTRY }} + username: ${{ secrets.QUAYIO_USERNAME }} + password: ${{ secrets.QUAYIO_PASSWORD }} + continue-on-error: true diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 59326ebe..910f247b 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -36,14 +36,12 @@ jobs: - name: Install Node.js packages if: ${{ steps.modules-cache.outputs.cache-hit != 'true' }} run: npm install - - name: Lint and test - run: npm test - - name: Code coverage - if: ${{ success() && contains(matrix.node-version, env.COV_NODE_VERSION) }} - uses: codecov/codecov-action@v4.0.0 - - name: Confirm build integration - if: ${{ success() }} - run: npm run build + - name: Linting + run: npm run test:ci-lint + - name: Build integration + run: npm run test:ci-build + - name: Unit tests + run: npm run test:ci-coverage - name: Compress upstream assets if: ${{ success() && startsWith(matrix.node-version, env.COV_NODE_VERSION) && startsWith(github.ref, 'refs/tags/') }} run: | diff --git a/Containerfile b/Containerfile new file mode 100644 index 00000000..24807716 --- /dev/null +++ b/Containerfile @@ -0,0 +1,18 @@ +FROM registry.access.redhat.com/ubi9/nodejs-18 as npm_builder +# Become root before installing anything +USER root +RUN dnf update -y && dnf clean all + +# install dependencies in a separate layer to save dev time +WORKDIR /app +COPY package.json package-lock.json . +RUN npm install + +COPY . . +RUN npm run build + +FROM registry.access.redhat.com/ubi9/nginx-122 +COPY --from=npm_builder /app/build /opt/app-root/src +COPY deploy/nginx.conf /etc/nginx/nginx.conf.template +COPY deploy/entrypoint.sh /opt/app-root/. +CMD ["/bin/bash", "/opt/app-root/entrypoint.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..8ee1051b --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ + +QUIPUCORDS_UI_CONTAINER_TAG ?= quipucords-ui + +help: + @echo "Please use 'make ' where is one of:" + @echo " help to show this message" + @echo " build-container to build the container image for the quipucords UI" + +all: help + +build-container: + podman build \ + -t $(QUIPUCORDS_UI_CONTAINER_TAG) --ulimit nofile=4096:4096 . + diff --git a/README.md b/README.md index 644ebac0..433aa009 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Quipucords UI [![Build Status](https://github.com/quipucords/quipucords-ui/actions/workflows/integration.yml/badge.svg)](https://github.com/quipucords/quipucords-ui/actions/workflows/integration.yml) -[![codecov](https://codecov.io/gh/quipucords/quipucords-ui/branch/main/graph/badge.svg)](https://codecov.io/gh/quipucords/quipucords-ui) [![License](https://img.shields.io/github/license/quipucords/quipucords-ui.svg)](https://github.com/quipucords/quipucords-ui/blob/main/LICENSE) Web user interface for [Quipucords](https://github.com/quipucords/quipucords), based on [Patternfly](https://www.patternfly.org/) diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100755 index 00000000..e1c30cf9 --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -e + +# on podman, host.containers.internal resolves to the host. this is equivalent to +# host.docker.internal for docker. +export QUIPUCORDS_SERVER_URL="${QUIPUCORDS_SERVER_URL:-http://host.containers.internal:8000}" +CERTS_PATH="/opt/app-root/certs" + +# verify if user provided certificates exist or create a self signed certificate. +mkdir -p ${CERTS_PATH} + +if ([ -f "${CERTS_PATH}/server.key" ] && [ -f "${CERTS_PATH}/server.crt" ]); then + echo "Using user provided certificates..." + openssl rsa -in "${CERTS_PATH}/server.key" -check + openssl x509 -in "${CERTS_PATH}/server.crt" -text -noout +elif [ ! -f "${CERTS_PATH}/server.key" ] && [ ! -f "${CERTS_PATH}/server.crt" ]; then + echo "No certificates provided. Creating them..." + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "${CERTS_PATH}/server.key" \ + -out "${CERTS_PATH}/server.crt" \ + -subj "/C=US/ST=Raleigh/L=Raleigh/O=IT/OU=IT Department/CN=example.com" +else + echo "Either key or certificate is missing." + echo "Please provide both named as server.key and server.crt." + echo "Tip: this container expects these files at ${CERTS_PATH}/" + exit 1 +fi + +envsubst "\$QUIPUCORDS_APP_PORT,\$QUIPUCORDS_APP_ENABLE_V2UI,\$QUIPUCORDS_SERVER_URL" \ + < /etc/nginx/nginx.conf.template \ + > /etc/nginx/nginx.conf + +mkdir -p /var/log/nginx +nginx -g "daemon off;" diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 00000000..615bff96 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,75 @@ +# configuration shamelessly copied from https://github.com/sclorg/nginx-container/blob/master/examples/1.22/test-app/nginx.conf +# For more information on configuration, see: +# * Official English Documentation: http://nginx.org/en/docs/ + +worker_processes auto; +error_log /var/log/nginx/error.log notice; +pid /run/nginx.pid; + +# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. +include /usr/share/nginx/modules/*.conf; + +events { + worker_connections 1024; +} + +http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + types_hash_max_size 4096; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server { + listen 8079 default_server; + listen [::]:8079 default_server; + server_name _; + return 301 https://$host$request_uri; + } + + server { + listen ${QUIPUCORDS_APP_PORT} ssl; + listen [::]:${QUIPUCORDS_APP_PORT} ssl; + server_name _; + root /opt/app-root/src; + + ssl_certificate "/opt/app-root/certs/server.crt"; + ssl_certificate_key "/opt/app-root/certs/server.key"; + ssl_session_cache shared:SSL:1m; + ssl_session_timeout 10m; + ssl_ciphers PROFILE=SYSTEM; + ssl_prefer_server_ciphers on; + + location / { + set $enable_v2ui "${QUIPUCORDS_APP_ENABLE_V2UI}"; + if ($enable_v2ui = "0") { + rewrite (.*) /ui-v1$1 last; + } + try_files $uri /index.html; + } + location /ui-v1/ { + rewrite /ui-v1/(.*) /$1 break; + proxy_pass ${QUIPUCORDS_SERVER_URL}; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass_header X-CSRFToken; + } + location /api/ { + proxy_pass ${QUIPUCORDS_SERVER_URL}; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass_header X-CSRFToken; + } + } +} + diff --git a/jest.config.js b/jest.config.js index a4c7343b..917361fd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,10 +2,10 @@ module.exports = { collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/.*/**', '!src/**/**/index.{ts,tsx}'], coverageThreshold: { global: { - branches: 50, - functions: 50, - lines: 50, - statements: 50 + branches: 60, + functions: 80, + lines: 80, + statements: 80 } }, moduleFileExtensions: ['web.js', 'js', 'web.ts', 'ts', 'web.tsx', 'tsx', 'json', 'web.jsx', 'jsx', 'node'], diff --git a/package.json b/package.json index 3f301a43..a6a1b2b7 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,10 @@ "start:js": "export NODE_ENV=development; weldable -l ts -x ./config/webpack.dev.js", "start:open": "xdg-open $PROTOCOL://$HOST:$PORT/login || open $PROTOCOL://$HOST:$PORT/login", "start:stage": "export PROTOCOL=https; export HOST=127.0.0.1; export PORT=${PORT:-3000}; export MOCK_PORT=${MOCK_PORT:-9443}; run-p -l api:stage start:js start:open", - "test": "export NODE_ENV=test; run-s test:spell* test:types test:lint test:ci", - "test:ci": "export CI=true; jest ./src --coverage", + "test": "export NODE_ENV=test; run-s test:ci-lint test:ci-build test:ci-coverage", + "test:ci-build": "run-s build", + "test:ci-coverage": "export CI=true; jest ./src --coverage", + "test:ci-lint": "run-s test:spell* test:types test:lint", "test:clearCache": "jest --clearCache", "test:dev": "export NODE_ENV=test; run-s test:lint test:local", "test:integration": "jest --roots=./tests", diff --git a/public/locales/en.json b/public/locales/en.json index 12d688c7..e041a4d0 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -27,7 +27,7 @@ "confirmation_heading_add-scan": " Are you sure you want to cancel this scan?", "confirmation_title_delete-scan": "Delete Scan", "confirmation_heading_delete-scan": "Are you sure you want to delete the scan <0>{{name}}?", - "confirmation_heading_delete-credential": "Are you sure you want to delete the credential <0>{{name}}?", + "confirmation_heading_delete-credential": "Are you sure you want to delete the credential {{name}}?", "confirmation_heading_delete-credential_other": "Are you sure you want to delete the following credentials?", "confirmation_title_delete-credential": "Delete Credential", "confirmation_title_delete-credential_other": "Delete Credentials", @@ -306,8 +306,12 @@ "description_add-source_hidden_error_edit": "{{message}}", "description_credential": "Credential was added", "description_credential_edit": "Credential was updated", - "description_deleted-credential": "Deleted credential {{name}}", - "description_deleted-credential_other": "Deleted credentials {{name}} and more", + "description_deleted-credential_one": "Credential {{name}} was successfully deleted.", + "description_deleted-credential_other": "Credentials {{name}} were successfully deleted.", + "description_deleted-credentials-skipped-credentials_one": "The following could be successfully deleted: {{deleted_names}}. Credential {{skipped_names}} could not be deleted due to an attached source.", + "description_deleted-credentials-skipped-credentials_other": "The following could be successfully deleted: {{deleted_names}}. Credentials {{skipped_names}} could not be deleted due to attached sources.", + "description_skipped-credential_one": "Credential {{name}} could not be deleted due to an attached source.", + "description_skipped-credential_other": "Credentials {{name}} could not be deleted due to attached sources.", "description_deleted-credential_error": "Error removing credential {{name}}. {{message}}", "description_deleted-credential_error_other": "Error removing credentials {{name}} and more. {{message}}", "description_deleted-source": "Source {{name}} deleted successfully.", diff --git a/src/helpers/__tests__/helpers.test.ts b/src/helpers/__tests__/helpers.test.ts index d5e1826f..7115cc6d 100644 --- a/src/helpers/__tests__/helpers.test.ts +++ b/src/helpers/__tests__/helpers.test.ts @@ -22,6 +22,11 @@ it(`should throw an error if the timestamp is not valid`, () => { expect(() => helpers.getTimeDisplayHowLongAgo('')).toThrow(`Invalid timestamp: `); }); +it('should support generated strings and flags', () => { + expect(helpers.generateId()).toBe('generatedid-'); + expect(helpers.generateId('lorem')).toBe('lorem-'); +}); + describe('getAuthType', () => { const generateAuthType = partialCredential => helpers.getAuthType({ diff --git a/src/helpers/helpers.ts b/src/helpers/helpers.ts index 92757a58..b6faad71 100644 --- a/src/helpers/helpers.ts +++ b/src/helpers/helpers.ts @@ -148,6 +148,15 @@ const downloadData = (data: string | ArrayBuffer | ArrayBufferView | Blob, fileN } }); +/** + * Generate a random ID. + * + * @param {string} prefix + * @returns {string} + */ +const generateId = (prefix = 'generatedid') => + `${prefix}-${(process.env.REACT_APP_ENV !== 'test' && Math.ceil(1e5 * Math.random())) || ''}`; + const PROD_MODE = process.env.REACT_APP_ENV === 'production'; const TEST_MODE = process.env.REACT_APP_ENV === 'test'; @@ -156,6 +165,7 @@ const helpers = { authType, downloadData, noopTranslate, + generateId, getAuthType, getTimeDisplayHowLongAgo, formatDate, diff --git a/src/hooks/__tests__/__snapshots__/useAlerts.test.ts.snap b/src/hooks/__tests__/__snapshots__/useAlerts.test.ts.snap new file mode 100644 index 00000000..68223187 --- /dev/null +++ b/src/hooks/__tests__/__snapshots__/useAlerts.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useAlerts should add an alert and verify it is in the alerts list: single alert list 1`] = ` +[ + { + "id": "alert-1", + "title": "Test Alert", + "variant": "success", + }, +] +`; + +exports[`useAlerts should allow removing alerts by any property: removed alerts list 1`] = ` +[ + { + "id": "dolor", + "title": "sit", + "variant": "success", + }, +] +`; diff --git a/src/hooks/__tests__/__snapshots__/useCredentialApi.test.ts.snap b/src/hooks/__tests__/__snapshots__/useCredentialApi.test.ts.snap new file mode 100644 index 00000000..4b9fd8bc --- /dev/null +++ b/src/hooks/__tests__/__snapshots__/useCredentialApi.test.ts.snap @@ -0,0 +1,196 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useDeleteCredentialApi should attempt an api call to delete credentials: apiCall 1`] = ` +[ + [ + "/api/v1/credentials/bulk_delete/", + { + "ids": [ + 456, + 789, + ], + }, + ], +] +`; + +exports[`useDeleteCredentialApi should handle errors while attempting to delete single and multiple credentials: deleteCredentials, error 1`] = ` +[ + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "deleted-credential_error", + "count": 1, + "message": "Mock error", + "name": "lorem ipsum dolor sit", + }, + ], + "variant": "danger", + }, + ], + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "deleted-credential_error", + "count": 2, + "message": "Mock error", + "name": "lorem ipsum, dolor sit", + }, + ], + "variant": "danger", + }, + ], +] +`; + +exports[`useDeleteCredentialApi should handle success while attempting to delete single and multiple credentials: deleteCredentials, success 1`] = ` +[ + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "deleted-credential", + "count": 1, + "name": "lorem ipsum dolor sit", + }, + ], + "variant": "success", + }, + ], + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "deleted-credential", + "count": 2, + "name": "lorem ipsum, dolor sit", + }, + ], + "variant": "success", + }, + ], +] +`; + +exports[`useDeleteCredentialApi should process an API error response: callbackError 1`] = ` +[ + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "deleted-credential_error", + "count": 2, + "message": "Lorem ipsum", + "name": "Lorem, Ipsum", + }, + ], + "variant": "danger", + }, + ], + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "deleted-credential_error", + "count": 2, + "message": "Dolor sit", + "name": "Lorem, Ipsum", + }, + ], + "variant": "danger", + }, + ], + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "deleted-credential_error", + "count": 2, + "message": "Amet", + "name": "Lorem, Ipsum", + }, + ], + "variant": "danger", + }, + ], + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "deleted-credential_error", + "count": 2, + "message": "Unknown error", + "name": "Lorem, Ipsum", + }, + ], + "variant": "danger", + }, + ], +] +`; + +exports[`useDeleteCredentialApi should process an API success response: callbackSuccess 1`] = ` +[ + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "skipped-credential", + "count": 2, + "name": "Lorem, Ipsum", + }, + ], + "variant": "danger", + }, + ], + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "deleted-credentials-skipped-credentials", + "count": 1, + "deleted_names": "Ipsum", + "skipped_names": "Lorem", + }, + ], + "variant": "warning", + }, + ], + [ + { + "id": "generatedid-", + "title": [ + "toast-notifications.description", + { + "context": "deleted-credential", + "count": 2, + "name": "Lorem, Ipsum", + }, + ], + "variant": "success", + }, + ], +] +`; diff --git a/src/hooks/__tests__/useAlerts.test.ts b/src/hooks/__tests__/useAlerts.test.ts new file mode 100644 index 00000000..71d99304 --- /dev/null +++ b/src/hooks/__tests__/useAlerts.test.ts @@ -0,0 +1,56 @@ +import { type AlertProps } from '@patternfly/react-core'; +import { act, renderHook } from '@testing-library/react'; +import { useAlerts } from '../useAlerts'; + +describe('useAlerts', () => { + it('should add an alert and verify it is in the alerts list', () => { + const { result } = renderHook(() => useAlerts()); + + act(() => { + result.current.addAlert({ title: 'Test Alert', variant: 'success', id: 'alert-1' }); + }); + + expect(result.current.alerts).toMatchSnapshot('single alert list'); + }); + + it('should add multiple alerts and verify all are present in the alerts list', () => { + const { result } = renderHook(() => useAlerts()); + const alerts: AlertProps[] = [ + { id: 'lorem', title: 'ipsum', variant: 'success' }, + { id: 'hello', title: 'world', variant: 'danger' }, + { id: 'dolor', title: 'sit', variant: 'success' } + ]; + + act(() => { + result.current.addAlert(alerts); + }); + + expect(result.current.alerts).toEqual(alerts); + }); + + it('should allow removing alerts by any property', () => { + const { result } = renderHook(() => useAlerts()); + + const alerts: AlertProps[] = [ + { id: 'lorem', title: 'ipsum', variant: 'success' }, + { id: 'hello', title: 'world', variant: 'danger' }, + { id: 'dolor', title: 'sit', variant: 'success' }, + { id: 'amet', title: 'consectetur', variant: 'danger' }, + { id: 'adipiscing', title: 'elit', variant: 'danger' } + ]; + + act(() => { + result.current.addAlert(alerts); + }); + + act(() => { + result.current.removeAlert(alerts[0].title); + result.current.removeAlert(alerts[1].id); + result.current.removeAlert('nonExistentValue'); + result.current.removeAlert('danger'); + }); + + expect(result.current.alerts).toMatchSnapshot('removed alerts list'); + expect(result.current.alerts).toHaveLength(1); + }); +}); diff --git a/src/hooks/__tests__/useCredentialApi.test.ts b/src/hooks/__tests__/useCredentialApi.test.ts new file mode 100644 index 00000000..341c7770 --- /dev/null +++ b/src/hooks/__tests__/useCredentialApi.test.ts @@ -0,0 +1,145 @@ +import { renderHook } from '@testing-library/react'; +import axios from 'axios'; +import { useDeleteCredentialApi } from '../useCredentialApi'; + +describe('useDeleteCredentialApi', () => { + let mockOnAddAlert; + let hookResult; + + beforeEach(() => { + mockOnAddAlert = jest.fn(); + const hook = renderHook(() => useDeleteCredentialApi(mockOnAddAlert)); + hookResult = hook?.result?.current; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should attempt an api call to delete credentials', () => { + const { apiCall } = hookResult; + const spyAxios = jest.spyOn(axios, 'post'); + + apiCall([456, 789]); + expect(spyAxios.mock.calls).toMatchSnapshot('apiCall'); + }); + + it('should handle success while attempting to delete single and multiple credentials', async () => { + const { deleteCredentials } = hookResult; + jest.spyOn(axios, 'post').mockImplementation(() => Promise.resolve({})); + + await deleteCredentials({ id: 123, name: 'lorem ipsum dolor sit' }); + await deleteCredentials([ + { id: 456, name: 'lorem ipsum' }, + { id: 789, name: 'dolor sit' } + ]); + expect(mockOnAddAlert.mock.calls).toMatchSnapshot('deleteCredentials, success'); + }); + + it('should handle errors while attempting to delete single and multiple credentials', async () => { + const { deleteCredentials } = hookResult; + jest.spyOn(axios, 'post').mockImplementation(() => Promise.reject({ isAxiosError: true, message: 'Mock error' })); + + await deleteCredentials({ id: 123, name: 'lorem ipsum dolor sit' }); + await deleteCredentials([ + { id: 456, name: 'lorem ipsum' }, + { id: 789, name: 'dolor sit' } + ]); + expect(mockOnAddAlert.mock.calls).toMatchSnapshot('deleteCredentials, error'); + }); + + it('should process an API success response', () => { + const { callbackSuccess } = hookResult; + + // danger + callbackSuccess( + { + data: { + skipped: [{ credential: 123 }, { credential: 456 }] + } + }, + [ + { id: 123, name: 'Lorem' }, + { id: 456, name: 'Ipsum' } + ] + ); + + // warning + callbackSuccess( + { + data: { + deleted: [456], + skipped: [{ credential: 123 }] + } + }, + [ + { id: 123, name: 'Lorem' }, + { id: 456, name: 'Ipsum' } + ] + ); + + // success + callbackSuccess( + { + data: { + deleted: [123, 456] + } + }, + [ + { id: 123, name: 'Lorem' }, + { id: 456, name: 'Ipsum' } + ] + ); + + expect(mockOnAddAlert.mock.calls).toMatchSnapshot('callbackSuccess'); + }); + + it('should process an API error response', () => { + const { callbackError } = hookResult; + + callbackError( + { + response: { + data: { + detail: 'Lorem ipsum' + } + } + }, + [ + { id: 123, name: 'Lorem' }, + { id: 456, name: 'Ipsum' } + ] + ); + + callbackError( + { + response: { + data: { + message: 'Dolor sit' + } + } + }, + [ + { id: 123, name: 'Lorem' }, + { id: 456, name: 'Ipsum' } + ] + ); + + callbackError( + { + message: 'Amet' + }, + [ + { id: 123, name: 'Lorem' }, + { id: 456, name: 'Ipsum' } + ] + ); + + callbackError({}, [ + { id: 123, name: 'Lorem' }, + { id: 456, name: 'Ipsum' } + ]); + + expect(mockOnAddAlert.mock.calls).toMatchSnapshot('callbackError'); + }); +}); diff --git a/src/hooks/useAlerts.ts b/src/hooks/useAlerts.ts index 8d6c7fab..30863752 100644 --- a/src/hooks/useAlerts.ts +++ b/src/hooks/useAlerts.ts @@ -1,44 +1,47 @@ /** - * This custom React Hook provides functions to manage alerts in your application. - * It allows you to add and remove alerts with specific titles, variants, and unique keys. + * This Hook provides functions to manage alerts. + * It allows you to add and remove alerts using PatternFly's AlertProps and unique IDs. * * @module useAlerts */ import React from 'react'; import { type AlertProps } from '@patternfly/react-core'; +import { helpers } from '../helpers'; const useAlerts = () => { - const [alert, setAlerts] = React.useState[]>([]); + const [alerts, setAlerts] = React.useState([]); /** - * Add an Alert + * Adds a new alert or multiple alerts to the list. * - * This function adds an alert to the list of alerts. - * - * @param {string} title - The title or content of the alert. - * @param {AlertProps['variant']} variant - The variant or style of the alert (e.g., 'success', 'danger'). - * @param {React.Key} key - A unique key to identify the alert. + * @param {AlertProps | AlertProps[]} options - The alert object or array of alert objects containing properties + * like `id`, `title`, `variant`, etc. */ - const addAlert = (title: string, variant: AlertProps['variant'], key: React.Key) => { - setAlerts(prevAlerts => [...prevAlerts, { title, variant, key }]); + const addAlert = (options: AlertProps | AlertProps[]) => { + const updatedOptions = ((Array.isArray(options) && options) || [options]).map(options => ({ + ...options, + id: options.id || helpers.generateId() + })); + + setAlerts(prevAlerts => [...prevAlerts, ...updatedOptions]); }; /** - * Remove an Alert by Key + * Removes the first alert whose properties include the given value. * - * This function removes an alert from the list of alerts based on its unique key. + * This function removes the first alert matching the provided value in any property. * - * @param {React.Key} key - The unique key of the alert to be removed. + * @param {unknown} value - The value to match in the alert's properties. */ - const removeAlert = (key: React.Key) => { - setAlerts(prevAlerts => [...prevAlerts.filter(alert => alert.key !== key)]); + const removeAlert = (value: unknown) => { + setAlerts(prevAlerts => [...prevAlerts.filter(alert => !Object.values(alert).includes(value))]); }; return { removeAlert, addAlert, - alerts: alert + alerts }; }; -export default useAlerts; +export { useAlerts as default, useAlerts }; diff --git a/src/hooks/useCredentialApi.ts b/src/hooks/useCredentialApi.ts index 8bca9afd..f0422f29 100644 --- a/src/hooks/useCredentialApi.ts +++ b/src/hooks/useCredentialApi.ts @@ -1,44 +1,153 @@ /** - * Credential API Hook + * Custom React hook for handling credential deletion operations. * - * This hook provides functions for for interacting with credential-related API calls, - * including adding,editing and deleting credentials and managing selected credentials. + * This hook manages the deletion of credentials via an API call and provides feedback to the user through alerts. * - * @module useCredentialApi + * @param {Function} onAddAlert - Callback function to add an alert to the UI. + * @returns {object} An object containing functions: + * - `apiCall(ids)`: Function to make the API call for deleting credentials with the given IDs. + * - `deleteCredentials(credential)`: Function to handle the entire credential deletion process (API call, + * response handling, and alerts). */ -import React from 'react'; -import axios from 'axios'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { type AlertProps } from '@patternfly/react-core'; +import axios, { type AxiosError, type AxiosResponse, isAxiosError } from 'axios'; +import { helpers } from '../helpers'; import { CredentialType } from '../types/types'; -const useCredentialApi = () => { - const [pendingDeleteCredential, setPendingDeleteCredential] = React.useState(); - - /** - * Executes a DELETE request to delete a credential. - * - * @param {CredentialType} [credential] - (Optional) The credential to be deleted. If not provided, it uses - * `pendingDeleteCredential`. - * @returns {AxiosResponse} - The Axios response object representing the result of the request. - */ - const deleteCredential = (credential?: CredentialType) => { - return axios.delete(`${process.env.REACT_APP_CREDENTIALS_SERVICE}${(credential || pendingDeleteCredential)?.id}/`); - }; +type ApiDeleteCredentialSuccessType = { + message: string; + deleted?: number[]; + missing?: number[]; + skipped?: { credential: number; sources: number[] }[]; +}; - /** - * Deletes several selected credentials based on user interaction. - * Add your logic to handle the deletion of selected items within this function. - */ - const onDeleteSelectedCredentials = () => { - const selectedItems = []; - console.log('Deleting selected credentials:', selectedItems); - }; +type ApiDeleteCredentialErrorType = { + detail?: string; + message: string; +}; + +const useDeleteCredentialApi = (onAddAlert: (alert: AlertProps) => void) => { + const { t } = useTranslation(); + + const apiCall = useCallback( + (ids: CredentialType['id'][]): Promise> => + axios.post(`${process.env.REACT_APP_CREDENTIALS_SERVICE_BULK_DELETE}`, { ids }), + [] + ); + + const callbackSuccess = useCallback( + (response: AxiosResponse, updatedCredentials: CredentialType[]) => { + const { data } = response; + + const deletedNames = data?.deleted + ?.map(id => updatedCredentials.find(cred => id === cred.id)?.name) + .filter(Boolean) + .join(', '); + + const skippedNames = data?.skipped + ?.map(({ credential }) => updatedCredentials.find(cred => credential === cred.id)?.name) + .filter(Boolean) + .join(', '); + + const missingNames = data?.missing + ?.map(id => updatedCredentials.find(cred => id === cred.id)?.name) + .filter(Boolean) + .join(', '); + + const missingCredsMsg = `The following credentials could not be found: ${missingNames}`; + + // No credentials deleted, either all skipped or all missing + if (data?.skipped?.length === updatedCredentials.length || data?.missing?.length === updatedCredentials.length) { + onAddAlert({ + title: t('toast-notifications.description', { + context: 'skipped-credential', + name: skippedNames, + count: data?.skipped?.length + }), + variant: 'danger', + id: helpers.generateId() + }); + + if (data?.missing?.length) { + console.log(missingCredsMsg); + } + return; + } + + // Some credentials deleted, some skipped or missing + if (data?.skipped?.length || data?.missing?.length) { + onAddAlert({ + title: t('toast-notifications.description', { + context: 'deleted-credentials-skipped-credentials', + deleted_names: deletedNames, + skipped_names: skippedNames, + count: data?.skipped?.length + }), + variant: 'warning', + id: helpers.generateId() + }); + + if (data?.missing?.length) { + console.log(missingCredsMsg); + } + return; + } + + // All credentials were deleted successfully + onAddAlert({ + title: t('toast-notifications.description', { + context: 'deleted-credential', + count: updatedCredentials.length, + name: updatedCredentials.map(({ name }) => name).join(', ') + }), + variant: 'success', + id: helpers.generateId() + }); + + return; + }, + [onAddAlert, t] + ); + + const callbackError = useCallback( + ({ message, response }: AxiosError, updatedCredentials: CredentialType[]) => { + const errorMessage = t('toast-notifications.description', { + context: 'deleted-credential_error', + count: updatedCredentials.length, + name: updatedCredentials.map(({ name }) => name).join(', '), + message: response?.data?.detail || response?.data?.message || message || 'Unknown error' + }); + onAddAlert({ title: errorMessage, variant: 'danger', id: helpers.generateId() }); + }, + [onAddAlert, t] + ); + + const deleteCredentials = useCallback( + async (credential: CredentialType | CredentialType[]) => { + const updatedCredentials = (Array.isArray(credential) && credential) || [credential]; + try { + const response = await apiCall(updatedCredentials.map(({ id }) => id)); + callbackSuccess(response, updatedCredentials); + } catch (error) { + if (isAxiosError(error)) { + return callbackError(error, updatedCredentials); + } + if (!helpers.TEST_MODE) { + console.error(error); + } + } + }, + [apiCall, callbackSuccess, callbackError] + ); return { - deleteCredential, - onDeleteSelectedCredentials, - pendingDeleteCredential, - setPendingDeleteCredential + apiCall, + callbackError, + callbackSuccess, + deleteCredentials }; }; -export default useCredentialApi; +export { useDeleteCredentialApi as default, useDeleteCredentialApi }; diff --git a/src/hooks/useSearchParam.ts b/src/hooks/useSearchParam.ts deleted file mode 100644 index e37e0b0d..00000000 --- a/src/hooks/useSearchParam.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * A custom hook for managing URL search parameters with React Router, allowing for getting and setting values with - * ease. Supports default values, optional replacement of the history stack, - * and automatic unsetting of parameters when set to their default value. - * - * @module useSearchParam - */ -import React from 'react'; -import { useSearchParams } from 'react-router-dom'; - -const useSearchParam = ( - name: string, - defaultValue: string | null = null, - options?: { - replace?: boolean; - unsetWhenDefaultValue?: boolean; - } - // eslint-disable-next-line no-unused-vars -): [string | null, (newValue: string) => void, () => void] => { - const defaultValueRef = React.useRef(defaultValue); - const [searchParams, setSearchParams] = useSearchParams(); - const value = searchParams.has(name) ? searchParams.get(name) : defaultValueRef.current; - const unsetWhenDefaultValue = options?.unsetWhenDefaultValue ?? true; - const replace = options?.replace ?? true; - const set = React.useCallback( - (newValue: string) => { - const newSearchParams = new URLSearchParams(window.location.search); - if (newSearchParams.get(name) !== newValue) { - if (unsetWhenDefaultValue && newValue === defaultValueRef.current) { - newSearchParams.delete(name); - } else { - newSearchParams.set(name, newValue); - } - setSearchParams(newSearchParams, { replace }); - } - }, - [name, setSearchParams, unsetWhenDefaultValue, replace] - ); - - const unset = React.useCallback(() => { - const newSearchParams = new URLSearchParams(window.location.search); - if (newSearchParams.has(name)) { - newSearchParams.delete(name); - setSearchParams(newSearchParams); - } - }, [name, setSearchParams]); - - return [value, set, unset]; -}; - -export default useSearchParam; diff --git a/src/types/types.ts b/src/types/types.ts index 3591e1f9..be0a1204 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -4,7 +4,7 @@ * network sources and their connections, as well as comprehensive information about scans and their outcomes. */ export type CredentialType = { - id: string; + id: number; name: string; created_at: Date; updated_at: Date; diff --git a/src/views/credentials/viewCredentialsList.tsx b/src/views/credentials/viewCredentialsList.tsx index 9d012a24..a4820c42 100644 --- a/src/views/credentials/viewCredentialsList.tsx +++ b/src/views/credentials/viewCredentialsList.tsx @@ -43,8 +43,8 @@ import { RefreshTimeButton } from '../../components/refreshTimeButton/refreshTim import { SimpleDropdown } from '../../components/simpleDropdown/simpleDropdown'; import { API_CREDS_LIST_QUERY, API_DATA_SOURCE_TYPES, API_QUERY_TYPES } from '../../constants/apiConstants'; import { helpers } from '../../helpers'; -import useAlerts from '../../hooks/useAlerts'; -import useCredentialApi from '../../hooks/useCredentialApi'; +import { useAlerts } from '../../hooks/useAlerts'; +import { useDeleteCredentialApi } from '../../hooks/useCredentialApi'; import useQueryClientConfig from '../../queryClientConfig'; import { CredentialType, SourceType } from '../../types/types'; import { useCredentialsQuery } from './useCredentialsQuery'; @@ -54,10 +54,10 @@ const CredentialsListView: React.FunctionComponent = () => { const [refreshTime, setRefreshTime] = React.useState(); const [sourcesSelected, setSourcesSelected] = React.useState([]); const [addCredentialModal, setAddCredentialModal] = React.useState(); - const { deleteCredential, onDeleteSelectedCredentials, pendingDeleteCredential, setPendingDeleteCredential } = - useCredentialApi(); + const [pendingDeleteCredential, setPendingDeleteCredential] = React.useState(); + const { addAlert, alerts, removeAlert } = useAlerts(); + const { deleteCredentials } = useDeleteCredentialApi(addAlert); const { queryClient } = useQueryClientConfig(); - const { alerts, addAlert, removeAlert } = useAlerts(); /** * Fetches the translated label for a credential type. @@ -77,31 +77,6 @@ const CredentialsListView: React.FunctionComponent = () => { queryClient.invalidateQueries({ queryKey: [API_CREDS_LIST_QUERY] }); }; - /** - * Deletes the pending credential and handles success, error, and cleanup operations. - */ - const onDeleteCredential = () => { - deleteCredential() - .then(() => { - const successMessage = t('toast-notifications.description', { - context: 'deleted-credential', - name: pendingDeleteCredential?.name - }); - addAlert(successMessage, 'success', getUniqueId()); - onRefresh(); - }) - .catch(err => { - console.log(err); - const errorMessage = t('toast-notifications.description', { - context: 'deleted-credential_error', - name: pendingDeleteCredential?.name, - message: err.response.data.detail - }); - addAlert(errorMessage, 'danger', getUniqueId()); - }) - .finally(() => setPendingDeleteCredential(undefined)); - }; - /** * Initializes table state with URL persistence, including configurations for columns, filters, sorting, pagination, * and selection. @@ -232,7 +207,7 @@ const CredentialsListView: React.FunctionComponent = () => { @@ -350,40 +325,52 @@ const CredentialsListView: React.FunctionComponent = () => { )} - {!!pendingDeleteCredential && ( - setPendingDeleteCredential(undefined)} - actions={[ - , - - ]} - > - Are you sure you want to delete the credential " - {pendingDeleteCredential.name}" - - )} + setPendingDeleteCredential(undefined)} + actions={[ + , + + ]} + > + {t('form-dialog.confirmation_heading', { + context: 'delete-credential', + name: pendingDeleteCredential?.name + })} + - {alerts.map(({ key, variant, title }) => ( + {alerts.map(({ id, variant, title }) => ( key && removeAlert(key)} + onTimeout={() => id && removeAlert(id)} variant={AlertVariant[variant || 'info']} title={title} actionClose={ key && removeAlert(key)} + onClose={() => id && removeAlert(id)} /> } - key={key} + id={id} + key={id || getUniqueId()} /> ))} diff --git a/src/views/scans/viewScansList.tsx b/src/views/scans/viewScansList.tsx index 9a413cde..bded5b71 100644 --- a/src/views/scans/viewScansList.tsx +++ b/src/views/scans/viewScansList.tsx @@ -43,7 +43,7 @@ import { ContextIcon, ContextIconVariant } from '../../components/contextIcon/co import { RefreshTimeButton } from '../../components/refreshTimeButton/refreshTimeButton'; import { API_QUERY_TYPES, API_SCANS_LIST_QUERY } from '../../constants/apiConstants'; import { helpers } from '../../helpers'; -import useAlerts from '../../hooks/useAlerts'; +import { useAlerts } from '../../hooks/useAlerts'; import useScanApi from '../../hooks/useScanApi'; import useQueryClientConfig from '../../queryClientConfig'; import { ScanJobType, ScanType } from '../../types/types'; @@ -86,7 +86,7 @@ const ScansListView: React.FunctionComponent = () => { context: 'deleted-scan', name: pendingDeleteScan?.id }); - addAlert(successMessage, 'success', getUniqueId()); + addAlert({ title: successMessage, variant: 'success', id: getUniqueId() }); onRefresh(); }) .catch(err => { @@ -96,7 +96,7 @@ const ScansListView: React.FunctionComponent = () => { name: pendingDeleteScan?.id, message: err.response.data.detail }); - addAlert(errorMessage, 'danger', getUniqueId()); + addAlert({ title: errorMessage, variant: 'danger', id: getUniqueId() }); }) .finally(() => setPendingDeleteScan(undefined)); }; @@ -312,7 +312,11 @@ const ScansListView: React.FunctionComponent = () => { onDownload={reportId => { downloadReport(reportId) .then(res => { - addAlert(`Report "${reportId}" downloaded`, 'success', getUniqueId()); + addAlert({ + title: `Report "${reportId}" downloaded`, + variant: 'success', + id: getUniqueId() + }); helpers.downloadData( res.data, `report_id_${reportId}_${new Date() @@ -324,11 +328,11 @@ const ScansListView: React.FunctionComponent = () => { }) .catch(err => { console.error(err); - addAlert( - `Report "${reportId}" failed to download. ${JSON.stringify(err.response.data)}`, - 'danger', - getUniqueId() - ); + addAlert({ + title: `Report "${reportId}" failed to download. ${JSON.stringify(err.response.data)}`, + variant: 'danger', + id: getUniqueId() + }); }); }} onClose={() => { @@ -357,20 +361,21 @@ const ScansListView: React.FunctionComponent = () => { )} - {alerts.map(({ key, variant, title }) => ( + {alerts.map(({ id, variant, title }) => ( key && removeAlert(key)} + onTimeout={() => id && removeAlert(id)} variant={AlertVariant[variant || 'info']} title={title} actionClose={ key && removeAlert(key)} + onClose={() => id && removeAlert(id)} /> } - key={key} + id={id} + key={id || getUniqueId()} /> ))} diff --git a/src/views/sources/addSourceModal.tsx b/src/views/sources/addSourceModal.tsx index c62e2866..bee8ef02 100644 --- a/src/views/sources/addSourceModal.tsx +++ b/src/views/sources/addSourceModal.tsx @@ -35,7 +35,7 @@ export interface AddSourceModalProps { const AddSourceModal: React.FC = ({ source, type, onClose, onSubmit }) => { const [credOptions, setCredOptions] = React.useState<{ value: string; label: string }[]>([]); - const [credentials, setCredentials] = React.useState(source?.credentials?.map(c => c.id) || []); + const [credentials, setCredentials] = React.useState(source?.credentials?.map(c => c.id) || []); const [useParamiko, setUseParamiko] = React.useState(source?.options?.use_paramiko ?? false); const [sslVerify, setSslVerify] = React.useState(source?.options?.ssl_cert_verify ?? true); const [sslProtocol, setSslProtocol] = React.useState( @@ -121,7 +121,11 @@ const AddSourceModal: React.FC = ({ source, type, onClose, { + const selectedIds = selections.map(Number); + const validIds = selectedIds.filter(id => !isNaN(id)); + setCredentials(validIds); + }} options={credOptions} selectedOptions={credentials?.map(String) || []} /> diff --git a/src/views/sources/viewSourcesList.tsx b/src/views/sources/viewSourcesList.tsx index 941e490f..d7ca6ca7 100644 --- a/src/views/sources/viewSourcesList.tsx +++ b/src/views/sources/viewSourcesList.tsx @@ -45,7 +45,7 @@ import { RefreshTimeButton } from '../../components/refreshTimeButton/refreshTim import { SimpleDropdown } from '../../components/simpleDropdown/simpleDropdown'; import { API_DATA_SOURCE_TYPES, API_QUERY_TYPES, API_SOURCES_LIST_QUERY } from '../../constants/apiConstants'; import { helpers } from '../../helpers'; -import useAlerts from '../../hooks/useAlerts'; +import { useAlerts } from '../../hooks/useAlerts'; import useSourceApi from '../../hooks/useSourceApi'; import useQueryClientConfig from '../../queryClientConfig'; import { CredentialType, SourceType } from '../../types/types'; @@ -113,7 +113,11 @@ const SourcesListView: React.FunctionComponent = () => { context: 'deleted-source', name: pendingDeleteSource?.name }); - addAlert(successMessage, 'success', getUniqueId()); + addAlert({ + title: successMessage, + variant: 'success', + id: getUniqueId() + }); onRefresh(); }) .catch(err => { @@ -123,7 +127,11 @@ const SourcesListView: React.FunctionComponent = () => { name: pendingDeleteSource?.name, message: err.response.data.detail }); - addAlert(errorMessage, 'danger', getUniqueId()); + addAlert({ + title: errorMessage, + variant: 'danger', + id: getUniqueId() + }); }) .finally(() => setPendingDeleteSource(undefined)); }; @@ -140,7 +148,11 @@ const SourcesListView: React.FunctionComponent = () => { context: 'add-source_hidden_edit', name: pendingDeleteSource?.name }); - addAlert(successMessage, 'success', getUniqueId()); + addAlert({ + title: successMessage, + variant: 'success', + id: getUniqueId() + }); queryClient.invalidateQueries({ queryKey: [SOURCES_LIST_QUERY] }); setSourceBeingEdited(undefined); }) @@ -149,7 +161,11 @@ const SourcesListView: React.FunctionComponent = () => { const errorMessage = t('toast-notifications.title', { context: 'add-source_hidden_error_edit' }); - addAlert(errorMessage, 'danger', getUniqueId()); + addAlert({ + title: errorMessage, + variant: 'danger', + id: getUniqueId() + }); }); }; @@ -165,7 +181,11 @@ const SourcesListView: React.FunctionComponent = () => { context: 'scan-report_play', name: payload.name }); - addAlert(successMessage, 'success', getUniqueId()); + addAlert({ + title: successMessage, + variant: 'success', + id: getUniqueId() + }); queryClient.invalidateQueries({ queryKey: [SOURCES_LIST_QUERY] }); setScanSelected(undefined); }) @@ -176,7 +196,11 @@ const SourcesListView: React.FunctionComponent = () => { name: payload.name, message: JSON.stringify(err?.response?.data.name) }); - addAlert(errorMessage, 'danger', getUniqueId()); + addAlert({ + title: errorMessage, + variant: 'danger', + id: getUniqueId() + }); }); }; @@ -192,7 +216,11 @@ const SourcesListView: React.FunctionComponent = () => { context: 'add-source_hidden', name: payload.name }); - addAlert(successMessage, 'success', getUniqueId()); + addAlert({ + title: successMessage, + variant: 'success', + id: getUniqueId() + }); queryClient.invalidateQueries({ queryKey: [SOURCES_LIST_QUERY] }); setAddSourceModal(undefined); }) @@ -204,7 +232,11 @@ const SourcesListView: React.FunctionComponent = () => { name: payload.name, message: JSON.stringify(err?.response?.data) }); - addAlert(errorMessage, 'danger', getUniqueId()); + addAlert({ + title: errorMessage, + variant: 'danger', + id: getUniqueId() + }); }); }; @@ -549,20 +581,21 @@ const SourcesListView: React.FunctionComponent = () => { setScanSelected(undefined)} onSubmit={onRunScan} sources={scanSelected} /> )} - {alerts.map(({ key, variant, title }) => ( + {alerts.map(({ id, variant, title }) => ( key && removeAlert(key)} + onTimeout={() => id && removeAlert(id)} variant={AlertVariant[variant || 'info']} title={title} actionClose={ key && removeAlert(key)} + onClose={() => id && removeAlert(id)} /> } - key={key} + id={id} + key={id || getUniqueId()} /> ))} diff --git a/tests/__snapshots__/code.test.ts.snap b/tests/__snapshots__/code.test.ts.snap index e255aa49..1e54f921 100644 --- a/tests/__snapshots__/code.test.ts.snap +++ b/tests/__snapshots__/code.test.ts.snap @@ -8,23 +8,24 @@ exports[`General code checks should only have specific console.[warn|log|info|er "components/viewLayout/viewLayoutToolbar.tsx:79: console.log('selected', value);", "helpers/queryHelpers.ts:70: console.log(\`Query: \`, query);", "helpers/queryHelpers.ts:75: console.error(error);", - "hooks/useCredentialApi.ts:33: console.log('Deleting selected credentials:', selectedItems);", + "hooks/useCredentialApi.ts:74: console.log(missingCredsMsg);", + "hooks/useCredentialApi.ts:93: console.log(missingCredsMsg);", + "hooks/useCredentialApi.ts:138: console.error(error);", "hooks/useScanApi.ts:43: console.log('Deleting selected scans:', selectedItems);", "hooks/useSourceApi.ts:94: console.log('Deleting selected sources:', selectedItems);", - "views/credentials/viewCredentialsList.tsx:94: console.log(err);", "views/login/viewLogin.tsx:61: console.error('Failed to set config', error);", "views/login/viewLogin.tsx:68: console.error({ err });", "views/scans/showScansModal.tsx:70: console.log({ aValue, bValue });", "views/scans/viewScansList.tsx:93: console.log(err);", "views/scans/viewScansList.tsx:110: console.log('run scan:', source);", "views/scans/viewScansList.tsx:118: // console.error({ err });", - "views/scans/viewScansList.tsx:326: console.error(err);", + "views/scans/viewScansList.tsx:330: console.error(err);", "views/sources/addSourceModal.tsx:61: .catch(err => console.error(err));", - "views/sources/viewSourcesList.tsx:120: console.log(err);", - "views/sources/viewSourcesList.tsx:148: console.error({ err });", - "views/sources/viewSourcesList.tsx:173: console.error({ err });", - "views/sources/viewSourcesList.tsx:200: console.error({ err });", - "views/sources/viewSourcesList.tsx:201: console.log(JSON.stringify(err?.response?.data));", - "views/sources/viewSourcesList.tsx:225: .catch(err => console.error(err));", + "views/sources/viewSourcesList.tsx:124: console.log(err);", + "views/sources/viewSourcesList.tsx:160: console.error({ err });", + "views/sources/viewSourcesList.tsx:193: console.error({ err });", + "views/sources/viewSourcesList.tsx:228: console.error({ err });", + "views/sources/viewSourcesList.tsx:229: console.log(JSON.stringify(err?.response?.data));", + "views/sources/viewSourcesList.tsx:257: .catch(err => console.error(err));", ] `;