diff --git a/.changeset/new-jobs-deny.md b/.changeset/new-jobs-deny.md new file mode 100644 index 0000000000..e7b5bff71a --- /dev/null +++ b/.changeset/new-jobs-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Add possibility to re-use EntityPicker for filters with multiple select. diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index d356d67fab..ad7b24a9df 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -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", diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 095b6832a4..4756432857 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -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> | undefined; -}>) => JSX.Element; +}: PropsWithChildren<{ + value?: Partial>; +}>): JSX.Element; // @public export class MockStarredEntitiesApi implements StarredEntitiesApi { diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx new file mode 100644 index 0000000000..38edb9b064 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx @@ -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('', () => { + const mockCatalogApi = { + getEntityFacets: async () => ({ + facets: { + 'spec.options': options.map((value, idx) => ({ value, count: idx })), + }, + }), + } as unknown as CatalogApi; + + it('renders all options', async () => { + render( + + + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + 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( + + + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + 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( + + + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + showCounts + /> + + , + ); + 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( + + + value={{ + updateFilters, + queryParameters, + }} + > + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + options: new EntityOptionFilter(['option3']), + }), + ); + }); + + it('adds options to filters', async () => { + const updateFilters = jest.fn(); + render( + + + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + 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( + + + value={{ + updateFilters, + filters: { options: new EntityOptionFilter(['option1']) }, + }} + > + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + 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( + + + value={{ + updateFilters, + queryParameters: { options: ['option1'] }, + }} + > + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + options: new EntityOptionFilter(['option1']), + }), + ); + rendered.rerender( + + + value={{ + updateFilters, + queryParameters: { options: ['option2'] }, + }} + > + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + options: new EntityOptionFilter(['option2']), + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx new file mode 100644 index 0000000000..09d5a45246 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -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 extends V ? K : never; +type KeysMatching = { + [K in keyof T]-?: KeysMatchingCondition; +}[keyof T]; + +type AllowedEntityFilters = KeysMatching< + T, + EntityFilter & { values: string[] } +>; + +interface ConstructableFilter { + new (values: string[]): T; +} + +/** @public */ +export type EntityAutocompletePickerProps< + T extends DefaultEntityFilters = DefaultEntityFilters, + Name extends AllowedEntityFilters = AllowedEntityFilters, +> = { + label: string; + name: Name; + path: string; + showCounts?: boolean; + Filter: ConstructableFilter>; + InputProps?: TextFieldProps; +}; + +/** @public */ +export function EntityAutocompletePicker< + T extends DefaultEntityFilters = DefaultEntityFilters, + Name extends AllowedEntityFilters = AllowedEntityFilters, +>(props: EntityAutocompletePickerProps) { + const { label, name, path, showCounts, Filter, InputProps } = props; + + const { + updateFilters, + filters, + queryParameters: { [name]: queryParameter }, + } = useEntityList(); + + 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); + }, [name, shouldAddFilter, selectedOptions, Filter, updateFilters]); + + if ( + (filters[name] && !('values' in filters[name])) || + !availableOptions.length + ) { + return null; + } + + return ( + + + {label} + + setSelectedOptions(options) + } + renderOption={(option, { selected }) => ( + + )} + size="small" + popupIcon={ + + } + renderInput={params => ( + + )} + /> + + + ); +} diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx new file mode 100644 index 0000000000..4140feae01 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx @@ -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 ( + + ); +} diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerOption.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerOption.tsx new file mode 100644 index 0000000000..51b57257a9 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerOption.tsx @@ -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; + showCounts: boolean; +} + +const icon = ; +const checkedIcon = ; + +function OptionCheckbox({ selected }: { selected: boolean }) { + return ; +} + +export const EntityAutocompletePickerOption = memo((props: Props) => { + const { selected, value, availableOptions, showCounts } = props; + const label = showCounts ? `${value} (${availableOptions?.[value]})` : value; + + return ( + } + label={label} + /> + ); +}); diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts b/plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts new file mode 100644 index 0000000000..10ce0123b7 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts @@ -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'; diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index de7bbb89be..c70217f3a4 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -44,7 +44,7 @@ describe('', () => { ); 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('', () => { ); 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('', () => { ); 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('', () => { }), ); - 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('', () => { 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')); diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx index 4a1dc03e96..0521bb49f8 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx @@ -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 = ; -const checkedIcon = ; - /** @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 ( - - - Tags - setSelectedTags(value)} - renderOption={(option, { selected }) => ( - - } - label={ - props.showCounts - ? `${option} (${availableTags?.[option]})` - : option - } - /> - )} - size="small" - popupIcon={} - renderInput={params => ( - - )} - /> - - + ); }; diff --git a/plugins/catalog-react/src/testUtils/providers.tsx b/plugins/catalog-react/src/testUtils/providers.tsx index 5867026a8c..9bf5b21f94 100644 --- a/plugins/catalog-react/src/testUtils/providers.tsx +++ b/plugins/catalog-react/src/testUtils/providers.tsx @@ -27,26 +27,20 @@ import { } from '../hooks/useEntityListProvider'; /** @public */ -export const MockEntityListContextProvider = ({ +export function MockEntityListContextProvider< + T extends DefaultEntityFilters = DefaultEntityFilters, +>({ children, value, }: PropsWithChildren<{ - value?: Partial; -}>) => { + value?: Partial>; +}>) { // Provides a default implementation that stores filter state, for testing components that // reflect filter state. - const [filters, setFilters] = useState( - value?.filters ?? {}, - ); + const [filters, setFilters] = useState(value?.filters ?? ({} as T)); const updateFilters = useCallback( - ( - update: - | Partial - | (( - prevFilters: DefaultEntityFilters, - ) => Partial), - ) => { + (update: Partial | ((prevFilters: T) => Partial)) => { setFilters(prevFilters => { const newFilters = typeof update === 'function' ? update(prevFilters) : update; @@ -67,7 +61,7 @@ export const MockEntityListContextProvider = ({ [], ); - const resolvedValue: EntityListContextProps = useMemo( + const resolvedValue: EntityListContextProps = useMemo( () => ({ entities: value?.entities ?? defaultValues.entities, backendEntities: value?.backendEntities ?? defaultValues.backendEntities, @@ -85,4 +79,4 @@ export const MockEntityListContextProvider = ({ {children} ); -}; +} diff --git a/yarn.lock b/yarn.lock index fc7605bdf1..b6791bf778 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"