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

feat: [M3-8883] - Use our search parser on Images Landing #11233

5 changes: 5 additions & 0 deletions packages/api-v4/.changeset/pr-11233-added-1731517130535.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/api-v4": Added
---

Missing `+eq` type to `FilterConditionTypes` interface ([#11233](https://github.com/linode/manager/pull/11233))
2 changes: 1 addition & 1 deletion packages/api-v4/src/iam/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,4 @@ export interface Roles {
name: string;
description: string;
permissions?: PermissionType[];
}
}
3 changes: 2 additions & 1 deletion packages/api-v4/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,12 @@ export interface RequestOptions {
headers?: RequestHeaders;
}

interface FilterConditionTypes {
export interface FilterConditionTypes {
'+and'?: Filter[];
'+or'?: Filter[] | string[];
'+order_by'?: string;
'+order'?: 'asc' | 'desc';
'+eq'?: string | number;
'+gt'?: number;
'+gte'?: number;
'+lt'?: number;
Expand Down
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-11233-added-1731517041735.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

Ability to perform complex search queries on the Images landing page ([#11233](https://github.com/linode/manager/pull/11233))
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getAPIFilterFromQuery } from '@linode/search';
import {
CircleProgress,
IconButton,
Expand Down Expand Up @@ -28,6 +29,7 @@ import { TableCell } from 'src/components/TableCell';
import { TableHead } from 'src/components/TableHead';
import { TableRow } from 'src/components/TableRow';
import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
import { TableRowError } from 'src/components/TableRowError/TableRowError';
import { TableSortCell } from 'src/components/TableSortCell';
import { TextField } from 'src/components/TextField';
import { Typography } from 'src/components/Typography';
Expand Down Expand Up @@ -59,7 +61,7 @@ import type { Handlers as ImageHandlers } from './ImagesActionMenu';
import type { Filter, ImageStatus } from '@linode/api-v4';
import type { Theme } from '@mui/material/styles';

const searchQueryKey = 'query';
const searchParamKey = 'query';

const useStyles = makeStyles()((theme: Theme) => ({
imageTable: {
Expand Down Expand Up @@ -102,10 +104,34 @@ export const ImagesLanding = () => {
globalGrantType: 'add_images',
});
const queryParams = new URLSearchParams(location.search);
const imageLabelFromParam = queryParams.get(searchQueryKey) ?? '';
const query = queryParams.get(searchParamKey) ?? '';

const queryClient = useQueryClient();

/**
* At the time of writing: `label`, `tags`, `size`, `status`, `region` are filterable.
*
* Some fields like `status` and `region` can't be used in complex filters using '+or' / '+and'
*
* Using `tags` in a '+or' is currently broken. See ARB-5792
*/
const { error: searchParseError, filter } = getAPIFilterFromQuery(query, {
// Because Images have an array of region objects, we need to transform
// search queries like "region: us-east" to { regions: { region: "us-east" } }
// rather than the default behavior which is { region: { '+contains': "us-east" } }
filterShapeOverrides: {
'+contains': {
field: 'region',
filter: (value) => ({ regions: { region: value } }),
},
'+eq': {
field: 'region',
filter: (value) => ({ regions: { region: value } }),
},
},
searchableFieldsWithoutOperator: ['label', 'tags'],
});

const paginationForManualImages = usePagination(1, 'images-manual', 'manual');

const {
Expand All @@ -124,7 +150,7 @@ export const ImagesLanding = () => {
const manualImagesFilter: Filter = {
['+order']: manualImagesOrder,
['+order_by']: manualImagesOrderBy,
...(imageLabelFromParam && { label: { '+contains': imageLabelFromParam } }),
...filter,
};

const {
Expand All @@ -148,6 +174,10 @@ export const ImagesLanding = () => {
// to update Image region statuses. We should make the API
// team and Images team implement events for this.
refetchInterval: 30_000,
// If we have a search query, disable retries to keep the UI
// snappy if the user inputs an invalid X-Filter. Otherwise,
// pass undefined to use the default retry behavior.
retry: query ? false : undefined,
}
);

Expand All @@ -173,7 +203,7 @@ export const ImagesLanding = () => {
const automaticImagesFilter: Filter = {
['+order']: automaticImagesOrder,
['+order_by']: automaticImagesOrderBy,
...(imageLabelFromParam && { label: { '+contains': imageLabelFromParam } }),
...filter,
};

const {
Expand All @@ -190,6 +220,12 @@ export const ImagesLanding = () => {
...automaticImagesFilter,
is_public: false,
type: 'automatic',
},
{
// If we have a search query, disable retries to keep the UI
// snappy if the user inputs an invalid X-Filter. Otherwise,
// pass undefined to use the default retry behavior.
retry: query ? false : undefined,
}
);

Expand Down Expand Up @@ -331,13 +367,13 @@ export const ImagesLanding = () => {
};

const resetSearch = () => {
queryParams.delete(searchQueryKey);
queryParams.delete(searchParamKey);
history.push({ search: queryParams.toString() });
};

const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
queryParams.delete('page');
queryParams.set(searchQueryKey, e.target.value);
queryParams.set(searchParamKey, e.target.value);
history.push({ search: queryParams.toString() });
};

Expand Down Expand Up @@ -366,7 +402,7 @@ export const ImagesLanding = () => {
return <CircleProgress />;
}

if (manualImagesError || automaticImagesError) {
if (!query && (manualImagesError || automaticImagesError)) {
return (
<React.Fragment>
<DocumentTitleSegment segment="Images" />
Expand All @@ -375,11 +411,7 @@ export const ImagesLanding = () => {
);
}

if (
manualImages?.results === 0 &&
automaticImages?.results === 0 &&
!imageLabelFromParam
) {
if (manualImages?.results === 0 && automaticImages?.results === 0 && !query) {
return <ImagesLandingEmptyState />;
}

Expand All @@ -404,10 +436,9 @@ export const ImagesLanding = () => {
/>
<TextField
InputProps={{
endAdornment: imageLabelFromParam && (
endAdornment: query && (
<InputAdornment position="end">
{isFetching && <CircleProgress size="sm" />}

<IconButton
aria-label="Clear"
data-testid="clear-images-search"
Expand All @@ -422,11 +453,12 @@ export const ImagesLanding = () => {
onChange={debounce(400, (e) => {
onSearch(e);
})}
containerProps={{ mb: 2 }}
errorText={searchParseError?.message}
hideLabel
label="Search"
placeholder="Search Images"
sx={{ mb: 2 }}
value={imageLabelFromParam}
value={query}
/>
<Paper className={classes.imageTable}>
<div className={classes.imageTableHeader}>
Expand Down Expand Up @@ -499,6 +531,12 @@ export const ImagesLanding = () => {
message={`No Custom Images to display.`}
/>
)}
{manualImagesError && query && (
<TableRowError
colSpan={9}
message={manualImagesError[0].reason}
/>
)}
{manualImages?.data.map((manualImage) => (
<ImageRow
event={manualImagesEvents[manualImage.id]}
Expand Down Expand Up @@ -572,6 +610,12 @@ export const ImagesLanding = () => {
message={`No Recovery Images to display.`}
/>
)}
{automaticImagesError && query && (
<TableRowError
colSpan={9}
message={automaticImagesError[0].reason}
/>
)}
{automaticImages?.data.map((automaticImage) => (
<ImageRow
event={automaticImagesEvents[automaticImage.id]}
Expand Down
26 changes: 21 additions & 5 deletions packages/search/src/search.peggy
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ andQuery
subQuery
= '(' ws* query:orQuery ws* ')' { return query; }
/ EqualQuery
/ TagQuery
/ ContainsQuery
/ NotEqualQuery
/ LessThanQuery
Expand All @@ -27,14 +28,25 @@ DefaultQuery
}

EqualQuery
= key:FilterableField ws* Equal ws* value:Number { return { [key]: value }; }
/ key:FilterableField ws* Equal ws* value:String { return { [key]: value }; }
= key:FilterableField ws* Equal ws* value:SearchValue {
const override = options.filterShapeOverrides?.['+eq'];
if (override && key === override.field) {
return override.filter(value);
}
return { [key]: value };
}

ContainsQuery
= key:FilterableField ws* Contains ws* value:String { return { [key]: { "+contains": value } }; }
= key:FilterableField ws* Contains ws* value:String {
const override = options.filterShapeOverrides?.['+contains'];
if (override && key === override.field) {
return override.filter(value);
}
return { [key]: { "+contains": value } };
}

TagQuery
= "tag" ws* Equal ws* value:String { return { "tags": { "+contains": value } }; }
= "tag" ws* Contains ws* value:String { return { "tags": { "+contains": value } }; }

NotEqualQuery
= Not key:FilterableField ws* Equal ws* value:String { return { [key]: { "+neq": value } }; }
Expand Down Expand Up @@ -100,11 +112,15 @@ Word "word"
= [a-zA-Z0-9\-\.]+ { return text(); }

StringWithSpaces "string with spaces"
= [a-zA-Z0-9\-\. ]+ { return text(); }
= [a-zA-Z0-9\-|_\.\: ]+ { return text(); }

Number "numeric search value"
= number:[0-9\.]+ { return parseFloat(number.join("")); }
/ number:[0-9]+ { return parseInt(number.join(""), 10); }

SearchValue
= Number
/ String

ws "whitespace"
= [ \t\r\n]
16 changes: 16 additions & 0 deletions packages/search/src/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,20 @@ describe("getAPIFilterFromQuery", () => {
error: null,
});
});

it("allows custom filter transformations on a per-field basis", () => {
const query = "region: us-east";

expect(
getAPIFilterFromQuery(query, {
searchableFieldsWithoutOperator: [],
filterShapeOverrides: {
'+contains': { field: 'region', filter: (value) => ({ regions: { region: value } }) }
}
})
).toEqual({
filter: { regions: { region: 'us-east' } },
error: null,
});
});
});
12 changes: 9 additions & 3 deletions packages/search/src/search.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { generate } from 'peggy';
import type { Filter } from '@linode/api-v4';
import type { Filter, FilterConditionTypes } from '@linode/api-v4';
import grammar from './search.peggy?raw';

const parser = generate(grammar);
Expand All @@ -8,10 +8,16 @@ interface Options {
/**
* Defines the API fields filtered against (currently using +contains)
* when the search query contains no operators.
*
*
* @example ['label', 'tags']
*/
searchableFieldsWithoutOperator: string[];
/**
* Somtimes, we may need to change the way the parser transforms operations
* into API filters. This option allows you to specify a custom transformation
* for a specific searchable field.
*/
filterShapeOverrides?: Partial<Record<keyof FilterConditionTypes, { field: string; filter: (value: string) => Filter }>>;
}

/**
Expand All @@ -32,4 +38,4 @@ export function getAPIFilterFromQuery(query: string | null | undefined, options:
}

return { filter, error };
}
}