Skip to content

Commit

Permalink
[Embeddable Rebuild] [Controls] Refactor options list control (elasti…
Browse files Browse the repository at this point in the history
…c#186655)

Closes elastic#184374

## Summary

> [!NOTE]
> This PR has **no** user-facing changes - all work is contained in the
`examples` plugin.

### Design reviewers
The `options_list.scss` file in this PR is just a cleaned up /
simplified copy of
https://github.com/elastic/kibana/blob/main/src/plugins/controls/public/options_list/components/options_list.scss.
We are migrating the controls in the examples folder. Once all controls
are migrated, we will replace the embeddable controls with the migrated
controls from the examples



### Presentation reviewers

This PR refactors the options list control to the new React control
framework.




https://github.com/user-attachments/assets/2fcff028-4408-427e-aa19-7d1e4eaf1e76





### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
Heenawter and kibanamachine authored Aug 1, 2024
1 parent 5c9853d commit a1fe300
Show file tree
Hide file tree
Showing 49 changed files with 4,464 additions and 134 deletions.
129 changes: 129 additions & 0 deletions examples/controls_example/common/options_list/ip_search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { getIpRangeQuery, getIpSegments, getMinMaxIp } from './ip_search';

describe('test IP search functionality', () => {
test('get IP segments', () => {
expect(getIpSegments('')).toStrictEqual({ segments: [''], type: 'unknown' });
expect(getIpSegments('test')).toStrictEqual({ segments: ['test'], type: 'unknown' });
expect(getIpSegments('123.456')).toStrictEqual({ segments: ['123', '456'], type: 'ipv4' });
expect(getIpSegments('123..456...')).toStrictEqual({ segments: ['123', '456'], type: 'ipv4' });
expect(getIpSegments('abc:def:')).toStrictEqual({ segments: ['abc', 'def'], type: 'ipv6' });
expect(getIpSegments(':::x:::abc:::def:::')).toStrictEqual({
segments: ['x', 'abc', 'def'],
type: 'ipv6',
});
});

test('get min/max IP', () => {
expect(getMinMaxIp('ipv4', ['123'])).toStrictEqual({
min: '123.0.0.0',
max: '123.255.255.255',
});
expect(getMinMaxIp('ipv4', ['123', '456', '789'])).toStrictEqual({
min: '123.456.789.0',
max: '123.456.789.255',
});
expect(getMinMaxIp('ipv6', ['abc', 'def'])).toStrictEqual({
min: 'abc:def::',
max: 'abc:def:ffff:ffff:ffff:ffff:ffff:ffff',
});
expect(getMinMaxIp('ipv6', ['a', 'b', 'c', 'd', 'e', 'f', 'g'])).toStrictEqual({
min: 'a:b:c:d:e:f:g::',
max: 'a:b:c:d:e:f:g:ffff',
});
});

test('get IP range query', () => {
// invalid searches
expect(getIpRangeQuery('xyz')).toStrictEqual({
validSearch: false,
});
expect(getIpRangeQuery('123.456.OVER 9000')).toStrictEqual({
validSearch: false,
});
expect(getIpRangeQuery('abc:def:ghi')).toStrictEqual({
validSearch: false,
});

// full IP searches
expect(getIpRangeQuery('1.2.3.4')).toStrictEqual({
validSearch: true,
rangeQuery: [
{
key: 'ipv4',
mask: '1.2.3.4/32',
},
],
});
expect(getIpRangeQuery('1.2.3.256')).toStrictEqual({
validSearch: false,
rangeQuery: undefined,
});
expect(getIpRangeQuery('fbbe:a363:9e14:987c:49cf:d4d0:d8c8:bc42')).toStrictEqual({
validSearch: true,
rangeQuery: [
{
key: 'ipv6',
mask: 'fbbe:a363:9e14:987c:49cf:d4d0:d8c8:bc42/128',
},
],
});

// partial IP searches - ipv4
const partialIpv4 = getIpRangeQuery('12.34.');
expect(partialIpv4.validSearch).toBe(true);
expect(partialIpv4.rangeQuery?.[0]).toStrictEqual({
key: 'ipv4',
from: '12.34.0.0',
to: '12.34.255.255',
});
expect(getIpRangeQuery('123.456.7')).toStrictEqual({
validSearch: false,
rangeQuery: [],
});
expect(getIpRangeQuery('12:34.56')).toStrictEqual({
validSearch: false,
rangeQuery: [],
});

// partial IP searches - ipv6
const partialIpv6 = getIpRangeQuery('fbbe:a363:9e14:987c:49cf');
expect(partialIpv6.validSearch).toBe(true);
expect(partialIpv6.rangeQuery?.[0]).toStrictEqual({
key: 'ipv6',
from: 'fbbe:a363:9e14:987c:49cf::',
to: 'fbbe:a363:9e14:987c:49cf:ffff:ffff:ffff',
});

// partial IP searches - unknown type
let partialUnknownIp = getIpRangeQuery('1234');
expect(partialUnknownIp.validSearch).toBe(true);
expect(partialUnknownIp.rangeQuery?.length).toBe(1);
expect(partialUnknownIp.rangeQuery?.[0]).toStrictEqual({
key: 'ipv6',
from: '1234::',
to: '1234:ffff:ffff:ffff:ffff:ffff:ffff:ffff',
});

partialUnknownIp = getIpRangeQuery('123');
expect(partialUnknownIp.validSearch).toBe(true);
expect(partialUnknownIp.rangeQuery?.length).toBe(2);
expect(partialUnknownIp.rangeQuery?.[0]).toStrictEqual({
key: 'ipv4',
from: '123.0.0.0',
to: '123.255.255.255',
});
expect(partialUnknownIp.rangeQuery?.[1]).toStrictEqual({
key: 'ipv6',
from: '123::',
to: '123:ffff:ffff:ffff:ffff:ffff:ffff:ffff',
});
});
});
122 changes: 122 additions & 0 deletions examples/controls_example/common/options_list/ip_search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import ipaddr from 'ipaddr.js';

export interface IpRangeQuery {
validSearch: boolean;
rangeQuery?: Array<{ key: string; from: string; to: string } | { key: string; mask: string }>;
}
interface IpSegments {
segments: string[];
type: 'ipv4' | 'ipv6' | 'unknown';
}

export const getIsValidFullIp = (searchString: string) => {
return ipaddr.IPv4.isValidFourPartDecimal(searchString) || ipaddr.IPv6.isValid(searchString);
};

export const getIpSegments = (searchString: string): IpSegments => {
if (searchString.indexOf('.') !== -1) {
// ipv4 takes priority - so if search string contains both `.` and `:` then it will just be an invalid ipv4 search
const ipv4Segments = searchString.split('.').filter((segment) => segment !== '');
return { segments: ipv4Segments, type: 'ipv4' };
} else if (searchString.indexOf(':') !== -1) {
// note that currently, because of the logic of splitting here, searching for shorthand IPv6 IPs is not supported (for example,
// must search for `59fb:0:0:0:0:1005:cc57:6571` and not `59fb::1005:cc57:6571` to get the expected match)
const ipv6Segments = searchString.split(':').filter((segment) => segment !== '');
return { segments: ipv6Segments, type: 'ipv6' };
}
return { segments: [searchString], type: 'unknown' };
};

export const getMinMaxIp = (
type: 'ipv4' | 'ipv6',
segments: IpSegments['segments']
): { min: string; max: string } => {
const isIpv4 = type === 'ipv4';
const minIp = isIpv4
? segments.concat(Array(4 - segments.length).fill('0')).join('.')
: segments.join(':') + '::';
const maxIp = isIpv4
? segments.concat(Array(4 - segments.length).fill('255')).join('.')
: segments.concat(Array(8 - segments.length).fill('ffff')).join(':');
return {
min: minIp,
max: maxIp,
};
};

const buildFullIpSearchRangeQuery = (segments: IpSegments): IpRangeQuery['rangeQuery'] => {
const { type: ipType, segments: ipSegments } = segments;

const isIpv4 = ipType === 'ipv4';
const searchIp = ipSegments.join(isIpv4 ? '.' : ':');
if (ipaddr.isValid(searchIp)) {
return [
{
key: ipType,
mask: isIpv4 ? searchIp + '/32' : searchIp + '/128',
},
];
}
return undefined;
};

const buildPartialIpSearchRangeQuery = (segments: IpSegments): IpRangeQuery['rangeQuery'] => {
const { type: ipType, segments: ipSegments } = segments;

const ranges = [];
if (ipType === 'unknown' || ipType === 'ipv4') {
const { min: minIpv4, max: maxIpv4 } = getMinMaxIp('ipv4', ipSegments);

if (ipaddr.isValid(minIpv4) && ipaddr.isValid(maxIpv4)) {
ranges.push({
key: 'ipv4',
from: minIpv4,
to: maxIpv4,
});
}
}

if (ipType === 'unknown' || ipType === 'ipv6') {
const { min: minIpv6, max: maxIpv6 } = getMinMaxIp('ipv6', ipSegments);

if (ipaddr.isValid(minIpv6) && ipaddr.isValid(maxIpv6)) {
ranges.push({
key: 'ipv6',
from: minIpv6,
to: maxIpv6,
});
}
}

return ranges;
};

export const getIpRangeQuery = (searchString: string): IpRangeQuery => {
if (searchString.match(/^[A-Fa-f0-9.:]*$/) === null) {
return { validSearch: false };
}

const ipSegments = getIpSegments(searchString);
if (ipSegments.type === 'ipv4' && ipSegments.segments.length === 4) {
const ipv4RangeQuery = buildFullIpSearchRangeQuery(ipSegments);
return { validSearch: Boolean(ipv4RangeQuery), rangeQuery: ipv4RangeQuery };
}
if (ipSegments.type === 'ipv6' && ipSegments.segments.length === 8) {
const ipv6RangeQuery = buildFullIpSearchRangeQuery(ipSegments);
return { validSearch: Boolean(ipv6RangeQuery), rangeQuery: ipv6RangeQuery };
}

const partialRangeQuery = buildPartialIpSearchRangeQuery(ipSegments);
return {
validSearch: !(partialRangeQuery?.length === 0),
rangeQuery: partialRangeQuery,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { DataViewField } from '@kbn/data-views-plugin/common';

export type OptionsListSelection = string | number;

export const getSelectionAsFieldType = (
field: DataViewField,
key: string
): OptionsListSelection => {
const storeAsNumber = field.type === 'number' || field.type === 'date';
return storeAsNumber ? +key : key;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { isValidSearch } from './suggestions_searching';

describe('test validity of search strings', () => {
describe('number field', () => {
it('valid search - basic integer', () => {
expect(isValidSearch({ searchString: '123', fieldType: 'number' })).toBe(true);
});

it('valid search - floating point number', () => {
expect(isValidSearch({ searchString: '12.34', fieldType: 'number' })).toBe(true);
});

it('valid search - negative number', () => {
expect(isValidSearch({ searchString: '-42', fieldType: 'number' })).toBe(true);
});

it('invalid search - invalid character search string', () => {
expect(isValidSearch({ searchString: '1!a23', fieldType: 'number' })).toBe(false);
});
});

// we do not currently support searching date fields, so they will always be invalid
describe('date field', () => {
it('invalid search - formatted date', () => {
expect(isValidSearch({ searchString: 'December 12, 2023', fieldType: 'date' })).toBe(false);
});

it('invalid search - invalid character search string', () => {
expect(isValidSearch({ searchString: '!!12/12/23?', fieldType: 'date' })).toBe(false);
});
});

// only testing exact match validity here - the remainder of testing is covered by ./ip_search.test.ts
describe('ip field', () => {
it('valid search - ipv4', () => {
expect(
isValidSearch({
searchString: '1.2.3.4',
fieldType: 'ip',
searchTechnique: 'exact',
})
).toBe(true);
});

it('valid search - full ipv6', () => {
expect(
isValidSearch({
searchString: 'fbbe:a363:9e14:987c:49cf:d4d0:d8c8:bc42',
fieldType: 'ip',
searchTechnique: 'exact',
})
).toBe(true);
});

it('valid search - partial ipv6', () => {
expect(
isValidSearch({
searchString: 'fbbe:a363::',
fieldType: 'ip',
searchTechnique: 'exact',
})
).toBe(true);
});

it('invalid search - invalid character search string', () => {
expect(
isValidSearch({
searchString: '!!123.abc?',
fieldType: 'ip',
searchTechnique: 'exact',
})
).toBe(false);
});

it('invalid search - ipv4', () => {
expect(
isValidSearch({
searchString: '1.2.3.256',
fieldType: 'ip',
searchTechnique: 'exact',
})
).toBe(false);
});

it('invalid search - ipv6', () => {
expect(
isValidSearch({
searchString: '::fbbe:a363::',
fieldType: 'ip',
searchTechnique: 'exact',
})
).toBe(false);
});
});

// string field searches can never be invalid
describe('string field', () => {
it('valid search - basic search string', () => {
expect(isValidSearch({ searchString: 'abc', fieldType: 'string' })).toBe(true);
});

it('valid search - numeric search string', () => {
expect(isValidSearch({ searchString: '123', fieldType: 'string' })).toBe(true);
});

it('valid search - complex search string', () => {
expect(isValidSearch({ searchString: '!+@abc*&[]', fieldType: 'string' })).toBe(true);
});
});
});
Loading

0 comments on commit a1fe300

Please sign in to comment.