diff --git a/client/package.json b/client/package.json index 62c53bbb8..3670d8c76 100644 --- a/client/package.json +++ b/client/package.json @@ -41,6 +41,7 @@ "git-url-parse": "^16.1.0", "i18next": "^25.3.0", "i18next-http-backend": "^3.0.2", + "immer": "^10.1.3", "js-yaml": "^4.1.0", "keycloak-js": "^26.1.0", "monaco-editor": "^0.52.2", @@ -54,6 +55,7 @@ "react-measure": "^2.5.2", "react-router-dom": "^5.2.0", "tinycolor2": "^1.6.0", + "use-immer": "^0.11.0", "xmllint-wasm": "^5.0.0", "yup": "^0.32.11" }, diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index 6c3b2add4..ecb898cc0 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -36,7 +36,6 @@ "discardAssessment": "Discard assessment(s)", "discardReview": "Discard review", "discoverApplications": "Discover applications", - "discoverImport": "Discover & Import", "downloadCsvTemplate": "Download CSV template", "download": "Download {{what}}", "duplicate": "Duplicate", @@ -45,11 +44,12 @@ "export": "Export", "filterBy": "Filter by {{what}}", "import": "Import", + "importFromCsv": "Import applications from CSV", "logout": "Logout", "manageAccount": "User management", "manageCredentials": "Manage credentials", "manageDependencies": "Manage dependencies", - "manageImports": "Manage imports", + "manageApplicationImports": "Manage application imports", "manageTargetProfiles": "Manage target profiles", "next": "Next", "override": "Override", @@ -749,12 +749,18 @@ }, "platformDiscoverWizard": { "title": "Discover Applications", - "description": "Discover and import applications from the selected platform.", + "description": "Discover and import applications from a source platform.", "noPlatformSelected": "No platform selected for discovery.", + "noPlatformSelectedDescription": "A source platform must be selected to continue.", "toast": { "submittedOk": "Platform application discovery task submitted", "submittedFailed": "Platform application discovery task failed" }, + "platformSelect": { + "stepTitle": "Platform", + "title": "Select a platform", + "description": "Select a platform to access for application discovery and import." + }, "filterInput": { "stepTitle": "Filters", "title": "Configure Discovery Filters", diff --git a/client/src/app/pages/source-platforms/discover-import-wizard/discover-import-wizard.tsx b/client/src/app/components/discover-import-wizard/discover-import-wizard.tsx similarity index 69% rename from client/src/app/pages/source-platforms/discover-import-wizard/discover-import-wizard.tsx rename to client/src/app/components/discover-import-wizard/discover-import-wizard.tsx index 1ae4073e5..0b3392cde 100644 --- a/client/src/app/pages/source-platforms/discover-import-wizard/discover-import-wizard.tsx +++ b/client/src/app/components/discover-import-wizard/discover-import-wizard.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { - Button, Modal, ModalVariant, Wizard, @@ -16,6 +15,8 @@ import { universalComparator } from "@app/utils/utils"; import { FilterInput } from "./filter-input"; import { Results } from "./results"; import { Review } from "./review"; +import { SelectPlatform } from "./select-platform"; +import { SourcePlatformRequired } from "./source-platform-required"; import { useStartPlatformApplicationImport } from "./useStartPlatformApplicationImport"; import { useWizardReducer } from "./useWizardReducer"; @@ -43,15 +44,18 @@ export interface IDiscoverImportWizard { } const DiscoverImportWizardInner: React.FC = ({ - platform, + platform: initialPlatform, onClose, isOpen, }: IDiscoverImportWizard) => { const { t } = useTranslation(); const { pushNotification } = React.useContext(NotificationsContext); const { submitTask } = useStartPlatformApplicationImport(); - const { state, setFilters, setResults, reset } = useWizardReducer(); - const { results, filters } = state; + const { state, setPlatform, setFilters, setResults, reset } = + useWizardReducer((initial) => { + initial.platform = initialPlatform ?? null; + }); + const { platform, results, filters, isReady } = state; const handleCancel = () => { reset(); @@ -89,26 +93,6 @@ const DiscoverImportWizardInner: React.FC = ({ } }; - if (!platform) { - return ( - - {t("actions.close")} - - } - > -
-

{t("platformDiscoverWizard.noPlatformSelected")}

-
-
- ); - } - return ( = ({ } isVisitRequired > + {!initialPlatform ? ( + + + + ) : null} + - + {!platform ? ( + + ) : ( + + )} - {!results ? ( + {!platform ? ( + + ) : null} + + {platform && !results ? ( - ) : ( - - )} + ) : null} + + {platform && results ? : null} diff --git a/client/src/app/pages/source-platforms/discover-import-wizard/filter-input-cloudfoundry.tsx b/client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx similarity index 95% rename from client/src/app/pages/source-platforms/discover-import-wizard/filter-input-cloudfoundry.tsx rename to client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx index 206ea04cd..690089454 100644 --- a/client/src/app/pages/source-platforms/discover-import-wizard/filter-input-cloudfoundry.tsx +++ b/client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx @@ -157,23 +157,12 @@ export const FilterInputCloudFoundry: React.FC< names: formValueToStrings(values.names), spaces: formValueToStrings(values.spaces), }; - console.log("subscription document", asDocument); onDocumentChanged(asDocument); }, }); return () => subscription(); }, [subscribe, onDocumentChanged]); - // useFormUpdateHandler(form, onDocumentChanged); - - // // Initialize from values prop - // useEffect(() => { - // reset({ - // names: (values?.names as string[]) || [], - // spaces: (values?.spaces as string[]) || [], - // }); - // }, [values, reset]); - return ( diff --git a/client/src/app/pages/source-platforms/discover-import-wizard/filter-input.tsx b/client/src/app/components/discover-import-wizard/filter-input.tsx similarity index 98% rename from client/src/app/pages/source-platforms/discover-import-wizard/filter-input.tsx rename to client/src/app/components/discover-import-wizard/filter-input.tsx index 8d56b13fd..0d0df1733 100644 --- a/client/src/app/pages/source-platforms/discover-import-wizard/filter-input.tsx +++ b/client/src/app/components/discover-import-wizard/filter-input.tsx @@ -9,11 +9,10 @@ import { JsonDocument, SourcePlatform, TargetedSchema } from "@app/api/models"; import { HookFormPFGroupController } from "@app/components/HookFormPFFields"; import { SchemaDefinedField } from "@app/components/schema-defined-fields"; import { jsonSchemaToYupSchema } from "@app/components/schema-defined-fields/utils"; +import { usePlatformKindList } from "@app/hooks/usePlatformKindList"; import { useFetchPlatformDiscoveryFilterSchema } from "@app/queries/schemas"; import { wrapAsEvent } from "@app/utils/utils"; -import { usePlatformKindList } from "../usePlatformKindList"; - import { FilterInputCloudFoundry } from "./filter-input-cloudfoundry"; interface FiltersFormValues { diff --git a/client/src/app/components/discover-import-wizard/index.ts b/client/src/app/components/discover-import-wizard/index.ts new file mode 100644 index 000000000..3a7eca268 --- /dev/null +++ b/client/src/app/components/discover-import-wizard/index.ts @@ -0,0 +1 @@ +export * from "./discover-import-wizard"; diff --git a/client/src/app/pages/source-platforms/discover-import-wizard/results.tsx b/client/src/app/components/discover-import-wizard/results.tsx similarity index 100% rename from client/src/app/pages/source-platforms/discover-import-wizard/results.tsx rename to client/src/app/components/discover-import-wizard/results.tsx diff --git a/client/src/app/pages/source-platforms/discover-import-wizard/review-input-cloudfoundry.tsx b/client/src/app/components/discover-import-wizard/review-input-cloudfoundry.tsx similarity index 100% rename from client/src/app/pages/source-platforms/discover-import-wizard/review-input-cloudfoundry.tsx rename to client/src/app/components/discover-import-wizard/review-input-cloudfoundry.tsx diff --git a/client/src/app/pages/source-platforms/discover-import-wizard/review.tsx b/client/src/app/components/discover-import-wizard/review.tsx similarity index 98% rename from client/src/app/pages/source-platforms/discover-import-wizard/review.tsx rename to client/src/app/components/discover-import-wizard/review.tsx index 623674226..652496897 100644 --- a/client/src/app/pages/source-platforms/discover-import-wizard/review.tsx +++ b/client/src/app/components/discover-import-wizard/review.tsx @@ -12,8 +12,7 @@ import { import { SourcePlatform } from "@app/api/models"; import { EmptyTextMessage } from "@app/components/EmptyTextMessage"; import { SchemaDefinedField } from "@app/components/schema-defined-fields"; - -import { usePlatformKindList } from "../usePlatformKindList"; +import { usePlatformKindList } from "@app/hooks/usePlatformKindList"; import { FilterState } from "./filter-input"; import { ReviewInputCloudFoundry } from "./review-input-cloudfoundry"; @@ -24,6 +23,7 @@ export const Review: React.FC<{ }> = ({ platform, filters }) => { const { t } = useTranslation(); const { getDisplayLabel } = usePlatformKindList(); + const showFilters = filters.filterRequired && filters.schema && filters.document; diff --git a/client/src/app/components/discover-import-wizard/select-platform.tsx b/client/src/app/components/discover-import-wizard/select-platform.tsx new file mode 100644 index 000000000..fc7e42aaa --- /dev/null +++ b/client/src/app/components/discover-import-wizard/select-platform.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Form, Text, TextContent } from "@patternfly/react-core"; + +import { DEFAULT_SELECT_MAX_HEIGHT } from "@app/Constants"; +import { SourcePlatform } from "@app/api/models"; +import { useFetchPlatforms } from "@app/queries/platforms"; +import { toOptionLike } from "@app/utils/model-utils"; + +import { OptionWithValue, SimpleSelect } from "../SimpleSelect"; + +export interface SelectPlatformProps { + platform: SourcePlatform | null; + onPlatformSelected: (platform: SourcePlatform | null) => void; +} + +export const SelectPlatform: React.FC = ({ + platform, + onPlatformSelected, +}) => { + const { t } = useTranslation(); + const { platforms, isLoading } = useFetchPlatforms(); + const platformOptions = React.useMemo( + () => + platforms.map((platform) => ({ + value: platform, + toString: () => platform.name, + })), + [platforms] + ); + + return ( +
+ + + {t("platformDiscoverWizard.platformSelect.title")} + + + {t("platformDiscoverWizard.platformSelect.description")} + + + +
+ { + const selectionValue = selection as OptionWithValue; + onPlatformSelected(selectionValue.value); + }} + onClear={() => onPlatformSelected(null)} + /> + +
+ ); +}; diff --git a/client/src/app/components/discover-import-wizard/source-platform-required.tsx b/client/src/app/components/discover-import-wizard/source-platform-required.tsx new file mode 100644 index 000000000..cb08157a1 --- /dev/null +++ b/client/src/app/components/discover-import-wizard/source-platform-required.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Text, TextContent } from "@patternfly/react-core"; + +export const SourcePlatformRequired: React.FC<{ + title: string; +}> = ({ title }) => { + const { t } = useTranslation(); + + return ( +
+ + {title} + + {t("platformDiscoverWizard.noPlatformSelectedDescription")} + + +
+ ); +}; diff --git a/client/src/app/pages/source-platforms/discover-import-wizard/useStartPlatformApplicationImport.ts b/client/src/app/components/discover-import-wizard/useStartPlatformApplicationImport.ts similarity index 100% rename from client/src/app/pages/source-platforms/discover-import-wizard/useStartPlatformApplicationImport.ts rename to client/src/app/components/discover-import-wizard/useStartPlatformApplicationImport.ts diff --git a/client/src/app/components/discover-import-wizard/useWizardReducer.ts b/client/src/app/components/discover-import-wizard/useWizardReducer.ts new file mode 100644 index 000000000..9665a6c70 --- /dev/null +++ b/client/src/app/components/discover-import-wizard/useWizardReducer.ts @@ -0,0 +1,116 @@ +import { useCallback, useRef } from "react"; +import { produce } from "immer"; +import { useImmerReducer } from "use-immer"; + +import { SourcePlatform } from "@app/api/models"; + +import { FilterState } from "./filter-input"; +import { ResultsData } from "./results"; + +export interface WizardState { + platform: SourcePlatform | null; + filters: FilterState; + isReady: boolean; + results: ResultsData | null; +} + +const INITIAL_WIZARD_STATE: WizardState = { + platform: null, + filters: { + filterRequired: true, + isValid: false, + }, + isReady: false, + results: null, +}; + +type WizardReducer = (draft: WizardState, action?: WizardAction) => void; +type WizardAction = + | { type: "SET_PLATFORM"; payload: SourcePlatform | null } + | { type: "SET_FILTERS"; payload: FilterState } + | { type: "SET_RESULTS"; payload: ResultsData | null } + | { type: "RESET"; payload: WizardState }; + +const updateIsReady = (draft: WizardState) => { + draft.isReady = !!draft.platform && draft.filters.isValid; + return draft; +}; + +const wizardReducer: WizardReducer = (draft, action) => { + if (action) { + switch (action.type) { + case "SET_PLATFORM": + draft.platform = action.payload; + break; + case "SET_FILTERS": + draft.filters = action.payload; + break; + case "SET_RESULTS": + draft.results = action.payload; + break; + case "RESET": + return updateIsReady(action.payload); + } + } + + // Validate and update isReady state after any change + updateIsReady(draft); +}; + +export type InitialStateRecipe = (draftInitialState: WizardState) => void; + +const useImmerInitialState = ( + initialRecipe?: InitialStateRecipe +): WizardState => { + const initialRef = useRef(null); + if (initialRef.current === null) { + initialRef.current = produce(INITIAL_WIZARD_STATE, (draft) => { + initialRecipe?.(draft); + wizardReducer(draft); + }); + } + + return initialRef.current; +}; + +export const useWizardReducer = (init?: InitialStateRecipe) => { + // Ref: https://18.react.dev/reference/react/useReducer#avoiding-recreating-the-initial-state + // Allow RESET to have the same semantics as useReducer()'s initialState argument by just + // calculating the initial state once and storing it in a ref. + const firstInitialState = useImmerInitialState(init); + + const [state, dispatch] = useImmerReducer(wizardReducer, firstInitialState); + + const setPlatform = useCallback( + (platform: SourcePlatform | null) => { + dispatch({ type: "SET_PLATFORM", payload: platform }); + }, + [dispatch] + ); + + const setFilters = useCallback( + (filters: FilterState) => { + dispatch({ type: "SET_FILTERS", payload: filters }); + }, + [dispatch] + ); + + const setResults = useCallback( + (results: ResultsData | null) => { + dispatch({ type: "SET_RESULTS", payload: results }); + }, + [dispatch] + ); + + const reset = useCallback(() => { + dispatch({ type: "RESET", payload: firstInitialState }); + }, [firstInitialState, dispatch]); + + return { + state, + setPlatform, + setFilters, + setResults, + reset, + }; +}; diff --git a/client/src/app/pages/source-platforms/usePlatformKindList.ts b/client/src/app/hooks/usePlatformKindList.ts similarity index 100% rename from client/src/app/pages/source-platforms/usePlatformKindList.ts rename to client/src/app/hooks/usePlatformKindList.ts diff --git a/client/src/app/hooks/useRepositoryKind.ts b/client/src/app/hooks/useRepositoryKind.ts index c26358fb2..122db8f44 100644 --- a/client/src/app/hooks/useRepositoryKind.ts +++ b/client/src/app/hooks/useRepositoryKind.ts @@ -5,9 +5,9 @@ import { OptionWithValue } from "@app/components/SimpleSelect"; export type RepositoryKind = "git" | "subversion" | "" | null; -export const KIND_META: Map = new Map([ - ["git", { i18nKey: "repositoryKind.git" }], - ["subversion", { i18nKey: "repositoryKind.subversion" }], +export const KIND_META: Map = new Map([ + ["git", { labelKey: "repositoryKind.git" }], + ["subversion", { labelKey: "repositoryKind.subversion" }], ]); export const useRepositoryKind = () => { @@ -17,7 +17,7 @@ export const useRepositoryKind = () => { () => Array.from(KIND_META.entries()).map(([key, meta]) => ({ value: key, - toString: () => t(meta.i18nKey), + toString: () => t(meta.labelKey), })), [t] ); diff --git a/client/src/app/pages/applications/applications-table/applications-table.tsx b/client/src/app/pages/applications/applications-table/applications-table.tsx index 1e9721e73..93ff441d1 100644 --- a/client/src/app/pages/applications/applications-table/applications-table.tsx +++ b/client/src/app/pages/applications/applications-table/applications-table.tsx @@ -56,6 +56,7 @@ import { TableRowContentWithControls, } from "@app/components/TableControls"; import { ToolbarBulkSelector } from "@app/components/ToolbarBulkSelector"; +import { DiscoverImportWizard } from "@app/components/discover-import-wizard"; import { useBulkSelection } from "@app/hooks/selection/useBulkSelection"; import { useLocalTableControls } from "@app/hooks/table-controls"; import keycloak from "@app/keycloak"; @@ -207,6 +208,9 @@ export const ApplicationsTable: React.FC = () => { const [isApplicationImportModalOpen, setIsApplicationImportModalOpen] = useState(false); + const [isDiscoverImportWizardOpen, setIsDiscoverImportWizardOpen] = + useState(false); + // ----- Table data fetches and mutations const { tagItems } = useFetchTagsWithTagItems(); @@ -696,7 +700,7 @@ export const ApplicationsTable: React.FC = () => { component="button" onClick={() => setIsApplicationImportModalOpen(true)} > - {t("actions.import")} + {t("actions.importFromCsv")} ), importWriteAccess && ( @@ -706,7 +710,15 @@ export const ApplicationsTable: React.FC = () => { history.push(Paths.applicationsImports); }} > - {t("actions.manageImports")} + {t("actions.manageApplicationImports")} + + ), + tasksWriteAccess && ( + setIsDiscoverImportWizardOpen(true)} + > + {t("actions.discoverApplications")} ), ], @@ -1342,6 +1354,10 @@ export const ApplicationsTable: React.FC = () => { setGenerateAssetsApplications(null); }} /> + setIsDiscoverImportWizardOpen(false)} + /> WizardState; -type WizardAction = - | { type: "SET_FILTERS"; payload: FilterState } - | { type: "SET_RESULTS"; payload: ResultsData | null } - | { type: "RESET" }; - -const validateWizardState = (state: WizardState): WizardState => { - const isReady = state.filters.isValid; - return { ...state, isReady }; -}; - -const wizardReducer: WizardReducer = (state, action) => { - switch (action.type) { - case "SET_FILTERS": - return { ...state, filters: action.payload }; - case "SET_RESULTS": - return { ...state, results: action.payload }; - case "RESET": - return INITIAL_WIZARD_STATE; - default: - return state; - } -}; - -const validatedReducer: WizardReducer = (state, action) => - validateWizardState(wizardReducer(state, action)); - -export const useWizardReducer = () => { - const [state, dispatch] = React.useReducer( - validatedReducer, - INITIAL_WIZARD_STATE, - validateWizardState - ); - - // Create stable callbacks using useCallback - const setFilters = React.useCallback((filters: FilterState) => { - dispatch({ type: "SET_FILTERS", payload: filters }); - }, []); - - const setResults = React.useCallback((results: ResultsData | null) => { - dispatch({ type: "SET_RESULTS", payload: results }); - }, []); - - const reset = React.useCallback(() => { - dispatch({ type: "RESET" }); - }, []); - - return { - state, - setFilters, - setResults, - reset, - }; -}; diff --git a/client/src/app/pages/source-platforms/source-platforms.tsx b/client/src/app/pages/source-platforms/source-platforms.tsx index ab3110b8b..41e5c999b 100644 --- a/client/src/app/pages/source-platforms/source-platforms.tsx +++ b/client/src/app/pages/source-platforms/source-platforms.tsx @@ -43,7 +43,9 @@ import { TableHeaderContentWithControls, TableRowContentWithControls, } from "@app/components/TableControls"; +import { DiscoverImportWizard } from "@app/components/discover-import-wizard"; import { useLocalTableControls } from "@app/hooks/table-controls"; +import { usePlatformKindList } from "@app/hooks/usePlatformKindList"; import { useDeletePlatformMutation } from "@app/queries/platforms"; import { getAxiosErrorMessage } from "@app/utils/utils"; @@ -51,9 +53,7 @@ import { ColumnPlatformName } from "./components/column-platform-name"; import LinkToPlatformApplications from "./components/link-to-platform-applications"; import PlatformDetailDrawer from "./components/platform-detail-drawer"; import { PlatformForm } from "./components/platform-form"; -import { DiscoverImportWizard } from "./discover-import-wizard/discover-import-wizard"; import { useFetchPlatformsWithTasks } from "./useFetchPlatformsWithTasks"; -import { usePlatformKindList } from "./usePlatformKindList"; export const SourcePlatforms: React.FC = () => { const { t } = useTranslation(); diff --git a/package-lock.json b/package-lock.json index 1f59e8375..1107da2c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "git-url-parse": "^16.1.0", "i18next": "^25.3.0", "i18next-http-backend": "^3.0.2", + "immer": "^10.1.3", "js-yaml": "^4.1.0", "keycloak-js": "^26.1.0", "monaco-editor": "^0.52.2", @@ -101,6 +102,7 @@ "react-measure": "^2.5.2", "react-router-dom": "^5.2.0", "tinycolor2": "^1.6.0", + "use-immer": "^0.11.0", "xmllint-wasm": "^5.0.0", "yup": "^0.32.11" }, @@ -9240,6 +9242,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -17808,6 +17820,16 @@ } } }, + "node_modules/use-immer": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/use-immer/-/use-immer-0.11.0.tgz", + "integrity": "sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA==", + "license": "MIT", + "peerDependencies": { + "immer": ">=8.0.0", + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",