Properly capitalize Kind facet in CatalogTable title

Signed-off-by: Tyler Davis <tylerd@canva.com>
This commit is contained in:
Tyler Davis
2024-12-12 18:36:18 +11:00
parent 82749a2d7e
commit 1ffb9f36e9
20 changed files with 135 additions and 107 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': minor
---
Update `EntityKindFilter` to include a `label` field with the properly capitalized Kind facet string
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-techdocs': patch
---
Update tests that utilize `EntityKindFilter` to pass the new required `label` parameter
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
Update `CatalogTable` title to use properly capitalized Kind facets (e.g. 'Component' instead of 'component')
+3 -1
View File
@@ -243,10 +243,12 @@ export type EntityFilter = {
// @public
export class EntityKindFilter implements EntityFilter {
constructor(value: string);
constructor(value: string, label: string);
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
// (undocumented)
readonly label: string;
// (undocumented)
toQueryValue(): string;
// (undocumented)
readonly value: string;
@@ -335,7 +335,7 @@ describe('<EntityAutocompletePicker/>', () => {
<MockEntityListContextProvider
value={{
filters: {
kind: new EntityKindFilter('Component'),
kind: new EntityKindFilter('component', 'Component'),
type: new EntityTypeFilter(['service']),
},
}}
@@ -354,7 +354,7 @@ describe('<EntityAutocompletePicker/>', () => {
expect(mockCatalogApi.getEntityFacets).toHaveBeenCalledWith({
facets: ['spec.options'],
filter: {
kind: 'Component',
kind: 'component',
},
}),
);
@@ -367,7 +367,7 @@ describe('<EntityAutocompletePicker/>', () => {
<MockEntityListContextProvider
value={{
filters: {
kind: new EntityKindFilter('Component'),
kind: new EntityKindFilter('component', 'Component'),
type: new EntityTypeFilter(['service']),
},
}}
@@ -387,7 +387,7 @@ describe('<EntityAutocompletePicker/>', () => {
expect(mockCatalogApi.getEntityFacets).toHaveBeenCalledWith({
facets: ['spec.options'],
filter: {
kind: 'Component',
kind: 'component',
'spec.type': ['service'],
},
}),
@@ -78,7 +78,9 @@ describe('<EntityKindPicker/>', () => {
await renderInTestApp(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ filters: { kind: new EntityKindFilter('component') } }}
value={{
filters: { kind: new EntityKindFilter('component', 'Component') },
}}
>
<EntityKindPicker />
</MockEntityListContextProvider>
@@ -106,7 +108,7 @@ describe('<EntityKindPicker/>', () => {
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
filters: { kind: new EntityKindFilter('component') },
filters: { kind: new EntityKindFilter('component', 'Component') },
updateFilters,
}}
>
@@ -121,7 +123,7 @@ describe('<EntityKindPicker/>', () => {
fireEvent.click(screen.getByText('Domain'));
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('domain'),
kind: new EntityKindFilter('domain', 'Domain'),
});
});
@@ -143,7 +145,7 @@ describe('<EntityKindPicker/>', () => {
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('group'),
kind: new EntityKindFilter('group', 'Group'),
});
});
@@ -173,6 +175,8 @@ describe('<EntityKindPicker/>', () => {
const input = screen.getByTestId('select');
fireEvent.mouseDown(within(input).getByRole('button'));
// screen.debug();
expect(
screen.getByRole('option', { name: 'Component' }),
).toBeInTheDocument();
@@ -27,7 +27,7 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
function useEntityKindFilter(opts: { initialFilter: string }): {
loading: boolean;
error?: Error;
allKinds: string[];
allKinds: Map<string, string>;
selectedKind: string;
setSelectedKind: (kind: string) => void;
} {
@@ -62,18 +62,21 @@ function useEntityKindFilter(opts: { initialFilter: string }): {
}
}, [filters.kind]);
const { allKinds, loading, error } = useAllKinds();
const selectedKindLabel = allKinds.get(selectedKind) || selectedKind;
useEffect(() => {
updateFilters({
kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined,
kind: selectedKind
? new EntityKindFilter(selectedKind, selectedKindLabel)
: undefined,
});
}, [selectedKind, updateFilters]);
const { allKinds, loading, error } = useAllKinds();
}, [selectedKind, selectedKindLabel, updateFilters]);
return {
loading,
error,
allKinds: allKinds ?? [],
allKinds,
selectedKind,
setSelectedKind,
};
@@ -119,9 +122,9 @@ export const EntityKindPicker = (props: EntityKindPickerProps) => {
const options = filterKinds(allKinds, allowedKinds, selectedKind);
const items = Object.keys(options).map(key => ({
const items = [...options.entries()].map(([key, value]) => ({
label: value,
value: key,
label: options[key],
}));
return hidden ? null : (
@@ -19,12 +19,12 @@ import useAsync from 'react-use/esm/useAsync';
import { catalogApiRef } from '../../api';
/**
* Fetch and return all availible kinds.
* Fetch and return all available kinds.
*/
export function useAllKinds(): {
loading: boolean;
error?: Error;
allKinds: string[];
allKinds: Map<string, string>;
} {
const catalogApi = useApi(catalogApiRef);
@@ -33,50 +33,40 @@ export function useAllKinds(): {
loading,
value: allKinds,
} = useAsync(async () => {
const items = await catalogApi
.getEntityFacets({ facets: ['kind'] })
.then(response => response.facets.kind?.map(f => f.value).sort() || []);
return items;
const { facets } = await catalogApi.getEntityFacets({ facets: ['kind'] });
const kindFacets = (facets.kind ?? []).map(f => f.value);
return new Map(
kindFacets.map(kind => [kind.toLocaleLowerCase('en-US'), kind]),
);
}, [catalogApi]);
return { loading, error, allKinds: allKinds ?? [] };
return { loading, error, allKinds: allKinds ?? new Map() };
}
/**
* Filter and capitalize accessible kinds.
*/
export function filterKinds(
allKinds: string[],
allKinds: Map<string, string>,
allowedKinds?: string[],
forcedKinds?: string,
): Record<string, string> {
): Map<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 desiredKinds = allowedKinds
? allowedKinds.map(k => k.toLocaleLowerCase('en-US'))
: Array.from(allKinds.keys());
const kindsMap = availableKinds.sort().reduce((acc, kind) => {
acc[kind.toLocaleLowerCase('en-US')] = kind;
return acc;
}, {} as Record<string, string>);
const kindsMap = new Map(
desiredKinds.map(kind => [kind, allKinds.get(kind) || kind]),
);
if (forcedKinds && !kindsMap.has(forcedKinds)) {
kindsMap.set(forcedKinds.toLocaleLowerCase('en-US'), forcedKinds);
}
return kindsMap;
}
@@ -86,7 +86,9 @@ describe('<EntityTypePicker/>', () => {
await renderInTestApp(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ filters: { kind: new EntityKindFilter('component') } }}
value={{
filters: { kind: new EntityKindFilter('component', 'Component') },
}}
>
<EntityTypePicker />
</MockEntityListContextProvider>
@@ -110,7 +112,7 @@ describe('<EntityTypePicker/>', () => {
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
filters: { kind: new EntityKindFilter('component') },
filters: { kind: new EntityKindFilter('component', 'Component') },
updateFilters,
}}
>
@@ -252,7 +252,7 @@ describe('<UserListPicker />', () => {
value={{
updateFilters,
queryParameters,
filters: { kind: new EntityKindFilter('component') },
filters: { kind: new EntityKindFilter('component', 'Component') },
}}
>
<UserListPicker />
@@ -293,7 +293,7 @@ describe('<UserListPicker />', () => {
<MockEntityListContextProvider
value={{
updateFilters,
filters: { kind: new EntityKindFilter('component') },
filters: { kind: new EntityKindFilter('component', 'Component') },
}}
>
<UserListPicker />
@@ -341,7 +341,7 @@ describe('<UserListPicker />', () => {
updateFilters,
queryParameters: { user: ['all'], kind: 'component' },
filters: {
kind: new EntityKindFilter('component'),
kind: new EntityKindFilter('component', 'Component'),
user: undefined,
},
}}
@@ -368,7 +368,7 @@ describe('<UserListPicker />', () => {
updateFilters,
queryParameters: { user: ['owned'], kind: 'component' },
filters: {
kind: new EntityKindFilter('component'),
kind: new EntityKindFilter('component', 'Component'),
user: undefined,
},
}}
@@ -394,7 +394,7 @@ describe('<UserListPicker />', () => {
value={{
updateFilters,
filters: filters || {
kind: new EntityKindFilter('component'),
kind: new EntityKindFilter('component', 'Component'),
},
}}
>
+1 -1
View File
@@ -29,7 +29,7 @@ import { getEntityRelations } from './utils';
* @public
*/
export class EntityKindFilter implements EntityFilter {
constructor(readonly value: string) {}
constructor(readonly value: string, readonly label: string) {}
getCatalogFilters(): Record<string, string | string[]> {
return { kind: this.value };
@@ -87,7 +87,7 @@ const createWrapper =
const { updateFilters } = useEntityList();
useMountEffect(() => {
updateFilters({ kind: new EntityKindFilter('component') });
updateFilters({ kind: new EntityKindFilter('component', 'Component') });
});
return <>{children}</>;
@@ -252,7 +252,9 @@ describe('<EntityListProvider />', () => {
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
await act(async () => {
result.current.updateFilters({ kind: new EntityKindFilter('api') });
result.current.updateFilters({
kind: new EntityKindFilter('api', 'API'),
});
result.current.updateFilters({ type: new EntityTypeFilter('service') });
});
@@ -277,7 +279,9 @@ describe('<EntityListProvider />', () => {
mockCatalogApi.getEntities!.mockRejectedValueOnce('error');
act(() => {
result.current.updateFilters({ kind: new EntityKindFilter('api') });
result.current.updateFilters({
kind: new EntityKindFilter('api', 'API'),
});
});
await waitFor(() => {
expect(result.current.error).toBeDefined();
@@ -443,7 +447,9 @@ describe('<EntityListProvider pagination />', () => {
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1);
await act(async () => {
result.current.updateFilters({ kind: new EntityKindFilter('api') });
result.current.updateFilters({
kind: new EntityKindFilter('api', 'API'),
});
result.current.updateFilters({ type: new EntityTypeFilter('service') });
});
@@ -470,7 +476,9 @@ describe('<EntityListProvider pagination />', () => {
mockCatalogApi.queryEntities!.mockRejectedValueOnce('error');
act(() => {
result.current.updateFilters({ kind: new EntityKindFilter('api') });
result.current.updateFilters({
kind: new EntityKindFilter('api', 'API'),
});
});
await waitFor(() => {
expect(result.current.error).toBeDefined();
@@ -716,7 +724,9 @@ describe('<EntityListProvider pagination={{mode: offset}} />', () => {
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1);
await act(async () => {
result.current.updateFilters({ kind: new EntityKindFilter('api') });
result.current.updateFilters({
kind: new EntityKindFilter('api', 'API'),
});
result.current.updateFilters({ type: new EntityTypeFilter('service') });
});
@@ -768,7 +778,9 @@ describe('<EntityListProvider pagination={{mode: offset}} />', () => {
mockCatalogApi.queryEntities!.mockRejectedValueOnce('error');
act(() => {
result.current.updateFilters({ kind: new EntityKindFilter('api') });
result.current.updateFilters({
kind: new EntityKindFilter('api', 'API'),
});
});
await waitFor(() => {
expect(result.current.error).toBeDefined();
@@ -133,7 +133,7 @@ describe('<CatalogKindHeader />', () => {
fireEvent.click(option);
expect(updateFilters).toHaveBeenCalledWith({
kind: new EntityKindFilter('template'),
kind: new EntityKindFilter('template', 'Template'),
});
});
@@ -144,7 +144,7 @@ describe('<CatalogKindHeader />', () => {
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { kind: ['components'] },
queryParameters: { kind: ['component'] },
}}
>
<CatalogKindHeader />
@@ -152,7 +152,7 @@ describe('<CatalogKindHeader />', () => {
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('components'),
kind: new EntityKindFilter('component', 'Component'),
});
rendered.rerender(
<ApiProvider apis={apis}>
@@ -168,7 +168,7 @@ describe('<CatalogKindHeader />', () => {
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('template'),
kind: new EntityKindFilter('template', 'Template'),
}),
);
});
@@ -91,11 +91,16 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) {
}
}, [filters.kind]);
const selectedKindLabel =
allKinds.get(selectedKind.toLocaleLowerCase('en-US')) || selectedKind;
useEffect(() => {
updateFilters({
kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined,
kind: selectedKind
? new EntityKindFilter(selectedKind, selectedKindLabel)
: undefined,
});
}, [selectedKind, updateFilters]);
}, [selectedKind, selectedKindLabel, updateFilters]);
const options = filterKinds(allKinds, allowedKinds, selectedKind);
@@ -106,9 +111,9 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) {
onChange={e => setSelectedKind(e.target.value as string)}
classes={classes}
>
{Object.keys(options).map(kind => (
{[...options.keys()].map(kind => (
<MenuItem value={kind} key={kind}>
{`${pluralize(options[kind])}`}
{`${pluralize(options.get(kind) || kind)}`}
</MenuItem>
))}
</Select>
@@ -19,12 +19,12 @@ import { catalogApiRef } from '@backstage/plugin-catalog-react';
import useAsync from 'react-use/esm/useAsync';
/**
* Fetch and return all availible kinds.
* Fetch and return all available kinds.
*/
export function useAllKinds(): {
loading: boolean;
error?: Error;
allKinds: string[];
allKinds: Map<string, string>;
} {
const catalogApi = useApi(catalogApiRef);
@@ -33,50 +33,40 @@ export function useAllKinds(): {
loading,
value: allKinds,
} = useAsync(async () => {
const items = await catalogApi
.getEntityFacets({ facets: ['kind'] })
.then(response => response.facets.kind?.map(f => f.value).sort() || []);
return items;
const { facets } = await catalogApi.getEntityFacets({ facets: ['kind'] });
const kindFacets = (facets.kind ?? []).map(f => f.value);
return new Map(
kindFacets.map(kind => [kind.toLocaleLowerCase('en-US'), kind]),
);
}, [catalogApi]);
return { loading, error, allKinds: allKinds ?? [] };
return { loading, error, allKinds: allKinds ?? new Map() };
}
/**
* Filter and capitalize accessible kinds.
*/
export function filterKinds(
allKinds: string[],
allKinds: Map<string, string>,
allowedKinds?: string[],
forcedKinds?: string,
): Record<string, string> {
): Map<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 desiredKinds = allowedKinds
? allowedKinds.map(k => k.toLocaleLowerCase('en-US'))
: Array.from(allKinds.keys());
const kindsMap = availableKinds.sort().reduce((acc, kind) => {
acc[kind.toLocaleLowerCase('en-US')] = kind;
return acc;
}, {} as Record<string, string>);
const kindsMap = new Map(
desiredKinds.map(kind => [kind, allKinds.get(kind) || kind]),
);
if (forcedKinds && !kindsMap.has(forcedKinds)) {
kindsMap.set(forcedKinds.toLocaleLowerCase('en-US'), forcedKinds);
}
return kindsMap;
}
@@ -111,6 +111,7 @@ describe('CatalogTable component', () => {
),
kind: {
value: 'component',
label: 'Component',
getCatalogFilters: () => ({ kind: 'component' }),
toQueryValue: () => 'component',
},
@@ -126,7 +127,7 @@ describe('CatalogTable component', () => {
},
},
);
expect(screen.getByText(/Owned components \(3\)/)).toBeInTheDocument();
expect(screen.getByText(/Owned Components \(3\)/)).toBeInTheDocument();
expect(screen.getByText(/component1/)).toBeInTheDocument();
expect(screen.getByText(/component2/)).toBeInTheDocument();
expect(screen.getByText(/component3/)).toBeInTheDocument();
@@ -300,7 +301,9 @@ describe('CatalogTable component', () => {
value={{
entities,
filters: {
kind: kind ? new EntityKindFilter(kind) : undefined,
kind: kind
? new EntityKindFilter(kind.toLocaleLowerCase('en-US'), kind)
: undefined,
},
}}
>
@@ -420,6 +423,7 @@ describe('CatalogTable component', () => {
filters: {
kind: {
value: 'api',
label: 'API',
getCatalogFilters: () => ({ kind: 'api' }),
toQueryValue: () => 'api',
},
@@ -183,7 +183,7 @@ export const CatalogTable = (props: CatalogTableProps) => {
},
];
const currentKind = filters.kind?.value || '';
const currentKind = filters.kind?.label || '';
const currentType = filters.type?.value || '';
const currentCount = typeof totalItems === 'number' ? `(${totalItems})` : '';
// TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar
@@ -162,7 +162,7 @@ describe('CursorPaginatedCatalogTable', () => {
entities: data.map(e => e.entity),
totalItems: data.length,
filters: {
kind: new EntityKindFilter('component'),
kind: new EntityKindFilter('component', 'Component'),
},
}}
>
@@ -90,7 +90,7 @@ describe('<TemplateTypePicker/>', () => {
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
filters: { kind: new EntityKindFilter('template') },
filters: { kind: new EntityKindFilter('template', 'Template') },
backendEntities: entities,
}}
>
@@ -113,7 +113,7 @@ describe('<TemplateTypePicker/>', () => {
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
filters: { kind: new EntityKindFilter('template') },
filters: { kind: new EntityKindFilter('template', 'Template') },
backendEntities: entities,
}}
>
@@ -145,7 +145,7 @@ describe('CursorPaginatedDocsTable', () => {
entities: data.map(e => e.entity),
totalItems: data.length,
filters: {
kind: new EntityKindFilter('techdocs'),
kind: new EntityKindFilter('techdocs', 'TechDocs'),
},
}}
>