Merge branch 'master' into nikolarAutodesk/makeTechDocsCustomizable

Signed-off-by: Reyna Nikolayev <146387693+nikolarAutodesk@users.noreply.github.com>
This commit is contained in:
Reyna Nikolayev
2024-12-10 16:40:53 -08:00
committed by GitHub
1425 changed files with 48552 additions and 8946 deletions
+98
View File
@@ -1,5 +1,103 @@
# @backstage/plugin-techdocs
## 1.11.3-next.2
### Patch Changes
- Updated dependencies
- @backstage/core-compat-api@0.3.4-next.2
- @backstage/plugin-catalog-react@1.14.3-next.2
- @backstage/errors@1.2.6-next.0
- @backstage/catalog-model@1.7.2-next.0
- @backstage/config@1.3.1-next.0
- @backstage/core-components@0.16.2-next.2
- @backstage/core-plugin-api@1.10.2-next.0
- @backstage/frontend-plugin-api@0.9.3-next.2
- @backstage/integration@1.16.0-next.1
- @backstage/integration-react@1.2.2-next.1
- @backstage/theme@0.6.3-next.0
- @backstage/plugin-auth-react@0.1.10-next.2
- @backstage/plugin-search-common@1.2.16-next.0
- @backstage/plugin-search-react@1.8.4-next.2
- @backstage/plugin-techdocs-common@0.1.0
- @backstage/plugin-techdocs-react@1.2.12-next.2
## 1.11.3-next.1
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-react@1.14.3-next.1
- @backstage/core-components@0.16.2-next.1
- @backstage/catalog-model@1.7.1
- @backstage/config@1.3.0
- @backstage/core-compat-api@0.3.4-next.1
- @backstage/core-plugin-api@1.10.1
- @backstage/errors@1.2.5
- @backstage/frontend-plugin-api@0.9.3-next.1
- @backstage/integration@1.16.0-next.0
- @backstage/integration-react@1.2.2-next.0
- @backstage/theme@0.6.3-next.0
- @backstage/plugin-auth-react@0.1.10-next.1
- @backstage/plugin-search-common@1.2.15
- @backstage/plugin-search-react@1.8.4-next.1
- @backstage/plugin-techdocs-common@0.1.0
- @backstage/plugin-techdocs-react@1.2.12-next.1
## 1.11.3-next.0
### Patch Changes
- 7d8777d: Added support for the Search bar in docs residing in the entity page tab, and not only the global "/docs" page.
- Updated dependencies
- @backstage/integration@1.16.0-next.0
- @backstage/plugin-search-react@1.8.4-next.0
- @backstage/plugin-catalog-react@1.14.3-next.0
- @backstage/frontend-plugin-api@0.9.3-next.0
- @backstage/theme@0.6.3-next.0
- @backstage/catalog-model@1.7.1
- @backstage/config@1.3.0
- @backstage/core-compat-api@0.3.4-next.0
- @backstage/core-components@0.16.2-next.0
- @backstage/core-plugin-api@1.10.1
- @backstage/errors@1.2.5
- @backstage/integration-react@1.2.2-next.0
- @backstage/plugin-auth-react@0.1.10-next.0
- @backstage/plugin-search-common@1.2.15
- @backstage/plugin-techdocs-common@0.1.0
- @backstage/plugin-techdocs-react@1.2.12-next.0
## 1.11.1
### Patch Changes
- 37a7810: Fixed an issue where `<TechDocsReaderPageContent />` would re-render infinitely under certain conditions.
- e937ae7: Fix an issue with index page of documentation site being re-rendered.
- 90246a9: Fix techdocs config schema for custom elements sanitizer
- 605bdc0: Avoid page re-rendering when clicking on anchor links in the same documentation page.
- 4f0cb89: Added DomPurify sanitizer configuration for custom elements implementing RFC https://github.com/backstage/backstage/issues/26988.
See https://backstage.io/docs/features/techdocs/how-to-guides#how-to-enable-custom-elements-in-techdocs for how to enable it in the configuration.
- f246178: Removed `canvas` dev dependency.
- 4a2f73a: Fix an issue that caused the current documentation page to be re-rendered when navigating to
another one.
- Updated dependencies
- @backstage/config@1.3.0
- @backstage/theme@0.6.1
- @backstage/plugin-catalog-react@1.14.1
- @backstage/core-components@0.16.0
- @backstage/plugin-techdocs-react@1.2.10
- @backstage/catalog-model@1.7.1
- @backstage/core-compat-api@0.3.2
- @backstage/core-plugin-api@1.10.1
- @backstage/errors@1.2.5
- @backstage/frontend-plugin-api@0.9.1
- @backstage/integration@1.15.2
- @backstage/integration-react@1.2.1
- @backstage/plugin-auth-react@0.1.8
- @backstage/plugin-search-common@1.2.15
- @backstage/plugin-search-react@1.8.2
- @backstage/plugin-techdocs-common@0.1.0
## 1.11.1-next.3
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-techdocs",
"version": "1.11.1-next.3",
"version": "1.11.3-next.2",
"description": "The Backstage plugin that renders technical documentation for your components",
"backstage": {
"role": "frontend-plugin",
+15 -15
View File
@@ -31,21 +31,6 @@ const _default: FrontendPlugin<
},
{},
{
'api:techdocs': ExtensionDefinition<{
kind: 'api';
name: undefined;
config: {};
configInput: {};
output: ConfigurableExtensionDataRef<
AnyApiFactory,
'core.api.factory',
{}
>;
inputs: {};
params: {
factory: AnyApiFactory;
};
}>;
'page:techdocs': ExtensionDefinition<{
kind: 'page';
name: undefined;
@@ -97,6 +82,21 @@ const _default: FrontendPlugin<
routeRef: RouteRef<undefined>;
};
}>;
'api:techdocs': ExtensionDefinition<{
kind: 'api';
name: undefined;
config: {};
configInput: {};
output: ConfigurableExtensionDataRef<
AnyApiFactory,
'core.api.factory',
{}
>;
inputs: {};
params: {
factory: AnyApiFactory;
};
}>;
'api:techdocs/storage': ExtensionDefinition<{
kind: 'api';
name: 'storage';
+4 -1
View File
@@ -13,7 +13,7 @@ import { CSSProperties } from '@material-ui/styles/withStyles';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityFilterQuery } from '@backstage/catalog-client';
import { EntityOwnerPickerProps } from '@backstage/plugin-catalog-react';
import { EntityListPagination, EntityOwnerPickerProps } from '@backstage/plugin-catalog-react';
import { FetchApi } from '@backstage/core-plugin-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { JSX as JSX_2 } from 'react';
@@ -365,6 +365,7 @@ export type TechDocsIndexPageProps = {
options?: TableOptions<DocsTableRow>;
title?: string;
subtitle?: string;
pagination?: EntityListPagination;
};
// @public @deprecated (undocumented)
@@ -430,6 +431,7 @@ export const TechDocsReaderPageContent: (
export type TechDocsReaderPageContentProps = {
entityRef?: CompoundEntityRef;
withSearch?: boolean;
searchResultUrlMapper?: (url: string) => string;
onReady?: () => void;
};
@@ -490,6 +492,7 @@ export type TechDocsSearchProps = {
entityId: CompoundEntityRef;
entityTitle?: string;
debounceTime?: number;
searchResultUrlMapper?: (url: string) => string;
};
// @public
+18 -3
View File
@@ -25,12 +25,24 @@ import React from 'react';
import { TechDocsReaderPage } from './plugin';
import { TechDocsReaderPageContent } from './reader/components/TechDocsReaderPageContent';
import { TechDocsReaderPageSubheader } from './reader/components/TechDocsReaderPageSubheader';
import { useEntityPageTechDocsRedirect } from './search/hooks/useTechDocsLocation';
type EntityPageDocsProps = { entity: Entity };
type EntityPageDocsProps = {
entity: Entity;
/**
* Show or hide the content search bar, defaults to true.
*/
withSearch?: boolean;
};
export const EntityPageDocs = ({ entity }: EntityPageDocsProps) => {
export const EntityPageDocs = ({
entity,
withSearch = true,
}: EntityPageDocsProps) => {
let entityRef = getCompoundEntityRef(entity);
const searchResultUrlMapper = useEntityPageTechDocsRedirect(entityRef);
if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) {
try {
entityRef = parseEntityRef(
@@ -44,7 +56,10 @@ export const EntityPageDocs = ({ entity }: EntityPageDocsProps) => {
return (
<TechDocsReaderPage entityRef={entityRef}>
<TechDocsReaderPageSubheader />
<TechDocsReaderPageContent withSearch={false} />
<TechDocsReaderPageContent
withSearch={withSearch}
searchResultUrlMapper={searchResultUrlMapper}
/>
</TechDocsReaderPage>
);
};
@@ -56,6 +56,7 @@ export const DefaultTechDocsHome = (props: TechDocsIndexPageProps) => {
title,
subtitle,
showSupport = true,
pagination,
} = props;
const Wrapper = showHeader ? TechDocsPageWrapper : React.Fragment;
return (
@@ -68,7 +69,7 @@ export const DefaultTechDocsHome = (props: TechDocsIndexPageProps) => {
</SupportButton>
</ContentHeader>
)}
<EntityListProvider>
<EntityListProvider pagination={pagination}>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<TechDocsPicker />
@@ -0,0 +1,168 @@
/*
* Copyright 2024 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 React, { ReactNode } from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { CursorPaginatedDocsTable } from './CursorPaginatedDocsTable';
import { DocsTableRow } from './types';
import { renderInTestApp } from '@backstage/test-utils';
import {
DefaultEntityFilters,
EntityKindFilter,
EntityListContextProps,
} from '@backstage/plugin-catalog-react';
import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils';
describe('CursorPaginatedDocsTable', () => {
const data = new Array(100).fill(0).map((_, index) => {
const name = `techdocs-${index}`;
return {
entity: {
apiVersion: '1',
kind: 'TestKind',
metadata: {
name,
},
},
resolved: {
docsUrl: 'https://example.com',
ownedByRelationsTitle: 'owned',
ownedByRelations: [],
},
} as DocsTableRow;
});
const columns = [
{
title: 'Title',
field: 'entity.metadata.name',
searchable: true,
},
];
const wrapInContext = (
node: ReactNode,
value?: Partial<EntityListContextProps<DefaultEntityFilters>>,
) => {
return (
<MockEntityListContextProvider value={value}>
{node}
</MockEntityListContextProvider>
);
};
it('should display all the items', async () => {
await renderInTestApp(
wrapInContext(<CursorPaginatedDocsTable data={data} columns={columns} />),
);
for (const item of data) {
expect(screen.queryByText(item.entity.metadata.name)).toBeInTheDocument();
}
});
it('should display and invoke the next button', async () => {
const { rerender } = await renderInTestApp(
wrapInContext(
<CursorPaginatedDocsTable
data={data}
columns={columns}
next={undefined}
/>,
),
);
expect(
screen.queryAllByRole('button', { name: 'Next Page' })[0],
).toBeDisabled();
const fn = jest.fn();
rerender(
wrapInContext(
<CursorPaginatedDocsTable data={data} columns={columns} next={fn} />,
),
);
const nextButton = screen.queryAllByRole('button', {
name: 'Next Page',
})[0];
expect(nextButton).toBeEnabled();
fireEvent.click(nextButton);
expect(fn).toHaveBeenCalled();
});
it('should display and invoke the prev button', async () => {
const { rerender } = await renderInTestApp(
wrapInContext(
<CursorPaginatedDocsTable
data={data}
columns={columns}
prev={undefined}
/>,
),
);
expect(
screen.queryAllByRole('button', { name: 'Next Page' })[0],
).toBeDisabled();
const fn = jest.fn();
rerender(
wrapInContext(
<CursorPaginatedDocsTable data={data} columns={columns} prev={fn} />,
),
);
const prevButton = screen.queryAllByRole('button', {
name: 'Previous Page',
})[0];
expect(prevButton).toBeEnabled();
fireEvent.click(prevButton);
expect(fn).toHaveBeenCalled();
});
it('should display entity names when loading has finished and no error occurred', async () => {
await renderInTestApp(
<MockEntityListContextProvider
value={{
entities: data.map(e => e.entity),
totalItems: data.length,
filters: {
kind: new EntityKindFilter('techdocs'),
},
}}
>
<CursorPaginatedDocsTable
data={data}
columns={columns}
next={undefined}
title="My title"
/>
</MockEntityListContextProvider>,
);
expect(screen.getByText(/techdocs-0/)).toBeInTheDocument();
expect(screen.getByText(/techdocs-50/)).toBeInTheDocument();
expect(screen.getByText(/techdocs-99/)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/My title/)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,75 @@
/*
* Copyright 2024 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 React from 'react';
import { Table, TableProps } from '@backstage/core-components';
import { DocsTableRow } from './types';
type PaginatedDocsTableProps = {
prev?(): void;
next?(): void;
} & TableProps<DocsTableRow>;
/**
* @internal
*/
export function CursorPaginatedDocsTable(props: PaginatedDocsTableProps) {
const {
actions,
columns,
data,
next,
prev,
title,
isLoading,
options,
...restProps
} = props;
return (
<Table
title={isLoading ? '' : title}
columns={columns}
data={data}
options={{
paginationPosition: 'both',
...options,
// These settings are configured to force server side pagination
pageSizeOptions: [],
showFirstLastPageButtons: false,
pageSize: Number.MAX_SAFE_INTEGER,
emptyRowsWhenPaging: false,
actionsColumnIndex: -1,
}}
onPageChange={page => {
if (page > 0) {
next?.();
} else {
prev?.();
}
}}
/* this will enable the prev button accordingly */
page={prev ? 1 : 0}
/* this will enable the next button accordingly */
totalCount={next ? Number.MAX_VALUE : Number.MAX_SAFE_INTEGER}
localization={{ pagination: { labelDisplayedRows: '' } }}
isLoading={isLoading}
{...restProps}
/>
);
}
@@ -18,11 +18,7 @@ import React from 'react';
import useCopyToClipboard from 'react-use/esm/useCopyToClipboard';
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
getEntityRelations,
humanizeEntityRef,
} from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import { rootDocsRouteRef } from '../../../routes';
import {
EmptyState,
@@ -33,9 +29,9 @@ import {
TableProps,
} from '@backstage/core-components';
import { actionFactories } from './actions';
import { columnFactories } from './columns';
import { toLowerMaybe } from '../../../helpers';
import { columnFactories, defaultColumns } from './columns';
import { DocsTableRow } from './types';
import { entitiesToDocsMapper } from './helpers';
/**
* Props for {@link DocsTable}.
@@ -51,14 +47,6 @@ export type DocsTableProps = {
options?: TableOptions<DocsTableRow>;
};
const defaultColumns: TableColumn<DocsTableRow>[] = [
columnFactories.createTitleColumn({ hidden: true }),
columnFactories.createNameColumn(),
columnFactories.createOwnerColumn(),
columnFactories.createKindColumn(),
columnFactories.createTypeColumn(),
];
/**
* Component which renders a table documents
*
@@ -71,26 +59,11 @@ export const DocsTable = (props: DocsTableProps) => {
const config = useApi(configApiRef);
if (!entities) return null;
const documents = entities.map(entity => {
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
return {
entity,
resolved: {
docsUrl: getRouteToReaderPageFor({
namespace: toLowerMaybe(
entity.metadata.namespace ?? 'default',
config,
),
kind: toLowerMaybe(entity.kind, config),
name: toLowerMaybe(entity.metadata.name, config),
}),
ownedByRelations,
ownedByRelationsTitle: ownedByRelations
.map(r => humanizeEntityRef(r, { defaultKind: 'group' }))
.join(', '),
},
};
});
const documents = entitiesToDocsMapper(
entities,
getRouteToReaderPageFor,
config,
);
const defaultActions: TableProps<DocsTableRow>['actions'] = [
actionFactories.createCopyDocsUrlAction(copyToClipboard),
@@ -24,14 +24,19 @@ import {
TableProps,
WarningPanel,
} from '@backstage/core-components';
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
useEntityList,
useStarredEntities,
} from '@backstage/plugin-catalog-react';
import { DocsTable } from './DocsTable';
import { OffsetPaginatedDocsTable } from './OffsetPaginatedDocsTable';
import { CursorPaginatedDocsTable } from './CursorPaginatedDocsTable';
import { actionFactories } from './actions';
import { columnFactories } from './columns';
import { columnFactories, defaultColumns } from './columns';
import { DocsTableRow } from './types';
import { rootDocsRouteRef } from '../../../routes';
import { entitiesToDocsMapper } from './helpers';
/**
* Props for {@link EntityListDocsTable}.
@@ -51,9 +56,12 @@ export type EntityListDocsTableProps = {
*/
export const EntityListDocsTable = (props: EntityListDocsTableProps) => {
const { columns, actions, options } = props;
const { loading, error, entities, filters } = useEntityList();
const { loading, error, entities, filters, paginationMode, pageInfo } =
useEntityList();
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
const [, copyToClipboard] = useCopyToClipboard();
const getRouteToReaderPageFor = useRouteRef(rootDocsRouteRef);
const config = useApi(configApiRef);
const title = capitalize(filters.user?.value ?? 'all');
@@ -65,6 +73,38 @@ export const EntityListDocsTable = (props: EntityListDocsTableProps) => {
),
];
const documents = entitiesToDocsMapper(
entities,
getRouteToReaderPageFor,
config,
);
if (paginationMode === 'cursor') {
return (
<CursorPaginatedDocsTable
columns={columns || defaultColumns}
isLoading={loading}
title={title}
actions={actions || defaultActions}
options={options}
data={documents}
next={pageInfo?.next}
prev={pageInfo?.prev}
/>
);
} else if (paginationMode === 'offset') {
return (
<OffsetPaginatedDocsTable
columns={columns || defaultColumns}
isLoading={loading}
title={title}
actions={actions || defaultActions}
options={options}
data={documents}
/>
);
}
if (error) {
return (
<WarningPanel
@@ -0,0 +1,111 @@
/*
* Copyright 2024 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 React, { ReactNode } from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { DocsTableRow } from './types';
import { renderInTestApp } from '@backstage/test-utils';
import {
DefaultEntityFilters,
EntityListContextProps,
} from '@backstage/plugin-catalog-react';
import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils';
import { OffsetPaginatedDocsTable } from './OffsetPaginatedDocsTable';
describe('OffsetPaginatedDocsTable', () => {
const data = new Array(100).fill(0).map((_, index) => {
const name = `tectdocs-${index}`;
return {
entity: {
apiVersion: '1',
kind: 'TestKind',
metadata: {
name,
},
},
resolved: {
docsUrl: 'https://example.com',
ownedByRelationsTitle: 'owned',
ownedByRelations: [],
},
} as DocsTableRow;
});
const columns = [
{
title: 'Title',
field: 'entity.metadata.name',
searchable: true,
},
];
const wrapInContext = (
node: ReactNode,
value?: Partial<EntityListContextProps<DefaultEntityFilters>>,
) => {
return (
<MockEntityListContextProvider value={value}>
{node}
</MockEntityListContextProvider>
);
};
it('should display all the items', async () => {
await renderInTestApp(
wrapInContext(
<OffsetPaginatedDocsTable data={data} columns={columns} />,
{
setOffset: jest.fn(),
limit: Number.MAX_SAFE_INTEGER,
offset: 0,
totalItems: data.length,
},
),
);
for (const item of data) {
expect(screen.queryByText(item.entity.metadata.name)).toBeInTheDocument();
}
});
it('should display and invoke the next and previous buttons', async () => {
const offsetFn = jest.fn();
await renderInTestApp(
wrapInContext(
<OffsetPaginatedDocsTable data={data} columns={columns} />,
{ setOffset: offsetFn, limit: 10, totalItems: data.length, offset: 0 },
),
);
expect(offsetFn).toHaveBeenNthCalledWith(1, 0);
const nextButton = screen.queryAllByRole('button', {
name: 'Next Page',
})[0];
expect(nextButton).toBeEnabled();
fireEvent.click(nextButton);
expect(offsetFn).toHaveBeenNthCalledWith(2, 10);
const prevButton = screen.queryAllByRole('button', {
name: 'Previous Page',
})[0];
expect(prevButton).toBeEnabled();
fireEvent.click(prevButton);
expect(offsetFn).toHaveBeenNthCalledWith(3, 0);
});
});
@@ -0,0 +1,75 @@
/*
* Copyright 2024 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 React, { useEffect } from 'react';
import { Table, TableProps } from '@backstage/core-components';
import { DocsTableRow } from './types';
import {
EntityTextFilter,
useEntityList,
} from '@backstage/plugin-catalog-react';
/**
* @internal
*/
export function OffsetPaginatedDocsTable(props: TableProps<DocsTableRow>) {
const { actions, columns, data, isLoading, options } = props;
const { updateFilters, setLimit, setOffset, limit, totalItems, offset } =
useEntityList();
const [page, setPage] = React.useState(
offset && limit ? Math.floor(offset / limit) : 0,
);
useEffect(() => {
if (totalItems && page * limit >= totalItems) {
setOffset!(Math.max(0, totalItems - limit));
} else {
setOffset!(Math.max(0, page * limit));
}
}, [setOffset, page, limit, totalItems]);
return (
<Table<DocsTableRow>
columns={columns}
data={data}
options={{
paginationPosition: 'both',
pageSizeOptions: [5, 10, 20, 50, 100],
pageSize: limit,
emptyRowsWhenPaging: false,
actionsColumnIndex: -1,
...options,
}}
actions={actions}
onSearchChange={(searchText: string) =>
updateFilters({
text: searchText ? new EntityTextFilter(searchText) : undefined,
})
}
page={page}
onPageChange={newPage => {
setPage(newPage);
}}
onRowsPerPageChange={pageSize => {
setLimit(pageSize);
}}
totalCount={totalItems}
localization={{ pagination: { labelDisplayedRows: '' } }}
isLoading={isLoading}
/>
);
}
@@ -85,3 +85,11 @@ export const columnFactories = {
};
},
};
export const defaultColumns: TableColumn<DocsTableRow>[] = [
columnFactories.createTitleColumn({ hidden: true }),
columnFactories.createNameColumn(),
columnFactories.createOwnerColumn(),
columnFactories.createKindColumn(),
columnFactories.createTypeColumn(),
];
@@ -0,0 +1,56 @@
/*
* Copyright 2024 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 { RELATION_OWNED_BY, Entity } from '@backstage/catalog-model';
import {
getEntityRelations,
humanizeEntityRef,
} from '@backstage/plugin-catalog-react';
import { toLowerMaybe } from '../../../helpers';
import { ConfigApi, RouteFunc } from '@backstage/core-plugin-api';
type getRouteFunc = RouteFunc<{
namespace: string;
kind: string;
name: string;
}>;
export function entitiesToDocsMapper(
entities: Entity[],
getRouteToReaderPageFor: getRouteFunc,
config: ConfigApi,
) {
return entities.map(entity => {
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
return {
entity,
resolved: {
docsUrl: getRouteToReaderPageFor({
namespace: toLowerMaybe(
entity.metadata.namespace ?? 'default',
config,
),
kind: toLowerMaybe(entity.kind, config),
name: toLowerMaybe(entity.metadata.name, config),
}),
ownedByRelations,
ownedByRelationsTitle: ownedByRelations
.map(r => humanizeEntityRef(r, { defaultKind: 'group' }))
.join(', '),
},
};
});
}
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import {
catalogApiRef,
starredEntitiesApiRef,
MockStarredEntitiesApi,
} from '@backstage/plugin-catalog-react';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
@@ -37,7 +41,10 @@ const mockCatalogApi = catalogApiMock({
});
describe('TechDocsCustomHome', () => {
const apiRegistry = TestApiRegistry.from([catalogApiRef, mockCatalogApi]);
const apiRegistry = TestApiRegistry.from(
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
);
it('should render a TechDocs home page', async () => {
const tabsConfig = [
@@ -23,6 +23,7 @@ import {
catalogApiRef,
CatalogApi,
useEntityOwnership,
EntityListProvider,
} from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import { DocsTable, DocsTableRow } from './Tables';
@@ -157,11 +158,13 @@ export const CustomDocsPanel = ({
</ContentHeader>
)}
<div className={classes.panelContainer}>
<Panel
data-testid="techdocs-custom-panel"
entities={shownEntities}
{...config.panelProps}
/>
<EntityListProvider>
<Panel
data-testid="techdocs-custom-panel"
entities={shownEntities}
{...config.panelProps}
/>
</EntityListProvider>
</div>
</>
);
@@ -22,6 +22,7 @@ import {
TableOptions,
} from '@backstage/core-components';
import {
EntityListPagination,
EntityOwnerPickerProps,
UserListFilterKind,
} from '@backstage/plugin-catalog-react';
@@ -43,6 +44,7 @@ export type TechDocsIndexPageProps = {
options?: TableOptions<DocsTableRow>;
title?: string;
subtitle?: string;
pagination?: EntityListPagination;
};
export const TechDocsIndexPage = (props: TechDocsIndexPageProps) => {
@@ -64,6 +64,13 @@ export type TechDocsReaderPageContentProps = {
* Show or hide the search bar, defaults to true.
*/
withSearch?: boolean;
/**
* If {@link TechDocsReaderPageContentProps.withSearch | withSearch} is true,
* this will redirect the search result urls, e.g. turn search results into
* links within the "Docs" tab of the entity page, instead of the global docs
* page.
*/
searchResultUrlMapper?: (url: string) => string;
/**
* Callback called when the content is rendered.
*/
@@ -76,7 +83,7 @@ export type TechDocsReaderPageContentProps = {
*/
export const TechDocsReaderPageContent = withTechDocsReaderProvider(
(props: TechDocsReaderPageContentProps) => {
const { withSearch = true, onReady } = props;
const { withSearch = true, searchResultUrlMapper, onReady } = props;
const classes = useStyles();
const {
@@ -142,6 +149,7 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider(
<TechDocsSearch
entityId={entityRef}
entityTitle={entityMetadata?.metadata?.title}
searchResultUrlMapper={searchResultUrlMapper}
/>
</Grid>
)}
@@ -687,5 +687,43 @@ describe('useReaderState', () => {
expect.any(Function),
);
});
it('should return the same data if re-rendered', async () => {
techdocsStorageApi.getEntityDocs.mockResolvedValue('my content');
techdocsStorageApi.syncEntityDocs.mockImplementation(async () => {
return 'cached';
});
const { result, rerender } = renderHook(
() => useReaderState('Component', 'default', 'backstage', '/example'),
{ wrapper: Wrapper },
);
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
await waitFor(() => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
});
const firstResult = result.current;
rerender();
expect(result.current).toBe(firstResult);
});
});
});
@@ -343,13 +343,24 @@ export function useReaderState(
[state.activeSyncState, state.content, state.contentLoading],
);
return {
state: displayState,
contentReload,
path: state.path,
content: state.content,
contentErrorMessage: state.contentError?.toString(),
syncErrorMessage: state.syncError?.toString(),
buildLog: state.buildLog,
};
return useMemo(
() => ({
state: displayState,
contentReload,
path: state.path,
content: state.content,
contentErrorMessage: state.contentError?.toString(),
syncErrorMessage: state.syncError?.toString(),
buildLog: state.buildLog,
}),
[
displayState,
contentReload,
state.path,
state.content,
state.contentError,
state.syncError,
state.buildLog,
],
);
}
@@ -34,6 +34,7 @@ export type TechDocsSearchProps = {
entityId: CompoundEntityRef;
entityTitle?: string;
debounceTime?: number;
searchResultUrlMapper?: (url: string) => string;
};
type TechDocsDoc = {
@@ -58,7 +59,12 @@ const isTechDocsSearchResult = (
};
const TechDocsSearchBar = (props: TechDocsSearchProps) => {
const { entityId, entityTitle, debounceTime = 150 } = props;
const {
entityId,
entityTitle,
debounceTime = 150,
searchResultUrlMapper,
} = props;
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const {
@@ -102,7 +108,9 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
) => {
if (isTechDocsSearchResult(selection)) {
const { location } = selection.document;
navigate(location);
navigate(
searchResultUrlMapper ? searchResultUrlMapper(location) : location,
);
}
};
@@ -0,0 +1,62 @@
/*
* Copyright 2024 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 { useMemo } from 'react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { useRouteRef } from '@backstage/core-plugin-api';
import { rootCatalogDocsRouteRef, rootDocsRouteRef } from '../../routes';
const trimStartSlash = (path: string) => path.replace(/^\/+/, '');
const trimEndSlash = (path: string) => path.replace(/\/+$/, '');
/**
* Returns a function that takes a location to a Tech Docs entry, and returns a
* new location, re-routed to the catalog page tab.
*
* @internal
*/
export function useEntityPageTechDocsRedirect(entityRef: CompoundEntityRef) {
const { kind, name, namespace } = entityRef;
const routeDocsRoot = useRouteRef(rootDocsRouteRef);
const routeDocsCatalog = useRouteRef(rootCatalogDocsRouteRef);
// Re-routes a /docs/:namespace/:kind/:name/* location into
// /catalog/:namespace/:kind/:name/docs/*, while handling situations where
// these defaults are changed.
// eslint-disable-next-line react-hooks/rules-of-hooks
const reRouteLocationToCatalog = useMemo(() => {
const rootDocsPath = trimEndSlash(routeDocsRoot({ kind, namespace, name }));
const catalogDocsPath = trimEndSlash(routeDocsCatalog());
return (url: string): string => {
if (
url
.toLocaleLowerCase('en-US')
.startsWith(rootDocsPath.toLocaleLowerCase('en-US'))
) {
const suffix = trimStartSlash(url.slice(rootDocsPath.length));
return suffix.length === 0 || suffix.startsWith('#')
? `${catalogDocsPath}${suffix}`
: `${catalogDocsPath}/${suffix}`;
}
return url;
};
}, [routeDocsRoot, routeDocsCatalog, kind, name, namespace]);
return reRouteLocationToCatalog;
}