diff --git a/frontend/eslint.config.cjs b/frontend/eslint.config.cjs deleted file mode 100644 index 4ce265f..0000000 --- a/frontend/eslint.config.cjs +++ /dev/null @@ -1,92 +0,0 @@ -const { - defineConfig, -} = require("eslint/config"); - -const globals = require("globals"); -const tsParser = require("@typescript-eslint/parser"); -const typescriptEslint = require("@typescript-eslint/eslint-plugin"); -const react = require("eslint-plugin-react"); -const js = require("@eslint/js"); - -const { - FlatCompat, -} = require("@eslint/eslintrc"); - -const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all -}); - -module.exports = defineConfig([{ - languageOptions: { - globals: { - ...globals.browser, - heap: "writable", - DD_RUM: "writable", - }, - - parser: tsParser, - ecmaVersion: "latest", - sourceType: "module", - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - ecmaFeatures: { jsx: true }, - project: "./tsconfig.json", - }, - }, - - extends: compat.extends( - "enpitech", - "eslint:recommended", - "plugin:prettier/recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended", - // "plugin:@typescript-eslint/recommended-requiring-type-checking", TODO enable and fix the types - ), - - plugins: { - "@typescript-eslint": typescriptEslint, - react, - }, - - settings: { - react: { - version: "detect" - }, - }, - - rules: { - "no-console": ["error", { - allow: ["error"], - }], - - "no-alert": "error", - "no-debugger": "error", - "@typescript-eslint/ban-ts-comment": "off", - - "@typescript-eslint/no-unused-vars": ["error", { - vars: "all", - args: "after-used", - ignoreRestSiblings: true, - }], - - "react/react-in-jsx-scope": "off", - "linebreak-style": ["error", "unix"], - quotes: ["error", "double"], - semi: ["error", "always"], - "@typescript-eslint/no-explicit-any": "warn", - }, -}, { - languageOptions: { - globals: { - ...globals.node, - }, - - sourceType: "script", - parserOptions: {}, - }, - - files: ["**/.eslintrc.{js,cjs}"], -}]); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..68c3eac --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,160 @@ +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import tsParser from "@typescript-eslint/parser"; +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import react from "eslint-plugin-react"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; +import tscPlugin from "eslint-plugin-tsc"; +import { fileURLToPath } from "url"; +import path from "path"; + +const __filename = fileURLToPath(new URL(import.meta.url)); +const __dirname = path.dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default defineConfig([ + { + ignores: ["eslint.config.js"], + languageOptions: { + globals: { + ...globals.browser, + heap: "writable", + DD_RUM: "writable", + }, + + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module", + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { jsx: true }, + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + }, + }, + + extends: compat.extends( + "enpitech", + "eslint:recommended", + "plugin:prettier/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking" + ), + + plugins: { + "@typescript-eslint": typescriptEslint, + tsc: tscPlugin, + react, + }, + + settings: { + react: { + version: "detect", + }, + }, + + rules: { + "no-console": [ + "error", + { + allow: ["error"], + }, + ], + + "no-alert": "error", + "no-debugger": "error", + "@typescript-eslint/ban-ts-comment": "off", + + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + args: "after-used", + ignoreRestSiblings: true, + }, + ], + + "react/react-in-jsx-scope": "off", + "react/jsx-uses-react": "error", + "linebreak-style": ["error", "unix"], + quotes: ["error", "double"], + semi: ["error", "always"], + "no-restricted-properties": [ + "error", + { + object: "React", + property: "*", + message: "Using React.* is prohibited.", + }, + ], + + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/strict-boolean-expressions": "off", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + "@typescript-eslint/no-unsafe-return": "error", + "@typescript-eslint/no-unnecessary-type-assertion": "error", + "@typescript-eslint/consistent-type-assertions": [ + "error", + { + assertionStyle: "as", + objectLiteralTypeAssertions: "never", + }, + ], + "@typescript-eslint/no-restricted-types": [ + "error", + { + types: { + "React.FC": { + message: + "Avoid using React.FC. Use import type { FC } from React instead", + }, + "React.Node": { + message: + "Avoid using React.Node. Use import type { Node } from React instead", + }, + }, + }, + ], + "no-restricted-imports": [ + "error", + { + name: "react", + importNames: ["default", "*"], + message: + "Default and namespace React imports are prohibited. Use specific named imports only (e.g., import { useState, type ReactNode } from 'react').", + allowTypeImports: false, + }, + ], + + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + }, + ], + + "tsc/config": ["error", { configFile: "./tsconfig.json" }], + }, + }, + { + languageOptions: { + globals: { + ...globals.node, + }, + + sourceType: "script", + parserOptions: {}, + }, + + files: ["**/.eslintrc.{js,cjs}"], + }, +]); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index baed987..2f8cc11 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,8 @@ "uuid": "^13.0.0" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.3", + "@eslint/js": "^9.39.1", "@storybook/addon-docs": "^10.0.8", "@storybook/addon-links": "^10.0.8", "@storybook/mdx2-csf": "^1.1.0", @@ -48,6 +50,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-storybook": "^10.0.8", + "eslint-plugin-tsc": "^2.0.0", "flowbite": "^4.0.1", "globals": "^16.5.0", "husky": "^9.1.7", @@ -1133,9 +1136,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1145,7 +1148,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -6344,6 +6347,20 @@ "storybook": "^10.0.8" } }, + "node_modules/eslint-plugin-tsc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-tsc/-/eslint-plugin-tsc-2.0.0.tgz", + "integrity": "sha512-we7n063HSoWDpXjuqgplrYxfWnlVgq7GXteEjxtc/Ve6C0BjGQyoNGjApSVspyru1cckAM9ASwPnSU8Y0OTwTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "typescript-service": "^2.0.3" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -12466,6 +12483,27 @@ "node": ">=14.17" } }, + "node_modules/typescript-service": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/typescript-service/-/typescript-service-2.0.3.tgz", + "integrity": "sha512-FzRlqRp965UBzGvGwc6rbeko84jLILZrHf++I4cN8usZUB7F8Lh8/WDiCOUvy2l5os+jBWEz4fbYkkj1DhYJcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6", + "npm": ">=3" + } + }, + "node_modules/typescript-service/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2a94ee6..9f64aea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,8 @@ "uuid": "^13.0.0" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.3", + "@eslint/js": "^9.39.1", "@storybook/addon-docs": "^10.0.8", "@storybook/addon-links": "^10.0.8", "@storybook/mdx2-csf": "^1.1.0", @@ -44,6 +46,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-storybook": "^10.0.8", + "eslint-plugin-tsc": "^2.0.0", "flowbite": "^4.0.1", "globals": "^16.5.0", "husky": "^9.1.7", @@ -57,12 +60,8 @@ "vite-plugin-static-copy": "^3.1.4" }, "lint-staged": { - "src/*.{js,jsx,ts,tsx}": [ - "npm run lint:fix", - "npm run prettier:fix" - ], - "*.{json,css,md,mdx}": "npm run prettier:fix", - "src/*.{ts,tsx}": "npm run tsc:check" + "*.{js,jsx,ts,tsx}": "npm run lint:fix", + "*.{js,jsx,ts,tsx,json,css,md,mdx}": "npm run prettier:fix" }, "scripts": { "dev": "vite", @@ -75,7 +74,7 @@ "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", "lint": "npx eslint src/", - "lint:fix": "npm run lint -- --fix", + "lint:fix": "npm run lint -- --fix --max-warnings=0", "prettier": "npx prettier src/ --check", "prettier:fix": "npm run prettier -- --write", "cypress:open": "cypress open", diff --git a/frontend/src/API/apiService.ts b/frontend/src/API/apiService.ts index 8416fca..90d8282 100644 --- a/frontend/src/API/apiService.ts +++ b/frontend/src/API/apiService.ts @@ -1,4 +1,4 @@ -import { +import type { Chart, ChartVersion, Release, @@ -25,7 +25,7 @@ class ApiService { public async fetchWithDefaults( url: string, options?: RequestInit - ): Promise { + ): Promise { let response; if (this.currentCluster) { @@ -43,49 +43,66 @@ class ApiService { throw new Error(error); } - let data; - if (!response.headers.get("Content-Type")) { - return {} as T; - } else if (response.headers.get("Content-Type")?.includes("text/plain")) { - data = await response.text(); + const contentType = response.headers.get("Content-Type") || ""; + if (!contentType) { + return {} as unknown as T; + } else if (contentType.includes("text/plain")) { + return await response.text(); } else { - data = await response.json(); + return (await response.json()) as T; } + } + + public async fetchWithSafeDefaults({ + url, + options, + fallback, + }: { + url: string; + options?: RequestInit; + fallback: T; + }): Promise { + const data = await this.fetchWithDefaults(url, options); + if (!data) { + console.error(url, " response is empty"); + return fallback; + } + + if (typeof data === "string") { + console.error(url, " response is string"); + return fallback; + } + return data; } getToolVersion = async () => { - const response = await fetch("/status"); - const data = await response.json(); - return data; + return await this.fetchWithDefaults("/status"); }; getRepositoryLatestVersion = async (repositoryName: string) => { - const data = await this.fetchWithDefaults( + return await this.fetchWithDefaults( `/api/helm/repositories/latestver?name=${repositoryName}` ); - return data; }; getInstalledReleases = async () => { - const data = await this.fetchWithDefaults("/api/helm/releases"); - return data; + return await this.fetchWithDefaults("/api/helm/releases"); }; - getClusters = async () => { - const response = await fetch("/api/k8s/contexts"); - const data = (await response.json()) as ClustersResponse[]; - return data; + getClusters = async (): Promise => { + return await this.fetchWithSafeDefaults({ + url: "/api/k8s/contexts", + fallback: [], + }); }; getNamespaces = async () => { - const data = await this.fetchWithDefaults("/api/k8s/namespaces/list"); - return data; + return await this.fetchWithDefaults("/api/k8s/namespaces/list"); }; getRepositories = async () => { - const data = await this.fetchWithDefaults("/api/helm/repositories"); - return data; + return await this.fetchWithDefaults("/api/helm/repositories"); }; getRepositoryCharts = async ({ @@ -94,13 +111,12 @@ class ApiService { queryKey: readonly unknown[]; }): Promise => { const [, repository] = queryKey; - if (!repository) { + if (!repository || typeof repository !== "string") { return []; } - return await this.fetchWithDefaults( - `/api/helm/repositories/${repository}` - ); + const url = `/api/helm/repositories/${repository}`; + return await this.fetchWithSafeDefaults({ url, fallback: [] }); }; getChartVersions = async ({ @@ -108,25 +124,22 @@ class ApiService { }: QueryFunctionContext) => { const [, chart] = queryKey; - const data = await this.fetchWithDefaults( + return await this.fetchWithDefaults( `/api/helm/repositories/versions?name=${chart.name}` ); - return data; }; getResourceStatus = async ({ release, }: { release: Release; - }): Promise => { - if (!release) return null; + }): Promise => { + if (!release) return []; - const data = await this.fetchWithDefaults< - Promise - >( - `/api/helm/releases/${release.namespace}/${release.name}/resources?health=true` - ); - return data; + return await this.fetchWithSafeDefaults({ + url: `/api/helm/releases/${release.namespace}/${release.name}/resources?health=true`, + fallback: [], + }); }; getReleasesHistory = async ({ @@ -138,9 +151,10 @@ class ApiService { if (!params.namespace || !params.chart) return []; - return await this.fetchWithDefaults( - `/api/helm/releases/${params.namespace}/${params.chart}/history` - ); + return await this.fetchWithSafeDefaults({ + url: `/api/helm/releases/${params.namespace}/${params.chart}/history`, + fallback: [], + }); }; getValues = async ({ @@ -158,9 +172,7 @@ class ApiService { return Promise.reject(new Error("missing parameters")); const url = `/api/helm/repositories/values?chart=${namespace}/${chart.name}&version=${version}`; - const data = await this.fetchWithDefaults(url); - - return data; + return await this.fetchWithDefaults(url); }; } diff --git a/frontend/src/API/k8s.ts b/frontend/src/API/k8s.ts index e3c2205..81d2017 100644 --- a/frontend/src/API/k8s.ts +++ b/frontend/src/API/k8s.ts @@ -1,6 +1,10 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; -import { K8sResource, K8sResourceList, KubectlContexts } from "./interfaces"; +import type { + K8sResource, + K8sResourceList, + KubectlContexts, +} from "./interfaces"; import apiService from "./apiService"; // Get list of kubectl contexts configured locally @@ -8,7 +12,10 @@ function useGetKubectlContexts(options?: UseQueryOptions) { return useQuery({ queryKey: ["k8s", "contexts"], queryFn: () => - apiService.fetchWithDefaults("/api/k8s/contexts"), + apiService.fetchWithSafeDefaults({ + url: "/api/k8s/contexts", + fallback: { contexts: [] }, + }), ...(options ?? {}), }); } @@ -23,9 +30,10 @@ function useGetK8sResource( return useQuery({ queryKey: ["k8s", kind, "get", name, namespace], queryFn: () => - apiService.fetchWithDefaults( - `/api/k8s/${kind}/get?name=${name}&namespace=${namespace}` - ), + apiService.fetchWithSafeDefaults({ + url: `/api/k8s/${kind}/get?name=${name}&namespace=${namespace}`, + fallback: { kind: "", name: "", namespace: "" }, + }), ...(options ?? {}), }); } @@ -38,7 +46,10 @@ function useGetK8sResourceList( return useQuery({ queryKey: ["k8s", kind, "list"], queryFn: () => - apiService.fetchWithDefaults(`/api/k8s/${kind}/list`), + apiService.fetchWithSafeDefaults({ + url: `/api/k8s/${kind}/list`, + fallback: { items: [] }, + }), ...(options ?? {}), }); } diff --git a/frontend/src/API/other.ts b/frontend/src/API/other.ts index ab4600b..6ea3fc2 100644 --- a/frontend/src/API/other.ts +++ b/frontend/src/API/other.ts @@ -4,14 +4,14 @@ import { useMutation, useQuery, } from "@tanstack/react-query"; -import { ApplicationStatus } from "./interfaces"; +import type { ApplicationStatus } from "./interfaces"; import apiService from "./apiService"; // Shuts down the Helm Dashboard application export function useShutdownHelmDashboard( - options?: UseMutationOptions + options?: UseMutationOptions ) { - return useMutation({ + return useMutation({ mutationFn: () => apiService.fetchWithDefaults("/", { method: "DELETE", @@ -22,11 +22,16 @@ export function useShutdownHelmDashboard( // Gets application status export function useGetApplicationStatus( - options?: UseQueryOptions + options?: UseQueryOptions ) { - return useQuery({ + return useQuery({ queryKey: ["status"], - queryFn: () => apiService.fetchWithDefaults("/status"), + queryFn: async () => + await apiService.fetchWithSafeDefaults({ + url: "/status", + fallback: null, + }), + ...(options ?? {}), }); } diff --git a/frontend/src/API/releases.ts b/frontend/src/API/releases.ts index d6ee097..92844ad 100644 --- a/frontend/src/API/releases.ts +++ b/frontend/src/API/releases.ts @@ -4,8 +4,8 @@ import { useQuery, type UseQueryOptions, } from "@tanstack/react-query"; -import { ChartVersion, Release } from "../data/types"; -import { LatestChartVersion } from "./interfaces"; +import type { ChartVersion, Release } from "../data/types"; +import type { LatestChartVersion } from "./interfaces"; import apiService from "./apiService"; import { getVersionManifestFormData } from "./shared"; import { isNewerVersion } from "../utils"; @@ -16,7 +16,10 @@ export function useGetInstalledReleases(context: string) { return useQuery({ queryKey: ["installedReleases", context], queryFn: () => - apiService.fetchWithDefaults("/api/helm/releases"), + apiService.fetchWithSafeDefaults({ + url: "/api/helm/releases", + fallback: [], + }), retry: false, }); } @@ -65,44 +68,47 @@ export function useGetReleaseManifest({ return useQuery({ queryKey: ["manifest", namespace, chartName], queryFn: () => - apiService.fetchWithDefaults( - `/api/helm/releases/${namespace}/${chartName}/manifests` - ), + apiService.fetchWithSafeDefaults({ + url: `/api/helm/releases/${namespace}/${chartName}/manifests`, + fallback: [], + }), ...(options ?? {}), }); } // List of installed k8s resources for this release export function useGetResources(ns: string, name: string, enabled?: boolean) { - const { data, ...rest } = useQuery({ + return useQuery({ queryKey: ["resources", ns, name], queryFn: () => - apiService.fetchWithDefaults( - `/api/helm/releases/${ns}/${name}/resources?health=true` - ), + apiService.fetchWithSafeDefaults({ + url: `/api/helm/releases/${ns}/${name}/resources?health=true`, + fallback: [], + }), + select: (data) => + data + ?.map((resource) => ({ + ...resource, + status: { + ...resource.status, + conditions: resource.status.conditions.filter( + (c) => c.type === HD_RESOURCE_CONDITION_TYPE + ), + }, + })) + .sort((a, b) => { + const interestingResources = [ + "STATEFULSET", + "DEAMONSET", + "DEPLOYMENT", + ]; + return ( + interestingResources.indexOf(b.kind.toUpperCase()) - + interestingResources.indexOf(a.kind.toUpperCase()) + ); + }), enabled, }); - - return { - data: data - ?.map((resource) => ({ - ...resource, - status: { - ...resource.status, - conditions: resource.status.conditions.filter( - (c) => c.type === HD_RESOURCE_CONDITION_TYPE - ), - }, - })) - .sort((a, b) => { - const interestingResources = ["STATEFULSET", "DEAMONSET", "DEPLOYMENT"]; - return ( - interestingResources.indexOf(b.kind.toUpperCase()) - - interestingResources.indexOf(a.kind.toUpperCase()) - ); - }), - ...rest, - }; } export function useGetResourceDescription( @@ -130,9 +136,10 @@ export function useGetLatestVersion( return useQuery({ queryKey: ["latestver", chartName], queryFn: () => - apiService.fetchWithDefaults( - `/api/helm/repositories/latestver?name=${chartName}` - ), + apiService.fetchWithSafeDefaults({ + url: `/api/helm/repositories/latestver?name=${chartName}`, + fallback: [], + }), gcTime: 0, ...(options ?? {}), }); @@ -143,10 +150,13 @@ export function useGetVersions( ) { return useQuery({ queryKey: ["versions", chartName], - queryFn: () => - apiService.fetchWithDefaults( - `/api/helm/repositories/versions?name=${chartName}` - ), + queryFn: async () => { + const url = `/api/helm/repositories/versions?name=${chartName}`; + return await apiService.fetchWithSafeDefaults({ + url, + fallback: [], + }); + }, select: (data) => data?.sort((a, b) => (isNewerVersion(a.version, b.version) ? 1 : -1)), ...(options ?? {}), @@ -192,21 +202,21 @@ export function useGetDiff( // Rollback the release to a previous revision export function useRollbackRelease( options?: UseMutationOptions< - void, - unknown, + string, + Error, { ns: string; name: string; revision: number } > ) { return useMutation< - void, - unknown, + string, + Error, { ns: string; name: string; revision: number } >({ mutationFn: ({ ns, name, revision }) => { const formData = new FormData(); formData.append("revision", revision.toString()); - return apiService.fetchWithDefaults( + return apiService.fetchWithDefaults( `/api/helm/releases/${ns}/${name}/rollback`, { method: "POST", @@ -220,11 +230,11 @@ export function useRollbackRelease( // Run the tests on a release export function useTestRelease( - options?: UseMutationOptions + options?: UseMutationOptions ) { - return useMutation({ + return useMutation({ mutationFn: ({ ns, name }) => { - return apiService.fetchWithDefaults( + return apiService.fetchWithDefaults( `/api/helm/releases/${ns}/${name}/test`, { method: "POST", @@ -309,19 +319,22 @@ export const useVersionData = ({ releaseName, }); - const fetchUrl = isInstallRepoChart + const url = isInstallRepoChart ? `/api/helm/releases/${namespace || "default"}` : `/api/helm/releases/${ namespace ? namespace : "[empty]" }${`/${releaseName}`}`; - return await apiService.fetchWithDefaults<{ [key: string]: string }>( - fetchUrl, - { + return await apiService.fetchWithSafeDefaults<{ + [key: string]: string; + }>({ + url, + options: { method: "post", body: formData, - } - ); + }, + fallback: {}, + }); }, enabled, diff --git a/frontend/src/API/repositories.ts b/frontend/src/API/repositories.ts index ca9caf0..7ed1e17 100644 --- a/frontend/src/API/repositories.ts +++ b/frontend/src/API/repositories.ts @@ -4,7 +4,7 @@ import { useMutation, useQuery, } from "@tanstack/react-query"; -import { HelmRepositories } from "./interfaces"; +import type { HelmRepositories } from "./interfaces"; import apiService from "./apiService"; // Get list of Helm repositories @@ -14,7 +14,11 @@ export function useGetRepositories( return useQuery({ queryKey: ["helm", "repositories"], queryFn: () => - apiService.fetchWithDefaults("/api/helm/repositories"), + apiService.fetchWithSafeDefaults({ + url: "/api/helm/repositories", + fallback: [], + }), + select: (data) => data?.sort((a, b) => a?.name?.localeCompare(b?.name)), ...(options ?? {}), }); } @@ -22,11 +26,11 @@ export function useGetRepositories( // Update repository from remote export function useUpdateRepo( repo: string, - options?: UseMutationOptions + options?: UseMutationOptions ) { - return useMutation({ + return useMutation({ mutationFn: () => { - return apiService.fetchWithDefaults( + return apiService.fetchWithDefaults( `/api/helm/repositories/${repo}`, { method: "POST", @@ -40,11 +44,11 @@ export function useUpdateRepo( // Remove repository export function useDeleteRepo( repo: string, - options?: UseMutationOptions + options?: UseMutationOptions ) { - return useMutation({ + return useMutation({ mutationFn: () => { - return apiService.fetchWithDefaults( + return apiService.fetchWithDefaults( `/api/helm/repositories/${repo}`, { method: "DELETE", diff --git a/frontend/src/API/scanners.ts b/frontend/src/API/scanners.ts index f149dc9..90304bf 100644 --- a/frontend/src/API/scanners.ts +++ b/frontend/src/API/scanners.ts @@ -7,14 +7,19 @@ import { useMutation, useQuery, } from "@tanstack/react-query"; -import { ScanResult, ScanResults, ScannersList } from "./interfaces"; +import type { ScanResults, ScannersList } from "./interfaces"; +import { ScanResult } from "./interfaces"; import apiService from "./apiService"; // Get list of discovered scanners function useGetDiscoveredScanners(options?: UseQueryOptions) { return useQuery({ queryKey: ["scanners"], - queryFn: () => apiService.fetchWithDefaults("/api/scanners"), + queryFn: () => + apiService.fetchWithSafeDefaults({ + url: "/api/scanners", + fallback: { scanners: [] }, + }), ...(options ?? {}), }); } @@ -28,9 +33,13 @@ function useScanManifests( formData.append("manifest", manifest); return useMutation({ mutationFn: () => - apiService.fetchWithDefaults("/api/scanners/manifests", { - method: "POST", - body: formData, + apiService.fetchWithSafeDefaults({ + url: "/api/scanners/manifests", + options: { + method: "POST", + body: formData, + }, + fallback: {}, }), ...(options ?? {}), }); @@ -46,9 +55,10 @@ function useScanK8sResource( return useQuery({ queryKey: ["scanners", "resource", kind, namespace, name], queryFn: () => - apiService.fetchWithDefaults( - `/api/scanners/resource/${kind}?namespace=${namespace}&name=${name}` - ), + apiService.fetchWithSafeDefaults({ + url: `/api/scanners/resource/${kind}?namespace=${namespace}&name=${name}`, + fallback: {}, + }), ...(options ?? {}), }); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3c140f8..d60b455 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,8 +4,10 @@ import Installed from "./pages/Installed"; import RepositoryPage from "./pages/Repository"; import Revision from "./pages/Revision"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { FC } from "react"; import { useState } from "react"; -import { ErrorAlert, ErrorModalContext } from "./context/ErrorModalContext"; +import type { ErrorAlert } from "./context/ErrorModalContext"; +import { ErrorModalContext } from "./context/ErrorModalContext"; import GlobalErrorModal from "./components/modal/GlobalErrorModal"; import { AppContextProvider } from "./context/AppContext"; import apiService from "./API/apiService"; @@ -31,7 +33,7 @@ const PageLayout = () => { ); }; -const SyncContext: React.FC = () => { +const SyncContext: FC = () => { const { context } = useParams(); if (context) { apiService.setCluster(decodeURIComponent(context)); @@ -52,31 +54,29 @@ export default function App() { - } /> + } /> }> }> + } + /> } /> } /> - } /> - } - /> } /> - } /> - setShowErrorModal(undefined)} - titleText={shouldShowErrorModal?.title || ""} - contentText={shouldShowErrorModal?.msg || ""} - /> + setShowErrorModal(undefined)} + titleText={shouldShowErrorModal?.title || ""} + contentText={shouldShowErrorModal?.msg || ""} + /> diff --git a/frontend/src/components/Badge.stories.tsx b/frontend/src/components/Badge.stories.tsx index a3066fe..c2d109d 100644 --- a/frontend/src/components/Badge.stories.tsx +++ b/frontend/src/components/Badge.stories.tsx @@ -14,7 +14,7 @@ * @see https://storybook.js.org/docs/react/writing-stories/introduction */ -import { Meta } from "@storybook/react-vite"; +import type { Meta } from "@storybook/react-vite"; import Badge from "./Badge"; // We set the metadata for the story. diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx index bd93aec..b8ff813 100644 --- a/frontend/src/components/Badge.tsx +++ b/frontend/src/components/Badge.tsx @@ -17,7 +17,7 @@ * * */ -import { JSX, ReactNode } from "react"; +import type { JSX, ReactNode } from "react"; export type BadgeCode = "success" | "warning" | "error" | "unknown"; diff --git a/frontend/src/components/Button.stories.tsx b/frontend/src/components/Button.stories.tsx index 60d8255..7a6f154 100644 --- a/frontend/src/components/Button.stories.tsx +++ b/frontend/src/components/Button.stories.tsx @@ -1,4 +1,4 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import Button from "./Button"; const meta = { diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 30bee01..d63a600 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -12,7 +12,7 @@ * * */ -import { HTMLAttributes, JSX, ReactNode } from "react"; +import type { HTMLAttributes, JSX, ReactNode } from "react"; // this is a type declaration for the action prop. // it is a function that takes a string as an argument and returns void. diff --git a/frontend/src/components/ClustersList.cy.tsx b/frontend/src/components/ClustersList.cy.tsx index 9faeb2f..aec5162 100644 --- a/frontend/src/components/ClustersList.cy.tsx +++ b/frontend/src/components/ClustersList.cy.tsx @@ -2,7 +2,8 @@ import { AppContextProvider } from "../context/AppContext"; import ClustersList from "./ClustersList"; import { BrowserRouter } from "react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Release } from "../data/types"; +import type { Release } from "../data/types"; +import { DeploymentStatus } from "./common/StatusLabel"; type ClustersListProps = { onClusterChange: (clusterName: string) => void; @@ -17,7 +18,7 @@ const generateTestReleaseData = (): Release => ({ namespace: "default", revision: 1, updated: "2024-01-23T15:37:35.0992836+02:00", - status: "deployed", + status: DeploymentStatus.DEPLOYED, chart: "helm-dashboard-0.1.10", chart_name: "helm-dashboard", chart_ver: "0.1.10", diff --git a/frontend/src/components/ClustersList.stories.tsx b/frontend/src/components/ClustersList.stories.tsx index 57bb30c..bfaec80 100644 --- a/frontend/src/components/ClustersList.stories.tsx +++ b/frontend/src/components/ClustersList.stories.tsx @@ -1,4 +1,4 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import ClustersList from "./ClustersList"; const meta = { diff --git a/frontend/src/components/ClustersList.tsx b/frontend/src/components/ClustersList.tsx index 07fb8db..6c889cd 100644 --- a/frontend/src/components/ClustersList.tsx +++ b/frontend/src/components/ClustersList.tsx @@ -1,5 +1,5 @@ -import { useEffect, useEffectEvent, useMemo, useState } from "react"; -import { Cluster, Release } from "../data/types"; +import { useEffect, useEffectEvent, useMemo } from "react"; +import type { Cluster, Release } from "../data/types"; import apiService from "../API/apiService"; import { useQuery } from "@tanstack/react-query"; import useCustomSearchParams from "../hooks/useCustomSearchParams"; @@ -43,21 +43,19 @@ function ClustersList({ }: ClustersListProps) { const { upsertSearchParams, removeSearchParam } = useCustomSearchParams(); const { clusterMode } = useAppContext(); - const [sortedClusters, setSortedClusters] = useState([]); - const { data: clusters, isSuccess } = useQuery({ + const { data: clusters = [], isSuccess } = useQuery({ queryKey: ["clusters", selectedCluster], queryFn: apiService.getClusters, + select: (data) => + data?.sort((a, b) => + getCleanClusterName(a.Name).localeCompare(getCleanClusterName(b.Name)) + ), }); const onSuccess = useEffectEvent((clusters: Cluster[]) => { - const sortedData = [...clusters].sort((a, b) => - getCleanClusterName(a.Name).localeCompare(getCleanClusterName(b.Name)) - ); - setSortedClusters(sortedData); - - if (sortedData && sortedData.length > 0 && !selectedCluster) { - onClusterChange(sortedData[0].Name); + if (clusters && clusters.length && !selectedCluster) { + onClusterChange(clusters[0].Name); } if (selectedCluster) { @@ -111,10 +109,10 @@ function ClustersList({ {!clusterMode ? ( <> - {sortedClusters?.map((cluster) => { + {clusters?.map((cluster) => { return ( ({ + const { data: statusData = [], isLoading } = useQuery({ queryKey: ["resourceStatus", release], queryFn: () => apiService.getResourceStatus({ release }), enabled: inView, @@ -61,14 +61,21 @@ export default function InstalledPackageCard({ setIsMouseOver(false); }; - const handleOnClick = () => { + const onClick = async () => { const { name, namespace } = release; - navigate(`/${namespace}/${name}/installed/revision/${release.revision}`, { - state: release, - }); + await navigate( + `/${namespace}/${name}/installed/revision/${release.revision}`, + { + state: release, + } + ); }; - const statusColor = getStatusColor(release.status as DeploymentStatus); + const handleClick = () => { + void onClick(); + }; + + const statusColor = getStatusColor(release.status); const borderLeftColor: { [key: string]: string } = { [DeploymentStatus.DEPLOYED]: "border-l-border-deployed", [DeploymentStatus.FAILED]: "border-l-text-danger", @@ -85,7 +92,7 @@ export default function InstalledPackageCard({ }`} onMouseOver={handleMouseOver} onMouseOut={handleMouseOut} - onClick={handleOnClick} + onClick={handleClick} >
- {statusData ? ( - - ) : ( + {isLoading ? ( + ) : ( + )}
diff --git a/frontend/src/components/InstalledPackages/InstalledPackagesHeader.stories.tsx b/frontend/src/components/InstalledPackages/InstalledPackagesHeader.stories.tsx index 5ad0f83..9f98dc7 100644 --- a/frontend/src/components/InstalledPackages/InstalledPackagesHeader.stories.tsx +++ b/frontend/src/components/InstalledPackages/InstalledPackagesHeader.stories.tsx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/react-vite"; +import type { Meta } from "@storybook/react-vite"; import InstalledPackagesHeader from "./InstalledPackagesHeader"; const meta = { diff --git a/frontend/src/components/InstalledPackages/InstalledPackagesHeader.tsx b/frontend/src/components/InstalledPackages/InstalledPackagesHeader.tsx index 28736fe..19d8ae7 100644 --- a/frontend/src/components/InstalledPackages/InstalledPackagesHeader.tsx +++ b/frontend/src/components/InstalledPackages/InstalledPackagesHeader.tsx @@ -1,9 +1,10 @@ import HeaderLogo from "../../assets/packges-header.svg"; -import { Release } from "../../data/types"; +import type { Release } from "../../data/types"; +import type { Dispatch, SetStateAction } from "react"; type InstalledPackagesHeaderProps = { filteredReleases?: Release[]; - setFilterKey: React.Dispatch>; + setFilterKey: Dispatch>; isLoading: boolean; }; diff --git a/frontend/src/components/InstalledPackages/InstalledPackagesList.stories.tsx b/frontend/src/components/InstalledPackages/InstalledPackagesList.stories.tsx index 57d2d6c..be2c80f 100644 --- a/frontend/src/components/InstalledPackages/InstalledPackagesList.stories.tsx +++ b/frontend/src/components/InstalledPackages/InstalledPackagesList.stories.tsx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/react-vite"; +import type { Meta } from "@storybook/react-vite"; import InstalledPackagesList from "./InstalledPackagesList"; const meta = { diff --git a/frontend/src/components/InstalledPackages/InstalledPackagesList.tsx b/frontend/src/components/InstalledPackages/InstalledPackagesList.tsx index 2cb5387..392cd68 100644 --- a/frontend/src/components/InstalledPackages/InstalledPackagesList.tsx +++ b/frontend/src/components/InstalledPackages/InstalledPackagesList.tsx @@ -1,5 +1,5 @@ import InstalledPackageCard from "./InstalledPackageCard"; -import { Release } from "../../data/types"; +import type { Release } from "../../data/types"; type InstalledPackagesListProps = { filteredReleases: Release[]; diff --git a/frontend/src/components/LinkWithSearchParams.tsx b/frontend/src/components/LinkWithSearchParams.tsx index 097fada..42464db 100644 --- a/frontend/src/components/LinkWithSearchParams.tsx +++ b/frontend/src/components/LinkWithSearchParams.tsx @@ -27,13 +27,9 @@ const LinkWithSearchParams = ({ prefixedUrl = `/${encodeURIComponent(context)}${to}`; } - return ( - - ); + const url = `${prefixedUrl}/?${params.toString()}`; + + return ; }; export default LinkWithSearchParams; diff --git a/frontend/src/components/SelectMenu.stories.tsx b/frontend/src/components/SelectMenu.stories.tsx index 4701408..a51a0be 100644 --- a/frontend/src/components/SelectMenu.stories.tsx +++ b/frontend/src/components/SelectMenu.stories.tsx @@ -6,7 +6,7 @@ * The default story renders the component with the default props. */ -import { Meta, StoryObj } from "@storybook/react-vite"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; import SelectMenu, { SelectMenuItem } from "./SelectMenu"; diff --git a/frontend/src/components/SelectMenu.tsx b/frontend/src/components/SelectMenu.tsx index fb62c5f..9eb6588 100644 --- a/frontend/src/components/SelectMenu.tsx +++ b/frontend/src/components/SelectMenu.tsx @@ -24,7 +24,7 @@ * * */ -import { JSX } from "react"; +import type { JSX, ReactNode } from "react"; // define the SelectMenuItem type: // This is an object with a label and id. @@ -39,7 +39,7 @@ export interface SelectMenuItemProps { export interface SelectMenuProps { header: string; - children: React.ReactNode; + children: ReactNode; selected: number; onSelect: (id: number) => void; } diff --git a/frontend/src/components/ShutDownButton.stories.tsx b/frontend/src/components/ShutDownButton.stories.tsx index 1a9ed9c..ab9ea52 100644 --- a/frontend/src/components/ShutDownButton.stories.tsx +++ b/frontend/src/components/ShutDownButton.stories.tsx @@ -1,4 +1,4 @@ -import { StoryFn, Meta } from "@storybook/react-vite"; +import type { StoryFn, Meta } from "@storybook/react-vite"; import ShutDownButton from "./ShutDownButton"; const meta = { diff --git a/frontend/src/components/ShutDownButton.tsx b/frontend/src/components/ShutDownButton.tsx index f3581d3..4259b4c 100644 --- a/frontend/src/components/ShutDownButton.tsx +++ b/frontend/src/components/ShutDownButton.tsx @@ -1,5 +1,4 @@ import { BsPower } from "react-icons/bs"; - import Modal from "./modal/Modal"; import { useShutdownHelmDashboard } from "../API/other"; diff --git a/frontend/src/components/Tabs.stories.tsx b/frontend/src/components/Tabs.stories.tsx index 9f99924..71e5551 100644 --- a/frontend/src/components/Tabs.stories.tsx +++ b/frontend/src/components/Tabs.stories.tsx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/react-vite"; +import type { Meta } from "@storybook/react-vite"; import Tabs from "./Tabs"; const meta = { diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 978de64..9a9a8fb 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import useCustomSearchParams from "../hooks/useCustomSearchParams"; export interface Tab { diff --git a/frontend/src/components/TabsBar.stories.tsx b/frontend/src/components/TabsBar.stories.tsx index 179491e..9028ce2 100644 --- a/frontend/src/components/TabsBar.stories.tsx +++ b/frontend/src/components/TabsBar.stories.tsx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/react-vite"; +import type { Meta } from "@storybook/react-vite"; import TabsBar from "./TabsBar"; const meta = { diff --git a/frontend/src/components/TabsBar.tsx b/frontend/src/components/TabsBar.tsx index 1e1a08d..be669de 100644 --- a/frontend/src/components/TabsBar.tsx +++ b/frontend/src/components/TabsBar.tsx @@ -14,7 +14,7 @@ * * */ -import { JSX } from "react"; +import type { JSX } from "react"; interface TabsBarProps { tabs: Array<{ name: string; component: JSX.Element }>; diff --git a/frontend/src/components/TextInput.stories.tsx b/frontend/src/components/TextInput.stories.tsx index 00c9099..368550a 100644 --- a/frontend/src/components/TextInput.stories.tsx +++ b/frontend/src/components/TextInput.stories.tsx @@ -4,7 +4,7 @@ * the first story simply renders the component with the default props. */ -import { Meta } from "@storybook/react-vite"; +import type { Meta } from "@storybook/react-vite"; import TextInput from "./TextInput"; const meta = { diff --git a/frontend/src/components/TextInput.tsx b/frontend/src/components/TextInput.tsx index 528b9c2..b46d1ad 100644 --- a/frontend/src/components/TextInput.tsx +++ b/frontend/src/components/TextInput.tsx @@ -12,13 +12,13 @@ * @return JSX.Element * */ -import { JSX } from "react"; +import type { ChangeEvent, JSX } from "react"; export interface TextInputProps { label: string; placeholder: string; isMandatory?: boolean; - onChange: (event: React.ChangeEvent) => void; + onChange: (event: ChangeEvent) => void; } export default function TextInput(props: TextInputProps): JSX.Element { diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx index d390ddd..7534883 100644 --- a/frontend/src/components/Tooltip.tsx +++ b/frontend/src/components/Tooltip.tsx @@ -1,4 +1,5 @@ -import { type ReactElement, cloneElement, HTMLAttributes } from "react"; +import type { HTMLAttributes } from "react"; +import { type ReactElement, cloneElement } from "react"; export default function Tooltip({ id, @@ -15,7 +16,7 @@ export default function Tooltip({ element as ReactElement>, { "data-tooltip-target": id, - } as HTMLAttributes + } as unknown as HTMLAttributes )}
; +} as unknown as Meta; export default meta; diff --git a/frontend/src/components/common/DropDown.tsx b/frontend/src/components/common/DropDown.tsx index ec4b444..a0eef6c 100644 --- a/frontend/src/components/common/DropDown.tsx +++ b/frontend/src/components/common/DropDown.tsx @@ -1,4 +1,5 @@ -import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import ArrowDownIcon from "../../assets/arrow-down-icon.svg"; export type DropDownItem = { diff --git a/frontend/src/components/common/Header/Header.stories.tsx b/frontend/src/components/common/Header/Header.stories.tsx index 931cd73..e910660 100644 --- a/frontend/src/components/common/Header/Header.stories.tsx +++ b/frontend/src/components/common/Header/Header.stories.tsx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/react-vite"; +import type { Meta } from "@storybook/react-vite"; import { Header } from "./Header"; diff --git a/frontend/src/components/common/StatusLabel.stories.tsx b/frontend/src/components/common/StatusLabel.stories.tsx index bf9547b..44f127d 100644 --- a/frontend/src/components/common/StatusLabel.stories.tsx +++ b/frontend/src/components/common/StatusLabel.stories.tsx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/react-vite"; +import type { Meta } from "@storybook/react-vite"; import StatusLabel, { DeploymentStatus } from "./StatusLabel"; const meta = { diff --git a/frontend/src/components/common/StatusLabel.tsx b/frontend/src/components/common/StatusLabel.tsx index 60e1417..b4eb20f 100644 --- a/frontend/src/components/common/StatusLabel.tsx +++ b/frontend/src/components/common/StatusLabel.tsx @@ -1,10 +1,5 @@ import { AiOutlineReload } from "react-icons/ai"; -type StatusLabelProps = { - status: string; - isRollback?: boolean; -}; - export enum DeploymentStatus { DEPLOYED = "deployed", FAILED = "failed", @@ -12,6 +7,11 @@ export enum DeploymentStatus { SUPERSEDED = "superseded", } +type StatusLabelProps = { + status: DeploymentStatus; + isRollback?: boolean; +}; + export function getStatusColor(status: DeploymentStatus) { if (status === DeploymentStatus.DEPLOYED) return "text-deployed"; if (status === DeploymentStatus.FAILED) return "text-failed"; @@ -20,7 +20,7 @@ export function getStatusColor(status: DeploymentStatus) { } function StatusLabel({ status, isRollback }: StatusLabelProps) { - const statusColor = getStatusColor(status as DeploymentStatus); + const statusColor = getStatusColor(status); return (
{ + const addRepository = async () => { const body = new FormData(); body.append("name", formData.name ?? ""); body.append("url", formData.url ?? ""); @@ -45,32 +45,34 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) { setIsLoading(true); - apiService - .fetchWithDefaults("/api/helm/repositories", { + try { + await apiService.fetchWithDefaults("/api/helm/repositories", { method: "POST", body, - }) - .then(() => { - setIsLoading(false); - onClose(); - - queryClient.invalidateQueries({ - queryKey: ["helm", "repositories"], - }); - setSelectedRepo(formData.name || ""); - navigate(`/repository/${formData.name}`, { - replace: true, - }); - }) - .catch((error) => { - alertError.setShowErrorModal({ - title: "Failed to add repo", - msg: error.message, - }); - }) - .finally(() => { - setIsLoading(false); }); + + setIsLoading(false); + onClose(); + + await queryClient.invalidateQueries({ + queryKey: ["helm", "repositories"], + }); + setSelectedRepo(formData.name || ""); + await navigate(`/repository/${formData.name}`, { + replace: true, + }); + } catch (err) { + alertError.setShowErrorModal({ + title: "Failed to add repo", + msg: err instanceof Error ? err.message : String(err), + }); + } finally { + setIsLoading(false); + } + }; + + const handleAddRepository = () => { + void addRepository(); }; return ( @@ -84,7 +86,7 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {