From 969427f0fb6fc860efec6dcc4ca0d44dfaa373b5 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 23 Sep 2026 14:40:53 -0400 Subject: [PATCH 1/6] add shared management column helper &&add application column management Signed-off-by: Atif Ali --- locales/en/plugin__gitops-plugin.json | 9 + locales/es/plugin__gitops-plugin.json | 9 + locales/fr/plugin__gitops-plugin.json | 9 + locales/ja/plugin__gitops-plugin.json | 9 + locales/ko/plugin__gitops-plugin.json | 9 + locales/zh/plugin__gitops-plugin.json | 9 + .../components/shared/ApplicationList.tsx | 241 +++++++++-------- .../shared/ApplicationSetApplicationsView.tsx | 22 +- .../GitOpsColumnManagementControl.tsx | 60 +++++ .../GitOpsColumnManagementModal.scss | 24 ++ .../GitOpsColumnManagementModal.tsx | 247 ++++++++++++++++++ .../columnManagementUtils.test.ts | 227 ++++++++++++++++ .../ColumnManagement/columnManagementUtils.ts | 120 +++++++++ .../shared/ColumnManagement/index.ts | 28 ++ .../shared/ColumnManagement/types.ts | 33 +++ .../useGitOpsColumnManagement.tsx | 110 ++++++++ .../shared/GitOpsListPageToolbar.scss | 53 ++++ .../shared/GitOpsListPageToolbar.tsx | 32 +++ .../shared/applicationListColumns.test.ts | 62 +++++ .../shared/applicationListColumns.ts | 30 +++ src/gitops/models/ApplicationModel.ts | 2 + 21 files changed, 1220 insertions(+), 125 deletions(-) create mode 100644 src/gitops/components/shared/ColumnManagement/GitOpsColumnManagementControl.tsx create mode 100644 src/gitops/components/shared/ColumnManagement/GitOpsColumnManagementModal.scss create mode 100644 src/gitops/components/shared/ColumnManagement/GitOpsColumnManagementModal.tsx create mode 100644 src/gitops/components/shared/ColumnManagement/columnManagementUtils.test.ts create mode 100644 src/gitops/components/shared/ColumnManagement/columnManagementUtils.ts create mode 100644 src/gitops/components/shared/ColumnManagement/index.ts create mode 100644 src/gitops/components/shared/ColumnManagement/types.ts create mode 100644 src/gitops/components/shared/ColumnManagement/useGitOpsColumnManagement.tsx create mode 100644 src/gitops/components/shared/GitOpsListPageToolbar.scss create mode 100644 src/gitops/components/shared/GitOpsListPageToolbar.tsx create mode 100644 src/gitops/components/shared/applicationListColumns.test.ts create mode 100644 src/gitops/components/shared/applicationListColumns.ts diff --git a/locales/en/plugin__gitops-plugin.json b/locales/en/plugin__gitops-plugin.json index a11904d03..855f753dc 100644 --- a/locales/en/plugin__gitops-plugin.json +++ b/locales/en/plugin__gitops-plugin.json @@ -102,6 +102,7 @@ "Argo CD project that this ApplicationSet belongs to.": "Argo CD project that this ApplicationSet belongs to.", "Git repository URL where the ApplicationSet configuration is stored.": "Git repository URL where the ApplicationSet configuration is stored.", "Progressive Sync Step {{x}}": "Progressive Sync Step {{x}}", + "Application": "Application", "Applications": "Applications", "Show all match expressions": "Show all match expressions", "Edit ApplicationSet": "Edit ApplicationSet", @@ -342,6 +343,14 @@ "Created at": "Created at", "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.": "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.", "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.": "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.", + "Manage columns": "Manage columns", + "Selected columns will appear in the table.": "Selected columns will appear in the table.", + "The namespace column is only shown when in \"All projects\"": "The namespace column is only shown when in \"All projects\"", + "Default {{resourceKind}} columns": "Default {{resourceKind}} columns", + "Additional columns": "Additional columns", + "Restore default columns": "Restore default columns", + "Save": "Save", + "Cancel": "Cancel", "Pagination": "Pagination", "Go to first page": "Go to first page", "Go to previous page": "Go to previous page", diff --git a/locales/es/plugin__gitops-plugin.json b/locales/es/plugin__gitops-plugin.json index 8350ce242..26423f9b1 100644 --- a/locales/es/plugin__gitops-plugin.json +++ b/locales/es/plugin__gitops-plugin.json @@ -102,6 +102,7 @@ "Argo CD project that this ApplicationSet belongs to.": "Proyecto Argo CD al que pertenece este ApplicationSet.", "Git repository URL where the ApplicationSet configuration is stored.": "URL del repositorio Git en el que se almacena la configuración de ApplicationSet.", "Progressive Sync Step {{x}}": "Paso de sincronización progresiva {{x}}", + "Application": "Aplicación", "Applications": "Aplicaciones", "Show all match expressions": "Mostrar todas las expresiones de coincidencia", "Edit ApplicationSet": "Editar ApplicationSet", @@ -342,6 +343,14 @@ "Created at": "Creado en", "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.": "Time es una envoltura de time. Tiempo que admite la serialización correcta a YAML y JSON.", "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.": "Las referencias del propietario vinculan este recurso con su objeto principal. Por ejemplo, las aplicaciones generadas por un ApplicationSet tendrán a ese ApplicationSet como propietario. Esta relación permite una gestión adecuada del ciclo de vida de los recursos y la recolección de basura.", + "Manage columns": "Administrar columnas", + "Selected columns will appear in the table.": "Las columnas seleccionadas aparecerán en la tabla.", + "The namespace column is only shown when in \"All projects\"": "La columna de espacio de nombres solo se muestra en \"Todos los proyectos\"", + "Default {{resourceKind}} columns": "Columnas {{resourceKind}} predeterminadas", + "Additional columns": "Columnas adicionales", + "Restore default columns": "Restaurar columnas predeterminadas", + "Save": "Guardar", + "Cancel": "Cancelar", "Pagination": "Pagination", "Go to first page": "Go to first page", "Go to previous page": "Go to previous page", diff --git a/locales/fr/plugin__gitops-plugin.json b/locales/fr/plugin__gitops-plugin.json index 7684479e0..0a17c84d4 100644 --- a/locales/fr/plugin__gitops-plugin.json +++ b/locales/fr/plugin__gitops-plugin.json @@ -102,6 +102,7 @@ "Argo CD project that this ApplicationSet belongs to.": "Projet Argo CD auquel appartient cet ApplicationSet.", "Git repository URL where the ApplicationSet configuration is stored.": "URL du dépôt Git où est stockée la configuration ApplicationSet.", "Progressive Sync Step {{x}}": "Étape de synchronisation progressive {{x}}", + "Application": "Application", "Applications": "Applications", "Show all match expressions": "Afficher toutes les expressions de correspondance", "Edit ApplicationSet": "Modifier l'ensemble d'applications", @@ -342,6 +343,14 @@ "Created at": "Heure de création", "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.": "Le temps est un emballage autour du temps. Temps permettant une sérialisation correcte en YAML et JSON.", "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.": "Les références de propriétaire relient cette ressource à son objet parent. Par exemple, les applications générées par un ApplicationSet auront cet ApplicationSet comme propriétaire. Cette relation permet une gestion adéquate du cycle de vie des ressources et une collecte efficace des déchets.", + "Manage columns": "Gérer les colonnes", + "Selected columns will appear in the table.": "Les colonnes sélectionnées apparaîtront dans le tableau.", + "The namespace column is only shown when in \"All projects\"": "La colonne d’espace de noms n’est affichée que dans « Tous les projets ».", + "Default {{resourceKind}} columns": "Colonnes {{resourceKind}} par défaut", + "Additional columns": "Colonnes supplémentaires", + "Restore default columns": "Restaurer les colonnes par défaut", + "Save": "Enregistrer", + "Cancel": "Annuler", "Pagination": "Pagination", "Go to first page": "Go to first page", "Go to previous page": "Go to previous page", diff --git a/locales/ja/plugin__gitops-plugin.json b/locales/ja/plugin__gitops-plugin.json index 9e39988ee..a46261ae5 100644 --- a/locales/ja/plugin__gitops-plugin.json +++ b/locales/ja/plugin__gitops-plugin.json @@ -102,6 +102,7 @@ "Argo CD project that this ApplicationSet belongs to.": "この ApplicationSet が属する Argo CD プロジェクト。", "Git repository URL where the ApplicationSet configuration is stored.": "ApplicationSet の設定が保存されている Git リポジトリーの URL。", "Progressive Sync Step {{x}}": "Progressive Sync ステップ {{x}}", + "Application": "アプリケーション", "Applications": "アプリケーション", "Show all match expressions": "すべてのマッチ式の表示", "Edit ApplicationSet": "ApplicationSet の編集", @@ -342,6 +343,14 @@ "Created at": "作成日時", "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.": "Time は time のラッパーです。YAML と JSON への正しいマーシャリングをサポートする時間。", "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.": "オーナーリファレンスは、このリソースをその親オブジェクトにリンクします。たとえば、ApplicationSet によって生成されたアプリケーションは、その ApplicationSet を所有者として持つことになります。この関係により、適切なリソースライフサイクル管理とガベージコレクションが可能になります。", + "Manage columns": "列の管理", + "Selected columns will appear in the table.": "選択した列がテーブルに表示されます。", + "The namespace column is only shown when in \"All projects\"": "namespace 列は \"すべてのプロジェクト\" の場合にのみ表示されます", + "Default {{resourceKind}} columns": "デフォルトの {{resourceKind}} 列", + "Additional columns": "追加の列", + "Restore default columns": "デフォルト列の復元", + "Save": "保存", + "Cancel": "キャンセル", "Pagination": "Pagination", "Go to first page": "Go to first page", "Go to previous page": "Go to previous page", diff --git a/locales/ko/plugin__gitops-plugin.json b/locales/ko/plugin__gitops-plugin.json index 5319036c3..ebf95c3bc 100644 --- a/locales/ko/plugin__gitops-plugin.json +++ b/locales/ko/plugin__gitops-plugin.json @@ -102,6 +102,7 @@ "Argo CD project that this ApplicationSet belongs to.": "이 ApplicationSet이 속한 Argo CD 프로젝트입니다.", "Git repository URL where the ApplicationSet configuration is stored.": "ApplicationSet 구성이 저장되는 Git 리포지토리 URL입니다.", "Progressive Sync Step {{x}}": "점진적 동기화 단계 {{x}}", + "Application": "애플리케이션", "Applications": "애플리케이션", "Show all match expressions": "모든 일치 표현식 표시", "Edit ApplicationSet": "ApplicationSet 편집", @@ -342,6 +343,14 @@ "Created at": "생성 시간", "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.": "Time은 시간을 나타내는 래퍼입니다. Time 타입은 올바른 YAML 및 JSON 마샬링을 지원합니다.", "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.": "소유자 참조는 이 리소스를 상위 객체에 연결합니다. 예를 들어 ApplicationSet에서 생성된 애플리케이션은 해당 ApplicationSet이 소유자로 지정됩니다. 이 관계를 통해 적절한 리소스 라이프사이클 관리 및 가비지 컬렉션이 가능합니다.", + "Manage columns": "열 관리", + "Selected columns will appear in the table.": "선택한 열이 테이블에 표시됩니다.", + "The namespace column is only shown when in \"All projects\"": "네임 스페이스 열은 \"All projects\"에 있을 때만 표시됩니다.", + "Default {{resourceKind}} columns": "기본 {{resourceKind}} 열", + "Additional columns": "추가 열", + "Restore default columns": "기본 열 복원", + "Save": "저장", + "Cancel": "취소", "Pagination": "Pagination", "Go to first page": "Go to first page", "Go to previous page": "Go to previous page", diff --git a/locales/zh/plugin__gitops-plugin.json b/locales/zh/plugin__gitops-plugin.json index 3db9df646..386626779 100644 --- a/locales/zh/plugin__gitops-plugin.json +++ b/locales/zh/plugin__gitops-plugin.json @@ -102,6 +102,7 @@ "Argo CD project that this ApplicationSet belongs to.": "此 ApplicationSet 所属的 Argo CD 项目。", "Git repository URL where the ApplicationSet configuration is stored.": "存储 ApplicationSet 配置的 Git 存储库 URL。", "Progressive Sync Step {{x}}": "渐进式同步步骤 {{x}}", + "Application": "应用程序", "Applications": "应用程序", "Show all match expressions": "显示所有匹配表达式", "Edit ApplicationSet": "编辑 ApplicationSet", @@ -342,6 +343,14 @@ "Created at": "创建于", "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.": "Time 是对 time 的封装。支持正确进行 YAML 和 JSON 编组的 Time 类型。", "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.": "所有者引用将此资源关联到其父对象。例如,一个 ApplicationSet 生成的应用程序会将 ApplicationSet 作为所有者。这种关联关系可实现正确的资源生命周期管理和垃圾回收。", + "Manage columns": "管理列", + "Selected columns will appear in the table.": "选定的列将出现在表中。", + "The namespace column is only shown when in \"All projects\"": "仅在“所有项目”中显示名称空间列", + "Default {{resourceKind}} columns": "默认 {{resourceKind}} 列", + "Additional columns": "额外列", + "Restore default columns": "恢复默认列", + "Save": "保存", + "Cancel": "取消", "Pagination": "Pagination", "Go to first page": "Go to first page", "Go to previous page": "Go to previous page", diff --git a/src/gitops/components/shared/ApplicationList.tsx b/src/gitops/components/shared/ApplicationList.tsx index 8cf9df0cc..69bc7fd7d 100644 --- a/src/gitops/components/shared/ApplicationList.tsx +++ b/src/gitops/components/shared/ApplicationList.tsx @@ -15,6 +15,7 @@ import { } from '@openshift-console/dynamic-plugin-sdk'; import { ErrorState } from '@patternfly/react-component-groups'; import { EmptyState, EmptyStateBody, Flex, FlexItem, Spinner, Title } from '@patternfly/react-core'; +import type { DataViewTd } from '@patternfly/react-data-view/dist/esm/DataViewTable'; import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; import { CubesIcon } from '@patternfly/react-icons'; import { Tbody, Td, ThProps, Tr } from '@patternfly/react-table'; @@ -48,11 +49,21 @@ import { parseRowFilterParam, } from './applicationListFilters'; import ApplicationSetApplicationsView from './ApplicationSetApplicationsView'; +import { + APPLICATION_LIST_COLUMN_MANAGEMENT_ID, + getApplicationManagedColumns, +} from './applicationListColumns'; +import { + type GitOpsManagedColumn, + NAMESPACE_COLUMN_ID, + useGitOpsColumnManagement, +} from './ColumnManagement'; import { GitOpsDataViewTable, useGitOpsDataViewSort, useGitOpsListPagePagination, } from './DataView'; +import { GitOpsListPageToolbar } from './GitOpsListPageToolbar'; import { filterByConsoleNameAndLabels, filterResourcesByLabelQuery, @@ -114,19 +125,24 @@ const ApplicationList: React.FC = ({ }); const { t } = useTranslation('plugin__gitops-plugin'); + const includeNamespaceColumn = !namespace; + const managedColumns = React.useMemo( + () => getApplicationManagedColumns(includeNamespaceColumn, t), + [includeNamespaceColumn, t], + ); + + const { activeColumnIds, columnManagement, filterDataView } = useGitOpsColumnManagement({ + columnManagementID: APPLICATION_LIST_COLUMN_MANAGEMENT_ID, + columns: managedColumns, + resourceType: t('Application'), + includeNamespaceColumn, + showNamespaceHelp: includeNamespaceColumn, + }); + const columnSortConfig = React.useMemo( () => - [ - 'name', - ...(!listAllNamespaces || !namespace ? ['namespace'] : []), - 'sync-status', - 'health-status', - 'revision', - 'labels', - 'project', - 'actions', - ].map((key) => ({ key })), - [listAllNamespaces, namespace], + managedColumns.filter((column) => !column.alwaysShown).map((column) => ({ key: column.id })), + [managedColumns], ); const { searchParams, sortBy, direction, getSortParams } = @@ -135,7 +151,7 @@ const ApplicationList: React.FC = ({ // Get search query from URL parameters const searchQuery = searchParams.get('q') || ''; - const columnsDV = useColumnsDV(namespace, getSortParams); + const columnsDV = useColumnsDV(managedColumns, getSortParams, columnSortConfig); const sortedApplications = React.useMemo(() => { return sortData(applications, sortBy, direction); }, [applications, sortBy, direction]); @@ -185,14 +201,22 @@ const ApplicationList: React.FC = ({ namespace, searchParams, }); - const rows = useApplicationRowsDV(pagedApplications, namespace); + const rows = useApplicationRowsDV(pagedApplications, managedColumns); + const columnIds = React.useMemo( + () => managedColumns.map((column) => column.id), + [managedColumns], + ); + const { columns: visibleColumnsDV, rows: visibleRows } = React.useMemo( + () => filterDataView(columnsDV, rows, columnIds, activeColumnIds), + [filterDataView, columnsDV, rows, columnIds, activeColumnIds], + ); // Check if there are applications owned by this ApplicationSet initially (before filters/search) const hasOwnedApplications = ownedApps.length > 0; const empty = ( - + {(() => { @@ -228,7 +252,7 @@ const ApplicationList: React.FC = ({ const error = loadError && ( - + = ({ ); + + const listPageFilter = !hideNameLabelFilters && hasOwnedApplications && ( + + ); + return (
{showTitle == undefined && (project == undefined || appset == undefined) && ( @@ -270,14 +305,8 @@ const ApplicationList: React.FC = ({ )} - {!appset && !hideNameLabelFilters && hasOwnedApplications && ( - + {!appset && hasOwnedApplications && ( + )} {appset && ( = ({ onFilterChange={onFilterChange} nameFilterPlaceholder={t('plugin__gitops-plugin~Search by name...')} loaded={loaded} - columns={columnsDV} - rows={rows} + columns={visibleColumnsDV} + rows={visibleRows} + columnManagement={columnManagement} isEmpty={filteredBySearch.length === 0} emptyState={empty} errorState={error || undefined} @@ -302,8 +332,8 @@ const ApplicationList: React.FC = ({ )} {!appset && ( ); }; -const useApplicationRowsDV = (applicationsList, namespace): DataViewTr[] => { +const useApplicationRowsDV = ( + applicationsList: ApplicationKind[], + managedColumns: GitOpsManagedColumn[], +): DataViewTr[] => { + const includeNamespaceColumn = managedColumns.some((column) => column.id === NAMESPACE_COLUMN_ID); const rows: DataViewTr[] = []; applicationsList.forEach((app, index) => { let sources: ApplicationSource[]; @@ -403,8 +437,9 @@ const useApplicationRowsDV = (applicationsList, namespace): DataViewTr[] => { sources = []; revisions = []; } - rows.push([ - { + + const cellsById: Record = { + name: { cell: (
{
), - id: app.metadata?.name, + id: 'name', dataLabel: 'Name', }, - ...(!namespace - ? [ - { + ...(includeNamespaceColumn + ? { + [NAMESPACE_COLUMN_ID]: { cell: , - id: app.metadata.namespace, + id: NAMESPACE_COLUMN_ID, dataLabel: 'Namespace', }, - ] - : []), - { - id: app.status?.sync?.status, + } + : {}), + 'sync-status': { + id: 'sync-status', cell: (
@@ -446,12 +481,12 @@ const useApplicationRowsDV = (applicationsList, namespace): DataViewTr[] => {
), }, - { - id: app.status?.health?.status, + 'health-status': { + id: 'health-status', cell: , }, - { - id: app?.status?.sync?.revision, + revision: { + id: 'revision', cell: ( <> {sources[0]?.targetRevision ? sources[0].targetRevision : 'HEAD'}  @@ -466,7 +501,7 @@ const useApplicationRowsDV = (applicationsList, namespace): DataViewTr[] => { ), }, - { + labels: { id: 'labels', dataLabel: 'Labels', cell: ( @@ -485,8 +520,8 @@ const useApplicationRowsDV = (applicationsList, namespace): DataViewTr[] => {
), }, - { - id: app.spec?.project, + project: { + id: 'project', cell: app.spec?.project && ( { /> ), }, - { - id: 'actions-' + index, + actions: { + id: 'actions', cell: , props: { style: { paddingTop: 8, paddingRight: 0, paddingLeft: 0, width: 10 } }, }, - ]); + }; + + rows.push(managedColumns.map((column) => cellsById[column.id])); }); return rows; }; const useColumnsDV = ( - namespace: string, + managedColumns: GitOpsManagedColumn[], getSortParams: (columnIndex: number) => ThProps['sort'], + columnSortConfig: { key: string }[], ): DataViewTh[] => { - const i: number = namespace ? 0 : 1; const { t } = useTranslation('plugin__gitops-plugin'); - const columns: DataViewTh[] = [ - { - cell: t('Name'), - props: { - 'aria-label': 'name', - className: 'pf-m-width-25', - sort: getSortParams(0), - }, - }, - ...(!namespace - ? [ - { - cell: t('Namespace'), - props: { - 'aria-label': 'namespace', - className: 'pf-m-width-15', - sort: getSortParams(1), - }, - }, - ] - : []), - { - cell: t('Sync Status'), - props: { - 'aria-label': 'sync status', - className: 'pf-m-width-15', - sort: getSortParams(1 + i), - }, - }, - { - cell: t('Health Status'), - props: { - 'aria-label': 'health status', - className: 'pf-m-width-15', - sort: getSortParams(2 + i), - }, - }, - { - cell: t('Revision'), - props: { - 'aria-label': 'revision', - className: 'pf-m-width-12', - sort: getSortParams(3 + i), - }, - }, - { - cell: t('Labels'), - props: { - 'aria-label': 'labels', - className: 'pf-m-width-20', - sort: getSortParams(4 + i), - }, - }, - { - cell: t('App Project'), + + const titleById: Record = { + name: t('Name'), + [NAMESPACE_COLUMN_ID]: t('Namespace'), + 'sync-status': t('Sync Status'), + 'health-status': t('Health Status'), + revision: t('Revision'), + labels: t('Labels'), + project: t('App Project'), + actions: '', + }; + + const widthById: Record = { + name: 'pf-m-width-25', + [NAMESPACE_COLUMN_ID]: 'pf-m-width-15', + 'sync-status': 'pf-m-width-15', + 'health-status': 'pf-m-width-15', + revision: 'pf-m-width-12', + labels: 'pf-m-width-20', + project: 'pf-m-width-20', + }; + + return managedColumns.map((column) => { + if (column.id === 'actions') { + return { + cell: '', + props: { 'aria-label': 'actions' }, + }; + } + const sortIndex = columnSortConfig.findIndex((entry) => entry.key === column.id); + return { + cell: titleById[column.id] ?? column.title, props: { - 'aria-label': 'project', - className: 'pf-m-width-20', - sort: getSortParams(5 + i), + 'aria-label': column.id, + className: widthById[column.id], + ...(sortIndex >= 0 ? { sort: getSortParams(sortIndex) } : {}), }, - }, - { - cell: '', - props: { 'aria-label': 'actions' }, - }, - ]; - return columns; + }; + }); }; export default ApplicationList; diff --git a/src/gitops/components/shared/ApplicationSetApplicationsView.tsx b/src/gitops/components/shared/ApplicationSetApplicationsView.tsx index 367aa5f1a..a2249841e 100644 --- a/src/gitops/components/shared/ApplicationSetApplicationsView.tsx +++ b/src/gitops/components/shared/ApplicationSetApplicationsView.tsx @@ -3,13 +3,14 @@ import * as React from 'react'; import { ApplicationKind } from '@gitops/models/ApplicationModel'; import { ApplicationSetKind } from '@gitops/models/ApplicationSetModel'; import { ListPageFilter, RowFilter, useUserSettings } from '@openshift-console/dynamic-plugin-sdk'; -import { Flex, FlexItem, Stack, StackItem } from '@patternfly/react-core'; +import { Stack, StackItem } from '@patternfly/react-core'; import { DataViewState } from '@patternfly/react-data-view/dist/esm/DataView'; import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; import { ApplicationSetGraphView } from '../appset/graph/ApplicationSetGraphView'; import { type GitOpsDataViewPagination, GitOpsDataViewTable } from './DataView'; +import { GitOpsListPageToolbar } from './GitOpsListPageToolbar'; import GitOpsViewSwitcher from './GitOpsViewSwitcher'; import { APPLICATION_SET_APPLICATIONS_VIEW_SETTING_KEY, GitOpsViewType } from './GitOpsViewType'; @@ -27,6 +28,7 @@ type ApplicationSetApplicationsViewProps = { loaded: boolean; columns: DataViewTh[]; rows: DataViewTr[]; + columnManagement?: React.ReactNode; emptyState: React.ReactNode; errorState?: React.ReactNode; isError?: boolean; @@ -47,6 +49,7 @@ const ApplicationSetApplicationsView: React.FC - - - {!hideNameLabelFilters && hasOwnedApplications && ( + - )} - - + ) : undefined + } + columnManagement={hasOwnedApplications ? columnManagement : undefined} + actions={ - - + } + />
void; + resourceType: string; + showNamespaceHelp?: boolean; +}; + +export const GitOpsColumnManagementControl: React.FC = ({ + appliedColumns, + applyColumns, + resourceType, + showNamespaceHelp, +}) => { + const { t } = useGitOpsTranslation(); + const [isOpen, setIsOpen] = React.useState(false); + const manageColumnsLabel = t('Manage columns'); + + const onClose = React.useCallback(() => { + setIsOpen(false); + }, []); + + const onApplyColumns = React.useCallback( + (columns: GitOpsColumnManagementModalColumn[]) => { + applyColumns(columns); + setIsOpen(false); + }, + [applyColumns], + ); + + return ( + <> + + + + + + + ); +}; diff --git a/src/gitops/components/shared/ColumnManagement/columnManagementUtils.test.ts b/src/gitops/components/shared/ColumnManagement/columnManagementUtils.test.ts new file mode 100644 index 000000000..f5e528f0b --- /dev/null +++ b/src/gitops/components/shared/ColumnManagement/columnManagementUtils.test.ts @@ -0,0 +1,227 @@ +import type { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; + +import { + filterDataViewColumnsAndRows, + getDefaultActiveColumnIds, + getManageableColumns, + getSavableColumnIds, + resolveActiveColumnIds, + toColumnManagementModalColumns, +} from './columnManagementUtils'; +import { + type GitOpsManagedColumn, + getGitOpsColumnManagementSettingKey, + NAMESPACE_COLUMN_ID, +} from './types'; + +const baseColumns: GitOpsManagedColumn[] = [ + { id: 'name', title: 'Name', isShownByDefault: true, isUntoggleable: true }, + { id: NAMESPACE_COLUMN_ID, title: 'Namespace', isShownByDefault: true }, + { id: 'status', title: 'Status', isShownByDefault: true }, + { id: 'labels', title: 'Labels', isShownByDefault: true }, + { id: 'extra', title: 'Extra', isShownByDefault: false }, + { id: 'actions', title: '', isShownByDefault: true, alwaysShown: true }, +]; + +describe('getGitOpsColumnManagementSettingKey', () => { + it('prefixes the table id', () => { + expect(getGitOpsColumnManagementSettingKey('applications')).toBe( + 'gitops.columnManagement.applications', + ); + }); +}); + +describe('getManageableColumns', () => { + it('omits always-shown columns from the modal list', () => { + expect(getManageableColumns(baseColumns).map((c) => c.id)).toEqual([ + 'name', + 'namespace', + 'status', + 'labels', + 'extra', + ]); + }); +}); + +describe('getDefaultActiveColumnIds', () => { + it('includes default and always-shown columns in definition order', () => { + expect(getDefaultActiveColumnIds(baseColumns)).toEqual([ + 'name', + 'namespace', + 'status', + 'labels', + 'actions', + ]); + }); +}); + +describe('resolveActiveColumnIds', () => { + it('uses defaults when nothing is saved', () => { + expect(resolveActiveColumnIds({ columns: baseColumns })).toEqual([ + 'name', + 'namespace', + 'status', + 'labels', + 'actions', + ]); + }); + + it('applies saved preferences and keeps always-shown columns', () => { + expect( + resolveActiveColumnIds({ + columns: baseColumns, + savedColumnIds: ['name', 'extra'], + }), + ).toEqual(['name', 'extra', 'actions']); + }); + + it('drops unknown saved ids', () => { + expect( + resolveActiveColumnIds({ + columns: baseColumns, + savedColumnIds: ['name', 'stale', 'labels'], + }), + ).toEqual(['name', 'labels', 'actions']); + }); + + it('always keeps untoggleable columns', () => { + expect( + resolveActiveColumnIds({ + columns: baseColumns, + savedColumnIds: ['labels'], + }), + ).toEqual(['name', 'labels', 'actions']); + }); + + it('hides namespace when includeNamespaceColumn is false', () => { + expect( + resolveActiveColumnIds({ + columns: baseColumns, + savedColumnIds: ['name', 'namespace', 'status'], + includeNamespaceColumn: false, + }), + ).toEqual(['name', 'status', 'actions']); + }); +}); + +describe('toColumnManagementModalColumns', () => { + it('maps manageable columns for the Manage columns modal', () => { + const active = new Set(['name', 'status', 'actions']); + expect(toColumnManagementModalColumns(baseColumns, active)).toEqual([ + { + id: 'name', + title: 'Name', + isShown: true, + isShownByDefault: true, + isUntoggleable: true, + additional: undefined, + }, + { + id: 'namespace', + title: 'Namespace', + isShown: false, + isShownByDefault: true, + isUntoggleable: undefined, + additional: undefined, + }, + { + id: 'status', + title: 'Status', + isShown: true, + isShownByDefault: true, + isUntoggleable: undefined, + additional: undefined, + }, + { + id: 'labels', + title: 'Labels', + isShown: false, + isShownByDefault: true, + isUntoggleable: undefined, + additional: undefined, + }, + { + id: 'extra', + title: 'Extra', + isShown: false, + isShownByDefault: false, + isUntoggleable: undefined, + additional: undefined, + }, + ]); + }); + + it('omits namespace from the modal when includeNamespaceColumn is false', () => { + const ids = toColumnManagementModalColumns(baseColumns, ['name', 'status'], false).map( + (c) => c.id, + ); + expect(ids).not.toContain('namespace'); + }); +}); + +describe('getSavableColumnIds', () => { + it('persists only shown manageable column ids in definition order', () => { + const modalColumns = toColumnManagementModalColumns( + baseColumns, + new Set(['name', 'extra', 'actions']), + ); + expect(getSavableColumnIds(baseColumns, modalColumns)).toEqual(['name', 'extra']); + }); + + it('keeps previously saved ids that the current modal does not manage', () => { + const projectScopedColumns = baseColumns.filter( + (column) => column.id !== NAMESPACE_COLUMN_ID, + ); + const modalColumns = toColumnManagementModalColumns( + projectScopedColumns, + new Set(['name', 'status', 'actions']), + false, + ); + + expect( + getSavableColumnIds(projectScopedColumns, modalColumns, [ + 'name', + 'namespace', + 'labels', + 'extra', + ]), + ).toEqual(['namespace', 'name', 'status']); + }); +}); + +describe('filterDataViewColumnsAndRows', () => { + const columns: DataViewTh[] = [ + { cell: 'Name' }, + { cell: 'Namespace' }, + { cell: 'Status' }, + { cell: '' }, + ]; + const columnIds = ['name', 'namespace', 'status', 'actions']; + const rows: DataViewTr[] = [ + [{ cell: 'a' }, { cell: 'ns-a' }, { cell: 'ok' }, { cell: '…' }], + { + id: 'row-2', + row: [{ cell: 'b' }, { cell: 'ns-b' }, { cell: 'err' }, { cell: '…' }], + }, + ]; + + it('filters headers and both array and object rows by active ids', () => { + const result = filterDataViewColumnsAndRows( + columns, + rows, + columnIds, + new Set(['name', 'status', 'actions']), + ); + + expect( + result.columns.map((c) => (typeof c === 'object' && c && 'cell' in c ? c.cell : c)), + ).toEqual(['Name', 'Status', '']); + expect(result.rows).toEqual([ + [{ cell: 'a' }, { cell: 'ok' }, { cell: '…' }], + { + id: 'row-2', + row: [{ cell: 'b' }, { cell: 'err' }, { cell: '…' }], + }, + ]); + }); +}); diff --git a/src/gitops/components/shared/ColumnManagement/columnManagementUtils.ts b/src/gitops/components/shared/ColumnManagement/columnManagementUtils.ts new file mode 100644 index 000000000..ec0e324f3 --- /dev/null +++ b/src/gitops/components/shared/ColumnManagement/columnManagementUtils.ts @@ -0,0 +1,120 @@ +import type { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; + +import { + type GitOpsColumnManagementModalColumn, + type GitOpsManagedColumn, + type ResolveActiveColumnIdsOptions, + NAMESPACE_COLUMN_ID, +} from './types'; + +export const getManageableColumns = (columns: GitOpsManagedColumn[]): GitOpsManagedColumn[] => + columns.filter((column) => !column.alwaysShown); + +export const getAlwaysShownColumnIds = (columns: GitOpsManagedColumn[]): string[] => + columns.filter((column) => column.alwaysShown).map((column) => column.id); + +export const getDefaultActiveColumnIds = (columns: GitOpsManagedColumn[]): string[] => { + const active = new Set( + columns + .filter((column) => column.alwaysShown || column.isShownByDefault) + .map((column) => column.id), + ); + return columns.map((column) => column.id).filter((id) => active.has(id)); +}; + +export const resolveActiveColumnIds = ({ + columns, + savedColumnIds, + includeNamespaceColumn = true, +}: ResolveActiveColumnIdsOptions): string[] => { + const manageable = getManageableColumns(columns); + const knownIds = new Set(manageable.map((column) => column.id)); + const alwaysShownIds = getAlwaysShownColumnIds(columns); + + const defaultManageableIds = manageable + .filter((column) => column.isShownByDefault) + .map((column) => column.id); + + let activeManageableIds = + savedColumnIds && savedColumnIds.length > 0 + ? savedColumnIds.filter((id) => knownIds.has(id)) + : defaultManageableIds; + + manageable.forEach((column) => { + if (column.isUntoggleable && !activeManageableIds.includes(column.id)) { + activeManageableIds = [...activeManageableIds, column.id]; + } + }); + + if (!includeNamespaceColumn) { + activeManageableIds = activeManageableIds.filter((id) => id !== NAMESPACE_COLUMN_ID); + } + + const active = new Set([...activeManageableIds, ...alwaysShownIds]); + return columns.map((column) => column.id).filter((id) => active.has(id)); +}; + +export const toColumnManagementModalColumns = ( + columns: GitOpsManagedColumn[], + activeColumnIds: ReadonlySet | string[], + includeNamespaceColumn = true, +): GitOpsColumnManagementModalColumn[] => { + const active = activeColumnIds instanceof Set ? activeColumnIds : new Set(activeColumnIds); + + return getManageableColumns(columns) + .filter((column) => includeNamespaceColumn || column.id !== NAMESPACE_COLUMN_ID) + .map((column) => ({ + id: column.id, + title: column.title, + isShown: active.has(column.id), + isShownByDefault: column.isShownByDefault, + isUntoggleable: column.isUntoggleable, + additional: column.additional, + })); +}; + +export const getSavableColumnIds = ( + columns: GitOpsManagedColumn[], + modalColumns: GitOpsColumnManagementModalColumn[], + previouslySavedIds?: string[] | null, +): string[] => { + const managedByModal = new Set(modalColumns.map((column) => column.id)); + const shownFromModal = new Set( + modalColumns.filter((column) => column.isShown).map((column) => column.id), + ); + + // Keep prefs for columns this modal did not offer (e.g. namespace while project-scoped). + const preserved = + previouslySavedIds?.filter((id) => !managedByModal.has(id)) ?? []; + + const fromModal = getManageableColumns(columns) + .map((column) => column.id) + .filter((id) => shownFromModal.has(id)); + + return [...new Set([...preserved, ...fromModal])]; +}; + +export const filterDataViewColumnsAndRows = ( + columns: DataViewTh[], + rows: DataViewTr[], + columnIds: string[], + activeColumnIds: ReadonlySet | string[], +): { columns: DataViewTh[]; rows: DataViewTr[] } => { + const active = activeColumnIds instanceof Set ? activeColumnIds : new Set(activeColumnIds); + const indexes = columnIds + .map((id, index) => (active.has(id) ? index : -1)) + .filter((index) => index >= 0); + + const filteredColumns = indexes.map((index) => columns[index]); + const filteredRows = rows.map((row) => { + if (Array.isArray(row)) { + return indexes.map((index) => row[index]); + } + return { + ...row, + row: indexes.map((index) => row.row[index]), + }; + }); + + return { columns: filteredColumns, rows: filteredRows }; +}; diff --git a/src/gitops/components/shared/ColumnManagement/index.ts b/src/gitops/components/shared/ColumnManagement/index.ts new file mode 100644 index 000000000..18ed72898 --- /dev/null +++ b/src/gitops/components/shared/ColumnManagement/index.ts @@ -0,0 +1,28 @@ +export { + filterDataViewColumnsAndRows, + getAlwaysShownColumnIds, + getDefaultActiveColumnIds, + getManageableColumns, + getSavableColumnIds, + resolveActiveColumnIds, + toColumnManagementModalColumns, +} from './columnManagementUtils'; +export type { GitOpsColumnManagementControlProps } from './GitOpsColumnManagementControl'; +export { GitOpsColumnManagementControl } from './GitOpsColumnManagementControl'; +export type { GitOpsColumnManagementModalProps } from './GitOpsColumnManagementModal'; +export { GitOpsColumnManagementModal } from './GitOpsColumnManagementModal'; +export type { + GitOpsColumnManagementModalColumn, + GitOpsManagedColumn, + ResolveActiveColumnIdsOptions, +} from './types'; +export { + getGitOpsColumnManagementSettingKey, + GITOPS_COLUMN_MANAGEMENT_SETTING_PREFIX, + NAMESPACE_COLUMN_ID, +} from './types'; +export type { + UseGitOpsColumnManagementOptions, + UseGitOpsColumnManagementResult, +} from './useGitOpsColumnManagement'; +export { useGitOpsColumnManagement } from './useGitOpsColumnManagement'; diff --git a/src/gitops/components/shared/ColumnManagement/types.ts b/src/gitops/components/shared/ColumnManagement/types.ts new file mode 100644 index 000000000..a3f03b1ff --- /dev/null +++ b/src/gitops/components/shared/ColumnManagement/types.ts @@ -0,0 +1,33 @@ +export const NAMESPACE_COLUMN_ID = 'namespace'; + +export const GITOPS_COLUMN_MANAGEMENT_SETTING_PREFIX = 'gitops.columnManagement.'; + +export const getGitOpsColumnManagementSettingKey = (columnManagementID: string): string => + `${GITOPS_COLUMN_MANAGEMENT_SETTING_PREFIX}${columnManagementID}`; + +export type GitOpsManagedColumn = { + id: string; + title: string; + isShownByDefault: boolean; + isUntoggleable?: boolean; + /** Listed under "Additional columns" in the Manage columns modal. */ + additional?: boolean; + /** Always visible in the table; omitted from the modal (e.g. actions). */ + alwaysShown?: boolean; +}; + +export type GitOpsColumnManagementModalColumn = { + id: string; + title: string; + isShown: boolean; + isShownByDefault: boolean; + isUntoggleable?: boolean; + additional?: boolean; +}; + +export type ResolveActiveColumnIdsOptions = { + columns: GitOpsManagedColumn[]; + savedColumnIds?: string[] | null; + /** When false, omit the namespace column (project-scoped views). */ + includeNamespaceColumn?: boolean; +}; diff --git a/src/gitops/components/shared/ColumnManagement/useGitOpsColumnManagement.tsx b/src/gitops/components/shared/ColumnManagement/useGitOpsColumnManagement.tsx new file mode 100644 index 000000000..1db9ed42b --- /dev/null +++ b/src/gitops/components/shared/ColumnManagement/useGitOpsColumnManagement.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; + +import { useUserSettings } from '@openshift-console/dynamic-plugin-sdk'; + +import { + filterDataViewColumnsAndRows, + getDefaultActiveColumnIds, + getSavableColumnIds, + resolveActiveColumnIds, + toColumnManagementModalColumns, +} from './columnManagementUtils'; +import { GitOpsColumnManagementControl } from './GitOpsColumnManagementControl'; +import { + type GitOpsColumnManagementModalColumn, + type GitOpsManagedColumn, + getGitOpsColumnManagementSettingKey, +} from './types'; + +export type UseGitOpsColumnManagementOptions = { + columnManagementID: string; + columns: GitOpsManagedColumn[]; + resourceType: string; + includeNamespaceColumn?: boolean; + showNamespaceHelp?: boolean; +}; + +export type UseGitOpsColumnManagementResult = { + loaded: boolean; + activeColumnIds: string[]; + activeColumnIdSet: Set; + visibleColumns: GitOpsManagedColumn[]; + isColumnActive: (columnId: string) => boolean; + columnManagement: React.ReactElement; + applyColumns: (modalColumns: GitOpsColumnManagementModalColumn[]) => void; + filterDataView: typeof filterDataViewColumnsAndRows; +}; + +export const useGitOpsColumnManagement = ({ + columnManagementID, + columns, + resourceType, + includeNamespaceColumn = true, + showNamespaceHelp = false, +}: UseGitOpsColumnManagementOptions): UseGitOpsColumnManagementResult => { + const settingKey = getGitOpsColumnManagementSettingKey(columnManagementID); + const defaultIds = React.useMemo(() => getDefaultActiveColumnIds(columns), [columns]); + + const [savedColumnIds, setSavedColumnIds, loaded] = useUserSettings( + settingKey, + defaultIds, + true, + ); + + const activeColumnIds = React.useMemo( + () => + resolveActiveColumnIds({ + columns, + savedColumnIds: loaded ? savedColumnIds : defaultIds, + includeNamespaceColumn, + }), + [columns, savedColumnIds, loaded, defaultIds, includeNamespaceColumn], + ); + + const activeColumnIdSet = React.useMemo(() => new Set(activeColumnIds), [activeColumnIds]); + + const visibleColumns = React.useMemo( + () => columns.filter((column) => activeColumnIdSet.has(column.id)), + [columns, activeColumnIdSet], + ); + + const appliedModalColumns = React.useMemo( + () => toColumnManagementModalColumns(columns, activeColumnIdSet, includeNamespaceColumn), + [columns, activeColumnIdSet, includeNamespaceColumn], + ); + + const applyColumns = React.useCallback( + (modalColumns: GitOpsColumnManagementModalColumn[]) => { + setSavedColumnIds(getSavableColumnIds(columns, modalColumns, savedColumnIds)); + }, + [columns, savedColumnIds, setSavedColumnIds], + ); + + const isColumnActive = React.useCallback( + (columnId: string) => activeColumnIdSet.has(columnId), + [activeColumnIdSet], + ); + + const columnManagement = React.useMemo( + () => ( + + ), + [appliedModalColumns, applyColumns, resourceType, showNamespaceHelp], + ); + + return { + loaded, + activeColumnIds, + activeColumnIdSet, + visibleColumns, + isColumnActive, + columnManagement, + applyColumns, + filterDataView: filterDataViewColumnsAndRows, + }; +}; diff --git a/src/gitops/components/shared/GitOpsListPageToolbar.scss b/src/gitops/components/shared/GitOpsListPageToolbar.scss new file mode 100644 index 000000000..bfda88be4 --- /dev/null +++ b/src/gitops/components/shared/GitOpsListPageToolbar.scss @@ -0,0 +1,53 @@ +.gitops-list-page-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--pf-t--global--spacer--sm); + margin-bottom: var(--pf-t--global--spacer--sm); + + &__primary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--pf-t--global--spacer--sm); + min-width: 0; + } + + &__filters { + flex: 0 1 auto; + min-width: 0; + + // ListPageFilter's toolbar is full-width by default; shrink so columns sit beside search. + .pf-v6-c-toolbar, + .pf-c-toolbar { + width: auto; + padding-block: 0; + } + + .pf-v6-c-toolbar__content, + .pf-c-toolbar__content { + padding-inline: 0; + } + + .pf-v6-c-toolbar__content-section, + .pf-c-toolbar__content-section { + flex-wrap: wrap; + row-gap: var(--pf-t--global--spacer--sm); + } + } + + &__columns { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + min-height: var(--pf-t--global--spacer--2xl); + } + + &__actions { + display: flex; + flex: 0 0 auto; + align-items: center; + margin-inline-start: auto; + } +} diff --git a/src/gitops/components/shared/GitOpsListPageToolbar.tsx b/src/gitops/components/shared/GitOpsListPageToolbar.tsx new file mode 100644 index 000000000..e1c3f1caf --- /dev/null +++ b/src/gitops/components/shared/GitOpsListPageToolbar.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; + +import './GitOpsListPageToolbar.scss'; + +export type GitOpsListPageToolbarProps = { + filters?: React.ReactNode; + columnManagement?: React.ReactNode; + actions?: React.ReactNode; +}; + +/** Filters + manage-columns control on one row; optional trailing actions. */ +export const GitOpsListPageToolbar: React.FC = ({ + filters, + columnManagement, + actions, +}) => { + if (!filters && !columnManagement && !actions) { + return null; + } + + return ( +
+
+ {filters &&
{filters}
} + {columnManagement && ( +
{columnManagement}
+ )} +
+ {actions &&
{actions}
} +
+ ); +}; diff --git a/src/gitops/components/shared/applicationListColumns.test.ts b/src/gitops/components/shared/applicationListColumns.test.ts new file mode 100644 index 000000000..a576b647f --- /dev/null +++ b/src/gitops/components/shared/applicationListColumns.test.ts @@ -0,0 +1,62 @@ +import { getDefaultActiveColumnIds } from './ColumnManagement'; + +import { + APPLICATION_LIST_COLUMN_MANAGEMENT_ID, + getApplicationManagedColumns, +} from './applicationListColumns'; + +const t = (key: string) => key; + +describe('APPLICATION_LIST_COLUMN_MANAGEMENT_ID', () => { + it('uses a stable user-settings key', () => { + expect(APPLICATION_LIST_COLUMN_MANAGEMENT_ID).toBe('gitops.applications'); + }); +}); + +describe('getApplicationManagedColumns', () => { + it('locks Name, keeps Labels default-visible, and always shows actions', () => { + const columns = getApplicationManagedColumns(true, t); + const byId = Object.fromEntries(columns.map((column) => [column.id, column])); + + expect(byId.name.isUntoggleable).toBe(true); + expect(byId.labels.isShownByDefault).toBe(true); + expect(byId.actions.alwaysShown).toBe(true); + expect(columns.some((column) => column.additional)).toBe(false); + }); + + it('includes Namespace only for All projects', () => { + expect(getApplicationManagedColumns(true, t).map((column) => column.id)).toEqual([ + 'name', + 'namespace', + 'sync-status', + 'health-status', + 'revision', + 'labels', + 'project', + 'actions', + ]); + expect(getApplicationManagedColumns(false, t).map((column) => column.id)).toEqual([ + 'name', + 'sync-status', + 'health-status', + 'revision', + 'labels', + 'project', + 'actions', + ]); + }); + + it('defaults to every non-additional column visible', () => { + const columns = getApplicationManagedColumns(true, t); + expect(getDefaultActiveColumnIds(columns)).toEqual([ + 'name', + 'namespace', + 'sync-status', + 'health-status', + 'revision', + 'labels', + 'project', + 'actions', + ]); + }); +}); diff --git a/src/gitops/components/shared/applicationListColumns.ts b/src/gitops/components/shared/applicationListColumns.ts new file mode 100644 index 000000000..5159809b7 --- /dev/null +++ b/src/gitops/components/shared/applicationListColumns.ts @@ -0,0 +1,30 @@ +import type { GitOpsManagedColumn } from './ColumnManagement'; +import { NAMESPACE_COLUMN_ID } from './ColumnManagement'; + +export const APPLICATION_LIST_COLUMN_MANAGEMENT_ID = 'gitops.applications'; + +/** Column management metadata for the Applications list (and shared AppSet/Project app tables). */ +export const getApplicationManagedColumns = ( + includeNamespaceColumn: boolean, + t: (key: string) => string, +): GitOpsManagedColumn[] => { + const columns: GitOpsManagedColumn[] = [ + { id: 'name', title: t('Name'), isShownByDefault: true, isUntoggleable: true }, + ]; + if (includeNamespaceColumn) { + columns.push({ + id: NAMESPACE_COLUMN_ID, + title: t('Namespace'), + isShownByDefault: true, + }); + } + columns.push( + { id: 'sync-status', title: t('Sync Status'), isShownByDefault: true }, + { id: 'health-status', title: t('Health Status'), isShownByDefault: true }, + { id: 'revision', title: t('Revision'), isShownByDefault: true }, + { id: 'labels', title: t('Labels'), isShownByDefault: true }, + { id: 'project', title: t('App Project'), isShownByDefault: true }, + { id: 'actions', title: '', isShownByDefault: true, alwaysShown: true }, + ); + return columns; +}; diff --git a/src/gitops/models/ApplicationModel.ts b/src/gitops/models/ApplicationModel.ts index e9e521957..8c9ff3414 100644 --- a/src/gitops/models/ApplicationModel.ts +++ b/src/gitops/models/ApplicationModel.ts @@ -136,6 +136,8 @@ export type OperationState = { export type CurrentSyncStatus = { revision?: string; + /** Multi-source Applications (one entry per source). */ + revisions?: string[]; status?: SyncStatus; }; From d217e1da0ee7035df6b1cdc15e66b2aed7d626a7 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 23 Sep 2026 16:18:18 -0400 Subject: [PATCH 2/6] apply coderabbit's review comments && cleanup Signed-off-by: Atif Ali --- .../components/shared/ApplicationList.tsx | 13 +++---- .../GitOpsColumnManagementModal.tsx | 3 -- .../columnManagementUtils.test.ts | 35 +++++++++++++------ .../ColumnManagement/columnManagementUtils.ts | 4 +-- .../shared/ColumnManagement/index.ts | 6 ++-- .../shared/ColumnManagement/types.ts | 9 +++++ .../useGitOpsColumnManagement.tsx | 9 ++--- .../shared/GitOpsListPageToolbar.tsx | 1 - .../shared/applicationListColumns.test.ts | 3 +- .../shared/applicationListColumns.ts | 4 +-- 10 files changed, 46 insertions(+), 41 deletions(-) diff --git a/src/gitops/components/shared/ApplicationList.tsx b/src/gitops/components/shared/ApplicationList.tsx index 69bc7fd7d..0da83ca3e 100644 --- a/src/gitops/components/shared/ApplicationList.tsx +++ b/src/gitops/components/shared/ApplicationList.tsx @@ -41,6 +41,10 @@ import { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, } from './AllNamespaces'; +import { + APPLICATION_LIST_COLUMN_MANAGEMENT_ID, + getApplicationManagedColumns, +} from './applicationListColumns'; import { APPLICATION_HEALTH_FILTER_PARAM, APPLICATION_SYNC_FILTER_PARAM, @@ -49,10 +53,6 @@ import { parseRowFilterParam, } from './applicationListFilters'; import ApplicationSetApplicationsView from './ApplicationSetApplicationsView'; -import { - APPLICATION_LIST_COLUMN_MANAGEMENT_ID, - getApplicationManagedColumns, -} from './applicationListColumns'; import { type GitOpsManagedColumn, NAMESPACE_COLUMN_ID, @@ -126,10 +126,7 @@ const ApplicationList: React.FC = ({ const { t } = useTranslation('plugin__gitops-plugin'); const includeNamespaceColumn = !namespace; - const managedColumns = React.useMemo( - () => getApplicationManagedColumns(includeNamespaceColumn, t), - [includeNamespaceColumn, t], - ); + const managedColumns = React.useMemo(() => getApplicationManagedColumns(true, t), [t]); const { activeColumnIds, columnManagement, filterDataView } = useGitOpsColumnManagement({ columnManagementID: APPLICATION_LIST_COLUMN_MANAGEMENT_ID, diff --git a/src/gitops/components/shared/ColumnManagement/GitOpsColumnManagementModal.tsx b/src/gitops/components/shared/ColumnManagement/GitOpsColumnManagementModal.tsx index fc93a0c47..d9d5d76da 100644 --- a/src/gitops/components/shared/ColumnManagement/GitOpsColumnManagementModal.tsx +++ b/src/gitops/components/shared/ColumnManagement/GitOpsColumnManagementModal.tsx @@ -63,9 +63,6 @@ const ColumnRow: React.FC<{ ); }; -/** - * Manage columns modal with Default / Additional sections (console list-page layout). - */ export const GitOpsColumnManagementModal: React.FC = ({ isOpen, onClose, diff --git a/src/gitops/components/shared/ColumnManagement/columnManagementUtils.test.ts b/src/gitops/components/shared/ColumnManagement/columnManagementUtils.test.ts index f5e528f0b..d9c3681ed 100644 --- a/src/gitops/components/shared/ColumnManagement/columnManagementUtils.test.ts +++ b/src/gitops/components/shared/ColumnManagement/columnManagementUtils.test.ts @@ -169,24 +169,39 @@ describe('getSavableColumnIds', () => { }); it('keeps previously saved ids that the current modal does not manage', () => { - const projectScopedColumns = baseColumns.filter( - (column) => column.id !== NAMESPACE_COLUMN_ID, - ); const modalColumns = toColumnManagementModalColumns( - projectScopedColumns, + baseColumns, new Set(['name', 'status', 'actions']), false, ); expect( - getSavableColumnIds(projectScopedColumns, modalColumns, [ - 'name', - 'namespace', - 'labels', - 'extra', - ]), + getSavableColumnIds(baseColumns, modalColumns, ['name', 'namespace', 'labels', 'extra']), ).toEqual(['namespace', 'name', 'status']); }); + + it('preserves Namespace after a first save in a project-scoped view', () => { + const defaultIds = getDefaultActiveColumnIds(baseColumns); + expect(defaultIds).toContain('namespace'); + + const projectModalColumns = toColumnManagementModalColumns( + baseColumns, + new Set(['name', 'status', 'labels', 'actions']), + false, + ); + expect(projectModalColumns.map((column) => column.id)).not.toContain('namespace'); + + const savedAfterProjectEdit = getSavableColumnIds(baseColumns, projectModalColumns, defaultIds); + expect(savedAfterProjectEdit).toContain('namespace'); + + expect( + resolveActiveColumnIds({ + columns: baseColumns, + savedColumnIds: savedAfterProjectEdit, + includeNamespaceColumn: true, + }), + ).toContain('namespace'); + }); }); describe('filterDataViewColumnsAndRows', () => { diff --git a/src/gitops/components/shared/ColumnManagement/columnManagementUtils.ts b/src/gitops/components/shared/ColumnManagement/columnManagementUtils.ts index ec0e324f3..cd7778073 100644 --- a/src/gitops/components/shared/ColumnManagement/columnManagementUtils.ts +++ b/src/gitops/components/shared/ColumnManagement/columnManagementUtils.ts @@ -83,9 +83,7 @@ export const getSavableColumnIds = ( modalColumns.filter((column) => column.isShown).map((column) => column.id), ); - // Keep prefs for columns this modal did not offer (e.g. namespace while project-scoped). - const preserved = - previouslySavedIds?.filter((id) => !managedByModal.has(id)) ?? []; + const preserved = previouslySavedIds?.filter((id) => !managedByModal.has(id)) ?? []; const fromModal = getManageableColumns(columns) .map((column) => column.id) diff --git a/src/gitops/components/shared/ColumnManagement/index.ts b/src/gitops/components/shared/ColumnManagement/index.ts index 18ed72898..a1b756460 100644 --- a/src/gitops/components/shared/ColumnManagement/index.ts +++ b/src/gitops/components/shared/ColumnManagement/index.ts @@ -15,14 +15,12 @@ export type { GitOpsColumnManagementModalColumn, GitOpsManagedColumn, ResolveActiveColumnIdsOptions, + UseGitOpsColumnManagementOptions, } from './types'; export { getGitOpsColumnManagementSettingKey, GITOPS_COLUMN_MANAGEMENT_SETTING_PREFIX, NAMESPACE_COLUMN_ID, } from './types'; -export type { - UseGitOpsColumnManagementOptions, - UseGitOpsColumnManagementResult, -} from './useGitOpsColumnManagement'; +export type { UseGitOpsColumnManagementResult } from './useGitOpsColumnManagement'; export { useGitOpsColumnManagement } from './useGitOpsColumnManagement'; diff --git a/src/gitops/components/shared/ColumnManagement/types.ts b/src/gitops/components/shared/ColumnManagement/types.ts index a3f03b1ff..0247b8096 100644 --- a/src/gitops/components/shared/ColumnManagement/types.ts +++ b/src/gitops/components/shared/ColumnManagement/types.ts @@ -31,3 +31,12 @@ export type ResolveActiveColumnIdsOptions = { /** When false, omit the namespace column (project-scoped views). */ includeNamespaceColumn?: boolean; }; + +export type UseGitOpsColumnManagementOptions = { + columnManagementID: string; + /** Full catalog for prefs; use includeNamespaceColumn to hide Namespace in project views. */ + columns: GitOpsManagedColumn[]; + resourceType: string; + includeNamespaceColumn?: boolean; + showNamespaceHelp?: boolean; +}; diff --git a/src/gitops/components/shared/ColumnManagement/useGitOpsColumnManagement.tsx b/src/gitops/components/shared/ColumnManagement/useGitOpsColumnManagement.tsx index 1db9ed42b..398b9d109 100644 --- a/src/gitops/components/shared/ColumnManagement/useGitOpsColumnManagement.tsx +++ b/src/gitops/components/shared/ColumnManagement/useGitOpsColumnManagement.tsx @@ -13,16 +13,11 @@ import { GitOpsColumnManagementControl } from './GitOpsColumnManagementControl'; import { type GitOpsColumnManagementModalColumn, type GitOpsManagedColumn, + type UseGitOpsColumnManagementOptions, getGitOpsColumnManagementSettingKey, } from './types'; -export type UseGitOpsColumnManagementOptions = { - columnManagementID: string; - columns: GitOpsManagedColumn[]; - resourceType: string; - includeNamespaceColumn?: boolean; - showNamespaceHelp?: boolean; -}; +export type { UseGitOpsColumnManagementOptions }; export type UseGitOpsColumnManagementResult = { loaded: boolean; diff --git a/src/gitops/components/shared/GitOpsListPageToolbar.tsx b/src/gitops/components/shared/GitOpsListPageToolbar.tsx index e1c3f1caf..e8086996e 100644 --- a/src/gitops/components/shared/GitOpsListPageToolbar.tsx +++ b/src/gitops/components/shared/GitOpsListPageToolbar.tsx @@ -8,7 +8,6 @@ export type GitOpsListPageToolbarProps = { actions?: React.ReactNode; }; -/** Filters + manage-columns control on one row; optional trailing actions. */ export const GitOpsListPageToolbar: React.FC = ({ filters, columnManagement, diff --git a/src/gitops/components/shared/applicationListColumns.test.ts b/src/gitops/components/shared/applicationListColumns.test.ts index a576b647f..c117b729d 100644 --- a/src/gitops/components/shared/applicationListColumns.test.ts +++ b/src/gitops/components/shared/applicationListColumns.test.ts @@ -1,9 +1,8 @@ -import { getDefaultActiveColumnIds } from './ColumnManagement'; - import { APPLICATION_LIST_COLUMN_MANAGEMENT_ID, getApplicationManagedColumns, } from './applicationListColumns'; +import { getDefaultActiveColumnIds } from './ColumnManagement'; const t = (key: string) => key; diff --git a/src/gitops/components/shared/applicationListColumns.ts b/src/gitops/components/shared/applicationListColumns.ts index 5159809b7..12a6e02ae 100644 --- a/src/gitops/components/shared/applicationListColumns.ts +++ b/src/gitops/components/shared/applicationListColumns.ts @@ -1,9 +1,7 @@ -import type { GitOpsManagedColumn } from './ColumnManagement'; -import { NAMESPACE_COLUMN_ID } from './ColumnManagement'; +import { type GitOpsManagedColumn, NAMESPACE_COLUMN_ID } from './ColumnManagement/types'; export const APPLICATION_LIST_COLUMN_MANAGEMENT_ID = 'gitops.applications'; -/** Column management metadata for the Applications list (and shared AppSet/Project app tables). */ export const getApplicationManagedColumns = ( includeNamespaceColumn: boolean, t: (key: string) => string, From 6d86d5ca6d0fa0bb1b0fb34eff9e53158e902916 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 23 Sep 2026 17:51:17 -0400 Subject: [PATCH 3/6] add mamanged columns for AppSets && add Additional columns for Apps&AppSets Signed-off-by: Atif Ali --- .../components/shared/ApplicationSetList.tsx | 222 +++++++++--------- .../shared/applicationListColumns.test.ts | 17 +- .../shared/applicationListColumns.ts | 4 +- .../shared/applicationSetListColumns.test.ts | 70 ++++++ .../shared/applicationSetListColumns.ts | 28 +++ 5 files changed, 221 insertions(+), 120 deletions(-) create mode 100644 src/gitops/components/shared/applicationSetListColumns.test.ts create mode 100644 src/gitops/components/shared/applicationSetListColumns.ts diff --git a/src/gitops/components/shared/ApplicationSetList.tsx b/src/gitops/components/shared/ApplicationSetList.tsx index 2d4f64299..96b2f84e1 100644 --- a/src/gitops/components/shared/ApplicationSetList.tsx +++ b/src/gitops/components/shared/ApplicationSetList.tsx @@ -14,8 +14,9 @@ import { } from '@openshift-console/dynamic-plugin-sdk'; import { ErrorState } from '@patternfly/react-component-groups'; import { EmptyState, EmptyStateBody } from '@patternfly/react-core'; -import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; import { DataViewState } from '@patternfly/react-data-view/dist/esm/DataView'; +import type { DataViewTd } from '@patternfly/react-data-view/dist/esm/DataViewTable'; +import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; import { CubesIcon } from '@patternfly/react-icons'; import { Tbody, Td, ThProps, Tr } from '@patternfly/react-table'; @@ -31,11 +32,21 @@ import { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, } from './AllNamespaces'; +import { + APPLICATION_SET_LIST_COLUMN_MANAGEMENT_ID, + getApplicationSetManagedColumns, +} from './applicationSetListColumns'; +import { + type GitOpsManagedColumn, + NAMESPACE_COLUMN_ID, + useGitOpsColumnManagement, +} from './ColumnManagement'; import { GitOpsDataViewTable, useGitOpsDataViewSort, useGitOpsListPagePagination, } from './DataView'; +import { GitOpsListPageToolbar } from './GitOpsListPageToolbar'; import { filterByConsoleNameAndLabels, filterResourcesByLabelQuery, @@ -122,20 +133,21 @@ const ApplicationSetList: React.FC = ({ }); const { t } = useTranslation('plugin__gitops-plugin'); + const includeNamespaceColumn = !namespace; + const managedColumns = React.useMemo(() => getApplicationSetManagedColumns(true, t), [t]); + + const { activeColumnIds, columnManagement, filterDataView } = useGitOpsColumnManagement({ + columnManagementID: APPLICATION_SET_LIST_COLUMN_MANAGEMENT_ID, + columns: managedColumns, + resourceType: t('ApplicationSet'), + includeNamespaceColumn, + showNamespaceHelp: includeNamespaceColumn, + }); const columnSortConfig = React.useMemo( () => - [ - 'name', - ...(!listAllNamespaces || !namespace ? ['namespace'] : []), - 'status', - 'generated-apps', - 'generators', - 'labels', - 'created-at', - 'actions', - ].map((key) => ({ key })), - [listAllNamespaces, namespace], + managedColumns.filter((column) => !column.alwaysShown).map((column) => ({ key: column.id })), + [managedColumns], ); const { searchParams, sortBy, direction, getSortParams } = @@ -146,7 +158,7 @@ const ApplicationSetList: React.FC = ({ const nameQuery = searchParams.get('name') || ''; const labelsParam = searchParams.get('labels') || ''; - const columnsDV = useColumnsDV(namespace, getSortParams); + const columnsDV = useColumnsDV(managedColumns, getSortParams, columnSortConfig); const sortedApplicationSets = React.useMemo(() => { return sortData( applicationSets as ApplicationSetKind[], @@ -175,7 +187,15 @@ const ApplicationSetList: React.FC = ({ namespace, searchParams, }); - const rows = useApplicationSetRowsDV(pagedItems, namespace, applications, appsLoaded); + const rows = useApplicationSetRowsDV(pagedItems, managedColumns, applications, appsLoaded); + const columnIds = React.useMemo( + () => managedColumns.map((column) => column.id), + [managedColumns], + ); + const { columns: visibleColumnsDV, rows: visibleRows } = React.useMemo( + () => filterDataView(columnsDV, rows, columnIds, activeColumnIds), + [filterDataView, columnsDV, rows, columnIds, activeColumnIds], + ); // Check if there are ApplicationSets initially (before search) const hasApplicationSets = React.useMemo(() => { @@ -200,7 +220,7 @@ const ApplicationSetList: React.FC = ({ const empty = ( - + = ({ const error = loadError && ( - + = ({ sortedApplicationSets.length === 0 || filteredBySearch.length === 0; + const listPageFilter = !hideNameLabelFilters && hasApplicationSets && ( + + ); + return (
{showTitle == undefined && ( @@ -255,17 +284,12 @@ const ApplicationSetList: React.FC = ({ )} - {!hideNameLabelFilters && hasApplicationSets && ( - + {hasApplicationSets && ( + )} { const rows: DataViewTr[] = []; applicationSetsList.forEach((appSet: ApplicationSetKind, index: number) => { - rows.push([ - { + const cellsById: Record = { + name: { cell: (
, - id: appSet.metadata.namespace, - dataLabel: 'Namespace', - }, - ] - : []), - { + [NAMESPACE_COLUMN_ID]: { + cell: , + id: appSet.metadata.namespace, + dataLabel: 'Namespace', + }, + status: { id: getAppSetStatus(appSet), cell: , }, - { + 'generated-apps': { id: 'generated-apps-' + index, cell:
{getGeneratedAppsCount(appSet, applications, appsLoaded).toString()}
, }, - { + generators: { id: 'generators-' + index, cell:
{getAppSetGeneratorCount(appSet).toString()}
, }, - { + labels: { id: 'labels', dataLabel: 'Labels', cell: ( @@ -362,93 +382,67 @@ const useApplicationSetRowsDV = (
), }, - { + 'created-at': { id: 'created-at-' + index, cell:
{formatCreationTimestamp(appSet.metadata.creationTimestamp)}
, }, - { + actions: { id: 'actions-' + index, cell: , props: { style: { paddingTop: 8, paddingRight: 0, paddingLeft: 0, width: 10 } }, }, - ]); + }; + + rows.push(managedColumns.map((column) => cellsById[column.id])); }); return rows; }; const useColumnsDV = ( - namespace: string, + managedColumns: GitOpsManagedColumn[], getSortParams: (columnIndex: number) => ThProps['sort'], + columnSortConfig: { key: string }[], ): DataViewTh[] => { - const i: number = namespace ? 0 : 1; const { t } = useTranslation('plugin__gitops-plugin'); - const columns: DataViewTh[] = [ - { - cell: t('Name'), - props: { - 'aria-label': 'name', - className: 'pf-m-width-25', - sort: getSortParams(0), - }, - }, - ...(!namespace - ? [ - { - cell: t('Namespace'), - props: { - 'aria-label': 'namespace', - className: 'pf-m-width-15', - sort: getSortParams(1), - }, - }, - ] - : []), - { - cell: t('Health Status'), - props: { - 'aria-label': 'health status', - className: 'pf-m-width-15', - sort: getSortParams(1 + i), - }, - }, - { - cell: t('Generated Apps'), - props: { - 'aria-label': 'generated apps', - className: 'pf-m-width-15', - sort: getSortParams(2 + i), - }, - }, - { - cell: t('Generators'), - props: { - 'aria-label': 'generators', - className: 'pf-m-width-15', - sort: getSortParams(3 + i), - }, - }, - { - cell: t('Labels'), - props: { - 'aria-label': 'labels', - className: 'pf-m-width-20', - sort: getSortParams(4 + i), - }, - }, - { - cell: t('Created At'), + + const titleById: Record = { + name: t('Name'), + [NAMESPACE_COLUMN_ID]: t('Namespace'), + status: t('Health Status'), + 'generated-apps': t('Generated Apps'), + generators: t('Generators'), + labels: t('Labels'), + 'created-at': t('Created At'), + actions: '', + }; + + const widthById: Record = { + name: 'pf-m-width-25', + [NAMESPACE_COLUMN_ID]: 'pf-m-width-15', + status: 'pf-m-width-15', + 'generated-apps': 'pf-m-width-15', + generators: 'pf-m-width-15', + labels: 'pf-m-width-20', + 'created-at': 'pf-m-width-15', + }; + + return managedColumns.map((column) => { + if (column.id === 'actions') { + return { + cell: '', + props: { 'aria-label': 'actions' }, + }; + } + const sortIndex = columnSortConfig.findIndex((entry) => entry.key === column.id); + return { + cell: titleById[column.id] ?? column.title, props: { - 'aria-label': 'created at', - className: 'pf-m-width-15', - sort: getSortParams(5 + i), + 'aria-label': column.id, + className: widthById[column.id], + ...(sortIndex >= 0 ? { sort: getSortParams(sortIndex) } : {}), }, - }, - { - cell: '', - props: { 'aria-label': 'actions' }, - }, - ]; - return columns; + }; + }); }; const getFilters = (t: (key: string) => string): RowFilter[] => [ diff --git a/src/gitops/components/shared/applicationListColumns.test.ts b/src/gitops/components/shared/applicationListColumns.test.ts index c117b729d..f78ad3109 100644 --- a/src/gitops/components/shared/applicationListColumns.test.ts +++ b/src/gitops/components/shared/applicationListColumns.test.ts @@ -20,7 +20,18 @@ describe('getApplicationManagedColumns', () => { expect(byId.name.isUntoggleable).toBe(true); expect(byId.labels.isShownByDefault).toBe(true); expect(byId.actions.alwaysShown).toBe(true); - expect(columns.some((column) => column.additional)).toBe(false); + }); + + it('puts Revision and App Project under Additional columns', () => { + const columns = getApplicationManagedColumns(true, t); + const byId = Object.fromEntries(columns.map((column) => [column.id, column])); + + expect(byId.revision).toMatchObject({ isShownByDefault: false, additional: true }); + expect(byId.project).toMatchObject({ isShownByDefault: false, additional: true }); + expect(columns.filter((column) => column.additional).map((column) => column.id)).toEqual([ + 'revision', + 'project', + ]); }); it('includes Namespace only for All projects', () => { @@ -45,16 +56,14 @@ describe('getApplicationManagedColumns', () => { ]); }); - it('defaults to every non-additional column visible', () => { + it('defaults to Default columns only (excludes Additional)', () => { const columns = getApplicationManagedColumns(true, t); expect(getDefaultActiveColumnIds(columns)).toEqual([ 'name', 'namespace', 'sync-status', 'health-status', - 'revision', 'labels', - 'project', 'actions', ]); }); diff --git a/src/gitops/components/shared/applicationListColumns.ts b/src/gitops/components/shared/applicationListColumns.ts index 12a6e02ae..1eb4fb790 100644 --- a/src/gitops/components/shared/applicationListColumns.ts +++ b/src/gitops/components/shared/applicationListColumns.ts @@ -19,9 +19,9 @@ export const getApplicationManagedColumns = ( columns.push( { id: 'sync-status', title: t('Sync Status'), isShownByDefault: true }, { id: 'health-status', title: t('Health Status'), isShownByDefault: true }, - { id: 'revision', title: t('Revision'), isShownByDefault: true }, + { id: 'revision', title: t('Revision'), isShownByDefault: false, additional: true }, { id: 'labels', title: t('Labels'), isShownByDefault: true }, - { id: 'project', title: t('App Project'), isShownByDefault: true }, + { id: 'project', title: t('App Project'), isShownByDefault: false, additional: true }, { id: 'actions', title: '', isShownByDefault: true, alwaysShown: true }, ); return columns; diff --git a/src/gitops/components/shared/applicationSetListColumns.test.ts b/src/gitops/components/shared/applicationSetListColumns.test.ts new file mode 100644 index 000000000..0ec68bd56 --- /dev/null +++ b/src/gitops/components/shared/applicationSetListColumns.test.ts @@ -0,0 +1,70 @@ +import { + APPLICATION_SET_LIST_COLUMN_MANAGEMENT_ID, + getApplicationSetManagedColumns, +} from './applicationSetListColumns'; +import { getDefaultActiveColumnIds } from './ColumnManagement'; + +const t = (key: string) => key; + +describe('APPLICATION_SET_LIST_COLUMN_MANAGEMENT_ID', () => { + it('uses a stable user-settings key', () => { + expect(APPLICATION_SET_LIST_COLUMN_MANAGEMENT_ID).toBe('gitops.applicationsets'); + }); +}); + +describe('getApplicationSetManagedColumns', () => { + it('locks Name, keeps Labels default-visible, and always shows actions', () => { + const columns = getApplicationSetManagedColumns(true, t); + const byId = Object.fromEntries(columns.map((column) => [column.id, column])); + + expect(byId.name.isUntoggleable).toBe(true); + expect(byId.labels.isShownByDefault).toBe(true); + expect(byId.actions.alwaysShown).toBe(true); + }); + + it('puts Generators and Created At under Additional columns', () => { + const columns = getApplicationSetManagedColumns(true, t); + const byId = Object.fromEntries(columns.map((column) => [column.id, column])); + + expect(byId.generators).toMatchObject({ isShownByDefault: false, additional: true }); + expect(byId['created-at']).toMatchObject({ isShownByDefault: false, additional: true }); + expect(columns.filter((column) => column.additional).map((column) => column.id)).toEqual([ + 'generators', + 'created-at', + ]); + }); + + it('includes Namespace only for All projects', () => { + expect(getApplicationSetManagedColumns(true, t).map((column) => column.id)).toEqual([ + 'name', + 'namespace', + 'status', + 'generated-apps', + 'generators', + 'labels', + 'created-at', + 'actions', + ]); + expect(getApplicationSetManagedColumns(false, t).map((column) => column.id)).toEqual([ + 'name', + 'status', + 'generated-apps', + 'generators', + 'labels', + 'created-at', + 'actions', + ]); + }); + + it('defaults to Default columns only (excludes Additional)', () => { + const columns = getApplicationSetManagedColumns(true, t); + expect(getDefaultActiveColumnIds(columns)).toEqual([ + 'name', + 'namespace', + 'status', + 'generated-apps', + 'labels', + 'actions', + ]); + }); +}); diff --git a/src/gitops/components/shared/applicationSetListColumns.ts b/src/gitops/components/shared/applicationSetListColumns.ts new file mode 100644 index 000000000..101feeaa3 --- /dev/null +++ b/src/gitops/components/shared/applicationSetListColumns.ts @@ -0,0 +1,28 @@ +import { type GitOpsManagedColumn, NAMESPACE_COLUMN_ID } from './ColumnManagement/types'; + +export const APPLICATION_SET_LIST_COLUMN_MANAGEMENT_ID = 'gitops.applicationsets'; + +export const getApplicationSetManagedColumns = ( + includeNamespaceColumn: boolean, + t: (key: string) => string, +): GitOpsManagedColumn[] => { + const columns: GitOpsManagedColumn[] = [ + { id: 'name', title: t('Name'), isShownByDefault: true, isUntoggleable: true }, + ]; + if (includeNamespaceColumn) { + columns.push({ + id: NAMESPACE_COLUMN_ID, + title: t('Namespace'), + isShownByDefault: true, + }); + } + columns.push( + { id: 'status', title: t('Health Status'), isShownByDefault: true }, + { id: 'generated-apps', title: t('Generated Apps'), isShownByDefault: true }, + { id: 'generators', title: t('Generators'), isShownByDefault: false, additional: true }, + { id: 'labels', title: t('Labels'), isShownByDefault: true }, + { id: 'created-at', title: t('Created At'), isShownByDefault: false, additional: true }, + { id: 'actions', title: '', isShownByDefault: true, alwaysShown: true }, + ); + return columns; +}; From c94d40e3002e68dfc6bfbc4fde624612c1e44501 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Sat, 26 Sep 2026 12:31:15 -0400 Subject: [PATCH 4/6] add managed columns for AppProjects Signed-off-by: Atif Ali --- src/gitops/components/project/ProjectList.tsx | 219 ++++++++++-------- .../shared/projectListColumns.test.ts | 64 +++++ .../components/shared/projectListColumns.ts | 27 +++ 3 files changed, 207 insertions(+), 103 deletions(-) create mode 100644 src/gitops/components/shared/projectListColumns.test.ts create mode 100644 src/gitops/components/shared/projectListColumns.ts diff --git a/src/gitops/components/project/ProjectList.tsx b/src/gitops/components/project/ProjectList.tsx index 44493112d..8dc0dccc8 100644 --- a/src/gitops/components/project/ProjectList.tsx +++ b/src/gitops/components/project/ProjectList.tsx @@ -19,6 +19,7 @@ import { import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; import { ErrorState } from '@patternfly/react-component-groups'; import { EmptyState, EmptyStateBody } from '@patternfly/react-core'; +import type { DataViewTd } from '@patternfly/react-data-view/dist/esm/DataViewTable'; import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; import { CubesIcon } from '@patternfly/react-icons'; import { ThProps } from '@patternfly/react-table'; @@ -30,13 +31,27 @@ import { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, } from '../shared/AllNamespaces'; +import { + type GitOpsManagedColumn, + NAMESPACE_COLUMN_ID, + useGitOpsColumnManagement, +} from '../shared/ColumnManagement'; import { GitOpsDataViewTable, useGitOpsDataViewSort, useGitOpsListPagePagination, } from '../shared/DataView'; -import { filterByConsoleNameAndLabels, getLabelsSortKey, parseLabelFilterParam } from '../shared/listPageTextFilters'; +import { GitOpsListPageToolbar } from '../shared/GitOpsListPageToolbar'; +import { + filterByConsoleNameAndLabels, + getLabelsSortKey, + parseLabelFilterParam, +} from '../shared/listPageTextFilters'; import { MetadataLabels } from '../shared/MetadataLabels/MetadataLabels'; +import { + getProjectManagedColumns, + PROJECT_LIST_COLUMN_MANAGEMENT_ID, +} from '../shared/projectListColumns'; import { useProjectActionsProvider } from './hooks/useProjectActionsProvider'; @@ -83,17 +98,23 @@ const ProjectList: React.FC = ({ namespace, }); - const columnSortConfig = React.useMemo(() => { - return [ - 'name', - ...(!listAllNamespaces || !namespace || namespace === '' ? ['namespace'] : []), - 'description', - 'applications', - 'labels', - 'last-updated', - 'actions', - ].map((key) => ({ key })); - }, [listAllNamespaces, namespace]); + const { t } = useTranslation('plugin__gitops-plugin'); + const includeNamespaceColumn = !namespace || namespace === ''; + const managedColumns = React.useMemo(() => getProjectManagedColumns(true, t), [t]); + + const { activeColumnIds, columnManagement, filterDataView } = useGitOpsColumnManagement({ + columnManagementID: PROJECT_LIST_COLUMN_MANAGEMENT_ID, + columns: managedColumns, + resourceType: t('AppProjects'), + includeNamespaceColumn, + showNamespaceHelp: includeNamespaceColumn, + }); + + const columnSortConfig = React.useMemo( + () => + managedColumns.filter((column) => !column.alwaysShown).map((column) => ({ key: column.id })), + [managedColumns], + ); const { searchParams, sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); @@ -103,9 +124,7 @@ const ProjectList: React.FC = ({ const nameQuery = searchParams.get('name') || ''; const labelsParam = searchParams.get('labels') || ''; - const { t } = useTranslation('plugin__gitops-plugin'); - - const columnsDV = useColumnsDV(namespace, getSortParams); + const columnsDV = useColumnsDV(managedColumns, getSortParams, columnSortConfig); const sortedProjects = React.useMemo(() => { return sortData(appProjects as AppProjectKind[], sortBy, direction, applications, appsLoaded); }, [appProjects, sortBy, direction, applications, appsLoaded]); @@ -149,8 +168,15 @@ const ProjectList: React.FC = ({ namespace, searchParams, }); - const rows = useProjectsRowsDV(pagedItems, namespace, applications, appsLoaded); - const showNamespaceColumn = !namespace || namespace === ''; + const rows = useProjectsRowsDV(pagedItems, managedColumns, applications, appsLoaded); + const columnIds = React.useMemo( + () => managedColumns.map((column) => column.id), + [managedColumns], + ); + const { columns: visibleColumnsDV, rows: visibleRows } = React.useMemo( + () => filterDataView(columnsDV, rows, columnIds, activeColumnIds), + [filterDataView, columnsDV, rows, columnIds, activeColumnIds], + ); // Check if there are projects initially (before search) const hasProjects = React.useMemo(() => { @@ -176,7 +202,7 @@ const ProjectList: React.FC = ({ const empty = ( - + = ({ const error = loadError && ( - + = ({ ); - const isEmptyState = !loadError && rows.length === 0; + const isEmptyState = !loadError && visibleRows.length === 0; + + const listPageFilter = !hideNameLabelFilters && hasProjects && ( + + ); return (
@@ -224,25 +260,19 @@ const ProjectList: React.FC = ({ )} - {!hideNameLabelFilters && hasProjects && ( - + {hasProjects && ( + )}
({ - 'aria-label': ariaLabel, - className, - sort, - tooltip: '', -}); - export const useColumnsDV = ( - namespace: string | undefined, + managedColumns: GitOpsManagedColumn[], getSortParams: (columnIndex: number) => ThProps['sort'], + columnSortConfig: { key: string }[], ): DataViewTh[] => { - const showNamespace = !namespace || namespace === ''; - const i: number = showNamespace ? 1 : 0; const { t } = useTranslation('plugin__gitops-plugin'); - const columns: DataViewTh[] = [ - { - cell: t('Name'), - props: sortableHeaderProps('name', 'pf-m-width-20', getSortParams(0)), - }, - ...(showNamespace - ? [ - { - cell: t('Namespace'), - props: sortableHeaderProps('namespace', 'pf-m-width-15', getSortParams(1)), - }, - ] - : []), - { - cell: t('Description'), - props: sortableHeaderProps('description', 'pf-m-width-10', getSortParams(1 + i)), - }, - { - cell: t('Applications'), - props: sortableHeaderProps('applications', 'pf-m-width-15', getSortParams(2 + i)), - }, - { - cell: t('Labels'), - props: sortableHeaderProps('labels', 'pf-m-width-20', getSortParams(3 + i)), - }, - { - cell: t('Last Updated'), - props: sortableHeaderProps( - 'last updated', - 'pf-m-width-15', - getSortParams(showNamespace ? 5 : 4), - ), - }, - { - cell: '', - props: { 'aria-label': 'actions' }, - }, - ]; - return columns; + const titleById: Record = { + name: t('Name'), + [NAMESPACE_COLUMN_ID]: t('Namespace'), + description: t('Description'), + applications: t('Applications'), + labels: t('Labels'), + 'last-updated': t('Last Updated'), + actions: '', + }; + + const widthById: Record = { + name: 'pf-m-width-20', + [NAMESPACE_COLUMN_ID]: 'pf-m-width-15', + description: 'pf-m-width-10', + applications: 'pf-m-width-15', + labels: 'pf-m-width-20', + 'last-updated': 'pf-m-width-15', + }; + + return managedColumns.map((column) => { + if (column.id === 'actions') { + return { + cell: '', + props: { 'aria-label': 'actions' }, + }; + } + const sortIndex = columnSortConfig.findIndex((entry) => entry.key === column.id); + return { + cell: titleById[column.id] ?? column.title, + props: { + 'aria-label': column.id, + className: widthById[column.id], + tooltip: '', + ...(sortIndex >= 0 ? { sort: getSortParams(sortIndex) } : {}), + }, + }; + }); }; export const useProjectsRowsDV = ( projectsList: AppProjectKind[], - namespace: string | undefined, + managedColumns: GitOpsManagedColumn[], applications: ApplicationKind[], appsLoaded: boolean, ): DataViewTr[] => { @@ -460,12 +476,11 @@ export const useProjectsRowsDV = ( if (projectsList == undefined || projectsList.length == 0) { return rows; } - const showNamespace = !namespace || namespace === ''; projectsList.forEach((obj, index) => { const appsCount = getApplicationsCount(obj, applications, appsLoaded); - rows.push([ - { + const cellsById: Record = { + name: { cell: ( , - id: obj.metadata.namespace, - dataLabel: 'Namespace', - }, - ] - : []), - { + [NAMESPACE_COLUMN_ID]: { + cell: , + id: obj.metadata.namespace, + dataLabel: 'Namespace', + }, + description: { id: 'description', dataLabel: 'Description', cell: ( {obj.spec?.description || '-'} ), }, - { + applications: { id: 'applications', dataLabel: 'Applications', cell: ( @@ -502,7 +513,7 @@ export const useProjectsRowsDV = ( ), }, - { + labels: { id: 'labels', dataLabel: 'Labels', cell: ( @@ -521,7 +532,7 @@ export const useProjectsRowsDV = (
), }, - { + 'last-updated': { id: 'last-updated', cell: (() => { const lastUpdate = getLastUpdateTimestamp(obj); @@ -532,12 +543,14 @@ export const useProjectsRowsDV = ( ); })(), }, - { + actions: { id: 'actions-' + index, cell: , props: { style: { paddingTop: 8, paddingRight: 0, paddingLeft: 0, width: 10 } }, }, - ]); + }; + + rows.push(managedColumns.map((column) => cellsById[column.id])); }); return rows; }; diff --git a/src/gitops/components/shared/projectListColumns.test.ts b/src/gitops/components/shared/projectListColumns.test.ts new file mode 100644 index 000000000..b169bf0b3 --- /dev/null +++ b/src/gitops/components/shared/projectListColumns.test.ts @@ -0,0 +1,64 @@ +import { getDefaultActiveColumnIds } from './ColumnManagement'; +import { getProjectManagedColumns, PROJECT_LIST_COLUMN_MANAGEMENT_ID } from './projectListColumns'; + +const t = (key: string) => key; + +describe('PROJECT_LIST_COLUMN_MANAGEMENT_ID', () => { + it('uses a stable user-settings key', () => { + expect(PROJECT_LIST_COLUMN_MANAGEMENT_ID).toBe('gitops.appprojects'); + }); +}); + +describe('getProjectManagedColumns', () => { + it('locks Name, keeps Labels default-visible, and always shows actions', () => { + const columns = getProjectManagedColumns(true, t); + const byId = Object.fromEntries(columns.map((column) => [column.id, column])); + + expect(byId.name.isUntoggleable).toBe(true); + expect(byId.labels.isShownByDefault).toBe(true); + expect(byId.actions.alwaysShown).toBe(true); + }); + + it('puts Description and Last Updated under Additional columns', () => { + const columns = getProjectManagedColumns(true, t); + const byId = Object.fromEntries(columns.map((column) => [column.id, column])); + + expect(byId.description).toMatchObject({ isShownByDefault: false, additional: true }); + expect(byId['last-updated']).toMatchObject({ isShownByDefault: false, additional: true }); + expect(columns.filter((column) => column.additional).map((column) => column.id)).toEqual([ + 'description', + 'last-updated', + ]); + }); + + it('includes Namespace only for All projects', () => { + expect(getProjectManagedColumns(true, t).map((column) => column.id)).toEqual([ + 'name', + 'namespace', + 'description', + 'applications', + 'labels', + 'last-updated', + 'actions', + ]); + expect(getProjectManagedColumns(false, t).map((column) => column.id)).toEqual([ + 'name', + 'description', + 'applications', + 'labels', + 'last-updated', + 'actions', + ]); + }); + + it('defaults to Default columns only (excludes Additional)', () => { + const columns = getProjectManagedColumns(true, t); + expect(getDefaultActiveColumnIds(columns)).toEqual([ + 'name', + 'namespace', + 'applications', + 'labels', + 'actions', + ]); + }); +}); diff --git a/src/gitops/components/shared/projectListColumns.ts b/src/gitops/components/shared/projectListColumns.ts new file mode 100644 index 000000000..e90c6f4d4 --- /dev/null +++ b/src/gitops/components/shared/projectListColumns.ts @@ -0,0 +1,27 @@ +import { type GitOpsManagedColumn, NAMESPACE_COLUMN_ID } from './ColumnManagement/types'; + +export const PROJECT_LIST_COLUMN_MANAGEMENT_ID = 'gitops.appprojects'; + +export const getProjectManagedColumns = ( + includeNamespaceColumn: boolean, + t: (key: string) => string, +): GitOpsManagedColumn[] => { + const columns: GitOpsManagedColumn[] = [ + { id: 'name', title: t('Name'), isShownByDefault: true, isUntoggleable: true }, + ]; + if (includeNamespaceColumn) { + columns.push({ + id: NAMESPACE_COLUMN_ID, + title: t('Namespace'), + isShownByDefault: true, + }); + } + columns.push( + { id: 'description', title: t('Description'), isShownByDefault: false, additional: true }, + { id: 'applications', title: t('Applications'), isShownByDefault: true }, + { id: 'labels', title: t('Labels'), isShownByDefault: true }, + { id: 'last-updated', title: t('Last Updated'), isShownByDefault: false, additional: true }, + { id: 'actions', title: '', isShownByDefault: true, alwaysShown: true }, + ); + return columns; +}; From 0595c86b45f89caaf2d4a60dee2171de0ed2bdda Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Sat, 26 Sep 2026 12:43:15 -0400 Subject: [PATCH 5/6] add managed columns for ImageUpdaters Signed-off-by: Atif Ali --- .../imageupdater/ImageUpdaterList.tsx | 244 +++++++++--------- .../shared/imageUpdaterListColumns.test.ts | 69 +++++ .../shared/imageUpdaterListColumns.ts | 28 ++ 3 files changed, 219 insertions(+), 122 deletions(-) create mode 100644 src/gitops/components/shared/imageUpdaterListColumns.test.ts create mode 100644 src/gitops/components/shared/imageUpdaterListColumns.ts diff --git a/src/gitops/components/imageupdater/ImageUpdaterList.tsx b/src/gitops/components/imageupdater/ImageUpdaterList.tsx index d65bce4bd..73b2cd6f1 100644 --- a/src/gitops/components/imageupdater/ImageUpdaterList.tsx +++ b/src/gitops/components/imageupdater/ImageUpdaterList.tsx @@ -19,6 +19,7 @@ import { } from '@openshift-console/dynamic-plugin-sdk'; import { ErrorState } from '@patternfly/react-component-groups'; import { EmptyState, EmptyStateBody } from '@patternfly/react-core'; +import type { DataViewTd } from '@patternfly/react-data-view/dist/esm/DataViewTable'; import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; import { CubesIcon } from '@patternfly/react-icons'; import { Tbody, Td, ThProps, Tr } from '@patternfly/react-table'; @@ -32,12 +33,26 @@ import { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, } from '../shared/AllNamespaces'; +import { + type GitOpsManagedColumn, + NAMESPACE_COLUMN_ID, + useGitOpsColumnManagement, +} from '../shared/ColumnManagement'; import { GitOpsDataViewTable, useGitOpsDataViewSort, useGitOpsListPagePagination, } from '../shared/DataView'; -import { filterByConsoleNameAndLabels, getLabelsSortKey, parseLabelFilterParam } from '../shared/listPageTextFilters'; +import { GitOpsListPageToolbar } from '../shared/GitOpsListPageToolbar'; +import { + getImageUpdaterManagedColumns, + IMAGE_UPDATER_LIST_COLUMN_MANAGEMENT_ID, +} from '../shared/imageUpdaterListColumns'; +import { + filterByConsoleNameAndLabels, + getLabelsSortKey, + parseLabelFilterParam, +} from '../shared/listPageTextFilters'; import MetadataLabels from '../shared/MetadataLabels'; import { useImageUpdaterActionsProvider } from './hooks/useImageUpdaterActionsProvider'; @@ -67,19 +82,23 @@ const ImageUpdaterList: React.FC = ({ namespace: effectiveNamespace, }); - const showNamespaceColumn = !effectiveNamespace || effectiveNamespace === ''; - const columnSortConfig = React.useMemo(() => { - return [ - 'name', - ...(showNamespaceColumn ? ['namespace'] : []), - 'apps', - 'images', - 'last-checked', - 'ready', - 'labels', - 'actions', - ].map((key) => ({ key })); - }, [showNamespaceColumn]); + const { t } = useTranslation('plugin__gitops-plugin'); + const includeNamespaceColumn = !effectiveNamespace || effectiveNamespace === ''; + const managedColumns = React.useMemo(() => getImageUpdaterManagedColumns(true, t), [t]); + + const { activeColumnIds, columnManagement, filterDataView } = useGitOpsColumnManagement({ + columnManagementID: IMAGE_UPDATER_LIST_COLUMN_MANAGEMENT_ID, + columns: managedColumns, + resourceType: t('ImageUpdaters'), + includeNamespaceColumn, + showNamespaceHelp: includeNamespaceColumn, + }); + + const columnSortConfig = React.useMemo( + () => + managedColumns.filter((column) => !column.alwaysShown).map((column) => ({ key: column.id })), + [managedColumns], + ); const { searchParams, sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); @@ -89,9 +108,7 @@ const ImageUpdaterList: React.FC = ({ const nameQuery = searchParams.get('name') || ''; const labelsParam = searchParams.get('labels') || ''; - const { t } = useTranslation('plugin__gitops-plugin'); - - const columnsDV = useColumnsDV(effectiveNamespace, getSortParams); + const columnsDV = useColumnsDV(managedColumns, getSortParams, columnSortConfig); const sortedItems = React.useMemo(() => { return sortData(imageUpdaters as ImageUpdaterKind[], sortBy, direction); }, [imageUpdaters, sortBy, direction]); @@ -129,7 +146,15 @@ const ImageUpdaterList: React.FC = ({ namespace: effectiveNamespace, searchParams, }); - const rows = useImageUpdaterRowsDV(pagedItems, effectiveNamespace); + const rows = useImageUpdaterRowsDV(pagedItems, managedColumns); + const columnIds = React.useMemo( + () => managedColumns.map((column) => column.id), + [managedColumns], + ); + const { columns: visibleColumnsDV, rows: visibleRows } = React.useMemo( + () => filterDataView(columnsDV, rows, columnIds, activeColumnIds), + [filterDataView, columnsDV, rows, columnIds, activeColumnIds], + ); const hasItems = React.useMemo(() => { return sortedItems.length > 0; @@ -156,7 +181,7 @@ const ImageUpdaterList: React.FC = ({ const empty = ( - + = ({ const error = loadError && ( - + = ({ ); - const isEmptyState = !loadError && rows.length === 0; + const isEmptyState = !loadError && visibleRows.length === 0; + + const listPageFilter = !hideNameLabelFilters && hasItems && ( + + ); return (
@@ -202,18 +237,12 @@ const ImageUpdaterList: React.FC = ({ )} - {!hideNameLabelFilters && hasItems && ( - + {hasItems && ( + )} ThProps['sort'], + columnSortConfig: { key: string }[], ): DataViewTh[] => { - const showNamespace = !namespace || namespace === ''; - const i: number = showNamespace ? 1 : 0; const { t } = useTranslation('plugin__gitops-plugin'); - const columns: DataViewTh[] = [ - { - cell: t('Name'), - props: { - 'aria-label': 'name', - className: 'pf-m-width-30', - sort: getSortParams(0), - style: { minWidth: '200px' }, - }, - }, - ...(showNamespace - ? [ - { - cell: t('Namespace'), - props: { - 'aria-label': 'namespace', - className: 'pf-m-width-15', - sort: getSortParams(1), - style: { minWidth: '150px' }, - }, - }, - ] - : []), - { - cell: t('Apps'), - props: { - 'aria-label': 'apps', - className: 'pf-m-width-20', - sort: getSortParams(1 + i), - }, - }, - { - cell: t('Images'), - props: { - 'aria-label': 'images', - className: 'pf-m-width-20', - sort: getSortParams(2 + i), - }, - }, - { - cell: t('Last Checked'), - props: { - 'aria-label': 'last checked', - className: 'pf-m-width-20', - sort: getSortParams(3 + i), - }, - }, - { - cell: t('Ready'), - props: { - 'aria-label': 'ready', - className: 'pf-m-width-10', - sort: getSortParams(4 + i), - }, - }, - { - cell: t('Labels'), + + const titleById: Record = { + name: t('Name'), + [NAMESPACE_COLUMN_ID]: t('Namespace'), + apps: t('Apps'), + images: t('Images'), + 'last-checked': t('Last Checked'), + ready: t('Ready'), + labels: t('Labels'), + actions: '', + }; + + const widthById: Record = { + name: 'pf-m-width-30', + [NAMESPACE_COLUMN_ID]: 'pf-m-width-15', + apps: 'pf-m-width-20', + images: 'pf-m-width-20', + 'last-checked': 'pf-m-width-20', + ready: 'pf-m-width-10', + labels: 'pf-m-width-10', + }; + + const styleById: Record = { + name: { minWidth: '200px' }, + [NAMESPACE_COLUMN_ID]: { minWidth: '150px' }, + }; + + return managedColumns.map((column) => { + if (column.id === 'actions') { + return { + cell: '', + props: { 'aria-label': 'actions' }, + }; + } + const sortIndex = columnSortConfig.findIndex((entry) => entry.key === column.id); + return { + cell: titleById[column.id] ?? column.title, props: { - 'aria-label': 'labels', - className: 'pf-m-width-10', - sort: getSortParams(5 + i), + 'aria-label': column.id, + className: widthById[column.id], + ...(styleById[column.id] ? { style: styleById[column.id] } : {}), + ...(sortIndex >= 0 ? { sort: getSortParams(sortIndex) } : {}), }, - }, - { - cell: '', - props: { 'aria-label': 'actions' }, - }, - ]; - - return columns; + }; + }); }; export const useImageUpdaterRowsDV = ( imageUpdaterList: ImageUpdaterKind[], - namespace: string | null | undefined, + managedColumns: GitOpsManagedColumn[], ): DataViewTr[] => { const rows: DataViewTr[] = []; if (imageUpdaterList === undefined || imageUpdaterList.length === 0) { return rows; } - const showNamespace = !namespace || namespace === ''; imageUpdaterList.forEach((obj, index) => { const readyCondition = obj.status?.conditions?.find((c) => c.type === 'Ready'); const isReady = readyCondition?.status === 'True'; - rows.push([ - { + const cellsById: Record = { + name: { cell: (
, - id: obj.metadata.namespace, - dataLabel: 'Namespace', - }, - ] - : []), - { + [NAMESPACE_COLUMN_ID]: { + cell: , + id: obj.metadata.namespace, + dataLabel: 'Namespace', + }, + apps: { id: 'apps', cell: obj.status?.applicationsMatched != null ? String(obj.status.applicationsMatched) : '-', dataLabel: 'Apps', }, - { + images: { id: 'images', cell: obj.status?.imagesManaged != null ? String(obj.status.imagesManaged) : '-', dataLabel: 'Images', }, - { + 'last-checked': { id: 'last-checked', cell: obj.status?.lastCheckedAt ? (
@@ -418,12 +416,12 @@ export const useImageUpdaterRowsDV = ( ), dataLabel: 'Last Checked', }, - { + ready: { id: 'ready', cell: readyCondition ? String(isReady) : '-', dataLabel: 'Ready', }, - { + labels: { id: 'labels', dataLabel: 'Labels', cell: ( @@ -442,12 +440,14 @@ export const useImageUpdaterRowsDV = (
), }, - { + actions: { id: 'actions-' + index, cell: , props: { className: 'gitops-imageupdater-list__actions-cell' }, }, - ]); + }; + + rows.push(managedColumns.map((column) => cellsById[column.id])); }); return rows; }; diff --git a/src/gitops/components/shared/imageUpdaterListColumns.test.ts b/src/gitops/components/shared/imageUpdaterListColumns.test.ts new file mode 100644 index 000000000..e8512232b --- /dev/null +++ b/src/gitops/components/shared/imageUpdaterListColumns.test.ts @@ -0,0 +1,69 @@ +import { getDefaultActiveColumnIds } from './ColumnManagement'; +import { + getImageUpdaterManagedColumns, + IMAGE_UPDATER_LIST_COLUMN_MANAGEMENT_ID, +} from './imageUpdaterListColumns'; + +const t = (key: string) => key; + +describe('IMAGE_UPDATER_LIST_COLUMN_MANAGEMENT_ID', () => { + it('uses a stable user-settings key', () => { + expect(IMAGE_UPDATER_LIST_COLUMN_MANAGEMENT_ID).toBe('gitops.imageupdaters'); + }); +}); + +describe('getImageUpdaterManagedColumns', () => { + it('locks Name, keeps Labels default-visible, and always shows actions', () => { + const columns = getImageUpdaterManagedColumns(true, t); + const byId = Object.fromEntries(columns.map((column) => [column.id, column])); + + expect(byId.name.isUntoggleable).toBe(true); + expect(byId.labels.isShownByDefault).toBe(true); + expect(byId.actions.alwaysShown).toBe(true); + }); + + it('puts Last Checked under Additional columns', () => { + const columns = getImageUpdaterManagedColumns(true, t); + const byId = Object.fromEntries(columns.map((column) => [column.id, column])); + + expect(byId['last-checked']).toMatchObject({ isShownByDefault: false, additional: true }); + expect(columns.filter((column) => column.additional).map((column) => column.id)).toEqual([ + 'last-checked', + ]); + }); + + it('includes Namespace only for All projects', () => { + expect(getImageUpdaterManagedColumns(true, t).map((column) => column.id)).toEqual([ + 'name', + 'namespace', + 'apps', + 'images', + 'last-checked', + 'ready', + 'labels', + 'actions', + ]); + expect(getImageUpdaterManagedColumns(false, t).map((column) => column.id)).toEqual([ + 'name', + 'apps', + 'images', + 'last-checked', + 'ready', + 'labels', + 'actions', + ]); + }); + + it('defaults to Default columns only (excludes Additional)', () => { + const columns = getImageUpdaterManagedColumns(true, t); + expect(getDefaultActiveColumnIds(columns)).toEqual([ + 'name', + 'namespace', + 'apps', + 'images', + 'ready', + 'labels', + 'actions', + ]); + }); +}); diff --git a/src/gitops/components/shared/imageUpdaterListColumns.ts b/src/gitops/components/shared/imageUpdaterListColumns.ts new file mode 100644 index 000000000..c62695b83 --- /dev/null +++ b/src/gitops/components/shared/imageUpdaterListColumns.ts @@ -0,0 +1,28 @@ +import { type GitOpsManagedColumn, NAMESPACE_COLUMN_ID } from './ColumnManagement/types'; + +export const IMAGE_UPDATER_LIST_COLUMN_MANAGEMENT_ID = 'gitops.imageupdaters'; + +export const getImageUpdaterManagedColumns = ( + includeNamespaceColumn: boolean, + t: (key: string) => string, +): GitOpsManagedColumn[] => { + const columns: GitOpsManagedColumn[] = [ + { id: 'name', title: t('Name'), isShownByDefault: true, isUntoggleable: true }, + ]; + if (includeNamespaceColumn) { + columns.push({ + id: NAMESPACE_COLUMN_ID, + title: t('Namespace'), + isShownByDefault: true, + }); + } + columns.push( + { id: 'apps', title: t('Apps'), isShownByDefault: true }, + { id: 'images', title: t('Images'), isShownByDefault: true }, + { id: 'last-checked', title: t('Last Checked'), isShownByDefault: false, additional: true }, + { id: 'ready', title: t('Ready'), isShownByDefault: true }, + { id: 'labels', title: t('Labels'), isShownByDefault: true }, + { id: 'actions', title: '', isShownByDefault: true, alwaysShown: true }, + ); + return columns; +}; From 2ce317a21a468e46dd45d3842baa089a1c805a0c Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Sat, 26 Sep 2026 13:19:05 -0400 Subject: [PATCH 6/6] add managed columns for Rollouts Signed-off-by: Atif Ali --- src/gitops/components/rollout/RolloutList.tsx | 254 +++++++++--------- .../components/rollout/rollout-list.scss | 18 +- .../shared/rolloutListColumns.test.ts | 67 +++++ .../components/shared/rolloutListColumns.ts | 28 ++ 4 files changed, 236 insertions(+), 131 deletions(-) create mode 100644 src/gitops/components/shared/rolloutListColumns.test.ts create mode 100644 src/gitops/components/shared/rolloutListColumns.ts diff --git a/src/gitops/components/rollout/RolloutList.tsx b/src/gitops/components/rollout/RolloutList.tsx index 8c32e14b0..f8edbdd94 100644 --- a/src/gitops/components/rollout/RolloutList.tsx +++ b/src/gitops/components/rollout/RolloutList.tsx @@ -21,6 +21,7 @@ import { import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; import { ErrorState } from '@patternfly/react-component-groups'; import { EmptyState, EmptyStateBody, Spinner } from '@patternfly/react-core'; +import type { DataViewTd } from '@patternfly/react-data-view/dist/esm/DataViewTable'; import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; import { CubesIcon, SearchIcon } from '@patternfly/react-icons'; import { Tbody, Td, ThProps, Tr } from '@patternfly/react-table'; @@ -29,11 +30,17 @@ import { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, } from '../shared/AllNamespaces'; +import { + type GitOpsManagedColumn, + NAMESPACE_COLUMN_ID, + useGitOpsColumnManagement, +} from '../shared/ColumnManagement'; import { GitOpsDataViewTable, useGitOpsDataViewSort, useGitOpsListPagePagination, } from '../shared/DataView'; +import { GitOpsListPageToolbar } from '../shared/GitOpsListPageToolbar'; import { filterByConsoleNameAndLabels, filterResourcesByLabelQuery, @@ -41,6 +48,10 @@ import { parseLabelFilterParam, } from '../shared/listPageTextFilters'; import { MetadataLabels } from '../shared/MetadataLabels/MetadataLabels'; +import { + getRolloutManagedColumns, + ROLLOUT_LIST_COLUMN_MANAGEMENT_ID, +} from '../shared/rolloutListColumns'; import { useRolloutActionsProvider } from './hooks/useRolloutActionsProvider'; import { RolloutKind, RolloutModel } from './model/RolloutModel'; @@ -95,19 +106,23 @@ const RolloutList: React.FC = ({ namespaced: !listAllNamespaces, namespace, }); + + const { t } = useGitOpsTranslation(); + const includeNamespaceColumn = !namespace; + const managedColumns = React.useMemo(() => getRolloutManagedColumns(true, t), [t]); + + const { activeColumnIds, columnManagement, filterDataView } = useGitOpsColumnManagement({ + columnManagementID: ROLLOUT_LIST_COLUMN_MANAGEMENT_ID, + columns: managedColumns, + resourceType: t('Rollouts'), + includeNamespaceColumn, + showNamespaceHelp: includeNamespaceColumn, + }); + const columnSortConfig = React.useMemo( () => - [ - 'name', - ...(!listAllNamespaces || !namespace ? ['namespace'] : []), - 'status', - 'pods', - 'labels', - 'selector', - 'last-updated', - 'actions', - ].map((key) => ({ key })), - [listAllNamespaces, namespace], + managedColumns.filter((column) => !column.alwaysShown).map((column) => ({ key: column.id })), + [managedColumns], ); const { searchParams, sortBy, direction, getSortParams } = @@ -118,11 +133,9 @@ const RolloutList: React.FC = ({ const nameQuery = searchParams.get('name') || ''; const labelsParam = searchParams.get('labels') || ''; - const { t } = useGitOpsTranslation(); - - const columnsDV = useColumnsDV(namespace, getSortParams, t); + const columnsDV = useColumnsDV(managedColumns, getSortParams, columnSortConfig, t); const sortedRollouts = React.useMemo(() => { - return sortData(rollouts, sortBy, direction); + return sortData(rollouts as RolloutKind[], sortBy, direction); }, [rollouts, sortBy, direction]); const filters = getFilters(t); @@ -143,12 +156,22 @@ const RolloutList: React.FC = ({ namespace, searchParams, }); - const rows = useRolloutsRowsDV(pagedItems, namespace, t); + const rows = useRolloutsRowsDV(pagedItems, managedColumns, t); + const columnIds = React.useMemo( + () => managedColumns.map((column) => column.id), + [managedColumns], + ); + const { columns: visibleColumnsDV, rows: visibleRows } = React.useMemo( + () => filterDataView(columnsDV, rows, columnIds, activeColumnIds), + [filterDataView, columnsDV, rows, columnIds, activeColumnIds], + ); + + const hasRollouts = sortedRollouts.length > 0; const empty = ( - + {namespace @@ -163,7 +186,7 @@ const RolloutList: React.FC = ({ const error = loadError && ( - + = ({ ); - const isEmptyState = !loadError && rows.length === 0; + const isEmptyState = !loadError && visibleRows.length === 0; const topologyUrl = namespace ? '/topology/ns/' + namespace + '?view=graph' : '/topology/all-namespaces?view=graph'; + const listPageFilter = !hideNameLabelFilters && ( + + ); + + const topologyControl = + filteredBySearch.length > 0 && !loadError ? ( + {topologyLink(topologyUrl, t)} + ) : null; + + const columnAndTopologyControls = + hasRollouts || topologyControl ? ( + + {hasRollouts ? columnManagement : null} + {topologyControl} + + ) : undefined; + return ( <> {showTitle == undefined && ( @@ -196,26 +241,15 @@ const RolloutList: React.FC = ({ )} - {!hideNameLabelFilters && ( - - - - - {filteredBySearch.length > 0 && !loadError && ( - - {topologyLink(topologyUrl, t)} - - )} - + {(hasRollouts || !hideNameLabelFilters) && ( + )} ThProps['sort'], + columnSortConfig: { key: string }[], t: (key: string) => string, -) => { - const i: number = namespace ? 0 : 1; - const columns: DataViewTh[] = [ - { - cell: t('Name'), - props: { - 'aria-label': 'name', - className: 'pf-m-width-25', - sort: getSortParams(0), - }, - }, - ...(!namespace - ? [ - { - cell: t('Namespace'), - props: { - 'aria-label': 'namespace', - className: 'pf-m-width-15', - sort: getSortParams(1), - }, - }, - ] - : []), - { - cell: t('Status'), - props: { - 'aria-label': 'status', - className: 'pf-m-width-10', - sort: getSortParams(1 + i), - }, - }, - { - cell: t('Pods'), - props: { - 'aria-label': 'pods', - className: 'pf-m-width-10', - sort: getSortParams(2 + i), - }, - }, - { - cell: t('Labels'), - props: { - 'aria-label': 'labels', - className: 'pf-m-width-15', - sort: getSortParams(3 + i), - }, - }, - { - cell: t('Selector'), - props: { - 'aria-label': 'selector', - className: 'pf-m-width-15', - sort: getSortParams(4 + i), - }, - }, - { - cell: t('Last Updated'), +): DataViewTh[] => { + const titleById: Record = { + name: t('Name'), + [NAMESPACE_COLUMN_ID]: t('Namespace'), + status: t('Status'), + pods: t('Pods'), + labels: t('Labels'), + selector: t('Selector'), + 'last-updated': t('Last Updated'), + actions: '', + }; + + const widthById: Record = { + name: 'pf-m-width-25', + [NAMESPACE_COLUMN_ID]: 'pf-m-width-15', + status: 'pf-m-width-10', + pods: 'pf-m-width-10', + labels: 'pf-m-width-15', + selector: 'pf-m-width-15', + 'last-updated': 'pf-m-width-15', + }; + + return managedColumns.map((column) => { + if (column.id === 'actions') { + return { + cell: '', + props: { 'aria-label': 'actions' }, + }; + } + const sortIndex = columnSortConfig.findIndex((entry) => entry.key === column.id); + return { + cell: titleById[column.id] ?? column.title, props: { - 'aria-label': 'last updated', - className: 'pf-m-width-15', - sort: getSortParams(5 + i), + 'aria-label': column.id, + className: widthById[column.id], + ...(sortIndex >= 0 ? { sort: getSortParams(sortIndex) } : {}), }, - }, - { - cell: '', - props: { 'aria-label': 'actions' }, - }, - ]; - - return columns; + }; + }); }; export const useRolloutsRowsDV = ( - rolloutsList, - namespace, + rolloutsList: RolloutKind[], + managedColumns: GitOpsManagedColumn[], t: (key: string) => string, ): DataViewTr[] => { const rows: DataViewTr[] = []; @@ -365,8 +369,8 @@ export const useRolloutsRowsDV = ( return rows; } rolloutsList.forEach((obj, index) => { - rows.push([ - { + const cellsById: Record = { + name: { cell: (
, - id: obj.metadata.namespace, - dataLabel: 'Namespace', - }, - ] - : []), - { + [NAMESPACE_COLUMN_ID]: { + cell: , + id: obj.metadata.namespace, + dataLabel: 'Namespace', + }, + status: { id: 'status', cell: ( ), }, - { + pods: { id: 'pods', cell: ( <> @@ -418,7 +418,7 @@ export const useRolloutsRowsDV = ( ), }, - { + labels: { id: 'labels', dataLabel: 'Labels', cell: ( @@ -431,7 +431,7 @@ export const useRolloutsRowsDV = (
), }, - { + selector: { id: 'selector', cell: ( <> @@ -456,7 +456,7 @@ export const useRolloutsRowsDV = ( ), }, - { + 'last-updated': { id: 'last-updated', cell: ( <> @@ -464,12 +464,14 @@ export const useRolloutsRowsDV = ( ), }, - { + actions: { id: 'actions-' + index, cell: , props: { style: { paddingTop: 8, paddingRight: 0, paddingLeft: 0, width: 10 } }, }, - ]); + }; + + rows.push(managedColumns.map((column) => cellsById[column.id])); }); return rows; }; diff --git a/src/gitops/components/rollout/rollout-list.scss b/src/gitops/components/rollout/rollout-list.scss index d953da526..dab7c3fcc 100644 --- a/src/gitops/components/rollout/rollout-list.scss +++ b/src/gitops/components/rollout/rollout-list.scss @@ -1,6 +1,14 @@ .rollout-list-page { - &__topology-link { - margin-bottom: 15px; - margin-left: 10px; - } -} \ No newline at end of file + &__column-controls { + display: inline-flex; + align-items: center; + gap: var(--pf-t--global--spacer--sm); + min-height: var(--pf-t--global--spacer--2xl); + } + + &__topology-link { + display: inline-flex; + align-items: center; + line-height: 1; + } +} diff --git a/src/gitops/components/shared/rolloutListColumns.test.ts b/src/gitops/components/shared/rolloutListColumns.test.ts new file mode 100644 index 000000000..79e669259 --- /dev/null +++ b/src/gitops/components/shared/rolloutListColumns.test.ts @@ -0,0 +1,67 @@ +import { getDefaultActiveColumnIds } from './ColumnManagement'; +import { getRolloutManagedColumns, ROLLOUT_LIST_COLUMN_MANAGEMENT_ID } from './rolloutListColumns'; + +const t = (key: string) => key; + +describe('ROLLOUT_LIST_COLUMN_MANAGEMENT_ID', () => { + it('uses a stable user-settings key', () => { + expect(ROLLOUT_LIST_COLUMN_MANAGEMENT_ID).toBe('gitops.rollouts'); + }); +}); + +describe('getRolloutManagedColumns', () => { + it('locks Name, keeps Labels default-visible, and always shows actions', () => { + const columns = getRolloutManagedColumns(true, t); + const byId = Object.fromEntries(columns.map((column) => [column.id, column])); + + expect(byId.name.isUntoggleable).toBe(true); + expect(byId.labels.isShownByDefault).toBe(true); + expect(byId.actions.alwaysShown).toBe(true); + }); + + it('puts Selector and Last Updated under Additional columns', () => { + const columns = getRolloutManagedColumns(true, t); + const byId = Object.fromEntries(columns.map((column) => [column.id, column])); + + expect(byId.selector).toMatchObject({ isShownByDefault: false, additional: true }); + expect(byId['last-updated']).toMatchObject({ isShownByDefault: false, additional: true }); + expect(columns.filter((column) => column.additional).map((column) => column.id)).toEqual([ + 'selector', + 'last-updated', + ]); + }); + + it('includes Namespace only for All projects', () => { + expect(getRolloutManagedColumns(true, t).map((column) => column.id)).toEqual([ + 'name', + 'namespace', + 'status', + 'pods', + 'labels', + 'selector', + 'last-updated', + 'actions', + ]); + expect(getRolloutManagedColumns(false, t).map((column) => column.id)).toEqual([ + 'name', + 'status', + 'pods', + 'labels', + 'selector', + 'last-updated', + 'actions', + ]); + }); + + it('defaults to Default columns only (excludes Additional)', () => { + const columns = getRolloutManagedColumns(true, t); + expect(getDefaultActiveColumnIds(columns)).toEqual([ + 'name', + 'namespace', + 'status', + 'pods', + 'labels', + 'actions', + ]); + }); +}); diff --git a/src/gitops/components/shared/rolloutListColumns.ts b/src/gitops/components/shared/rolloutListColumns.ts new file mode 100644 index 000000000..d8817f35d --- /dev/null +++ b/src/gitops/components/shared/rolloutListColumns.ts @@ -0,0 +1,28 @@ +import { type GitOpsManagedColumn, NAMESPACE_COLUMN_ID } from './ColumnManagement/types'; + +export const ROLLOUT_LIST_COLUMN_MANAGEMENT_ID = 'gitops.rollouts'; + +export const getRolloutManagedColumns = ( + includeNamespaceColumn: boolean, + t: (key: string) => string, +): GitOpsManagedColumn[] => { + const columns: GitOpsManagedColumn[] = [ + { id: 'name', title: t('Name'), isShownByDefault: true, isUntoggleable: true }, + ]; + if (includeNamespaceColumn) { + columns.push({ + id: NAMESPACE_COLUMN_ID, + title: t('Namespace'), + isShownByDefault: true, + }); + } + columns.push( + { id: 'status', title: t('Status'), isShownByDefault: true }, + { id: 'pods', title: t('Pods'), isShownByDefault: true }, + { id: 'labels', title: t('Labels'), isShownByDefault: true }, + { id: 'selector', title: t('Selector'), isShownByDefault: false, additional: true }, + { id: 'last-updated', title: t('Last Updated'), isShownByDefault: false, additional: true }, + { id: 'actions', title: '', isShownByDefault: true, alwaysShown: true }, + ); + return columns; +};