diff --git a/.changeset/late-papayas-push.md b/.changeset/late-papayas-push.md new file mode 100644 index 0000000000..07422e1b1b --- /dev/null +++ b/.changeset/late-papayas-push.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Add support for `SidebarGroup` on the sidebar item extension. diff --git a/.changeset/olive-cars-rescue.md b/.changeset/olive-cars-rescue.md new file mode 100644 index 0000000000..fd6c8fe331 --- /dev/null +++ b/.changeset/olive-cars-rescue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Create an experimental search plugin that is compatible with the declarative integration system, it is exported from `/alpha` subpath. diff --git a/.changeset/purple-forks-fetch.md b/.changeset/purple-forks-fetch.md new file mode 100644 index 0000000000..022fc32e71 --- /dev/null +++ b/.changeset/purple-forks-fetch.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Register default api implementations when creating declarative integrated apps. diff --git a/.changeset/tasty-avocados-hope.md b/.changeset/tasty-avocados-hope.md new file mode 100644 index 0000000000..0772b55683 --- /dev/null +++ b/.changeset/tasty-avocados-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Migrate catalog api to declarative integration system, it is exported from `/alpha` subpath. diff --git a/packages/app-next/src/examples/pagesPlugin.tsx b/packages/app-next/src/examples/pagesPlugin.tsx index c805442305..290c6b118e 100644 --- a/packages/app-next/src/examples/pagesPlugin.tsx +++ b/packages/app-next/src/examples/pagesPlugin.tsx @@ -48,6 +48,9 @@ const IndexPage = createPageExtension({
GraphiQL
+
+ Search +
); }; diff --git a/packages/frontend-app-api/src/extensions/CoreNav.tsx b/packages/frontend-app-api/src/extensions/CoreNav.tsx index e66bee8212..71ab71a6aa 100644 --- a/packages/frontend-app-api/src/extensions/CoreNav.tsx +++ b/packages/frontend-app-api/src/extensions/CoreNav.tsx @@ -19,6 +19,8 @@ import { createExtension, coreExtensionData, createExtensionInput, + useRouteRef, + NavTarget, } from '@backstage/frontend-plugin-api'; import { makeStyles } from '@material-ui/core'; import { @@ -29,7 +31,6 @@ import { SidebarDivider, SidebarItem, } from '@backstage/core-components'; -import { GraphiQLIcon } from '@backstage/plugin-graphiql'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import LogoIcon from '../../../app/src/components/Root/LogoIcon'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports @@ -63,25 +64,33 @@ const SidebarLogo = () => { ); }; +const SidebarNavItem = (props: NavTarget) => { + const { icon: Icon, title, routeRef } = props; + const to = useRouteRef(routeRef)(); + // TODO: Support opening modal, for example, the search one + return ; +}; + export const CoreNav = createExtension({ id: 'core.nav', at: 'core.layout/nav', inputs: { items: createExtensionInput({ - path: coreExtensionData.navTarget, + target: coreExtensionData.navTarget, }), }, output: { element: coreExtensionData.reactElement, }, - factory({ bind }) { + factory({ bind, inputs }) { bind({ - // TODO: set base path using the logic from AppRouter element: ( - + {inputs.items.map((item, index) => ( + + ))} ), }); diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index bb50dea075..dc7db79022 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -49,6 +49,7 @@ import { featureFlagsApiRef, attachComponentData, useRouteRef, + identityApiRef, } from '@backstage/core-plugin-api'; import { getAvailablePlugins } from './discovery'; import { @@ -62,6 +63,8 @@ import { // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { AppThemeProvider } from '../../../core-app-api/src/app/AppThemeProvider'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports import { AppContextProvider } from '../../../core-app-api/src/app/AppContext'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { LocalStorageFeatureFlags } from '../../../core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags'; @@ -366,13 +369,13 @@ function createApiHolder( ): ApiHolder { const factoryRegistry = new ApiFactoryRegistry(); - const apiFactories = + const pluginApis = coreExtension.attachments .get('apis') ?.map(e => e.getData(coreExtensionData.apiFactory)) .filter((x): x is AnyApiFactory => !!x) ?? []; - for (const factory of apiFactories) { + for (const factory of [...defaultApis, ...pluginApis]) { factoryRegistry.register('default', factory); } @@ -383,6 +386,38 @@ function createApiHolder( factory: () => new LocalStorageFeatureFlags(), }); + factoryRegistry.register('static', { + api: identityApiRef, + deps: {}, + factory: () => { + const appIdentityProxy = new AppIdentityProxy(); + // TODO: Remove this when sign-in page is migrated + appIdentityProxy.setTarget( + { + getUserId: () => 'guest', + getIdToken: async () => undefined, + getProfile: () => ({ + email: 'guest@example.com', + displayName: 'Guest', + }), + getProfileInfo: async () => ({ + email: 'guest@example.com', + displayName: 'Guest', + }), + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/guest', + ownershipEntityRefs: ['user:default/guest'], + }), + getCredentials: async () => ({}), + signOut: async () => {}, + }, + { signOutTargetUrl: '/' }, + ); + return appIdentityProxy; + }, + }); + factoryRegistry.register('static', { api: appThemeApiRef, deps: {}, diff --git a/plugins/catalog/alpha-api-report.md b/plugins/catalog/alpha-api-report.md index 27de408f86..631c43e73c 100644 --- a/plugins/catalog/alpha-api-report.md +++ b/plugins/catalog/alpha-api-report.md @@ -3,8 +3,13 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BackstagePlugin } from '@backstage/frontend-plugin-api'; +import { Extension } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; +// @alpha (undocumented) +export const CatalogApi: Extension<{}>; + // @alpha (undocumented) export const catalogTranslationRef: TranslationRef< 'catalog', @@ -14,5 +19,9 @@ export const catalogTranslationRef: TranslationRef< } >; +// @alpha (undocumented) +const _default: BackstagePlugin; +export default _default; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 412c7d0716..4ef0db4910 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -51,6 +51,7 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "@backstage/integration-react": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", diff --git a/plugins/catalog/src/alpha.ts b/plugins/catalog/src/alpha.ts index 3509c1fb7f..d9c2af338d 100644 --- a/plugins/catalog/src/alpha.ts +++ b/plugins/catalog/src/alpha.ts @@ -15,3 +15,40 @@ */ export * from './translation'; + +import { + DiscoveryApi, + FetchApi, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; +import { CatalogClient } from '@backstage/catalog-client'; +import { + createApiExtension, + createPlugin, +} from '@backstage/frontend-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; + +/** @alpha */ +export const CatalogApi = createApiExtension({ + factory: { + api: catalogApiRef, + deps: { + discoveryApi: discoveryApiRef, + fetchApi: fetchApiRef, + }, + factory: ({ + discoveryApi, + fetchApi, + }: { + discoveryApi: DiscoveryApi; + fetchApi: FetchApi; + }) => new CatalogClient({ discoveryApi, fetchApi }), + }, +}); + +/** @alpha */ +export default createPlugin({ + id: 'catalog', + extensions: [CatalogApi], +}); diff --git a/plugins/search/alpha-api-report.md b/plugins/search/alpha-api-report.md new file mode 100644 index 0000000000..5799ff5cd6 --- /dev/null +++ b/plugins/search/alpha-api-report.md @@ -0,0 +1,28 @@ +## API Report File for "@backstage/plugin-search" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackstagePlugin } from '@backstage/frontend-plugin-api'; +import { Extension } from '@backstage/frontend-plugin-api'; + +// @alpha (undocumented) +const _default: BackstagePlugin; +export default _default; + +// @alpha (undocumented) +export const SearchApi: Extension<{}>; + +// @alpha (undocumented) +export const SearchNavItem: Extension<{ + title: string; +}>; + +// @alpha (undocumented) +export const SearchPage: Extension<{ + path: string; + noTrack: boolean; +}>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/search/package.json b/plugins/search/package.json index 77c3bd4727..8b90867a5a 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -6,9 +6,22 @@ "types": "src/index.ts", "license": "Apache-2.0", "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" + "access": "public" + }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.tsx", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.tsx" + ], + "package.json": [ + "package.json" + ] + } }, "backstage": { "role": "frontend-plugin" @@ -38,6 +51,7 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@backstage/plugin-search-react": "workspace:^", diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx new file mode 100644 index 0000000000..2a9a714abd --- /dev/null +++ b/plugins/search/src/alpha.tsx @@ -0,0 +1,245 @@ +/* + * Copyright 2023 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 { makeStyles, Theme, Grid, Paper } from '@material-ui/core'; +import SearchIcon from '@material-ui/icons/Search'; + +import { + CatalogIcon, + Content, + DocsIcon, + Header, + Page, + useSidebarPinState, +} from '@backstage/core-components'; +import { + createRouteRef, + useApi, + DiscoveryApi, + IdentityApi, + discoveryApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; + +import { + createPlugin, + createApiExtension, + createPageExtension, + createExtensionInput, + createNavItemExtension, + createSchemaFromZod, +} from '@backstage/frontend-plugin-api'; + +import { + catalogApiRef, + CATALOG_FILTER_EXISTS, +} from '@backstage/plugin-catalog-react'; + +import { + DefaultResultListItem, + SearchBar, + SearchFilter, + SearchPagination, + SearchResult as SearchResults, + SearchResultPager, + useSearch, + SearchContextProvider, +} from '@backstage/plugin-search-react'; +import { SearchResult } from '@backstage/plugin-search-common'; +import { searchApiRef } from '@backstage/plugin-search-react'; +import { searchResultItemExtensionData } from '@backstage/plugin-search-react/alpha'; + +import { SearchClient } from './apis'; +import { SearchType } from './components/SearchType'; +import { UrlUpdater } from './components/SearchPage/SearchPage'; + +/** @alpha */ +export const SearchApi = createApiExtension({ + factory: { + api: searchApiRef, + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ + identityApi, + discoveryApi, + }: { + identityApi: IdentityApi; + discoveryApi: DiscoveryApi; + }) => new SearchClient({ discoveryApi, identityApi }), + }, +}); + +const useSearchPageStyles = makeStyles((theme: Theme) => ({ + filter: { + '& + &': { + marginTop: theme.spacing(2.5), + }, + }, + filters: { + padding: theme.spacing(2), + marginTop: theme.spacing(2), + }, +})); + +const searchRouteRef = createRouteRef({ id: 'plugin.search.page' }); + +/** @alpha */ +export const SearchPage = createPageExtension({ + id: 'plugin.search.page', + routeRef: searchRouteRef, + configSchema: createSchemaFromZod(z => + z.object({ + path: z.string().default('/search'), + noTrack: z.boolean().default(false), + }), + ), + inputs: { + items: createExtensionInput({ + item: searchResultItemExtensionData, + }), + }, + loader: async ({ config, inputs }) => { + const getResultItemComponent = (result: SearchResult) => { + const value = inputs.items.find(({ item }) => item?.predicate?.(result)); + return value?.item.component ?? DefaultResultListItem; + }; + + const Component = () => { + const classes = useSearchPageStyles(); + const { isMobile } = useSidebarPinState(); + const { types } = useSearch(); + const catalogApi = useApi(catalogApiRef); + + return ( + + {!isMobile &&
} + + + + + + {!isMobile && ( + + , + }, + { + value: 'techdocs', + name: 'Documentation', + icon: , + }, + { + value: 'adr', + name: 'Architecture Decision Records', + icon: , + }, + ]} + /> + + {types.includes('techdocs') && ( + { + // Return a list of entities which are documented. + const { items } = await catalogApi.getEntities({ + fields: ['metadata.name'], + filter: { + 'metadata.annotations.backstage.io/techdocs-ref': + CATALOG_FILTER_EXISTS, + }, + }); + + const names = items.map( + entity => entity.metadata.name, + ); + names.sort(); + return names; + }} + /> + )} + + + + + )} + + + + {({ results }) => + results.map((result, index) => { + const SearchResultListItem = + getResultItemComponent(result); + return ( + + ); + }) + } + + + + + + + ); + }; + + return ( + + + + + ); + }, +}); + +/** @alpha */ +export const SearchNavItem = createNavItemExtension({ + id: 'plugin.search.nav.index', + routeRef: searchRouteRef, + title: 'Search', + icon: SearchIcon, +}); + +/** @alpha */ +export default createPlugin({ + id: 'plugin.search', + extensions: [SearchApi, SearchPage, SearchNavItem], +}); diff --git a/plugins/search/src/components/SearchPage/UrlUpdater.tsx b/plugins/search/src/components/SearchPage/UrlUpdater.tsx new file mode 100644 index 0000000000..d44195b6e2 --- /dev/null +++ b/plugins/search/src/components/SearchPage/UrlUpdater.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2023 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 { useEffect } from 'react'; +import usePrevious from 'react-use/lib/usePrevious'; +import qs from 'qs'; +import { useLocation } from 'react-router-dom'; +import { useSearch } from '@backstage/plugin-search-react'; +import { JsonObject } from '@backstage/types'; + +export const UrlUpdater = () => { + const location = useLocation(); + const { + term, + setTerm, + types, + setTypes, + pageCursor, + setPageCursor, + filters, + setFilters, + } = useSearch(); + + const prevQueryParams = usePrevious(location.search); + + useEffect(() => { + // Only respond to changes to url query params + if (location.search === prevQueryParams) { + return; + } + + const query = + qs.parse(location.search.substring(1), { arrayLimit: 0 }) || {}; + + if (query.filters) { + setFilters(query.filters as JsonObject); + } + + if (query.query) { + setTerm(query.query as string); + } + + if (query.pageCursor) { + setPageCursor(query.pageCursor as string); + } + + if (query.types) { + setTypes(query.types as string[]); + } + }, [prevQueryParams, location, setTerm, setTypes, setPageCursor, setFilters]); + + useEffect(() => { + const newParams = qs.stringify( + { + query: term, + types, + pageCursor, + filters, + }, + { arrayFormat: 'brackets' }, + ); + const newUrl = `${window.location.pathname}?${newParams}`; + + // We directly manipulate window history here in order to not re-render + // infinitely (state => location => state => etc). The intention of this + // code is just to ensure the right query/filters are loaded when a user + // clicks the "back" button after clicking a result. + window.history.replaceState(null, document.title, newUrl); + }, [term, types, pageCursor, filters]); + + return null; +}; diff --git a/yarn.lock b/yarn.lock index e429806816..2fe1ebc6f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6023,6 +6023,7 @@ __metadata: "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/integration-react": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" @@ -9117,6 +9118,7 @@ __metadata: "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@backstage/plugin-search-react": "workspace:^"