Merge pull request #14366 from GLundh/fix-catalog-2
Catalog kind filter fixes & clean-up
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': minor
|
||||
---
|
||||
|
||||
Fixes in kind selectors (now OwnershipCards work again)
|
||||
EntityKindPicker now accepts an optional allowedKinds prop, just like CatalogKindHeader.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
---
|
||||
|
||||
Cleanup and small fixes for the kind selector
|
||||
@@ -9,6 +9,7 @@ Airbrake
|
||||
Airbrakes
|
||||
Alaria
|
||||
Alef
|
||||
allowedKinds
|
||||
Anddddd
|
||||
Apdex
|
||||
api
|
||||
|
||||
@@ -175,6 +175,7 @@ export const EntityKindPicker: (
|
||||
|
||||
// @public
|
||||
export interface EntityKindPickerProps {
|
||||
allowedKinds?: string[];
|
||||
// (undocumented)
|
||||
hidden?: boolean;
|
||||
// (undocumented)
|
||||
|
||||
@@ -147,37 +147,56 @@ describe('<EntityKindPicker/>', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('responds to external queryParameters changes', async () => {
|
||||
const updateFilters = jest.fn();
|
||||
const rendered = await renderWithEffects(
|
||||
it('renders unknown kinds provided in query parameters', async () => {
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters: { kind: 'component' },
|
||||
}}
|
||||
value={{ queryParameters: { kind: 'FROb' } }}
|
||||
>
|
||||
<EntityKindPicker />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
kind: new EntityKindFilter('component'),
|
||||
});
|
||||
rendered.rerender(
|
||||
|
||||
expect(screen.getByText('FROb')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('limits kinds when allowedKinds is set', async () => {
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters: { kind: 'domain' },
|
||||
}}
|
||||
>
|
||||
<EntityKindPicker />
|
||||
<MockEntityListContextProvider>
|
||||
<EntityKindPicker allowedKinds={['component', 'domain']} />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
kind: new EntityKindFilter('domain'),
|
||||
});
|
||||
|
||||
const input = screen.getByTestId('select');
|
||||
fireEvent.click(input);
|
||||
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Component' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Domain' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Template' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders kind from the query parameter even when not in allowedKinds', async () => {
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{ queryParameters: { kind: 'Frob' } }}
|
||||
>
|
||||
<EntityKindPicker allowedKinds={['domain']} />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Frob')).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByTestId('select');
|
||||
fireEvent.click(input);
|
||||
expect(screen.getByRole('option', { name: 'Domain' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,64 +17,15 @@
|
||||
import { Select } from '@backstage/core-components';
|
||||
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { Box } from '@material-ui/core';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { EntityKindFilter } from '../../filters';
|
||||
import { useEntityList } from '../../hooks';
|
||||
|
||||
function useAvailableKinds() {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
const [availableKinds, setAvailableKinds] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
error,
|
||||
loading,
|
||||
value: facets,
|
||||
} = useAsync(async () => {
|
||||
const facet = 'kind';
|
||||
const items = await catalogApi
|
||||
.getEntityFacets({
|
||||
facets: [facet],
|
||||
})
|
||||
.then(response => response.facets[facet] || []);
|
||||
|
||||
return items;
|
||||
}, [catalogApi]);
|
||||
|
||||
const facetsRef = useRef(facets);
|
||||
useEffect(() => {
|
||||
const oldFacets = facetsRef.current;
|
||||
facetsRef.current = facets;
|
||||
// Delay processing hook until facets load updates have settled to generate list of kinds;
|
||||
// This prevents resetting the kind filter due to saved kind value from query params not matching the
|
||||
// empty set of kind values while values are still being loaded; also only run this hook on changes
|
||||
// to facets
|
||||
if (loading || oldFacets === facets || !facets) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newKinds = [
|
||||
...new Set(
|
||||
sortBy(facets, f => f.value).map(f =>
|
||||
f.value.toLocaleLowerCase('en-US'),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
setAvailableKinds(newKinds);
|
||||
}, [loading, facets, setAvailableKinds]);
|
||||
|
||||
return { loading, error, availableKinds };
|
||||
}
|
||||
import { filterKinds, useAllKinds } from './kindFilterUtils';
|
||||
|
||||
function useEntityKindFilter(opts: { initialFilter: string }): {
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
availableKinds: string[];
|
||||
allKinds: string[];
|
||||
selectedKind: string;
|
||||
setSelectedKind: (kind: string) => void;
|
||||
} {
|
||||
@@ -84,22 +35,22 @@ function useEntityKindFilter(opts: { initialFilter: string }): {
|
||||
updateFilters,
|
||||
} = useEntityList();
|
||||
|
||||
const flattenedQueryKind = useMemo(
|
||||
const queryParamKind = useMemo(
|
||||
() => [kindParameter].flat()[0],
|
||||
[kindParameter],
|
||||
);
|
||||
|
||||
const [selectedKind, setSelectedKind] = useState(
|
||||
flattenedQueryKind ?? filters.kind?.value ?? opts.initialFilter,
|
||||
queryParamKind ?? filters.kind?.value ?? opts.initialFilter,
|
||||
);
|
||||
|
||||
// Set selected kinds on query parameter updates; this happens at initial page load and from
|
||||
// external updates to the page location.
|
||||
useEffect(() => {
|
||||
if (flattenedQueryKind) {
|
||||
setSelectedKind(flattenedQueryKind);
|
||||
if (queryParamKind) {
|
||||
setSelectedKind(queryParamKind);
|
||||
}
|
||||
}, [flattenedQueryKind]);
|
||||
}, [queryParamKind]);
|
||||
|
||||
// Set selected kind from filters; this happens when the kind filter is
|
||||
// updated from another component
|
||||
@@ -109,18 +60,18 @@ function useEntityKindFilter(opts: { initialFilter: string }): {
|
||||
}
|
||||
}, [filters.kind]);
|
||||
|
||||
const { availableKinds, loading, error } = useAvailableKinds();
|
||||
|
||||
useEffect(() => {
|
||||
updateFilters({
|
||||
kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined,
|
||||
});
|
||||
}, [selectedKind, updateFilters]);
|
||||
|
||||
const { allKinds, loading, error } = useAllKinds();
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
availableKinds,
|
||||
allKinds: allKinds ?? [],
|
||||
selectedKind,
|
||||
setSelectedKind,
|
||||
};
|
||||
@@ -132,17 +83,22 @@ function useEntityKindFilter(opts: { initialFilter: string }): {
|
||||
* @public
|
||||
*/
|
||||
export interface EntityKindPickerProps {
|
||||
/**
|
||||
* Entity kinds to show in the dropdown; by default all kinds are fetched from the catalog and
|
||||
* displayed.
|
||||
*/
|
||||
allowedKinds?: string[];
|
||||
initialFilter?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const EntityKindPicker = (props: EntityKindPickerProps) => {
|
||||
const { hidden, initialFilter = 'component' } = props;
|
||||
const { allowedKinds, hidden, initialFilter = 'component' } = props;
|
||||
|
||||
const alertApi = useApi(alertApiRef);
|
||||
|
||||
const { error, availableKinds, selectedKind, setSelectedKind } =
|
||||
const { error, allKinds, selectedKind, setSelectedKind } =
|
||||
useEntityKindFilter({
|
||||
initialFilter: initialFilter,
|
||||
});
|
||||
@@ -156,21 +112,21 @@ export const EntityKindPicker = (props: EntityKindPickerProps) => {
|
||||
}
|
||||
}, [error, alertApi]);
|
||||
|
||||
if (availableKinds?.length === 0 || error) return null;
|
||||
if (error) return null;
|
||||
|
||||
const items = [
|
||||
...availableKinds.map((kind: string) => ({
|
||||
value: kind,
|
||||
label: capitalize(kind),
|
||||
})),
|
||||
];
|
||||
const options = filterKinds(allKinds, allowedKinds, selectedKind);
|
||||
|
||||
const items = Object.keys(options).map(key => ({
|
||||
value: key,
|
||||
label: options[key],
|
||||
}));
|
||||
|
||||
return hidden ? null : (
|
||||
<Box pb={1} pt={1}>
|
||||
<Select
|
||||
label="Kind"
|
||||
items={items}
|
||||
selected={selectedKind}
|
||||
selected={selectedKind.toLocaleLowerCase('en-US')}
|
||||
onChange={value => setSelectedKind(String(value))}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { catalogApiRef } from '../../api';
|
||||
|
||||
/**
|
||||
* Fetch and return all availible kinds.
|
||||
*/
|
||||
export function useAllKinds(): {
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
allKinds: string[];
|
||||
} {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
const {
|
||||
error,
|
||||
loading,
|
||||
value: allKinds,
|
||||
} = useAsync(async () => {
|
||||
const items = await catalogApi
|
||||
.getEntityFacets({ facets: ['kind'] })
|
||||
.then(response => response.facets.kind?.map(f => f.value).sort() || []);
|
||||
return items;
|
||||
}, [catalogApi]);
|
||||
|
||||
return { loading, error, allKinds: allKinds ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and capitalize accessible kinds.
|
||||
*/
|
||||
export function filterKinds(
|
||||
allKinds: string[],
|
||||
allowedKinds?: string[],
|
||||
forcedKinds?: string,
|
||||
): Record<string, string> {
|
||||
// Before allKinds is loaded, or when a kind is entered manually in the URL, selectedKind may not
|
||||
// be present in allKinds. It should still be shown in the dropdown, but may not have the nice
|
||||
// enforced casing from the catalog-backend. This makes a key/value record for the Select options,
|
||||
// including selectedKind if it's unknown - but allows the selectedKind to get clobbered by the
|
||||
// more proper catalog kind if it exists.
|
||||
let availableKinds = allKinds;
|
||||
if (allowedKinds) {
|
||||
availableKinds = availableKinds.filter(k =>
|
||||
allowedKinds.some(
|
||||
a => a.toLocaleLowerCase('en-US') === k.toLocaleLowerCase('en-US'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
forcedKinds &&
|
||||
!allKinds.some(
|
||||
a =>
|
||||
a.toLocaleLowerCase('en-US') === forcedKinds.toLocaleLowerCase('en-US'),
|
||||
)
|
||||
) {
|
||||
availableKinds = availableKinds.concat([forcedKinds]);
|
||||
}
|
||||
|
||||
const kindsMap = availableKinds.sort().reduce((acc, kind) => {
|
||||
acc[kind.toLocaleLowerCase('en-US')] = kind;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return kindsMap;
|
||||
}
|
||||
@@ -166,7 +166,7 @@ describe('<EntityListProvider />', () => {
|
||||
location: `/catalog?${query}`,
|
||||
},
|
||||
});
|
||||
await waitFor(() => !!result.current.queryParameters);
|
||||
await act(() => waitFor(() => !!result.current.queryParameters));
|
||||
expect(result.current.queryParameters).toEqual({
|
||||
kind: 'component',
|
||||
type: 'service',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
@@ -106,14 +106,14 @@ describe('<CatalogKindHeader />', () => {
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{ queryParameters: { kind: 'frob' } }}
|
||||
value={{ queryParameters: { kind: 'FROb' } }}
|
||||
>
|
||||
<CatalogKindHeader />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Frobs')).toBeInTheDocument();
|
||||
expect(screen.getByText('FRObs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates the kind filter', async () => {
|
||||
@@ -166,9 +166,11 @@ describe('<CatalogKindHeader />', () => {
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
kind: new EntityKindFilter('template'),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
kind: new EntityKindFilter('template'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('limits kinds when allowedKinds is set', async () => {
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
capitalize,
|
||||
createStyles,
|
||||
InputBase,
|
||||
makeStyles,
|
||||
@@ -25,13 +24,11 @@ import {
|
||||
Theme,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
catalogApiRef,
|
||||
EntityKindFilter,
|
||||
useEntityList,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import pluralize from 'pluralize';
|
||||
import { filterKinds, useAllKinds } from './kindFilterUtils';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
@@ -66,12 +63,7 @@ export interface CatalogKindHeaderProps {
|
||||
export function CatalogKindHeader(props: CatalogKindHeaderProps) {
|
||||
const { initialFilter = 'component', allowedKinds } = props;
|
||||
const classes = useStyles();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value: allKinds } = useAsync(async () => {
|
||||
return await catalogApi
|
||||
.getEntityFacets({ facets: ['kind'] })
|
||||
.then(response => response.facets.kind?.map(f => f.value).sort() || []);
|
||||
});
|
||||
const { allKinds } = useAllKinds();
|
||||
const {
|
||||
filters,
|
||||
updateFilters,
|
||||
@@ -79,13 +71,22 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) {
|
||||
} = useEntityList();
|
||||
|
||||
const queryParamKind = useMemo(
|
||||
() => [kindParameter].flat()[0]?.toLocaleLowerCase('en-US'),
|
||||
() => [kindParameter].flat()[0],
|
||||
[kindParameter],
|
||||
);
|
||||
|
||||
const [selectedKind, setSelectedKind] = useState(
|
||||
queryParamKind ?? initialFilter,
|
||||
queryParamKind ?? filters.kind?.value ?? initialFilter,
|
||||
);
|
||||
|
||||
// Set selected kinds on query parameter updates; this happens at initial page load and from
|
||||
// external updates to the page location.
|
||||
useEffect(() => {
|
||||
if (queryParamKind) {
|
||||
setSelectedKind(queryParamKind);
|
||||
}
|
||||
}, [queryParamKind]);
|
||||
|
||||
// Set selected kind from filters; this happens when the kind filter is
|
||||
// updated from another component
|
||||
useEffect(() => {
|
||||
@@ -100,37 +101,12 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) {
|
||||
});
|
||||
}, [selectedKind, updateFilters]);
|
||||
|
||||
// Set selected Kind on query parameter updates; this happens at initial page load and from
|
||||
// external updates to the page location.
|
||||
useEffect(() => {
|
||||
if (queryParamKind) {
|
||||
setSelectedKind(queryParamKind);
|
||||
}
|
||||
}, [queryParamKind]);
|
||||
|
||||
// Before allKinds is loaded, or when a kind is entered manually in the URL, selectedKind may not
|
||||
// be present in allKinds. It should still be shown in the dropdown, but may not have the nice
|
||||
// enforced casing from the catalog-backend. This makes a key/value record for the Select options,
|
||||
// including selectedKind if it's unknown - but allows the selectedKind to get clobbered by the
|
||||
// more proper catalog kind if it exists.
|
||||
const availableKinds = [capitalize(selectedKind)].concat(
|
||||
allKinds?.filter(k =>
|
||||
allowedKinds
|
||||
? allowedKinds.some(
|
||||
a => a.toLocaleLowerCase('en-US') === k.toLocaleLowerCase('en-US'),
|
||||
)
|
||||
: true,
|
||||
) ?? [],
|
||||
);
|
||||
const options = availableKinds.sort().reduce((acc, kind) => {
|
||||
acc[kind.toLocaleLowerCase('en-US')] = kind;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
const options = filterKinds(allKinds, allowedKinds, selectedKind);
|
||||
|
||||
return (
|
||||
<Select
|
||||
input={<InputBase value={selectedKind} />}
|
||||
value={selectedKind}
|
||||
input={<InputBase />}
|
||||
value={selectedKind.toLocaleLowerCase('en-US')}
|
||||
onChange={e => setSelectedKind(e.target.value as string)}
|
||||
classes={classes}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
/**
|
||||
* Fetch and return all availible kinds.
|
||||
*/
|
||||
export function useAllKinds(): {
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
allKinds: string[];
|
||||
} {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
const {
|
||||
error,
|
||||
loading,
|
||||
value: allKinds,
|
||||
} = useAsync(async () => {
|
||||
const items = await catalogApi
|
||||
.getEntityFacets({ facets: ['kind'] })
|
||||
.then(response => response.facets.kind?.map(f => f.value).sort() || []);
|
||||
return items;
|
||||
}, [catalogApi]);
|
||||
|
||||
return { loading, error, allKinds: allKinds ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and capitalize accessible kinds.
|
||||
*/
|
||||
export function filterKinds(
|
||||
allKinds: string[],
|
||||
allowedKinds?: string[],
|
||||
forcedKinds?: string,
|
||||
): Record<string, string> {
|
||||
// Before allKinds is loaded, or when a kind is entered manually in the URL, selectedKind may not
|
||||
// be present in allKinds. It should still be shown in the dropdown, but may not have the nice
|
||||
// enforced casing from the catalog-backend. This makes a key/value record for the Select options,
|
||||
// including selectedKind if it's unknown - but allows the selectedKind to get clobbered by the
|
||||
// more proper catalog kind if it exists.
|
||||
let availableKinds = allKinds;
|
||||
if (allowedKinds) {
|
||||
availableKinds = availableKinds.filter(k =>
|
||||
allowedKinds.some(
|
||||
a => a.toLocaleLowerCase('en-US') === k.toLocaleLowerCase('en-US'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
forcedKinds &&
|
||||
!allKinds.some(
|
||||
a =>
|
||||
a.toLocaleLowerCase('en-US') === forcedKinds.toLocaleLowerCase('en-US'),
|
||||
)
|
||||
) {
|
||||
availableKinds = availableKinds.concat([forcedKinds]);
|
||||
}
|
||||
|
||||
const kindsMap = availableKinds.sort().reduce((acc, kind) => {
|
||||
acc[kind.toLocaleLowerCase('en-US')] = kind;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return kindsMap;
|
||||
}
|
||||
Reference in New Issue
Block a user