Merge pull request #15112 from IlyaSavich/entity-filter-refactoring

Refactor entity picker
This commit is contained in:
Fredrik Adelöw
2023-02-16 15:41:47 +01:00
committed by GitHub
12 changed files with 579 additions and 139 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': minor
---
Add possibility to re-use EntityPicker for filters with multiple select.
+1
View File
@@ -114,6 +114,7 @@
"@types/stoppable": "^1.1.0",
"@types/supertest": "^2.0.8",
"@types/tar": "^6.1.1",
"@types/triple-beam": "^1.3.2",
"@types/webpack-env": "^1.15.2",
"@types/yauzl": "^2.10.0",
"aws-sdk-mock": "^5.2.1",
+7 -7
View File
@@ -393,9 +393,7 @@ export class EntityTagFilter implements EntityFilter {
}
// @public (undocumented)
export const EntityTagPicker: (
props: EntityTagPickerProps,
) => JSX.Element | null;
export const EntityTagPicker: (props: EntityTagPickerProps) => JSX.Element;
// @public (undocumented)
export type EntityTagPickerProps = {
@@ -480,12 +478,14 @@ export function InspectEntityDialog(props: {
export function isOwnerOf(owner: Entity, entity: Entity): boolean;
// @public (undocumented)
export const MockEntityListContextProvider: ({
export function MockEntityListContextProvider<
T extends DefaultEntityFilters = DefaultEntityFilters,
>({
children,
value,
}: React_2.PropsWithChildren<{
value?: Partial<EntityListContextProps<DefaultEntityFilters>> | undefined;
}>) => JSX.Element;
}: PropsWithChildren<{
value?: Partial<EntityListContextProps<T>>;
}>): JSX.Element;
// @public
export class MockStarredEntitiesApi implements StarredEntitiesApi {
@@ -0,0 +1,272 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fireEvent, render, waitFor, screen } from '@testing-library/react';
import React from 'react';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import { EntityAutocompletePicker } from './EntityAutocompletePicker';
import { TestApiProvider } from '@backstage/test-utils';
import { catalogApiRef } from '../../api';
import { CatalogApi } from '@backstage/catalog-client';
import { DefaultEntityFilters } from '../../hooks';
import { Entity } from '@backstage/catalog-model';
import { EntityFilter } from '../../types';
interface EntityFilters extends DefaultEntityFilters {
options?: EntityOptionFilter;
}
const options = ['option1', 'option2', 'option3', 'option4'];
class EntityOptionFilter implements EntityFilter {
constructor(readonly values: string[]) {}
filterEntity(entity: Entity): boolean {
return this.values.every(v =>
((entity.spec?.options ?? []) as string[]).includes(v),
);
}
toQueryValue(): string[] {
return this.values;
}
}
describe('<EntityAutocompletePicker/>', () => {
const mockCatalogApi = {
getEntityFacets: async () => ({
facets: {
'spec.options': options.map((value, idx) => ({ value, count: idx })),
},
}),
} as unknown as CatalogApi;
it('renders all options', async () => {
render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
<MockEntityListContextProvider value={{}}>
<EntityAutocompletePicker<EntityFilters>
label="Options"
path="spec.options"
name="options"
Filter={EntityOptionFilter}
/>
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.getByText('Options')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('options-picker-expand'));
options.forEach(option => {
expect(screen.getByText(option)).toBeInTheDocument();
});
});
it('renders unique options in alphabetical order', async () => {
render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
<MockEntityListContextProvider value={{}}>
<EntityAutocompletePicker<EntityFilters>
label="Options"
path="spec.options"
name="options"
Filter={EntityOptionFilter}
/>
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.getByText('Options')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('options-picker-expand'));
expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
'option1',
'option2',
'option3',
'option4',
]);
});
it('renders options with counts', async () => {
render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
<MockEntityListContextProvider value={{}}>
<EntityAutocompletePicker<EntityFilters>
label="Options"
path="spec.options"
name="options"
Filter={EntityOptionFilter}
showCounts
/>
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.getByText('Options')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('options-picker-expand'));
expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
'option1 (0)',
'option2 (1)',
'option3 (2)',
'option4 (3)',
]);
});
it('respects the query parameter filter value', async () => {
const updateFilters = jest.fn();
const queryParameters = { options: ['option3'] };
render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
<MockEntityListContextProvider<EntityFilters>
value={{
updateFilters,
queryParameters,
}}
>
<EntityAutocompletePicker<EntityFilters>
label="Options"
path="spec.options"
name="options"
Filter={EntityOptionFilter}
/>
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
options: new EntityOptionFilter(['option3']),
}),
);
});
it('adds options to filters', async () => {
const updateFilters = jest.fn();
render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
<MockEntityListContextProvider
value={{
updateFilters,
}}
>
<EntityAutocompletePicker<EntityFilters>
label="Options"
path="spec.options"
name="options"
Filter={EntityOptionFilter}
/>
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
options: undefined,
}),
);
fireEvent.click(screen.getByTestId('options-picker-expand'));
fireEvent.click(screen.getByText('option1'));
expect(updateFilters).toHaveBeenLastCalledWith({
options: new EntityOptionFilter(['option1']),
});
});
it('removes options from filters', async () => {
const updateFilters = jest.fn();
render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
<MockEntityListContextProvider<EntityFilters>
value={{
updateFilters,
filters: { options: new EntityOptionFilter(['option1']) },
}}
>
<EntityAutocompletePicker<EntityFilters>
label="Options"
path="spec.options"
name="options"
Filter={EntityOptionFilter}
/>
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
options: new EntityOptionFilter(['option1']),
}),
);
fireEvent.click(screen.getByTestId('options-picker-expand'));
expect(screen.getByLabelText('option1')).toBeChecked();
fireEvent.click(screen.getByLabelText('option1'));
expect(updateFilters).toHaveBeenLastCalledWith({
options: undefined,
});
});
it('responds to external queryParameters changes', async () => {
const updateFilters = jest.fn();
const rendered = render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
<MockEntityListContextProvider<EntityFilters>
value={{
updateFilters,
queryParameters: { options: ['option1'] },
}}
>
<EntityAutocompletePicker<EntityFilters>
label="Options"
path="spec.options"
name="options"
Filter={EntityOptionFilter}
/>
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
options: new EntityOptionFilter(['option1']),
}),
);
rendered.rerender(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
<MockEntityListContextProvider<EntityFilters>
value={{
updateFilters,
queryParameters: { options: ['option2'] },
}}
>
<EntityAutocompletePicker<EntityFilters>
label="Options"
path="spec.options"
name="options"
Filter={EntityOptionFilter}
/>
</MockEntityListContextProvider>
</TestApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
options: new EntityOptionFilter(['option2']),
});
});
});
@@ -0,0 +1,154 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Box, TextFieldProps, Typography } from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { Autocomplete } from '@material-ui/lab';
import React, { useEffect, useMemo, useState } from 'react';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { EntityAutocompletePickerOption } from './EntityAutocompletePickerOption';
import { EntityAutocompletePickerInput } from './EntityAutocompletePickerInput';
import {
DefaultEntityFilters,
useEntityList,
} from '../../hooks/useEntityListProvider';
import { EntityFilter } from '../../types';
import _ from 'lodash';
type KeysMatchingCondition<T, V, K> = T extends V ? K : never;
type KeysMatching<T, V> = {
[K in keyof T]-?: KeysMatchingCondition<T[K], V, K>;
}[keyof T];
type AllowedEntityFilters<T extends DefaultEntityFilters> = KeysMatching<
T,
EntityFilter & { values: string[] }
>;
interface ConstructableFilter<T> {
new (values: string[]): T;
}
/** @public */
export type EntityAutocompletePickerProps<
T extends DefaultEntityFilters = DefaultEntityFilters,
Name extends AllowedEntityFilters<T> = AllowedEntityFilters<T>,
> = {
label: string;
name: Name;
path: string;
showCounts?: boolean;
Filter: ConstructableFilter<NonNullable<T[Name]>>;
InputProps?: TextFieldProps;
};
/** @public */
export function EntityAutocompletePicker<
T extends DefaultEntityFilters = DefaultEntityFilters,
Name extends AllowedEntityFilters<T> = AllowedEntityFilters<T>,
>(props: EntityAutocompletePickerProps<T, Name>) {
const { label, name, path, showCounts, Filter, InputProps } = props;
const {
updateFilters,
filters,
queryParameters: { [name]: queryParameter },
} = useEntityList<T>();
const catalogApi = useApi(catalogApiRef);
const { value: availableValues } = useAsync(async () => {
const facet = path;
const { facets } = await catalogApi.getEntityFacets({
facets: [facet],
filter: filters.kind?.getCatalogFilters(),
});
return Object.fromEntries(
facets[facet].map(({ value, count }) => [value, count]),
);
}, [filters.kind]);
const queryParameters = useMemo(
() => [queryParameter].flat().filter(Boolean) as string[],
[queryParameter],
);
const [selectedOptions, setSelectedOptions] = useState(
queryParameters.length
? queryParameters
: (filters[name] as unknown as { values: string[] })?.values ?? [],
);
// Set selected options on query parameter updates; this happens at initial page load and from
// external updates to the page location
useEffect(() => {
if (
queryParameters.length &&
!_.isEqual(selectedOptions, queryParameters)
) {
setSelectedOptions(queryParameters);
}
}, [selectedOptions, queryParameters]);
const availableOptions = Object.keys(availableValues ?? {});
const shouldAddFilter = selectedOptions.length && availableOptions.length;
useEffect(() => {
updateFilters({
[name]: shouldAddFilter ? new Filter(selectedOptions) : undefined,
} as Partial<T>);
}, [name, shouldAddFilter, selectedOptions, Filter, updateFilters]);
if (
(filters[name] && !('values' in filters[name])) ||
!availableOptions.length
) {
return null;
}
return (
<Box pb={1} pt={1}>
<Typography variant="button" component="label">
{label}
<Autocomplete
multiple
options={availableOptions}
value={selectedOptions}
onChange={(_event: object, options: string[]) =>
setSelectedOptions(options)
}
renderOption={(option, { selected }) => (
<EntityAutocompletePickerOption
selected={selected}
value={option}
availableOptions={availableValues}
showCounts={!!showCounts}
/>
)}
size="small"
popupIcon={
<ExpandMoreIcon data-testid={`${String(name)}-picker-expand`} />
}
renderInput={params => (
<EntityAutocompletePickerInput {...params} {...InputProps} />
)}
/>
</Typography>
</Box>
);
}
@@ -0,0 +1,39 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { makeStyles, TextField, TextFieldProps } from '@material-ui/core';
import React from 'react';
import classnames from 'classnames';
const useStyles = makeStyles(
{
input: {},
},
{
name: 'CatalogReactEntityAutocompletePickerInput',
},
);
export function EntityAutocompletePickerInput(params: TextFieldProps) {
const classes = useStyles();
return (
<TextField
variant="outlined"
{...params}
className={classnames(classes.input, params.className)}
/>
);
}
@@ -0,0 +1,45 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Checkbox, FormControlLabel } from '@material-ui/core';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import React, { memo } from 'react';
interface Props {
selected: boolean;
value: string;
availableOptions?: Record<string, number>;
showCounts: boolean;
}
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
function OptionCheckbox({ selected }: { selected: boolean }) {
return <Checkbox icon={icon} checkedIcon={checkedIcon} checked={selected} />;
}
export const EntityAutocompletePickerOption = memo((props: Props) => {
const { selected, value, availableOptions, showCounts } = props;
const label = showCounts ? `${value} (${availableOptions?.[value]})` : value;
return (
<FormControlLabel
control={<OptionCheckbox selected={selected} />}
label={label}
/>
);
});
@@ -0,0 +1,18 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityAutocompletePicker } from './EntityAutocompletePicker';
export type { EntityAutocompletePickerProps } from './EntityAutocompletePicker';
@@ -44,7 +44,7 @@ describe('<EntityTagPicker/>', () => {
);
await waitFor(() => expect(screen.getByText('Tags')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('tag-picker-expand'));
fireEvent.click(screen.getByTestId('tags-picker-expand'));
tags.forEach(tag => {
expect(screen.getByText(tag)).toBeInTheDocument();
});
@@ -60,7 +60,7 @@ describe('<EntityTagPicker/>', () => {
);
await waitFor(() => expect(screen.getByText('Tags')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('tag-picker-expand'));
fireEvent.click(screen.getByTestId('tags-picker-expand'));
expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
'tag1',
@@ -80,7 +80,7 @@ describe('<EntityTagPicker/>', () => {
);
await waitFor(() => expect(screen.getByText('Tags')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('tag-picker-expand'));
fireEvent.click(screen.getByTestId('tags-picker-expand'));
expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
'tag1 (0)',
@@ -132,7 +132,7 @@ describe('<EntityTagPicker/>', () => {
}),
);
fireEvent.click(screen.getByTestId('tag-picker-expand'));
fireEvent.click(screen.getByTestId('tags-picker-expand'));
fireEvent.click(screen.getByText('tag1'));
expect(updateFilters).toHaveBeenLastCalledWith({
tags: new EntityTagFilter(['tag1']),
@@ -158,7 +158,7 @@ describe('<EntityTagPicker/>', () => {
tags: new EntityTagFilter(['tag1']),
}),
);
fireEvent.click(screen.getByTestId('tag-picker-expand'));
fireEvent.click(screen.getByTestId('tags-picker-expand'));
expect(screen.getByLabelText('tag1')).toBeChecked();
fireEvent.click(screen.getByLabelText('tag1'));
@@ -14,132 +14,36 @@
* limitations under the License.
*/
import {
Box,
Checkbox,
FormControlLabel,
makeStyles,
TextField,
Typography,
} from '@material-ui/core';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { Autocomplete } from '@material-ui/lab';
import React, { useEffect, useMemo, useState } from 'react';
import { useEntityList } from '../../hooks/useEntityListProvider';
import { makeStyles } from '@material-ui/core';
import React from 'react';
import { EntityTagFilter } from '../../filters';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { EntityAutocompletePicker } from '../EntityAutocompletePicker/EntityAutocompletePicker';
/** @public */
export type CatalogReactEntityTagPickerClassKey = 'input';
const useStyles = makeStyles(
{
input: {},
},
{
name: 'CatalogReactEntityTagPicker',
},
);
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
/** @public */
export type EntityTagPickerProps = {
showCounts?: boolean;
};
const useStyles = makeStyles(
{ input: {} },
{ name: 'CatalogReactEntityTagPicker' },
);
/** @public */
export const EntityTagPicker = (props: EntityTagPickerProps) => {
const classes = useStyles();
const {
updateFilters,
filters,
queryParameters: { tags: tagsParameter },
} = useEntityList();
const catalogApi = useApi(catalogApiRef);
const { value: availableTags } = useAsync(async () => {
const facet = 'metadata.tags';
const { facets } = await catalogApi.getEntityFacets({
facets: [facet],
filter: filters.kind?.getCatalogFilters(),
});
return Object.fromEntries(
facets[facet].map(({ value, count }) => [value, count]),
);
}, [filters.kind]);
const queryParamTags = useMemo(
() => [tagsParameter].flat().filter(Boolean) as string[],
[tagsParameter],
);
const [selectedTags, setSelectedTags] = useState(
queryParamTags.length ? queryParamTags : filters.tags?.values ?? [],
);
// Set selected tags on query parameter updates; this happens at initial page load and from
// external updates to the page location.
useEffect(() => {
if (queryParamTags.length) {
setSelectedTags(queryParamTags);
}
}, [queryParamTags]);
useEffect(() => {
const tags = Object.keys(availableTags ?? {});
updateFilters({
tags:
selectedTags.length && tags.length
? new EntityTagFilter(selectedTags)
: undefined,
});
}, [selectedTags, updateFilters, availableTags]);
if (!Object.keys(availableTags ?? {}).length) return null;
return (
<Box pb={1} pt={1}>
<Typography variant="button" component="label">
Tags
<Autocomplete
multiple
options={Object.keys(availableTags ?? {})}
value={selectedTags}
onChange={(_: object, value: string[]) => setSelectedTags(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
}
label={
props.showCounts
? `${option} (${availableTags?.[option]})`
: option
}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon data-testid="tag-picker-expand" />}
renderInput={params => (
<TextField
{...params}
className={classes.input}
variant="outlined"
/>
)}
/>
</Typography>
</Box>
<EntityAutocompletePicker
label="Tags"
name="tags"
path="metadata.tags"
Filter={EntityTagFilter}
showCounts={props.showCounts}
InputProps={{ className: classes.input }}
/>
);
};
@@ -27,26 +27,20 @@ import {
} from '../hooks/useEntityListProvider';
/** @public */
export const MockEntityListContextProvider = ({
export function MockEntityListContextProvider<
T extends DefaultEntityFilters = DefaultEntityFilters,
>({
children,
value,
}: PropsWithChildren<{
value?: Partial<EntityListContextProps>;
}>) => {
value?: Partial<EntityListContextProps<T>>;
}>) {
// Provides a default implementation that stores filter state, for testing components that
// reflect filter state.
const [filters, setFilters] = useState<DefaultEntityFilters>(
value?.filters ?? {},
);
const [filters, setFilters] = useState<T>(value?.filters ?? ({} as T));
const updateFilters = useCallback(
(
update:
| Partial<DefaultEntityFilters>
| ((
prevFilters: DefaultEntityFilters,
) => Partial<DefaultEntityFilters>),
) => {
(update: Partial<T> | ((prevFilters: T) => Partial<T>)) => {
setFilters(prevFilters => {
const newFilters =
typeof update === 'function' ? update(prevFilters) : update;
@@ -67,7 +61,7 @@ export const MockEntityListContextProvider = ({
[],
);
const resolvedValue: EntityListContextProps = useMemo(
const resolvedValue: EntityListContextProps<T> = useMemo(
() => ({
entities: value?.entities ?? defaultValues.entities,
backendEntities: value?.backendEntities ?? defaultValues.backendEntities,
@@ -85,4 +79,4 @@ export const MockEntityListContextProvider = ({
{children}
</EntityListContext.Provider>
);
};
}
+8
View File
@@ -3457,6 +3457,7 @@ __metadata:
"@types/stoppable": ^1.1.0
"@types/supertest": ^2.0.8
"@types/tar": ^6.1.1
"@types/triple-beam": ^1.3.2
"@types/webpack-env": ^1.15.2
"@types/yauzl": ^2.10.0
archiver: ^5.0.2
@@ -15594,6 +15595,13 @@ __metadata:
languageName: node
linkType: hard
"@types/triple-beam@npm:^1.3.2":
version: 1.3.2
resolution: "@types/triple-beam@npm:1.3.2"
checksum: dd7b4a563fb710abc992e5d59eac481bed9e303fada2e276e37b00be31c392e03300ee468e57761e616512872e77935f92472877d0704a19688d15a726cee17b
languageName: node
linkType: hard
"@types/trusted-types@npm:*":
version: 2.0.2
resolution: "@types/trusted-types@npm:2.0.2"