Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

ACM-16425-add-negative-label-filtering-to-Discovered-policies-table #4199

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 54 additions & 15 deletions frontend/src/components/HighlightSearchText.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
/* Copyright Contributors to the Open Cluster Management project */
import { css } from '@emotion/css'
import { Button } from '@patternfly/react-core'
import { parseLabel } from '../resources/utils'
import { MouseEventHandler } from 'react'

const MAX_LABEL_WIDTH = 28

export function HighlightSearchText(props: Readonly<{ text?: string; searchText?: string; isTruncate?: boolean }>) {
const { text, searchText, isTruncate } = props
const buttonClass = css({
padding: '4px 2px !important',
lineHeight: '10px !important',
margin: '0 2px',
minWidth: '24px',
':before, :after': {
borderColor: 'lightgray !important',
},
})

const highlightClass = css({
color: 'var(--pf-v5-global--link--Color)',
textDecoration: 'underline',
background: 'none',
fontWeight: 600,
})

// render text with highlights for searched filter text
// if text is a label like 'key=value', add a toggle button that toggles between = and !=
// if truncate is set, also make label smaller with ellipses
export function HighlightSearchText(
props: Readonly<{
text?: string
searchText?: string
supportsInequality?: boolean
toggleEquality?: () => void
isTruncate?: boolean
}>
) {
const { text, searchText, supportsInequality, toggleEquality, isTruncate } = props
const segments = getSlicedText(text, searchText)
if (segments.length > 1) {
const isTruncateLabel = isTruncate && text && text.length > MAX_LABEL_WIDTH
return (
<>
{segments.map((seg, inx) => {
if (supportsInequality && !!parseLabel(seg.text).oper) {
return renderToggleButton(seg.text, toggleEquality)
}
return (
<span
key={Number(inx)}
style={
seg.isBold
? {
color: 'var(--pf-v5-global--link--Color)',
textDecoration: 'underline',
background: 'none',
fontWeight: 600,
}
: {}
}
>
<span key={Number(inx)} className={seg.isBold ? highlightClass : ''}>
{isTruncateLabel && !seg.isBold ? '...' : seg.text}
</span>
)
Expand All @@ -31,10 +55,25 @@ export function HighlightSearchText(props: Readonly<{ text?: string; searchText?
)
} else if (isTruncate) {
return truncate(text)
} else if (supportsInequality && text) {
return renderToggleButton(text, toggleEquality)
}
return text
}

const renderToggleButton = (label: string, toggleEquality: MouseEventHandler<HTMLButtonElement> | undefined) => {
const { prefix, oper, suffix } = parseLabel(label)
return (
<>
<span>{prefix}</span>
<Button variant="plain" className={buttonClass} onClick={toggleEquality}>
{oper}
</Button>
<span style={{ marginRight: '4px' }}>{suffix}</span>
</>
)
}

interface SlicedText {
text: string
isBold: boolean
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/resources/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ export function returnCSVSafeString(exportValue: string | ReactNode) {
return `"${typeof exportValue === 'string' ? exportValue.split('\n').join() : exportValue}"`
}

export function parseLabel(label?: string | null) {
let prefix, oper, suffix
if (label && label.includes('=')) {
;[prefix, suffix] = label.split('=')
if (prefix.endsWith('!')) {
prefix = prefix.slice(0, -1)
oper = '!='
} else {
oper = '='
}
}
return { prefix, oper, suffix }
}

export function matchesFilterValue(supportsInequality: boolean, label: string, filterLabel: string | null) {
if (supportsInequality) {
const p = parseLabel(filterLabel)
return label === `${p.prefix}=${p.suffix}`
} else {
return label === filterLabel
}
}

export const getMoment = (timestamp: string, locale = '') => {
const momentObj = moment(
timestamp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@
import * as useFetchPolicies from './useFetchPolicies'
import DiscoveredPolicies from './DiscoveredPolicies'
import { getSourceFilterOptions } from './ByCluster/common'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { waitForText, waitForNotText } from '../../../lib/test-util'
import { MemoryRouter } from 'react-router-dom-v5-compat'
import { ApolloError } from '@apollo/client'

const storageMock = (function () {
return {
getItem: function () {
return '{"label":["governance!=some"]}'
},
setItem: function () {},
}
})()

describe('useFetchPolicies custom hook', () => {
test('Should render all cols for discovered policies', async () => {
Object.defineProperty(window, 'localStorage', {
value: storageMock,
})
jest.spyOn(useFetchPolicies, 'useFetchPolicies').mockReturnValue({
isFetching: false,
data: [
Expand All @@ -29,7 +41,7 @@ describe('useFetchPolicies custom hook', () => {
created: '2024-08-15T14:01:52Z',
kind: 'ConfigurationPolicy',
kind_plural: 'configurationpolicies',
label: '',
label: 'governance=all',
name: 'check-policy-reports',
namespace: 'managed2',
compliant: 'Compliant',
Expand All @@ -49,7 +61,7 @@ describe('useFetchPolicies custom hook', () => {
created: '2024-08-15T14:01:52Z',
kind: 'ConfigurationPolicy',
kind_plural: 'configurationpolicies',
label: '',
label: 'governance=all',
name: 'check-policy-reports',
namespace: 'managed2',
compliant: 'NomCompliant',
Expand Down Expand Up @@ -83,7 +95,7 @@ describe('useFetchPolicies custom hook', () => {
apigroup: 'constraints.gatekeeper.sh',
apiversion: 'v1beta1',
cluster: 'local-cluster',
label: '',
label: 'governance=any',
created: '2024-09-13T13:05:13Z',
kind: 'K8sRequiredLabels',
kind_plural: 'k8srequiredlabels',
Expand All @@ -106,7 +118,56 @@ describe('useFetchPolicies custom hook', () => {
},
],
err: undefined,
labelData: undefined,
labelData: {
labelMap: {
'ns-must-have-gkK8sRequiredLabelsconstraints.gatekeeper.sh': {
pairs: {
governance: 'all',
},
labels: [
'cluster-name=local-cluster',
'cluster-namespace=local-cluster',
'governance=all',
'policy.open-cluster-management.io/cluster-name=local-cluster',
'policy.open-cluster-management.io/cluster-namespace=local-cluster',
'policy.open-cluster-management.io/policy=default.test3',
],
},
'check-policy-reportsConfigurationPolicy': {
pairs: {
governance: 'all',
},
labels: [
'cluster-name=local-cluster',
'cluster-namespace=local-cluster',
'governance=all',
'policy.open-cluster-management.io/cluster-name=local-cluster',
'policy.open-cluster-management.io/cluster-namespace=local-cluster',
'policy.open-cluster-management.io/policy=default.test5',
],
},
'policy-podConfigurationPolicypolicy.open-cluster-management.io': {
pairs: {},
labels: [
'cluster-name=local-cluster',
'cluster-namespace=local-cluster',
'policy.open-cluster-management.io/cluster-name=local-cluster',
'policy.open-cluster-management.io/cluster-namespace=local-cluster',
'policy.open-cluster-management.io/policy=default.test1',
],
},
},
labelOptions: [
{
label: 'governance=some',
value: 'governance=some',
},
{
label: 'governance=all',
value: 'governance=all',
},
],
},
})

render(
Expand Down Expand Up @@ -146,14 +207,25 @@ describe('useFetchPolicies custom hook', () => {

// Test the kind filter
await waitForText('Filter')
screen.getByRole('button', { name: 'Options menu' }).click()
screen.getAllByRole('button', { name: 'Options menu' })[0].click()
screen.getByRole('checkbox', { name: 'Gatekeeper constraint 1' }).click()

await waitForNotText('check-policy-reports')
await waitForText('ns-must-have-gk')

// Unset the filter so the state doesn't carry over
screen.getByRole('checkbox', { name: 'Gatekeeper constraint 1' }).click()

// click != button in label filter
screen.getAllByRole('button', { name: 'Options menu' })[1].click()
const group = screen.getByRole('group', {
name: /acm-table-filter-select-key/i,
})
within(group)
.getByRole('button', {
name: /!=/i,
})
.click()
})

test('Should render error page', async () => {
Expand Down
27 changes: 25 additions & 2 deletions frontend/src/routes/Governance/discovered/DiscoveredPolicies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
severityCell,
} from './ByCluster/common'
import { ClusterPolicyViolationIcons2 } from '../components/ClusterPolicyViolations'
import { exportObjectString } from '../../../resources/utils'
import { exportObjectString, parseLabel } from '../../../resources/utils'

function nameCell(item: DiscoverdPolicyTableItem): ReactNode {
return (
Expand Down Expand Up @@ -230,8 +230,31 @@ export default function DiscoveredPolicies() {
id: 'label',
label: t('Label'),
options: labelOptions || [],
supportsInequality: true, // table will allow user to convert filtered values to a=b or a!=b
tableFilterFn: (selectedValues, item) => {
return selectedValues.some((val) => labelMap?.[item.id].labels.includes(val))
// if no filters, let all items thru
if (!selectedValues.length) return true
// if all fillters have != thru all items that don't have that label
const allInequity = selectedValues.every((val) => {
return val.includes('!=')
})
const labels = labelMap?.[item.id]?.labels || []
if (allInequity) {
return selectedValues.every((val) => {
const p = parseLabel(val)
return !labels.includes(`${p.prefix}=${p.suffix}`)
})
} else {
// else if an item has a match, but doen't have a !=, let it thru
let hasEquity = false
let hasInequity = false
selectedValues.forEach((val) => {
const p = parseLabel(val)
if (p.oper === '=' && labels.includes(val)) hasEquity = true
if (p.oper === '!=' && labels.includes(`${p.prefix}=${p.suffix}`)) hasInequity = true
})
return !hasInequity && hasEquity
}
},
},
{
Expand Down
Loading