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 701d558452..203f5f2ed5 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}`); @@ -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/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: "", 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..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 @@ -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), + [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/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/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/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]); }, }); diff --git a/client/src/app/utils/model-utils.tsx b/client/src/app/utils/model-utils.tsx index 03d2d1a6ac..1136fad3ee 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); + +/** + * 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); 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[]; +}