From bf587d3e28cef7a7e0457020907b0c4a75b9b3bf Mon Sep 17 00:00:00 2001 From: Jeff Ortel Date: Thu, 12 Sep 2024 09:25:56 -0500 Subject: [PATCH 1/7] :sparkles: Add /kai reverse-proxy route. (#2089) Related: https://github.com/konveyor/tackle2-hub/pull/750 Signed-off-by: Jeff Ortel --- common/src/proxies.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/common/src/proxies.ts b/common/src/proxies.ts index 94246c544b..a70039342c 100644 --- a/common/src/proxies.ts +++ b/common/src/proxies.ts @@ -60,4 +60,35 @@ export const proxyMap: Record = { } }, }, + + "/kai": { + target: KONVEYOR_ENV.TACKLE_HUB_URL || "http://localhost:9002", + logLevel: process.env.DEBUG ? "debug" : "info", + + changeOrigin: true, + pathRewrite: { + "^/kai": "/services/kai", + }, + + onProxyReq: (proxyReq, req, _res) => { + // Add the Bearer token to the request if it is not already present, AND if + // the token is part of the request as a cookie + if (req.cookies?.keycloak_cookie && !req.headers["authorization"]) { + proxyReq.setHeader( + "Authorization", + `Bearer ${req.cookies.keycloak_cookie}` + ); + } + }, + onProxyRes: (proxyRes, req, res) => { + const includesJsonHeaders = + req.headers.accept?.includes("application/json"); + if ( + (!includesJsonHeaders && proxyRes.statusCode === 401) || + (!includesJsonHeaders && proxyRes.statusMessage === "Unauthorized") + ) { + res.redirect("/"); + } + }, + }, }; From 5c728cbb30ba48bfc28f543aa05d751bf9a50527 Mon Sep 17 00:00:00 2001 From: Scott Dickerson Date: Thu, 12 Sep 2024 13:56:11 -0400 Subject: [PATCH 2/7] :seedling: Skip PR CI actions for docs/hacks changes (#2084) When a PR only contains changes to root level markdown, or anything under `docs/` or `hack/`, there is no need to run the CI github actions. To satisfy branch protection rules that may exist that require the "unit-test" job to run, the `ci-repo.yml` action looks up details about a PR and will skip the unit test if it detects that the only changes made in the PR are docs or hacks. The ignores do not apply to pushes to main, pushes to release, or workflow calls. Signed-off-by: Scott J Dickerson --- .github/workflows/ci-global.yml | 4 ++ .github/workflows/ci-image-build.yml | 4 ++ .github/workflows/ci-repo.yml | 59 ++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-global.yml b/.github/workflows/ci-global.yml index f6d8d6d618..3497b0acc9 100644 --- a/.github/workflows/ci-global.yml +++ b/.github/workflows/ci-global.yml @@ -6,6 +6,10 @@ on: - "main" pull_request: + paths-ignore: + - "docs/**" + - "hack/**" + - "*.md" branches: - "main" diff --git a/.github/workflows/ci-image-build.yml b/.github/workflows/ci-image-build.yml index 6199750d93..6a165b27df 100644 --- a/.github/workflows/ci-image-build.yml +++ b/.github/workflows/ci-image-build.yml @@ -2,6 +2,10 @@ name: CI (test image build for a PR with build related changes) on: pull_request: + paths-ignore: + - "docs/**" + - "hack/**" + - "*.md" branches: - "main" - "release-*" diff --git a/.github/workflows/ci-repo.yml b/.github/workflows/ci-repo.yml index 9920661914..3089ef5174 100644 --- a/.github/workflows/ci-repo.yml +++ b/.github/workflows/ci-repo.yml @@ -20,10 +20,12 @@ concurrency: cancel-in-progress: true jobs: - unit-test-lookup-image: + unit-test-lookup: runs-on: ubuntu-latest outputs: builder-image: ${{ steps.grepBuilder.outputs.builder }} + should-test: ${{ steps.check-changes.outputs.should-test }} + steps: - uses: actions/checkout@v4 @@ -31,15 +33,64 @@ jobs: id: grepBuilder run: | builder=$(grep 'as builder' Dockerfile | sed -e 's/^FROM \(.*\) as builder$/\1/') - echo "Builder image: \`$builder\`" >> "$GITHUB_STEP_SUMMARY" echo "builder=$builder" >> "$GITHUB_OUTPUT" + - name: Did docs and hacks change? + id: docs-and-hacks + uses: tj-actions/changed-files@v44 + with: + files: | + docs/** + hack/** + *.md + + - name: Check if only docs and hacks changes have been made in a PR + id: check-changes + env: + IS_PR: ${{ !!github.event.pull_request }} + ONLY_DOCS: ${{ steps.docs-and-hacks.outputs.only_modified }} + run: | + SHOULD_TEST=$( + if [[ $IS_PR == true ]] && [[ $ONLY_DOCS == true ]]; then + echo "false" + else + echo "true" + fi + ) + + echo "is-pr=$IS_PR" >> "$GITHUB_OUTPUT" + echo "changes_only_docs=${ONLY_DOCS:-false}" >> "$GITHUB_OUTPUT" + echo "should-test=$SHOULD_TEST" >> "$GITHUB_OUTPUT" + + - name: Summarize findings + env: + ONLY_DOCS: ${{ steps.docs-and-hacks.outputs.only_modified }} + MODIFIED_FILES: ${{ steps.docs-and-hacks.outputs.all_modified_files }} + run: | + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_STEP_SUMMARY" + for file in ${MODIFIED_FILES}; do + echo " - \`$file\`" >> "$GITHUB_STEP_SUMMARY" + done + fi + unit-test: runs-on: ubuntu-latest - needs: unit-test-lookup-image + needs: unit-test-lookup + if: ${{ needs.unit-test-lookup.outputs.should-test == 'true' }} # Use the same container as the Dockerfile's "FROM * as builder" - container: ${{ needs.unit-test-lookup-image.outputs.builder-image }} + container: ${{ needs.unit-test-lookup.outputs.builder-image }} steps: - uses: actions/checkout@v4 From 14918d5dae47960df6e99bbaf34b5a380d0dbbc5 Mon Sep 17 00:00:00 2001 From: Scott Dickerson Date: Thu, 12 Sep 2024 16:23:11 -0400 Subject: [PATCH 3/7] :seedling: Image build, use variables for repo push (#2090) To help out people building images in a fork, use github action variables to be able to configure the target registry and image names. Now on a fork, the destination of an image build can be set by using action variables[^1]: - IMAGE_BUILD_REGISTRY (default: "quay.io/konveyor") - IMAGE_BUILD_IMAGE_NAME (default: "tackle2-ui") and action secrets[^2]: - QUAY_PUBLISH_ROBOT - QUAY_PUBLISH_TOKEN [^1]: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#creating-configuration-variables-for-a-repository [^2]: https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secret Signed-off-by: Scott J Dickerson --- .github/workflows/image-build.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/image-build.yaml b/.github/workflows/image-build.yaml index 7bcbcc5fff..22b3ab25be 100644 --- a/.github/workflows/image-build.yaml +++ b/.github/workflows/image-build.yaml @@ -17,8 +17,8 @@ jobs: image-build: uses: konveyor/release-tools/.github/workflows/build-push-images.yaml@main with: - registry: "quay.io/konveyor" - image_name: "tackle2-ui" + registry: ${{ vars.IMAGE_BUILD_REGISTRY || 'quay.io/konveyor' }} + image_name: ${{ vars.IMAGE_BUILD_IMAGE_NAME || 'tackle2-ui' }} containerfile: "./Dockerfile" # keep the architectures in sync with `ci-image-build.yml` From 1e068fe22f242d3d9ea3fc7f31b7814898ab02c8 Mon Sep 17 00:00:00 2001 From: Scott Dickerson Date: Fri, 13 Sep 2024 12:53:07 -0400 Subject: [PATCH 4/7] :ghost: Update browserslist/caniuse DB (#2091) Updated the database after starting to see a few DB out-of-date warnings. Signed-off-by: Scott J Dickerson --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b25f9513ff..9eb5e33fe7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4314,9 +4314,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001600", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001600.tgz", - "integrity": "sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==", + "version": "1.0.30001660", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001660.tgz", + "integrity": "sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==", "dev": true, "funding": [ { From 7768bf67b1f3f1a9ce2d2c8e3ed96d0d4ea1905f Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Fri, 13 Sep 2024 23:25:31 +0200 Subject: [PATCH 5/7] :bug: Fix closing filter chips created via SelectFilterControl (#2093) Resolves: https://github.com/konveyor/tackle2-ui/issues/2094 Signed-off-by: Radoslaw Szwajkowski --- .../app/components/FilterToolbar/SelectFilterControl.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/src/app/components/FilterToolbar/SelectFilterControl.tsx b/client/src/app/components/FilterToolbar/SelectFilterControl.tsx index e33eb5083f..b1b6bf4183 100644 --- a/client/src/app/components/FilterToolbar/SelectFilterControl.tsx +++ b/client/src/app/components/FilterToolbar/SelectFilterControl.tsx @@ -5,6 +5,7 @@ import { Select, SelectList, SelectOption, + ToolbarChip, ToolbarFilter, } from "@patternfly/react-core"; import { IFilterControlProps } from "./FilterControl"; @@ -56,8 +57,9 @@ export const SelectFilterControl = ({ setIsFilterDropdownOpen(false); }; - const onFilterClear = (chip: string) => { - const newValue = filterValue?.filter((val) => val !== chip); + const onFilterClear = (chip: string | ToolbarChip) => { + const chipValue = typeof chip === "string" ? chip : chip.key; + const newValue = filterValue?.filter((val) => val !== chipValue); setFilterValue(newValue?.length ? newValue : null); }; @@ -90,7 +92,7 @@ export const SelectFilterControl = ({ onFilterClear(chip as string)} + deleteChip={(_, chip) => onFilterClear(chip)} categoryName={category.title} showToolbarItem={showToolbarItem} > From 0d87a57996445e81a584f2d4d9c363fb35bd1f8b Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Sat, 14 Sep 2024 16:39:14 +0200 Subject: [PATCH 6/7] :bug: Fix fetching in InfiniteScroller (#2085) Fixes: 1. initial delay in first fetch - root cause was delayed initialization of sentinel reference 2. extra page fetch request - algorithm based on items count was not sufficient Reference-Url: https://github.com/konveyor/tackle2-ui/pull/2049 Signed-off-by: Radoslaw Szwajkowski Co-authored-by: Scott Dickerson --- .../InfiniteScroller/InfiniteScroller.tsx | 41 +++++++++---------- .../InfiniteScroller/useVisibilityTracker.tsx | 11 +++-- .../task-manager/TaskManagerDrawer.tsx | 7 +++- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/client/src/app/components/InfiniteScroller/InfiniteScroller.tsx b/client/src/app/components/InfiniteScroller/InfiniteScroller.tsx index 9e4ddfbc69..eb990f39a9 100644 --- a/client/src/app/components/InfiniteScroller/InfiniteScroller.tsx +++ b/client/src/app/components/InfiniteScroller/InfiniteScroller.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useEffect, useRef } from "react"; +import React, { ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useVisibilityTracker } from "./useVisibilityTracker"; import "./InfiniteScroller.css"; @@ -12,6 +12,7 @@ export interface InfiniteScrollerProps { hasMore: boolean; // number of items currently displayed/known itemCount: number; + pageSize: number; } export const InfiniteScroller = ({ @@ -19,40 +20,38 @@ export const InfiniteScroller = ({ fetchMore, hasMore, itemCount, + pageSize, }: InfiniteScrollerProps) => { const { t } = useTranslation(); - // Track how many items were known at time of triggering the fetch. - // This allows to detect edge case when second(or more) fetchMore() is triggered before - // IntersectionObserver is able to detect out-of-view event. - // Initializing with zero ensures that the effect will be triggered immediately - // (parent is expected to display empty state until some items are available). - const itemCountRef = useRef(0); + const [readyForFetch, setReadyForFetch] = useState(false); const { visible: isSentinelVisible, nodeRef: sentinelRef } = useVisibilityTracker({ enable: hasMore, }); + useEffect(() => { + // enable or clear the flag depending on the sentinel visibility + setReadyForFetch(!!isSentinelVisible); + }, [isSentinelVisible]); - useEffect( - () => { - if ( - isSentinelVisible && - itemCountRef.current !== itemCount && - fetchMore() // fetch may be blocked if background refresh is in progress (or other manual fetch) - ) { - // fetchMore call was triggered (it may fail but will be subject to React Query retry policy) - itemCountRef.current = itemCount; - } - }, + useEffect(() => { + if (readyForFetch) { + // clear the flag if fetch request is accepted + setReadyForFetch(!fetchMore()); + } // reference to fetchMore() changes based on query state and ensures that the effect is triggered in the right moment // i.e. after fetch triggered by the previous fetchMore() call finished - [isSentinelVisible, fetchMore, itemCount] - ); + }, [fetchMore, readyForFetch]); return (
{children} {hasMore && ( -
+
{t("message.loadingTripleDot")}
)} diff --git a/client/src/app/components/InfiniteScroller/useVisibilityTracker.tsx b/client/src/app/components/InfiniteScroller/useVisibilityTracker.tsx index c6f8135b24..c30b42d7d0 100644 --- a/client/src/app/components/InfiniteScroller/useVisibilityTracker.tsx +++ b/client/src/app/components/InfiniteScroller/useVisibilityTracker.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; export function useVisibilityTracker({ enable }: { enable: boolean }) { const nodeRef = useRef(null); - const [visible, setVisible] = useState(false); + const [visible, setVisible] = useState(false); const node = nodeRef.current; // state is set from IntersectionObserver callbacks which may not align with React lifecycle @@ -22,14 +22,19 @@ export function useVisibilityTracker({ enable }: { enable: boolean }) { }, []); useEffect(() => { + if (enable && !node) { + // use falsy value different than initial value - state change will trigger render() + // otherwise we need to wait for the next render() to read node ref + setVisibleSafe(undefined); + return undefined; + } + if (!enable || !node) { return undefined; } // Observer with default options - the whole view port used. // Note that if root element is used then it needs to be the ancestor of the target. - // In case of infinite scroller the target is always within the (scrollable!)parent - // even if the node is technically hidden from the user. // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#root const observer = new IntersectionObserver( (entries: IntersectionObserverEntry[]) => diff --git a/client/src/app/components/task-manager/TaskManagerDrawer.tsx b/client/src/app/components/task-manager/TaskManagerDrawer.tsx index 4a1faf69fd..10824bc16e 100644 --- a/client/src/app/components/task-manager/TaskManagerDrawer.tsx +++ b/client/src/app/components/task-manager/TaskManagerDrawer.tsx @@ -66,7 +66,8 @@ interface TaskManagerDrawerProps { export const TaskManagerDrawer: React.FC = forwardRef( (_props, ref) => { const { isExpanded, setIsExpanded, queuedCount } = useTaskManagerContext(); - const { tasks, hasNextPage, fetchNextPage } = useTaskManagerData(); + const { tasks, hasNextPage, fetchNextPage, pageSize } = + useTaskManagerData(); const [expandedItems, setExpandedItems] = useState([]); const [taskWithExpandedActions, setTaskWithExpandedAction] = useState< @@ -106,6 +107,7 @@ export const TaskManagerDrawer: React.FC = forwardRef( fetchMore={fetchNextPage} hasMore={hasNextPage} itemCount={tasks?.length ?? 0} + pageSize={pageSize} > {tasks.map((task) => ( @@ -282,6 +284,7 @@ const useTaskManagerData = () => { [data] ); + // note that the callback will change when query fetching state changes const fetchMore = useCallback(() => { // forced fetch is not allowed when background fetch or other forced fetch is in progress if (!isFetching && !isFetchingNextPage) { @@ -297,6 +300,6 @@ const useTaskManagerData = () => { isFetching, hasNextPage, fetchNextPage: fetchMore, - isReadyToFetch: !isFetching && !isFetchingNextPage, + pageSize: PAGE_SIZE, }; }; From f389539cb2c6ec7720dad107227dc9edcbd77ca7 Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Sat, 14 Sep 2024 17:37:13 +0200 Subject: [PATCH 7/7] :bug: Support custom serializers in table hooks (#2079) Changes: 1. add persistence provider to feature level persistence (IFeaturePersistenceArgs) but not to table layer persistence (ITablePersistenceArgs). This simplifies the provider code (feature-specific providers). 2. remove meta-persistence target "default" - no target has the same effect. 3. when in persistence provider mode use default values when the deserialized value is nulish. This solves the problem of initialFilterValues being overwritten in target cards scenario (the hook is called more then once and useState() based logic fails to detect initial load). Using the newly added feature, store the filters selected in the Target step (Analysis wizard) inside react-hook form in the same way as other values. Resolves: https://issues.redhat.com/browse/MTA-3438 Signed-off-by: Radoslaw Szwajkowski Co-authored-by: Scott Dickerson --- .../active-item/useActiveItemState.ts | 10 ++++- .../expansion/useExpansionState.ts | 14 +++++-- .../filtering/useFilterState.ts | 16 ++++++-- .../pagination/usePaginationState.ts | 12 ++++-- .../table-controls/sorting/useSortState.ts | 15 +++++-- client/src/app/hooks/table-controls/types.ts | 17 +++++++- .../table-controls/useTableControlState.ts | 36 +++++++++++----- client/src/app/hooks/usePersistentState.ts | 41 ++++++++++++++++++- .../analysis-wizard/analysis-wizard.tsx | 2 + .../applications/analysis-wizard/schema.ts | 2 + .../analysis-wizard/set-targets.tsx | 18 ++++++-- 11 files changed, 150 insertions(+), 33 deletions(-) diff --git a/client/src/app/hooks/table-controls/active-item/useActiveItemState.ts b/client/src/app/hooks/table-controls/active-item/useActiveItemState.ts index f0e3f3626f..54988da75d 100644 --- a/client/src/app/hooks/table-controls/active-item/useActiveItemState.ts +++ b/client/src/app/hooks/table-controls/active-item/useActiveItemState.ts @@ -1,5 +1,5 @@ import { parseMaybeNumericString } from "@app/utils/utils"; -import { IFeaturePersistenceArgs } from "../types"; +import { IFeaturePersistenceArgs, isPersistenceProvider } from "../types"; import { usePersistentState } from "@app/hooks/usePersistentState"; /** @@ -76,7 +76,13 @@ export const useActiveItemState = < persistTo, key: "activeItem", } - : { persistTo }), + : isPersistenceProvider(persistTo) + ? { + persistTo: "provider", + serialize: persistTo.write, + deserialize: () => persistTo.read() as string | number | null, + } + : { persistTo: "state" }), }); return { activeItemId, setActiveItemId }; }; diff --git a/client/src/app/hooks/table-controls/expansion/useExpansionState.ts b/client/src/app/hooks/table-controls/expansion/useExpansionState.ts index ac3a873bbf..848e62d12b 100644 --- a/client/src/app/hooks/table-controls/expansion/useExpansionState.ts +++ b/client/src/app/hooks/table-controls/expansion/useExpansionState.ts @@ -1,6 +1,6 @@ import { usePersistentState } from "@app/hooks/usePersistentState"; import { objectKeys } from "@app/utils/utils"; -import { IFeaturePersistenceArgs } from "../types"; +import { IFeaturePersistenceArgs, isPersistenceProvider } from "../types"; import { DiscriminatedArgs } from "@app/utils/type-utils"; /** @@ -93,7 +93,9 @@ export const useExpansionState = < ? { persistTo, keys: ["expandedCells"], - serialize: (expandedCellsObj) => { + serialize: ( + expandedCellsObj: Partial> + ) => { if (!expandedCellsObj || objectKeys(expandedCellsObj).length === 0) return { expandedCells: null }; return { expandedCells: JSON.stringify(expandedCellsObj) }; @@ -111,7 +113,13 @@ export const useExpansionState = < persistTo, key: "expandedCells", } - : { persistTo }), + : isPersistenceProvider(persistTo) + ? { + persistTo: "provider", + serialize: persistTo.write, + deserialize: () => persistTo.read() as TExpandedCells, + } + : { persistTo: "state" }), }); return { expandedCells, setExpandedCells }; }; diff --git a/client/src/app/hooks/table-controls/filtering/useFilterState.ts b/client/src/app/hooks/table-controls/filtering/useFilterState.ts index 2de43e1f7c..e52a43c28f 100644 --- a/client/src/app/hooks/table-controls/filtering/useFilterState.ts +++ b/client/src/app/hooks/table-controls/filtering/useFilterState.ts @@ -1,5 +1,5 @@ import { FilterCategory, IFilterValues } from "@app/components/FilterToolbar"; -import { IFeaturePersistenceArgs } from "../types"; +import { IFeaturePersistenceArgs, isPersistenceProvider } from "../types"; import { usePersistentState } from "@app/hooks/usePersistentState"; import { serializeFilterUrlParams } from "./helpers"; import { deserializeFilterUrlParams } from "./helpers"; @@ -90,7 +90,6 @@ export const useFilterState = < "filters" >({ isEnabled: !!isFilterEnabled, - defaultValue: initialFilterValues, persistenceKeyPrefix, // Note: For the discriminated union here to work without TypeScript getting confused // (e.g. require the urlParams-specific options when persistTo === "urlParams"), @@ -99,12 +98,21 @@ export const useFilterState = < ? { persistTo, keys: ["filters"], + defaultValue: initialFilterValues, serialize: serializeFilterUrlParams, deserialize: deserializeFilterUrlParams, } : persistTo === "localStorage" || persistTo === "sessionStorage" - ? { persistTo, key: "filters" } - : { persistTo }), + ? { persistTo, key: "filters", defaultValue: initialFilterValues } + : isPersistenceProvider(persistTo) + ? { + persistTo: "provider", + serialize: persistTo.write, + deserialize: () => + persistTo.read() as IFilterValues, + defaultValue: isFilterEnabled ? args?.initialFilterValues ?? {} : {}, + } + : { persistTo: "state", defaultValue: initialFilterValues }), }); return { filterValues, setFilterValues }; }; diff --git a/client/src/app/hooks/table-controls/pagination/usePaginationState.ts b/client/src/app/hooks/table-controls/pagination/usePaginationState.ts index 6fd87c84b1..61f8b6e981 100644 --- a/client/src/app/hooks/table-controls/pagination/usePaginationState.ts +++ b/client/src/app/hooks/table-controls/pagination/usePaginationState.ts @@ -1,5 +1,5 @@ import { usePersistentState } from "@app/hooks/usePersistentState"; -import { IFeaturePersistenceArgs } from "../types"; +import { IFeaturePersistenceArgs, isPersistenceProvider } from "../types"; import { DiscriminatedArgs } from "@app/utils/type-utils"; /** @@ -94,7 +94,7 @@ export const usePaginationState = < ? { persistTo, keys: ["pageNumber", "itemsPerPage"], - serialize: (state) => { + serialize: (state: Partial) => { const { pageNumber, itemsPerPage } = state || {}; return { pageNumber: pageNumber ? String(pageNumber) : undefined, @@ -116,7 +116,13 @@ export const usePaginationState = < persistTo, key: "pagination", } - : { persistTo }), + : isPersistenceProvider(persistTo) + ? { + persistTo: "provider", + serialize: persistTo.write, + deserialize: () => persistTo.read() as IActivePagination, + } + : { persistTo: "state" }), }); const { pageNumber, itemsPerPage } = paginationState || defaultValue; const setPageNumber = (num: number) => diff --git a/client/src/app/hooks/table-controls/sorting/useSortState.ts b/client/src/app/hooks/table-controls/sorting/useSortState.ts index fc583142e9..91d2840a1b 100644 --- a/client/src/app/hooks/table-controls/sorting/useSortState.ts +++ b/client/src/app/hooks/table-controls/sorting/useSortState.ts @@ -1,5 +1,5 @@ import { DiscriminatedArgs } from "@app/utils/type-utils"; -import { IFeaturePersistenceArgs } from ".."; +import { IFeaturePersistenceArgs, isPersistenceProvider } from ".."; import { usePersistentState } from "@app/hooks/usePersistentState"; /** @@ -96,7 +96,9 @@ export const useSortState = < ? { persistTo, keys: ["sortColumn", "sortDirection"], - serialize: (activeSort) => ({ + serialize: ( + activeSort: Partial | null> + ) => ({ sortColumn: activeSort?.columnKey || null, sortDirection: activeSort?.direction || null, }), @@ -113,7 +115,14 @@ export const useSortState = < persistTo, key: "sort", } - : { persistTo }), + : isPersistenceProvider(persistTo) + ? { + persistTo: "provider", + serialize: persistTo.write, + deserialize: () => + persistTo.read() as IActiveSort | null, + } + : { persistTo: "state" }), }); return { activeSort, setActiveSort }; }; diff --git a/client/src/app/hooks/table-controls/types.ts b/client/src/app/hooks/table-controls/types.ts index d2293c2348..38b8e00b8a 100644 --- a/client/src/app/hooks/table-controls/types.ts +++ b/client/src/app/hooks/table-controls/types.ts @@ -64,6 +64,17 @@ export type TableFeature = | "activeItem" | "columns"; +export interface PersistenceProvider { + write: (value: T) => void; + read: () => T; +} + +export const isPersistenceProvider = ( + persistTo?: PersistTarget | PersistenceProvider +): persistTo is PersistenceProvider => + !!(persistTo as PersistenceProvider)?.write && + !!(persistTo as PersistenceProvider)?.read; + /** * Identifier for where to persist state for a single table feature or for all table features. * - "state" (default) - Plain React state. Resets on component unmount or page reload. @@ -106,7 +117,7 @@ export type IFeaturePersistenceArgs< /** * Where to persist state for this feature. */ - persistTo?: PersistTarget; + persistTo?: PersistTarget | PersistenceProvider; }; export interface ColumnSetting { @@ -131,7 +142,9 @@ export type ITablePersistenceArgs< */ persistTo?: | PersistTarget - | Partial>; + | Partial< + Record> + >; }; /** diff --git a/client/src/app/hooks/table-controls/useTableControlState.ts b/client/src/app/hooks/table-controls/useTableControlState.ts index 7e9d1ca651..85b36543e2 100644 --- a/client/src/app/hooks/table-controls/useTableControlState.ts +++ b/client/src/app/hooks/table-controls/useTableControlState.ts @@ -1,7 +1,8 @@ import { + IFeaturePersistenceArgs, ITableControlState, + ITablePersistenceArgs, IUseTableControlStateArgs, - PersistTarget, TableFeature, } from "./types"; import { useFilterState } from "./filtering"; @@ -11,6 +12,21 @@ import { useActiveItemState } from "./active-item"; import { useExpansionState } from "./expansion"; import { useColumnState } from "./column/useColumnState"; +const getPersistTo = ({ + feature, + persistTo, +}: { + feature: TableFeature; + persistTo: ITablePersistenceArgs["persistTo"]; +}): { + persistTo: IFeaturePersistenceArgs["persistTo"]; +} => ({ + persistTo: + !persistTo || typeof persistTo === "string" + ? persistTo + : persistTo[feature], +}); + /** * Provides the "source of truth" state for all table features. * - State can be persisted in one or more configurable storage targets, either the same for the entire table or different targets per feature. @@ -41,31 +57,29 @@ export const useTableControlState = < TFilterCategoryKey, TPersistenceKeyPrefix > => { - const getPersistTo = (feature: TableFeature): PersistTarget | undefined => - !args.persistTo || typeof args.persistTo === "string" - ? args.persistTo - : args.persistTo[feature] || args.persistTo.default; - const filterState = useFilterState< TItem, TFilterCategoryKey, TPersistenceKeyPrefix - >({ ...args, persistTo: getPersistTo("filter") }); + >({ + ...args, + ...getPersistTo({ feature: "filter", persistTo: args.persistTo }), + }); const sortState = useSortState({ ...args, - persistTo: getPersistTo("sort"), + ...getPersistTo({ feature: "sort", persistTo: args.persistTo }), }); const paginationState = usePaginationState({ ...args, - persistTo: getPersistTo("pagination"), + ...getPersistTo({ persistTo: args.persistTo, feature: "pagination" }), }); const expansionState = useExpansionState({ ...args, - persistTo: getPersistTo("expansion"), + ...getPersistTo({ persistTo: args.persistTo, feature: "expansion" }), }); const activeItemState = useActiveItemState({ ...args, - persistTo: getPersistTo("activeItem"), + ...getPersistTo({ persistTo: args.persistTo, feature: "activeItem" }), }); const { columnNames, tableName, initialColumns } = args; diff --git a/client/src/app/hooks/usePersistentState.ts b/client/src/app/hooks/usePersistentState.ts index ccd50c452a..5dd6045fac 100644 --- a/client/src/app/hooks/usePersistentState.ts +++ b/client/src/app/hooks/usePersistentState.ts @@ -9,7 +9,15 @@ import { DisallowCharacters } from "@app/utils/type-utils"; type PersistToStateOptions = { persistTo?: "state" }; -type PersistToUrlParamsOptions< +type PersistToProvider = { + persistTo: "provider"; + defaultValue: TValue; + isEnabled?: boolean; + serialize: (params: TValue) => void; + deserialize: () => TValue; +}; + +export type PersistToUrlParamsOptions< TValue, TPersistenceKeyPrefix extends string, TURLParamKey extends string, @@ -33,6 +41,7 @@ export type UsePersistentStateOptions< | PersistToStateOptions | PersistToUrlParamsOptions | PersistToStorageOptions + | PersistToProvider ); export const usePersistentState = < @@ -92,7 +101,37 @@ export const usePersistentState = < ? { ...options, key: prefixKey(options.key) } : { ...options, isEnabled: false, key: "" } ), + provider: usePersistenceProvider( + isPersistenceProviderOptions(options) + ? options + : { + serialize: () => {}, + deserialize: () => defaultValue, + defaultValue, + isEnabled: false, + persistTo: "provider", + } + ), }; const [value, setValue] = persistence[persistTo || "state"]; return isEnabled ? [value, setValue] : [defaultValue, () => {}]; }; + +const usePersistenceProvider = ({ + serialize, + deserialize, + defaultValue, +}: PersistToProvider): [TValue, (val: TValue) => void] => { + // use default value if nulish value was deserialized + return [deserialize() ?? defaultValue, serialize]; +}; + +export const isPersistenceProviderOptions = < + TValue, + TPersistenceKeyPrefix extends string, + TURLParamKey extends string, +>( + o: Partial< + UsePersistentStateOptions + > +): o is PersistToProvider => o.persistTo === "provider"; diff --git a/client/src/app/pages/applications/analysis-wizard/analysis-wizard.tsx b/client/src/app/pages/applications/analysis-wizard/analysis-wizard.tsx index a432b85be9..05541b89b1 100644 --- a/client/src/app/pages/applications/analysis-wizard/analysis-wizard.tsx +++ b/client/src/app/pages/applications/analysis-wizard/analysis-wizard.tsx @@ -166,6 +166,8 @@ export const AnalysisWizard: React.FC = ({ mode: "source-code-deps", formLabels: [], selectedTargets: [], + // defaults will be passed as initialFilterValues to the table hook + targetFilters: undefined, selectedSourceLabels: [], withKnownLibs: "app", includedPackages: [], diff --git a/client/src/app/pages/applications/analysis-wizard/schema.ts b/client/src/app/pages/applications/analysis-wizard/schema.ts index 770dbff5a2..a1294e7f23 100644 --- a/client/src/app/pages/applications/analysis-wizard/schema.ts +++ b/client/src/app/pages/applications/analysis-wizard/schema.ts @@ -57,12 +57,14 @@ const useModeStepSchema = ({ export interface TargetsStepValues { formLabels: TargetLabel[]; selectedTargets: Target[]; + targetFilters?: Record; } const useTargetsStepSchema = (): yup.SchemaOf => { return yup.object({ formLabels: yup.array(), selectedTargets: yup.array(), + targetFilters: yup.object(), }); }; diff --git a/client/src/app/pages/applications/analysis-wizard/set-targets.tsx b/client/src/app/pages/applications/analysis-wizard/set-targets.tsx index 75d5730647..3b255d375e 100644 --- a/client/src/app/pages/applications/analysis-wizard/set-targets.tsx +++ b/client/src/app/pages/applications/analysis-wizard/set-targets.tsx @@ -101,7 +101,7 @@ interface SetTargetsInternalProps { isLoading: boolean; isError: boolean; languageProviders: string[]; - initialFilters: string[]; + applicationProviders: string[]; } const SetTargetsInternal: React.FC = ({ @@ -109,7 +109,7 @@ const SetTargetsInternal: React.FC = ({ isLoading, isError, languageProviders, - initialFilters = [], + applicationProviders = [], }) => { const { t } = useTranslation(); @@ -177,13 +177,23 @@ const SetTargetsInternal: React.FC = ({ tableName: "target-cards", items: targets, idProperty: "name", - initialFilterValues: { name: initialFilters }, + initialFilterValues: { name: applicationProviders }, columnNames: { name: "name", }, isFilterEnabled: true, isPaginationEnabled: false, isLoading, + persistTo: { + filter: { + write(value) { + setValue("targetFilters", value as Record); + }, + read() { + return getValues().targetFilters; + }, + }, + }, filterCategories: [ { selectOptions: languageProviders?.map((language) => ({ @@ -281,7 +291,7 @@ export const SetTargets: React.FC = ({ applications }) => { return (