Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🐛 Use standard filters for languages in the Set Targets step #2045

Merged
merged 10 commits into from
Aug 14, 2024
5 changes: 4 additions & 1 deletion client/src/app/components/FilterToolbar/FilterToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SelectOptionProps,
ToolbarToggleGroup,
ToolbarItem,
ToolbarToggleGroupProps,
} from "@patternfly/react-core";
import FilterIcon from "@patternfly/react-icons/dist/esm/icons/filter-icon";

Expand Down Expand Up @@ -110,6 +111,7 @@ export interface IFilterToolbarProps<TItem, TFilterCategoryKey extends string> {
pagination?: JSX.Element;
showFiltersSideBySide?: boolean;
isDisabled?: boolean;
breakpoint?: ToolbarToggleGroupProps["breakpoint"];
}

export const FilterToolbar = <TItem, TFilterCategoryKey extends string>({
Expand All @@ -119,6 +121,7 @@ export const FilterToolbar = <TItem, TFilterCategoryKey extends string>({
pagination,
showFiltersSideBySide = false,
isDisabled = false,
breakpoint = "2xl",
}: React.PropsWithChildren<
IFilterToolbarProps<TItem, TFilterCategoryKey>
>): JSX.Element | null => {
Expand Down Expand Up @@ -192,7 +195,7 @@ export const FilterToolbar = <TItem, TFilterCategoryKey extends string>({
<ToolbarToggleGroup
variant="filter-group"
toggleIcon={<FilterIcon />}
breakpoint="2xl"
breakpoint={breakpoint}
spaceItems={
showFiltersSideBySide ? { default: "spaceItemsMd" } : undefined
}
Expand Down
2 changes: 1 addition & 1 deletion client/src/app/components/SimpleSelectCheckbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing";

export interface ISimpleSelectBasicProps {
onChange: (selection: string | string[]) => void;
onChange: (selection: string[]) => void;
options: SelectOptionProps[];
value?: string[];
placeholderText?: string;
Expand Down
230 changes: 150 additions & 80 deletions client/src/app/pages/applications/analysis-wizard/set-targets.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useMemo } from "react";
import {
Title,
TextContent,
Expand All @@ -7,30 +7,89 @@ import {
GalleryItem,
Form,
Alert,
SelectOptionProps,
Toolbar,
ToolbarContent,
Bullseye,
Spinner,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useFormContext } from "react-hook-form";

import { TargetCard } from "@app/components/target-card/target-card";
import { AnalysisWizardFormValues } from "./schema";
import { useSetting } from "@app/queries/settings";
import { useFetchTargets } from "@app/queries/targets";
import { Application, TagCategory, Target } from "@app/api/models";
import { Application, Target } from "@app/api/models";
import { useFetchTagCategories } from "@app/queries/tags";
import { SimpleSelectCheckbox } from "@app/components/SimpleSelectCheckbox";
import { getUpdatedFormLabels, toggleSelectedTargets } from "./utils";
import { unique } from "radash";
import { FilterToolbar, FilterType } from "@app/components/FilterToolbar";
import { useLocalTableControls } from "@app/hooks/table-controls";
import { ConditionalTableBody } from "@app/components/TableControls";
import { useSetting } from "@app/queries/settings";

interface SetTargetsProps {
applications: Application[];
initialFilters?: string[];
}

export const SetTargets: React.FC<SetTargetsProps> = ({ applications }) => {
const { t } = useTranslation();
const useEnhancedTargets = (applications: Application[]) => {
const {
targets,
isFetching: isTargetsLoading,
fetchError: isTargetsError,
} = useFetchTargets();
const { tagCategories, isFetching: isTagCategoriesLoading } =
useFetchTagCategories();
const { data: targetOrder = [], isLoading: isTargetOrderLoading } =
useSetting("ui.target.order");

const { targets } = useFetchTargets();
const languageProviders = useMemo(
() => unique(targets.map(({ provider }) => provider).filter(Boolean)),
[targets]
);

const languageTags =
tagCategories?.find((category) => category.name === "Language")?.tags ?? [];
rszwajko marked this conversation as resolved.
Show resolved Hide resolved

const applicationProviders = unique(
applications
rszwajko marked this conversation as resolved.
Show resolved Hide resolved
.flatMap((app) => app.tags || [])
.filter((tag) => languageTags.find((lt) => lt.id === tag.id))
.map((languageTag) => languageTag.name)
.filter((language) => languageProviders.includes(language))
);

// 1. missing target order setting is not a blocker (only lowers user experience)
// 2. targets without manual order are put at the end
const targetsWithOrder = targets.map((target) => {
const index = targetOrder.findIndex((id) => id === target.id);
rszwajko marked this conversation as resolved.
Show resolved Hide resolved
return {
target,
order: index === -1 ? targets.length : index,
};
});
targetsWithOrder.sort((a, b) => a.order - b.order);

rszwajko marked this conversation as resolved.
Show resolved Hide resolved
return {
// keep the same meaning as in react query
// isLoading == no data yet
isLoading:
isTagCategoriesLoading || isTargetsLoading || isTargetOrderLoading,
isError: !!isTargetsError,
targets: targetsWithOrder.map(({ target }) => target),
applicationProviders,
languageProviders,
};
};

const SetTargetsInternal: React.FC<SetTargetsProps> = ({
applications,
initialFilters = [],
}) => {
const { t } = useTranslation();

const targetOrderSetting = useSetting("ui.target.order");
const { targets, isLoading, isError, languageProviders } =
useEnhancedTargets(applications);

const { watch, setValue, getValues } =
useFormContext<AnalysisWizardFormValues>();
Expand All @@ -39,32 +98,6 @@ export const SetTargets: React.FC<SetTargetsProps> = ({ applications }) => {
const formLabels = watch("formLabels");
const selectedTargets = watch("selectedTargets");

const { tagCategories } = useFetchTagCategories();

const findCategoryForTag = (tagId: number) => {
return tagCategories.find(
(category: TagCategory) =>
category.tags?.some((categoryTag) => categoryTag.id === tagId)
);
};

const initialProviders = Array.from(
new Set(
applications
.flatMap((app) => app.tags || [])
.map((tag) => {
return {
category: findCategoryForTag(tag.id),
tag,
};
})
.filter((tagWithCat) => tagWithCat?.category?.name === "Language")
.map((tagWithCat) => tagWithCat.tag.name)
)
).filter(Boolean);

const [providers, setProviders] = useState(initialProviders);

const handleOnSelectedCardTargetChange = (selectedLabelName: string) => {
const otherSelectedLabels = formLabels?.filter((formLabel) => {
return formLabel.name !== selectedLabelName;
Expand Down Expand Up @@ -118,17 +151,36 @@ export const SetTargets: React.FC<SetTargetsProps> = ({ applications }) => {
setValue("formLabels", updatedFormLabels);
};

const allProviders = targets.flatMap((target) => target.provider);
const languageOptions = Array.from(new Set(allProviders));
const tableControls = useLocalTableControls({
tableName: "target-cards",
items: targets,
idProperty: "name",
initialFilterValues: { name: initialFilters },
columnNames: {
name: "name",
},
isFilterEnabled: true,
isPaginationEnabled: false,
isLoading,
filterCategories: [
{
selectOptions: languageProviders?.map((language) => ({
value: language,
})),
placeholderText: "Filter by language...",
categoryKey: "name",
title: "Languages",
type: FilterType.multiselect,
matcher: (filter, target) => !!target.provider?.includes(filter),
logicOperator: "OR",
},
rszwajko marked this conversation as resolved.
Show resolved Hide resolved
],
});

const targetsToRender: Target[] = !targetOrderSetting.isSuccess
? []
: targetOrderSetting.data
.map((targetId) => targets.find((target) => target.id === targetId))
.filter(Boolean)
.filter((target) =>
providers.some((p) => target.provider?.includes(p) ?? false)
);
const {
currentPageItems,
propHelpers: { toolbarProps, filterToolbarProps },
} = tableControls;

return (
<Form
Expand All @@ -142,22 +194,14 @@ export const SetTargets: React.FC<SetTargetsProps> = ({ applications }) => {
</Title>
<Text>{t("wizard.label.setTargets")}</Text>
</TextContent>
<SimpleSelectCheckbox
placeholderText="Filter by language..."
width={300}
value={providers}
options={languageOptions?.map((language): SelectOptionProps => {
return {
children: <div>{language}</div>,
value: language,
};
})}
onChange={(selection) => {
setProviders(selection as string[]);
}}
id="filter-by-language"
toggleId="action-select-toggle"
/>
<Toolbar
{...toolbarProps}
clearAllFilters={() => filterToolbarProps.setFilterValues({})}
>
<ToolbarContent>
<FilterToolbar {...filterToolbarProps} breakpoint="md" />
</ToolbarContent>
</Toolbar>

{values.selectedTargets.length === 0 &&
values.customRulesFiles.length === 0 &&
Expand All @@ -168,25 +212,51 @@ export const SetTargets: React.FC<SetTargetsProps> = ({ applications }) => {
title={t("wizard.label.skipTargets")}
/>
)}

<Gallery hasGutter>
{targetsToRender.map((target) => (
<GalleryItem key={target.id}>
<TargetCard
readOnly
item={target}
cardSelected={selectedTargets.some(({ id }) => id === target.id)}
onSelectedCardTargetChange={(selectedTarget) => {
handleOnSelectedCardTargetChange(selectedTarget);
}}
onCardClick={(isSelecting, selectedLabelName, target) => {
handleOnCardClick(isSelecting, selectedLabelName, target);
}}
formLabels={formLabels}
/>
</GalleryItem>
))}
</Gallery>
<ConditionalTableBody
isLoading={isLoading}
isError={isError}
isNoData={targets.length === 0}
numRenderedColumns={1}
>
<Gallery hasGutter>
{currentPageItems.map((target) => (
<GalleryItem key={target.id}>
<TargetCard
readOnly
item={target}
cardSelected={selectedTargets.some(
({ id }) => id === target.id
)}
onSelectedCardTargetChange={(selectedTarget) => {
handleOnSelectedCardTargetChange(selectedTarget);
}}
onCardClick={(isSelecting, selectedLabelName, target) => {
handleOnCardClick(isSelecting, selectedLabelName, target);
}}
formLabels={formLabels}
/>
</GalleryItem>
))}
</Gallery>
</ConditionalTableBody>
</Form>
);
};

export const SetTargets: React.FC<SetTargetsProps> = ({ applications }) => {
// pre-fetch data but leave error handling to the real page
const { isLoading, applicationProviders } = useEnhancedTargets(applications);
if (isLoading) {
return (
<Bullseye>
<Spinner size="xl" />
</Bullseye>
);
rszwajko marked this conversation as resolved.
Show resolved Hide resolved
}
return (
<SetTargetsInternal
applications={applications}
initialFilters={applicationProviders}
/>
);
};
3 changes: 2 additions & 1 deletion client/src/app/queries/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { AxiosError } from "axios";
export const TargetsQueryKey = "targets";

export const useFetchTargets = () => {
const { data, isLoading, error, refetch } = useQuery<Target[]>({
const { data, isLoading, isSuccess, error, refetch } = useQuery<Target[]>({
queryKey: [TargetsQueryKey],
queryFn: async () => await getTargets(),
onError: (err) => console.log(err),
Expand All @@ -21,6 +21,7 @@ export const useFetchTargets = () => {
return {
targets: data || [],
isFetching: isLoading,
isSuccess,
fetchError: error,
refetch,
};
Expand Down
Loading