From 3b7f85353a60dbda9f3b2cf54e05191bfbf723be Mon Sep 17 00:00:00 2001 From: Scott Dickerson Date: Thu, 28 Sep 2023 17:41:20 -0400 Subject: [PATCH 1/4] :bug: Applications form: Fix contributors and tags field handling (#1408) ### Contributors field The `contributors` field on the `Application` payload needs to be a pure `Ref` object or it will be rejected by the REST API call. Adopt the same set of data transforms used in the archetype-form to handle getting the correct set of data. Related changes (came up when working on #1331 that ended up revealing #1404): - REST `createApplication()` function updated to have the proper return type (response data is not unwrapped) - Query `useCreateApplicationMutation()` updated to properly pass the newly created `Application` to the `onSuccess()` handler - `onCreateApplicationSuccess()` in the application form updated to use the correct `onSuccess()` response data Resolves: #1404 ### Tags field Followup #1403 to update the way tags are updated to an existing application. Tags that are from an analysis need to be included with the update payload or they will be removed. This is different behavior from archetype or assessment sourced tags. No updates to an application can remove archetype or assessment tags. Summary of changes and refactoring: - As form values, keep the tags using just the tag name as a string - Moved all data access/mutation code to hook `useApplicationFormData()` to logically divide concerns (data access v. UI handling) - Tags dropdown will only display/edit the application's "Manual tags" - `onSubmit()`'s payload building simplified using partially curried helper functions - Migrate to use `ItemsSelect` for handling the Tags Update `useUpdateApplicationMutation()` to provide the mutation's payload to the `onSuccess()` function. This allows the `onSuccess()` function to toast a message with the application's name. Add utility functions to `model-utils.tsx`: - convert objects that look like `Ref` object into exactly a `Ref` object: - toRef() - toRefs() - Match a set of items that look like `Ref` objects against a (set of) values and return the matching items as exactly `Ref` objects: - matchItemsToRef() - matchItemsToRefs() --------- Signed-off-by: Scott J Dickerson --- client/src/app/api/rest.ts | 4 +- .../application-form/application-form.tsx | 312 +++++++++--------- client/src/app/queries/applications.ts | 14 +- client/src/app/utils/model-utils.tsx | 68 ++++ 4 files changed, 229 insertions(+), 169 deletions(-) diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index 701d558452..14e1cb1ebf 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -271,8 +271,8 @@ export const deleteIdentity = (identity: Identity): AxiosPromise => { // Axios direct -export const createApplication = (obj: Application): Promise => - axios.post(`${APPLICATIONS}`, obj); +export const createApplication = (data: Application) => + axios.post(`${APPLICATIONS}`, data); export const deleteApplication = (id: number): Promise => axios.delete(`${APPLICATIONS}/${id}`); diff --git a/client/src/app/pages/applications/components/application-form/application-form.tsx b/client/src/app/pages/applications/components/application-form/application-form.tsx index c82a6eb00c..476175bea0 100644 --- a/client/src/app/pages/applications/components/application-form/application-form.tsx +++ b/client/src/app/pages/applications/components/application-form/application-form.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useState } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { AxiosError, AxiosResponse } from "axios"; +import { AxiosError } from "axios"; import { object, string } from "yup"; import { ActionGroup, @@ -16,13 +16,17 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { SimpleSelect, OptionWithValue } from "@app/components/SimpleSelect"; import { DEFAULT_SELECT_MAX_HEIGHT } from "@app/Constants"; -import { Application, TagRef } from "@app/api/models"; +import { Application, Tag } from "@app/api/models"; import { customURLValidation, duplicateNameCheck, getAxiosErrorMessage, } from "@app/utils/utils"; -import { toOptionLike } from "@app/utils/model-utils"; +import { + matchItemsToRef, + matchItemsToRefs, + toOptionLike, +} from "@app/utils/model-utils"; import { useCreateApplicationMutation, useFetchApplications, @@ -39,14 +43,15 @@ import { import { QuestionCircleIcon } from "@patternfly/react-icons"; import { useFetchStakeholders } from "@app/queries/stakeholders"; import { NotificationsContext } from "@app/components/NotificationsContext"; -import { Autocomplete } from "@app/components/Autocomplete"; +import ItemsSelect from "@app/components/items-select/items-select"; export interface FormValues { + id: number; name: string; description: string; comments: string; businessServiceName: string; - tags: TagRef[]; + tags: string[]; owner: string | null; contributors: string[]; kind: string; @@ -57,7 +62,6 @@ export interface FormValues { artifact: string; version: string; packaging: string; - id: number; } export interface ApplicationFormProps { @@ -70,13 +74,20 @@ export const ApplicationForm: React.FC = ({ onClose, }) => { const { t } = useTranslation(); - const { pushNotification } = React.useContext(NotificationsContext); - - const { businessServices } = useFetchBusinessServices(); - const { stakeholders } = useFetchStakeholders(); - - const { tagCategories: tagCategories, refetch: fetchTagCategories } = - useFetchTagCategories(); + const { + existingApplications, + businessServices, + businessServiceToRef, + stakeholders, + stakeholderToRef, + stakeholdersToRefs, + tags, + tagsToRefs, + createApplication, + updateApplication, + } = useApplicationFormData({ + onActionSuccess: onClose, + }); const businessServiceOptions = businessServices.map((businessService) => { return { @@ -92,26 +103,12 @@ export const ApplicationForm: React.FC = ({ }; }); - useEffect(() => { - fetchTagCategories(); - }, [fetchTagCategories]); + const manualTags = application?.tags?.filter((t) => t.source === "") ?? []; - // Tags + const nonManualTags = application?.tags?.filter((t) => t.source !== "") ?? []; - const [tags, setTags] = useState(); - - useEffect(() => { - if (tagCategories) { - setTags(tagCategories.flatMap((f) => f.tags || [])); - } - }, []); - - const tagOptions = new Set( - (tags || []).reduce( - (acc, curr) => (!curr.source ? [...acc, curr.name] : acc), - [] - ) - ); + // TODO: Filter this if we want to exclude non-manual tags from manual tag selection + const allowedManualTags = tags; const getBinaryInitialValue = ( application: Application | null, @@ -132,8 +129,6 @@ export const ApplicationForm: React.FC = ({ } }; - const { data: applications } = useFetchApplications(); - const validationSchema = object().shape( { name: string() @@ -146,7 +141,7 @@ export const ApplicationForm: React.FC = ({ "An application with this name already exists. Use a different name.", (value) => duplicateNameCheck( - applications ? applications : [], + existingApplications, application || null, value || "" ) @@ -241,7 +236,7 @@ export const ApplicationForm: React.FC = ({ id: application?.id || 0, comments: application?.comments || "", businessServiceName: application?.businessService?.name || "", - tags: application?.tags || [], + tags: manualTags.map((tag) => tag.name) || [], owner: application?.owner?.name || undefined, contributors: application?.contributors?.map((contributor) => contributor.name) || [], @@ -258,107 +253,36 @@ export const ApplicationForm: React.FC = ({ mode: "all", }); - const buildBinaryFieldString = ( - group: string, - artifact: string, - version: string, - packaging: string - ) => { - if (packaging) { - return `${group}:${artifact}:${version}:${packaging}`; - } else { - return `${group}:${artifact}:${version}`; - } - }; - - const onCreateApplicationSuccess = (response: AxiosResponse) => { - pushNotification({ - title: t("toastr.success.createWhat", { - type: t("terms.application"), - what: response.data.name, - }), - variant: "success", - }); - onClose(); - }; - - const onUpdateApplicationSuccess = () => { - pushNotification({ - title: t("toastr.success.save", { - type: t("terms.application"), - }), - variant: "success", - }); - onClose(); - }; - - const onCreateUpdateApplicationError = (error: AxiosError) => { - pushNotification({ - title: getAxiosErrorMessage(error), - variant: "danger", - }); - }; - - const { mutate: createApplication } = useCreateApplicationMutation( - onCreateApplicationSuccess, - onCreateUpdateApplicationError - ); - - const { mutate: updateApplication } = useUpdateApplicationMutation( - onUpdateApplicationSuccess, - onCreateUpdateApplicationError - ); - const onSubmit = (formValues: FormValues) => { - const matchingBusinessService = businessServices.find( - (businessService) => - formValues?.businessServiceName === businessService.name - ); - - const matchingOwner = stakeholders.find( - (stakeholder) => formValues?.owner === stakeholder.name - ); - - const matchingContributors = stakeholders?.filter((stakeholder) => - formValues.contributors.includes(stakeholder.name) - ); + // Note: We need to manually retain the tags with source != "" in the payload + const tags = [...(tagsToRefs(formValues.tags) ?? []), ...nonManualTags]; const payload: Application = { + id: formValues.id, name: formValues.name.trim(), description: formValues.description.trim(), comments: formValues.comments.trim(), - businessService: matchingBusinessService + + businessService: businessServiceToRef(formValues.businessServiceName), + tags, + owner: stakeholderToRef(formValues.owner), + contributors: stakeholdersToRefs(formValues.contributors), + + repository: formValues.sourceRepository ? { - id: matchingBusinessService.id, - name: matchingBusinessService.name, + kind: formValues.kind.trim(), + url: formValues.sourceRepository.trim(), + branch: formValues.branch.trim(), + path: formValues.rootPath.trim(), } : undefined, - tags: formValues.tags, - owner: matchingOwner - ? { id: matchingOwner.id, name: matchingOwner.name } - : undefined, - contributors: matchingContributors, - ...(formValues.sourceRepository - ? { - repository: { - kind: formValues.kind.trim(), - url: formValues.sourceRepository - ? formValues.sourceRepository.trim() - : undefined, - branch: formValues.branch.trim(), - path: formValues.rootPath.trim(), - }, - } - : { repository: undefined }), - binary: buildBinaryFieldString( - formValues.group, - formValues.artifact, - formValues.version, - formValues.packaging - ), - id: formValues.id, - migrationWave: application ? application.migrationWave : null, - identities: application?.identities ? application.identities : undefined, + binary: formValues.packaging + ? `${formValues.group}:${formValues.artifact}:${formValues.version}:${formValues.packaging}` + : `${formValues.group}:${formValues.artifact}:${formValues.version}`, + + // Values not editable on the form but still need to be passed through + identities: application?.identities ?? undefined, + migrationWave: application?.migrationWave ?? null, }; if (application) { @@ -383,9 +307,6 @@ export const ApplicationForm: React.FC = ({ }, ]; - const getTagRef = (tagName: string) => - Object.assign({ source: "" }, tags?.find((tag) => tag.name === tagName)); - return (
= ({ )} /> - + items={allowedManualTags} control={control} name="tags" label={t("terms.tags")} fieldId="tags" - renderInput={({ field: { value, name, onChange } }) => { - const selections = value.reduce( - (acc, curr) => - curr.source === "" && tagOptions.has(curr.name) - ? [...acc, curr.name] - : acc, - [] - ); - - return ( - { - onChange( - selections - .map((sel) => getTagRef(sel)) - .filter((sel) => sel !== undefined) as TagRef[] - ); - }} - options={Array.from(tagOptions)} - placeholderText={t("composed.selectMany", { - what: t("terms.tags").toLowerCase(), - })} - searchInputAriaLabel="tags-select-toggle" - selections={selections} - /> - ); - }} + noResultsMessage={t("message.noResultsFoundTitle")} + placeholderText={t("composed.selectMany", { + what: t("terms.tags").toLowerCase(), + })} + searchInputAriaLabel="tags-select-toggle" /> + = ({ ); }; + +const useApplicationFormData = ({ + onActionSuccess = () => {}, + onActionFail = () => {}, +}: { + onActionSuccess?: () => void; + onActionFail?: () => void; +}) => { + const { t } = useTranslation(); + const { pushNotification } = React.useContext(NotificationsContext); + + // Fetch data + const { tagCategories } = useFetchTagCategories(); + const tags = useMemo( + () => tagCategories.flatMap((tc) => tc.tags).filter(Boolean) as Tag[], + [tagCategories] + ); + + const { businessServices } = useFetchBusinessServices(); + const { stakeholders } = useFetchStakeholders(); + const { data: existingApplications } = useFetchApplications(); + + // Helpers + const tagsToRefs = (names: string[] | undefined | null) => + matchItemsToRefs(tags, (i) => i.name, names); + + const businessServiceToRef = (name: string | undefined | null) => + matchItemsToRef(businessServices, (i) => i.name, name); + + const stakeholderToRef = (name: string | undefined | null) => + matchItemsToRef(stakeholders, (i) => i.name, name); + + const stakeholdersToRefs = (names: string[] | undefined | null) => + matchItemsToRefs(stakeholders, (i) => i.name, names); + + // Mutation notification handlers + const onCreateApplicationSuccess = (data: Application) => { + pushNotification({ + title: t("toastr.success.createWhat", { + type: t("terms.application"), + what: data.name, + }), + variant: "success", + }); + onActionSuccess(); + }; + + const onUpdateApplicationSuccess = (payload: Application) => { + pushNotification({ + title: t("toastr.success.saveWhat", { + type: t("terms.application"), + what: payload.name, + }), + variant: "success", + }); + onActionSuccess(); + }; + + const onCreateUpdateApplicationError = (error: AxiosError) => { + pushNotification({ + title: getAxiosErrorMessage(error), + variant: "danger", + }); + onActionFail(); + }; + + // Mutations + const { mutate: createApplication } = useCreateApplicationMutation( + onCreateApplicationSuccess, + onCreateUpdateApplicationError + ); + + const { mutate: updateApplication } = useUpdateApplicationMutation( + onUpdateApplicationSuccess, + onCreateUpdateApplicationError + ); + + // Send back source data and action that are needed by the ApplicationForm + return { + businessServices, + businessServiceToRef, + stakeholders, + stakeholderToRef, + stakeholdersToRefs, + existingApplications, + tagCategories, + tags, + tagsToRefs, + createApplication, + updateApplication, + }; +}; diff --git a/client/src/app/queries/applications.ts b/client/src/app/queries/applications.ts index 10211f6813..197ab755c0 100644 --- a/client/src/app/queries/applications.ts +++ b/client/src/app/queries/applications.ts @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; -import { MimeType } from "@app/api/models"; +import { Application, MimeType } from "@app/api/models"; import { createApplication, deleteApplication, @@ -59,14 +59,14 @@ export const useFetchApplicationById = (id?: number | string) => { }; export const useUpdateApplicationMutation = ( - onSuccess: () => void, + onSuccess: (payload: Application) => void, onError: (err: AxiosError) => void ) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: updateApplication, - onSuccess: () => { - onSuccess(); + onSuccess: (_res, payload) => { + onSuccess(payload); queryClient.invalidateQueries([ApplicationsQueryKey]); }, onError: onError, @@ -89,14 +89,14 @@ export const useUpdateAllApplicationsMutation = ( }; export const useCreateApplicationMutation = ( - onSuccess: (res: any) => void, + onSuccess: (data: Application) => void, onError: (err: AxiosError) => void ) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: createApplication, - onSuccess: (res) => { - onSuccess(res); + onSuccess: ({ data }) => { + onSuccess(data); queryClient.invalidateQueries([ApplicationsQueryKey]); }, onError: onError, diff --git a/client/src/app/utils/model-utils.tsx b/client/src/app/utils/model-utils.tsx index 03d2d1a6ac..5488be59b6 100644 --- a/client/src/app/utils/model-utils.tsx +++ b/client/src/app/utils/model-utils.tsx @@ -7,6 +7,7 @@ import { IdentityKind, IssueManagerKind, JobFunction, + Ref, Stakeholder, StakeholderGroup, TagCategory, @@ -211,3 +212,70 @@ export const IssueManagerOptions: OptionWithValue[] = [ toString: () => "Jira Server/Datacenter", }, ]; + +/** + * Convert any object that looks like a `Ref` into a `Ref`. If the source object + * is `undefined`, or doesn't look like a `Ref`, return `undefined`. + */ +export const toRef = ( + source: RefLike | undefined +): Ref | undefined => + source?.id && source?.name ? { id: source.id, name: source.name } : undefined; + +/** + * Convert an iterable collection of `Ref`-like objects to a `Ref[]`. Any items in the + * collection that cannot be converted to a `Ref` will be filtered out. + */ +export const toRefs = ( + source: Iterable +): Array | undefined => + !source ? undefined : ([...source].map(toRef).filter(Boolean) as Ref[]); + +/** + * Take an array of source items that look like a `Ref`, find the first one that matches + * a given value, and return it as a `Ref`. If no items match the value, or if the value + * is `undefined` or `null`, then return `undefined`. + * + * @param items Array of source items whose first matching item will be returned as a `Ref` + * @param itemMatchFn Function to extract data from each `item` that will be sent to `matchOperator` + * @param matchValue The single value to match every item against + * @param matchOperator Function to determine if `itemMatchFn` and `matchValue` match + */ +export const matchItemsToRef = ( + items: Array, + itemMatchFn: (item: RefLike) => V, + matchValue: V | undefined | null, + matchOperator?: (a: V, b: V) => boolean +): Ref | undefined => + !matchValue + ? undefined + : matchItemsToRefs(items, itemMatchFn, [matchValue], matchOperator)?.[0] ?? + undefined; + +/** + * Take an array of source items that look like a `Ref`, find the item that matches one + * of a given array of values, and return them all as a `Ref[]`. Any values without a + * match will be filtered out of the resulting `Ref[]`. If the array of values is + * `undefined` or `null`, then return `undefined`. + * + * @param items Array of source items whose first matching item will be returned as a `Ref` + * @param itemMatchFn Function to extract data from each `item` that will be sent to `matchOperator` + * @param matchValues The array of values to match every item against + * @param matchOperator Function to determine if `itemMatchFn` and `matchValue` match + */ +export const matchItemsToRefs = ( + items: Array, + itemMatchFn: (item: RefLike) => V, + matchValues: Array | undefined | null, + matchOperator: (a: V, b: V) => boolean = (a, b) => a === b +): Array | undefined => + !matchValues + ? undefined + : (matchValues + .map((toMatch) => + !toMatch + ? undefined + : items.find((item) => matchOperator(itemMatchFn(item), toMatch)) + ) + .map(toRef) + .filter(Boolean) as Ref[]); From 76a2645d84b6620e3249960be1035d1851c83224 Mon Sep 17 00:00:00 2001 From: Scott Dickerson Date: Thu, 28 Sep 2023 19:50:15 -0400 Subject: [PATCH 2/4] :seedling: Setup TS to know about `.filter(Boolean)` (#1416) With `array-filter-Boolean.ts`, TS will select a better Array.filter() override so this kind of thing will work automatically without an explicit type casting: ```js const a: Array = ["A", "B", undefined, null, "C", ""]; const b: string[] = a.filter(Boolean); // b = ["A", "B", "C"] ``` See https://www.karltarvas.com/typescript-array-filter-boolean.html See https://stackoverflow.com/a/51390763/1470607 Signed-off-by: Scott J Dickerson --- .../application-form/application-form.tsx | 2 +- client/src/app/utils/model-utils.tsx | 6 ++-- client/types/array-filter-Boolean.ts | 28 +++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 client/types/array-filter-Boolean.ts diff --git a/client/src/app/pages/applications/components/application-form/application-form.tsx b/client/src/app/pages/applications/components/application-form/application-form.tsx index 476175bea0..909dc0eda4 100644 --- a/client/src/app/pages/applications/components/application-form/application-form.tsx +++ b/client/src/app/pages/applications/components/application-form/application-form.tsx @@ -597,7 +597,7 @@ const useApplicationFormData = ({ // Fetch data const { tagCategories } = useFetchTagCategories(); const tags = useMemo( - () => tagCategories.flatMap((tc) => tc.tags).filter(Boolean) as Tag[], + () => tagCategories.flatMap((tc) => tc.tags).filter(Boolean), [tagCategories] ); diff --git a/client/src/app/utils/model-utils.tsx b/client/src/app/utils/model-utils.tsx index 5488be59b6..1136fad3ee 100644 --- a/client/src/app/utils/model-utils.tsx +++ b/client/src/app/utils/model-utils.tsx @@ -229,7 +229,7 @@ export const toRef = ( export const toRefs = ( source: Iterable ): Array | undefined => - !source ? undefined : ([...source].map(toRef).filter(Boolean) as Ref[]); + !source ? undefined : [...source].map(toRef).filter(Boolean); /** * Take an array of source items that look like a `Ref`, find the first one that matches @@ -271,11 +271,11 @@ export const matchItemsToRefs = ( ): Array | undefined => !matchValues ? undefined - : (matchValues + : matchValues .map((toMatch) => !toMatch ? undefined : items.find((item) => matchOperator(itemMatchFn(item), toMatch)) ) .map(toRef) - .filter(Boolean) as Ref[]); + .filter(Boolean); diff --git a/client/types/array-filter-Boolean.ts b/client/types/array-filter-Boolean.ts new file mode 100644 index 0000000000..741e1c984b --- /dev/null +++ b/client/types/array-filter-Boolean.ts @@ -0,0 +1,28 @@ +/** + * Fixes https://github.com/microsoft/TypeScript/issues/16655 for `Array.prototype.filter()` + * For example, using the fix the type of `bar` is `string[]` in the below snippet as it should be. + * + * const foo: (string | null | undefined)[] = []; + * const bar = foo.filter(Boolean); + * + * For related definitions, see https://github.com/microsoft/TypeScript/blob/master/src/lib/es5.d.ts + * + * Original licenses apply, see + * - https://github.com/microsoft/TypeScript/blob/master/LICENSE.txt + * - https://stackoverflow.com/help/licensing + */ + +/** See https://stackoverflow.com/a/51390763/1470607 */ +type Falsy = false | 0 | "" | null | undefined; + +interface Array { + /** + * Returns the elements of an array that meet the condition specified in a callback function. + * @param predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array. + * @param thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value. + */ + filter( + predicate: BooleanConstructor, + thisArg?: any + ): Exclude[]; +} From 8130ba2b92eefdcc061602673641db193ac95145 Mon Sep 17 00:00:00 2001 From: Scott Dickerson Date: Fri, 29 Sep 2023 14:51:37 -0400 Subject: [PATCH 3/4] :bug: Business services: Fix create/edit when owner is included (#1418) The `owner` field on the `BusinessService` payload needs to be a pure `Ref` object or it will be rejected by the REST API call. Adopt the same set of data transforms used in the application-form to handle getting the correct set of data. Related changes: - Business services related REST API functions updated to have the correct response types - Business services queries updated to pass REST API response and input values to `onSuccess()` and `onError()` handlers - `BusinessServiceForm` updated to use mutation response data to display the name of the business service in success messages - Refactored `business-service-form.tsx` to move all data access/mutation code to hook `useApplicationFormData() to logically divide concerns (data access v. UI handling) Resolves: https://issues.redhat.com/browse/MTA-1346 Signed-off-by: Scott J Dickerson --- client/src/app/api/models.ts | 2 +- client/src/app/api/rest.ts | 32 ++-- .../components/business-service-form.tsx | 160 +++++++++++------- client/src/app/queries/businessservices.ts | 32 ++-- 4 files changed, 134 insertions(+), 92 deletions(-) diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index 1fd51790c0..88c519539b 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -41,7 +41,7 @@ export interface BusinessService { id: number; name: string; description?: string; - owner?: Stakeholder; + owner?: Ref; } export interface Stakeholder { diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index 14e1cb1ebf..203f5f2ed5 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -618,27 +618,27 @@ export const updateStakeholderGroup = ( ): Promise => axios.put(`${STAKEHOLDER_GROUPS}/${obj.id}`, obj); +// --------------------------------------- // Business services +// +export const getBusinessServices = () => + axios + .get(BUSINESS_SERVICES) + .then((response) => response.data); -export const getBusinessServices = (): Promise => - axios.get(BUSINESS_SERVICES).then((response) => response.data); - -export const deleteBusinessService = ( - id: number | string -): Promise => axios.delete(`${BUSINESS_SERVICES}/${id}`); +export const getBusinessServiceById = (id: number | string) => + axios + .get(`${BUSINESS_SERVICES}/${id}`) + .then((response) => response.data); -export const createBusinessService = ( - obj: New -): Promise => axios.post(BUSINESS_SERVICES, obj); +export const createBusinessService = (obj: New) => + axios.post(BUSINESS_SERVICES, obj); -export const updateBusinessService = ( - obj: BusinessService -): Promise => axios.put(`${BUSINESS_SERVICES}/${obj.id}`, obj); +export const updateBusinessService = (obj: BusinessService) => + axios.put(`${BUSINESS_SERVICES}/${obj.id}`, obj); -export const getBusinessServiceById = ( - id: number | string -): Promise => - axios.get(`${BUSINESS_SERVICES}/${id}`).then((response) => response.data); +export const deleteBusinessService = (id: number | string) => + axios.delete(`${BUSINESS_SERVICES}/${id}`); // Job functions diff --git a/client/src/app/pages/controls/business-services/components/business-service-form.tsx b/client/src/app/pages/controls/business-services/components/business-service-form.tsx index 36a0123244..8365ae46a5 100644 --- a/client/src/app/pages/controls/business-services/components/business-service-form.tsx +++ b/client/src/app/pages/controls/business-services/components/business-service-form.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { AxiosError, AxiosResponse } from "axios"; +import { AxiosError } from "axios"; import { object, string } from "yup"; import { @@ -27,6 +27,7 @@ import { } from "@app/components/HookFormPFFields"; import { OptionWithValue, SimpleSelect } from "@app/components/SimpleSelect"; import { NotificationsContext } from "@app/components/NotificationsContext"; +import { matchItemsToRef } from "@app/utils/model-utils"; export interface FormValues { name: string; @@ -44,10 +45,16 @@ export const BusinessServiceForm: React.FC = ({ onClose, }) => { const { t } = useTranslation(); - const { pushNotification } = React.useContext(NotificationsContext); - const { businessServices } = useFetchBusinessServices(); - const { stakeholders } = useFetchStakeholders(); + const { + businessServices, + stakeholders, + stakeholderToRef, + createBusinessService, + updateBusinessService, + } = useBusinessServiceFormData({ + onActionSuccess: onClose, + }); const stakeholdersOptions = stakeholders.map((stakeholder) => { return { @@ -92,65 +99,11 @@ export const BusinessServiceForm: React.FC = ({ mode: "all", }); - const onCreateBusinessServiceSuccess = ( - response: AxiosResponse - ) => { - pushNotification({ - title: t("toastr.success.createWhat", { - type: t("terms.businessService"), - what: response.data.name, - }), - variant: "success", - }); - onClose(); - }; - - const onUpdateBusinessServiceSuccess = () => { - pushNotification({ - title: t("toastr.success.save", { - type: t("terms.businessService"), - }), - variant: "success", - }); - onClose(); - }; - - const onCreateBusinessServiceError = (error: AxiosError) => { - pushNotification({ - title: t("toastr.fail.create", { - type: t("terms.businessService").toLowerCase(), - }), - variant: "danger", - }); - }; - - const { mutate: createBusinessService } = useCreateBusinessServiceMutation( - onCreateBusinessServiceSuccess, - onCreateBusinessServiceError - ); - - const onUpdateBusinessServiceError = (error: AxiosError) => { - pushNotification({ - title: t("toastr.fail.save", { - type: t("terms.businessService").toLowerCase(), - }), - variant: "danger", - }); - }; - - const { mutate: updateBusinessService } = useUpdateBusinessServiceMutation( - onUpdateBusinessServiceSuccess, - onUpdateBusinessServiceError - ); - const onSubmit = (formValues: FormValues) => { - const matchingStakeholderRef = stakeholders.find( - (stakeholder) => stakeholder.name === formValues.owner - ); const payload: New = { name: formValues.name.trim(), description: formValues.description.trim(), - owner: matchingStakeholderRef, + owner: stakeholderToRef(formValues.owner), }; if (businessService) { @@ -158,7 +111,6 @@ export const BusinessServiceForm: React.FC = ({ } else { createBusinessService(payload); } - onClose(); }; return ( @@ -223,3 +175,91 @@ export const BusinessServiceForm: React.FC = ({ ); }; + +const useBusinessServiceFormData = ({ + onActionSuccess = () => {}, + onActionFail = () => {}, +}: { + onActionSuccess?: () => void; + onActionFail?: () => void; +}) => { + const { t } = useTranslation(); + const { pushNotification } = React.useContext(NotificationsContext); + + // Fetch data + const { businessServices } = useFetchBusinessServices(); + const { stakeholders } = useFetchStakeholders(); + + // Helpers + const stakeholderToRef = (name: string | undefined | null) => + matchItemsToRef(stakeholders, (i) => i.name, name); + + // Mutation notification handlers + const onCreateBusinessServiceSuccess = (data: BusinessService) => { + pushNotification({ + title: t("toastr.success.createWhat", { + type: t("terms.businessService"), + what: data.name, + }), + variant: "success", + }); + onActionSuccess(); + }; + + const onCreateBusinessServiceError = ( + _error: AxiosError, + _payload: New + ) => { + pushNotification({ + title: t("toastr.fail.create", { + type: t("terms.businessService").toLowerCase(), + }), + variant: "danger", + }); + onActionFail(); + }; + + const onUpdateBusinessServiceSuccess = (payload: BusinessService) => { + pushNotification({ + title: t("toastr.success.saveWhat", { + type: t("terms.businessService"), + what: payload.name, + }), + variant: "success", + }); + onActionSuccess(); + }; + + const onUpdateBusinessServiceError = ( + _error: AxiosError, + _payload: New + ) => { + pushNotification({ + title: t("toastr.fail.save", { + type: t("terms.businessService").toLowerCase(), + }), + variant: "danger", + }); + onActionFail(); + }; + + // Mutations + const { mutate: createBusinessService } = useCreateBusinessServiceMutation( + onCreateBusinessServiceSuccess, + onCreateBusinessServiceError + ); + + const { mutate: updateBusinessService } = useUpdateBusinessServiceMutation( + onUpdateBusinessServiceSuccess, + onUpdateBusinessServiceError + ); + + // Send back source data and action that are needed by the ApplicationForm + return { + businessServices, + stakeholders, + stakeholderToRef, + createBusinessService, + updateBusinessService, + }; +}; diff --git a/client/src/app/queries/businessservices.ts b/client/src/app/queries/businessservices.ts index 772ee682c8..abd764355c 100644 --- a/client/src/app/queries/businessservices.ts +++ b/client/src/app/queries/businessservices.ts @@ -8,6 +8,7 @@ import { getBusinessServices, updateBusinessService, } from "@app/api/rest"; +import { BusinessService, New } from "@app/api/models"; export const BusinessServicesQueryKey = "businessservices"; export const BusinessServiceQueryKey = "businessservice"; @@ -40,15 +41,15 @@ export const useFetchBusinessServiceByID = (id: number | string) => { }; export const useCreateBusinessServiceMutation = ( - onSuccess: (res: any) => void, - onError: (err: AxiosError) => void + onSuccess: (res: BusinessService) => void, + onError: (err: AxiosError, payload: New) => void ) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: createBusinessService, - onSuccess: (res) => { - onSuccess(res); + onSuccess: ({ data }, _payload) => { + onSuccess(data); queryClient.invalidateQueries([BusinessServicesQueryKey]); }, onError, @@ -56,14 +57,14 @@ export const useCreateBusinessServiceMutation = ( }; export const useUpdateBusinessServiceMutation = ( - onSuccess: () => void, - onError: (err: AxiosError) => void + onSuccess: (payload: BusinessService) => void, + onError: (err: AxiosError, payload: BusinessService) => void ) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: updateBusinessService, - onSuccess: () => { - onSuccess(); + onSuccess: (_res, payload) => { + onSuccess(payload); queryClient.invalidateQueries([BusinessServicesQueryKey]); }, onError: onError, @@ -71,18 +72,19 @@ export const useUpdateBusinessServiceMutation = ( }; export const useDeleteBusinessServiceMutation = ( - onSuccess: (res: any) => void, - onError: (err: AxiosError) => void + onSuccess: (id: number | string) => void, + onError: (err: AxiosError, id: number | string) => void ) => { const queryClient = useQueryClient(); - const { isLoading, mutate, error } = useMutation(deleteBusinessService, { - onSuccess: (res) => { - onSuccess(res); + const { isLoading, mutate, error } = useMutation({ + mutationFn: deleteBusinessService, + onSuccess: (_res, id) => { + onSuccess(id); queryClient.invalidateQueries([BusinessServicesQueryKey]); }, - onError: (err: AxiosError) => { - onError(err); + onError: (err: AxiosError, id) => { + onError(err, id); queryClient.invalidateQueries([BusinessServicesQueryKey]); }, }); From bc0fe72d475f3bc6939f905fee9c7eb1a89c44de Mon Sep 17 00:00:00 2001 From: Ian Bolton Date: Fri, 29 Sep 2023 19:32:50 -0400 Subject: [PATCH 4/4] :bug: String value breaking schema validation for repo type (#1415) Closes https://issues.redhat.com/browse/MTA-1047?filter=-1 Signed-off-by: ibolton336 --- .../app/pages/applications/analysis-wizard/analysis-wizard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dd85595c0a..c3a6ac4a6d 100644 --- a/client/src/app/pages/applications/analysis-wizard/analysis-wizard.tsx +++ b/client/src/app/pages/applications/analysis-wizard/analysis-wizard.tsx @@ -172,7 +172,7 @@ export const AnalysisWizard: React.FC = ({ hasExcludedPackages: false, associatedCredentials: "", rulesKind: "manual", - repositoryType: "", + repositoryType: undefined, sourceRepository: "", branch: "", rootPath: "",