diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index 42e84f648d..a9ff84acc9 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -177,6 +177,7 @@ "blockedDeleteTarget": "Cannot delete {{what}} because it is associated with a target.", "defaultBlockedDelete": "Cannot delete {{what}} because it is associated with another object.", "cannotDeleteApplicationsAssignedToMigrationWave": "Cannot delete applications that are assigned to a migration wave.", + "cannotDeleteNonEmptyTagCategory": "Cannot delete a tag category that contains tags.", "continueConfirmation": "Yes, continue", "copyAssessmentAndReviewBody": "Some of the selected target applications have an in-progress or complete assessment/review. By continuing, the existing assessment(s)/review(s) will be replaced by the copied assessment/review. Do you wish to continue?", "copyAssessmentAndReviewQuestion": "Copy assessment and review?", @@ -218,7 +219,9 @@ "toTagApplication": "Either no tags exist yet or you may not have permission to view any. If you have permission, try creating a new custom tag.", "unsavedChanges": "Are you sure you want to close the assessment? Any unsaved changes will be lost.", "noAnswers": "Are you sure you want to close the assessment? There are no answers to save.", - "unlinkTicket": "Unlink from Jira" + "unlinkTicket": "Unlink from Jira", + "noTagsAvailable": "No tags available", + "noAssociatedTags": "This tag category has no associated tags." }, "proposedActions": { "refactor": "Refactor", diff --git a/client/src/app/components/labels/item-tag-label/item-tag-label.tsx b/client/src/app/components/labels/item-tag-label/item-tag-label.tsx index ef6ad228a4..55b673f402 100644 --- a/client/src/app/components/labels/item-tag-label/item-tag-label.tsx +++ b/client/src/app/components/labels/item-tag-label/item-tag-label.tsx @@ -3,7 +3,7 @@ import { Tag, TagCategory } from "@app/api/models"; import { COLOR_HEX_VALUES_BY_NAME } from "@app/Constants"; import { LabelCustomColor } from "@app/components/LabelCustomColor"; -export const getTagCategoryFallbackColor = (category?: TagCategory) => { +export const getTagCategoryFallbackColor = (category?: TagCategory | null) => { if (!category?.id) return COLOR_HEX_VALUES_BY_NAME.gray; const colorValues = Object.values(COLOR_HEX_VALUES_BY_NAME); return colorValues[category?.id % colorValues.length]; diff --git a/client/src/app/pages/controls/tags/components/tag-table.tsx b/client/src/app/pages/controls/tags/components/tag-table.tsx index ee9fcfc413..6bc612288a 100644 --- a/client/src/app/pages/controls/tags/components/tag-table.tsx +++ b/client/src/app/pages/controls/tags/components/tag-table.tsx @@ -8,21 +8,10 @@ import { Tbody, Td, ActionsColumn, - IAction, - cellWidth, - ICell, - IRow, - IRowData, } from "@patternfly/react-table"; import { Tag, TagCategory } from "@app/api/models"; import "./tag-table.css"; -const ENTITY_FIELD = "entity"; - -const getRow = (rowData: IRowData): Tag => { - return rowData[ENTITY_FIELD]; -}; - export interface TabTableProps { tagCategory: TagCategory; onEdit: (tag: Tag) => void; @@ -30,79 +19,42 @@ export interface TabTableProps { } export const TagTable: React.FC = ({ - tagCategory: tagCategory, + tagCategory, onEdit, onDelete, }) => { const { t } = useTranslation(); - const columns: ICell[] = [ - { - title: t("terms.tagName"), - transforms: [cellWidth(100)], - cellFormatters: [], - props: { - className: "columnPadding", - }, - }, - ]; - - const rows: IRow[] = []; - (tagCategory.tags || []) - .sort((a, b) => a.name.localeCompare(b.name)) - .forEach((item) => { - rows.push({ - [ENTITY_FIELD]: item, - noPadding: true, - cells: [ - { - title: item.name, - }, - ], - }); - }); - - const editRow = (row: Tag) => { - onEdit(row); - }; - - const deleteRow = (row: Tag) => { - onDelete(row); - }; - - const defaultActions = (tag: IRowData): IAction[] => [ - { - title: t("actions.edit"), - onClick: () => editRow(getRow(tag)), - }, - { - title: t("actions.delete"), - onClick: () => deleteRow(getRow(tag)), - }, - ]; - return ( - + - {rows.map((row: IRow) => { - const rowActions = defaultActions(row); - return ( - - {row.cells?.map((cell: any) => ( - - ))} + {(tagCategory.tags || []) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((tag) => ( + + - ); - })} + ))}
{t("terms.tagName")}
{cell.title}
{tag.name} - {rowActions && } + onEdit(tag), + }, + { + title: t("actions.delete"), + onClick: () => onDelete(tag), + }, + ]} + />
); diff --git a/client/src/app/pages/controls/tags/tags.tsx b/client/src/app/pages/controls/tags/tags.tsx index f00bb7b64a..abbe2e8f60 100644 --- a/client/src/app/pages/controls/tags/tags.tsx +++ b/client/src/app/pages/controls/tags/tags.tsx @@ -1,36 +1,38 @@ import React from "react"; import { AxiosError } from "axios"; import { useTranslation } from "react-i18next"; -import { useSelectionState } from "@migtools/lib-ui"; import { Button, ButtonVariant, + EmptyState, + EmptyStateBody, + EmptyStateIcon, Modal, ModalVariant, + Title, + Toolbar, + ToolbarContent, ToolbarGroup, ToolbarItem, } from "@patternfly/react-core"; import { - expandable, - ICell, - IExtraData, - IRow, - IRowData, - sortable, + ExpandableRowContent, + Table, + Tbody, + Td, + Th, + Thead, + Tr, } from "@patternfly/react-table"; +import { CubesIcon } from "@patternfly/react-icons"; -import { dedupeFunction, getAxiosErrorMessage } from "@app/utils/utils"; -import { Tag, TagCategory } from "@app/api/models"; -import { TagTable } from "./components/tag-table"; -import { useLegacyPaginationState } from "@app/hooks/useLegacyPaginationState"; import { - FilterCategory, - FilterToolbar, - FilterType, -} from "@app/components/FilterToolbar"; -import { useLegacyFilterState } from "@app/hooks/useLegacyFilterState"; -import { useLegacySortState } from "@app/hooks/useLegacySortState"; -import { controlsWriteScopes, RBAC, RBAC_TYPE } from "@app/rbac"; + dedupeFunction, + getAxiosErrorMessage, + localeNumericCompare, +} from "@app/utils/utils"; +import { Tag, TagCategory } from "@app/api/models"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; import { useDeleteTagMutation, useDeleteTagCategoryMutation, @@ -45,15 +47,17 @@ import { AppTableActionButtons } from "@app/components/AppTableActionButtons"; import { Color } from "@app/components/Color"; import { ConditionalRender } from "@app/components/ConditionalRender"; import { AppPlaceholder } from "@app/components/AppPlaceholder"; -import { AppTableWithControls } from "@app/components/AppTableWithControls"; -import { NoDataEmptyState } from "@app/components/NoDataEmptyState"; import { ConfirmDialog } from "@app/components/ConfirmDialog"; - -const ENTITY_FIELD = "entity"; - -const getRow = (rowData: IRowData): TagCategory => { - return rowData[ENTITY_FIELD]; -}; +import { SimplePagination } from "@app/components/SimplePagination"; +import { + TableHeaderContentWithControls, + ConditionalTableBody, + TableRowContentWithControls, +} from "@app/components/TableControls"; +import { useLocalTableControls } from "@app/hooks/table-controls"; +import { RBAC, controlsWriteScopes, RBAC_TYPE } from "@app/rbac"; +import { TagTable } from "./components/tag-table"; +import i18n from "@app/i18n"; export const Tags: React.FC = () => { const { t } = useTranslation(); @@ -70,10 +74,6 @@ export const Tags: React.FC = () => { const tagCategoryToUpdate = tagCategoryModalState !== "create" ? tagCategoryModalState : null; - // const [isNewTagCategoryModalOpen, setIsNewTagCategoryModalOpen] = - // useState(false); - // const [rowToUpdate, setRowToUpdate] = useState(); - const [tagModalState, setTagModalState] = React.useState< "create" | Tag | null >(null); @@ -157,210 +157,110 @@ export const Tags: React.FC = () => { refetch, } = useFetchTagCategories(); - const { - isItemSelected: isItemExpanded, - toggleItemSelected: toggleItemExpanded, - } = useSelectionState({ - items: tagCategories || [], - isEqual: (a, b) => a.id === b.id, - }); - - const filterCategories: FilterCategory< - TagCategory, - "tags" | "rank" | "color" - >[] = [ - { - key: "tags", - title: t("terms.name"), - type: FilterType.multiselect, - placeholderText: - t("actions.filterBy", { - what: t("terms.name").toLowerCase(), - }) + "...", - getItemValue: (item) => { - const tagCategoryNames = item.name?.toString() || ""; - const tagNames = item?.tags - ?.map((tag) => tag.name) - .concat(tagCategoryNames) - .join(""); - - return tagNames || ""; - }, - selectOptions: dedupeFunction( - tagCategories - ?.map((tagCategory) => tagCategory?.tags) - .flat() - .filter((tag) => tag && tag.name) - .map((tag) => ({ key: tag?.name, value: tag?.name })) - .concat( - tagCategories?.map((tagCategory) => ({ - key: tagCategory?.name, - value: tagCategory?.name, - })) - ) - .sort((a, b) => { - if (a.value && b.value) { - return a?.value.localeCompare(b?.value); - } else { - return 0; - } - }) - ), - }, - { - key: "rank", - title: t("terms.rank"), - type: FilterType.search, - placeholderText: - t("actions.filterBy", { - what: t("terms.rank").toLowerCase(), - }) + "...", - getItemValue: (item) => { - return item.rank?.toString() || ""; - }, - }, - { - key: "color", - title: t("terms.color"), - type: FilterType.search, - placeholderText: - t("actions.filterBy", { - what: t("terms.color").toLowerCase(), - }) + "...", - getItemValue: (item) => { - const hex = item?.colour || ""; - const colorLabel = COLOR_NAMES_BY_HEX_VALUE[hex.toLowerCase()]; - return colorLabel || hex; - }, - }, - ]; - const { filterValues, setFilterValues, filteredItems } = useLegacyFilterState( - tagCategories || [], - filterCategories - ); - - const getSortValues = (item: TagCategory) => [ - "", - item?.name || "", - typeof item?.rank === "number" ? item.rank : Number.MAX_VALUE, - - "", - item?.tags?.length || 0, - "", // Action column - ]; - - const { sortBy, onSort, sortedItems } = useLegacySortState( - filteredItems, - getSortValues - ); - - const { currentPageItems, setPageNumber, paginationProps } = - useLegacyPaginationState(sortedItems, 10); - - const deleteTagFromTable = (row: Tag) => { - setTagToDelete(row); + const deleteTagFromTable = (tag: Tag) => { + setTagToDelete(tag); }; - const columns: ICell[] = [ - { - title: t("terms.tagCategory"), - transforms: [sortable], - cellFormatters: [expandable], - }, - { title: t("terms.rank"), transforms: [sortable] }, - { - title: t("terms.color"), - transforms: [], - }, - { - title: t("terms.tagCount"), - transforms: [sortable], + const tableControls = useLocalTableControls({ + idProperty: "name", + items: tagCategories, + columnNames: { + name: t("terms.name"), + rank: t("terms.rank"), + color: t("terms.color"), + tagCount: t("terms.tagCount"), }, - { - title: "", - props: { - className: "pf-v5-u-text-align-right", - }, - }, - ]; - - const rows: IRow[] = []; - currentPageItems.forEach((item) => { - const isExpanded = isItemExpanded(item) && !!item?.tags?.length; - const categoryColor = item.colour || getTagCategoryFallbackColor(item); - rows.push({ - [ENTITY_FIELD]: item, - isOpen: (item.tags || []).length > 0 ? isExpanded : undefined, - cells: [ - { - title: item.name, - }, - { - title: item.rank, - }, - { - title: , + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + hasActionsColumn: true, + isExpansionEnabled: true, + expandableVariant: "single", + filterCategories: [ + { + key: "tags", + title: t("terms.name"), + type: FilterType.multiselect, + placeholderText: t("actions.filterBy", { + what: t("terms.name").toLowerCase(), + }), + getItemValue: (item) => { + const tagCategoryNames = item.name?.toString() || ""; + const tagNames = item?.tags + ?.map((tag) => tag.name) + .concat(tagCategoryNames) + .join(""); + return tagNames || ""; }, - { - title: item.tags ? item.tags.length : 0, + selectOptions: dedupeFunction( + tagCategories + ?.flatMap((tagCategory) => tagCategory?.tags ?? []) + .filter((tag) => tag && tag.name) + .map((tag) => ({ key: tag.name, value: tag.name })) + .concat( + tagCategories?.map((tagCategory) => ({ + key: tagCategory?.name, + value: tagCategory?.name, + })) ?? [] + ) + .sort((a, b) => + localeNumericCompare(a.value, b.value, i18n.language) + ) + ), + }, + { + key: "rank", + title: t("terms.rank"), + type: FilterType.search, + placeholderText: + t("actions.filterBy", { + what: t("terms.rank").toLowerCase(), + }) + "...", + getItemValue: (item) => { + return item.rank?.toString() || ""; }, - { - title: ( - setTagCategoryModalState(item)} - onDelete={() => deleteRow(item)} - /> - ), + }, + { + key: "color", + title: t("terms.color"), + type: FilterType.search, + placeholderText: + t("actions.filterBy", { + what: t("terms.color").toLowerCase(), + }) + "...", + getItemValue: (item) => { + const hex = item?.colour || ""; + const colorLabel = COLOR_NAMES_BY_HEX_VALUE[hex.toLowerCase()]; + return colorLabel || hex; }, - ], - }); - - if (isExpanded) { - rows.push({ - parent: rows.length - 1, - fullWidth: true, - noPadding: true, - cells: [ - { - title: ( -
- -
- ), - }, - ], - }); - } + }, + ], + initialItemsPerPage: 10, + sortableColumns: ["name", "rank", "tagCount"], + initialSort: { columnKey: "name", direction: "asc" }, + getSortValues: (item) => ({ + name: item?.name || "", + rank: typeof item?.rank === "number" ? item.rank : Number.MAX_VALUE, + tagCount: item?.tags?.length || 0, + }), + isLoading: isFetching, }); - // Rows - - const collapseRow = ( - event: React.MouseEvent, - rowIndex: number, - isOpen: boolean, - rowData: IRowData, - extraData: IExtraData - ) => { - const row = getRow(rowData); - toggleItemExpanded(row); - }; - - const deleteRow = (row: TagCategory) => { - setTagCategoryToDelete(row); - }; - - // Advanced filters - - const handleOnClearAllFilters = () => { - setFilterValues({}); - }; + const { + currentPageItems, + numRenderedColumns, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, + }, + expansionDerivedState: { isCellExpanded }, + } = tableControls; return ( <> @@ -368,70 +268,165 @@ export const Tags: React.FC = () => { when={isFetching && !(tagCategories || fetchError)} then={} > - - } - toolbarActions={ - - - - - - - + + + + + + + + + + + + + + + + + + + + + {t("composed.noDataStateTitle", { + what: t("terms.tags").toLowerCase(), + })} + + + {t("composed.noDataStateBody", { + how: t("terms.create"), + what: t("terms.tags").toLowerCase(), + })} + + + } + numRenderedColumns={numRenderedColumns} + > + {currentPageItems?.map((tagCategory, rowIndex) => { + const hasTags = tagCategory.tags && tagCategory.tags.length > 0; + const categoryColor = + tagCategory.colour || + getTagCategoryFallbackColor(tagCategory); + + return ( + - {t("actions.createTagCategory")} - - - - - } - noDataState={ - + + + + + + + + + {isCellExpanded(tagCategory) && ( + + + + )} + + ); })} - // t('terms.stakeholderGroup') - description={t("composed.noDataStateBody", { - what: t("terms.tagCategory").toLowerCase(), - })} - /> - } - /> + +
+ + + + +
+ {tagCategory.name} + + {tagCategory.rank} + + + + {tagCategory.tags?.length || 0} + + setTagCategoryModalState(tagCategory)} + onDelete={() => setTagCategoryToDelete(tagCategory)} + /> +
+ + {hasTags ? ( + + ) : ( + + + + {t("message.noTagsAvailable")} + + + {t("message.noAssociatedTags")} + + + )} + +
+ +