Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

APP-driven catalog entity page with GitHub Actions integration & routes & tabs #2076

Merged

Conversation

shmidt-i
Copy link
Contributor

@shmidt-i shmidt-i commented Aug 21, 2020

Draft PR that solves most of the #1536 discovered problems 💪
Work towards #1982
in

TLDR

  • JSX-driven approach
  • APP defines top-level routing in JSX (for the standlalone-pages types-of-plugins)
  • Plugins define their own relative routing system using RouteRefs & ReactRouter v6 APIs
  • Plugin-catalog exports smart component CatalogPage which accepts EntityPage as a prop
  • EntityPage is defined inside the APP, using helpers from plugin-catalog (Tabs, useEntity, etc)
  • Plugins, that supposed to live inside the EntityPage are now there in the APP, making it possible to compose/configure all you want out of it ;)

TLDR (more details)

// packages/app/App.tsx

import { CatalogPlugin } from '@backstage/plugin-catalog';
import { EntityPage } from './components/catalog';

const AppRoutes = () => (
  <Routes>
    <Route
      path="/catalog/*"
      element={<CatalogPlugin EntityPage={EntityPage} />}
    />
    {/* <Route path="/explore" element={<ExplorePlugin />} /> */}
  </Routes>
);
// packages/app/components/catalog/index.tsx

import { useEntity } from '@backstage/plugin-catalog';
import { ComponentEntity } from './Component';

export const EntityPage = () => {
  const entity = useEntity();
  switch (entity.kind) {
    case 'Component':
      return <ComponentEntity />;
    default:
      return <UnkownEntityKind />;
  }
};
// packages/app/components/catalog/Component.tsx

import { useEntity, EntityMetadataCard, EntityPageTabs as Tabs } from '@backstage/plugin-catalog';
import {GITHUB_ACTIONS_ANNOTATION, GithubActionsPlugin} from '@backstage/plugin-github-actions';

export const ComponentEntity = () => {
  const entity = useEntity();

  switch (entity.spec!.type) {
    case 'service':
      return <Service />;
    case 'website':
      return <Website />;
    default:
      return <UnkownEntity />;
  }
};

const Service = () => {
	const entity = useEntity();

  const isCIAvailable = [
    entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION],
    entity!.metadata!.annotations?.[CIRCLE_CI_ANNOTATION],
  ].some(Boolean);

  return (
    <Tabs>
      <Tabs.Tab title="Overview" path="/" exact>
        <OverviewPage />
      </Tabs.Tab>
      {isCIAvailable && (
        <Tabs.Tab title="CI/CD" path="/ci-cd">
          {entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION] && (
            <GitHubActionsPlugin entity={entity} />
          )}
        </Tabs.Tab>
      )}
      <Tabs.Tab title="Docs" path="/docs">
        <div>Docs tab contents</div>
      </Tabs.Tab>
    </Tabs>
  );}

const OverviewPage = () => {
  const entity = useEntity();

  return (
    <Grid item sm={4}>
      <EntityMetadataCard entity={entity} />
    </Grid>
  );
};

@shmidt-i shmidt-i requested a review from a team August 21, 2020 21:23
@shmidt-i shmidt-i linked an issue Aug 21, 2020 that may be closed by this pull request
path="/catalog/*"
element={<CatalogPlugin EntityPage={EntityPage} />}
/>
{/* <Route path="/explore" element={<ExplorePlugin />} /> */}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top-level registration of other plugins

@@ -89,7 +89,7 @@ const Root: FC<{}> = ({ children }) => (
<SidebarSearchField onSearch={handleSearch} />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="./" text="Home" />
<SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this routing strategy catalog needs to move to /catalog

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which is probably fine, redirects can handle landing page changes

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here it's nicely clear that we could change it to "Home" and have any icon etc, so it's not the plugin that decided.

However, that leads to the question - when introducing a new plugin into your Backstage installation, will you HAVE to invent both paths, labels (such as in the sidebar), AND icons to wire them in? Or can we have some form of helper that just sorts it out based on a root route ref that holds a default icon and label and route, which all can be overridden? Kinda like <SidebarItem ref={catalogRef} /> or something

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup a routeRef (ref is reserved 😅) prop for the SidebarItem is missing, we definitely want that.

We should preferably be able to generate a sidebar given a static config similar to this:

sidebar:
  items:
  - ref: catalog.catalog
  - ref: scaffolder.create
  - divider
  - ref: explore.explore
  - ref: techdocs.docs

});
routes.push({
path: '/*',
element: <Navigate to="." />,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catch-all to go to the nearest /.
. works, .. works as well. / in this case would've been resolved to the very top level / as in localhost:3000/

});
});
const [matchedRoute] =
matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? [];
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are only inside /catalog/:kind/:optionalNameAndNamespace/* route at this particular point, this params['*'] gets resolved into anything that comes after optionalNameAndNamespace.
Example:
For the http://localhost:3000/catalog/Component/backstage/some/deep/route - params['*'] === "some/deep/route"

const currentTab = tabs[selectedIndex];
const title = currentTab.label;

const onTabChange = (index: number) => navigate(tabs[index].id.slice(1, -2));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing leading / and trailing /* to enable built-in React Router v6 relative route resolution mechanism

path: string;
exact?: boolean;
};
EntityPageTabs.Tab = (_props: TabProps) => null;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically a fake component, which allows us to collect its props


const REDIRECT_DELAY = 2000;

export const EntityContext = createContext<Entity>((null as any) as Entity);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guy powers useEntity hook

title: 'Entity',
});
export const entityRouteDefault = createRouteRef({
icon: NoIcon,
path: '/catalog/:kind/:optionalNamespaceAndName',
path: ':kind/:optionalNamespaceAndName',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is no leading / - to enable <Link to={someRouteRef.path} /> being relative link.
For params - generatePath from react-router works

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The primary use-case for route-refs is linking into a plugin from other plugins or the app, doesn't this break that?

Btw, once we've settled the way we wanna do composition we want to have another look at route refs. Maybe they're not needed at all, but if they are we should convert the path to a method that takes params. In this case the type of the route ref would be something like RouteRef<[kind: string, optionalNamespaceAndName: string]>

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original RFC briefly hinted at this, but what I think we're really looking for here are immutable relative route refs. Something that lets you specify a route ref in relation to another route ref, where overrides on the second one would be reflected in the first.

@@ -170,6 +162,10 @@ export const WorkflowRunDetails = () => {
}
return (
<div className={classes.root}>
<Breadcrumbs aria-label="breadcrumb">
<Link to="..">Workflow runs</Link>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.. means one level up, not to closest parent sadly. But I think this is good enough ;)

router.addRoute(projectRouteRef, WorkflowRunsPage);
router.addRoute(buildRouteRef, WorkflowRunDetailsPage);
},
register() {},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not needed anymore? 🤷‍♂️

switch (entity.kind) {
case 'Component':
return <ComponentEntity />;
default:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we could fit in the APIEntityPage, looking forward to these changes!

Copy link
Member

@freben freben left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry i only got partway ... will have to continue tomorrow

@@ -89,7 +89,7 @@ const Root: FC<{}> = ({ children }) => (
<SidebarSearchField onSearch={handleSearch} />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="./" text="Home" />
<SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which is probably fine, redirects can handle landing page changes

packages/app/src/App.tsx Show resolved Hide resolved
);
};

export const CatalogPlugin = ({ EntityPage }) => (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably not name a component *Plugin. The plugin should be the abstract notion of the entire plugin and all of the various things it exposes. CatalogPage or CatalogRoot or similar would make more sense.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yuuuuuuup. In general I think we can come up with a couple of suffixes for people to use depending on what is being exported. For example *Page and *Card.

I'm also thinking that all of these exported components will be wrapped by the plugin, so that we can inject a plugin context, pick up APIs, and ensure that they're all dynamically loaded.

<Routes>
<Route
path="/catalog/*"
element={<CatalogPlugin EntityPage={EntityPage} />}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be nice here if the EntityPage prop was optional and had a simple but usable default implementation.

@@ -89,7 +89,7 @@ const Root: FC<{}> = ({ children }) => (
<SidebarSearchField onSearch={handleSearch} />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="./" text="Home" />
<SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here it's nicely clear that we could change it to "Home" and have any icon etc, so it's not the plugin that decided.

However, that leads to the question - when introducing a new plugin into your Backstage installation, will you HAVE to invent both paths, labels (such as in the sidebar), AND icons to wire them in? Or can we have some form of helper that just sorts it out based on a root route ref that holds a default icon and label and route, which all can be overridden? Kinda like <SidebarItem ref={catalogRef} /> or something

import { Grid } from '@material-ui/core';

const OverviewPage = () => {
const entity = useEntity();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking that this API should probably be const { entity } = useEntity();, so we easily can tack more onto it - such as refresh() or whatever that is needed

const entity = useEntity();

const isCIAvailable = [
entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should entity be nullable? Is that in like error conditions or something, is my first question then? Would be nice as a consumer to know it's always set, or an exception is thrown by the hook.

One more thing, is the exclamation mark necessary on metadata? I think we set it as required.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably both things are fixable


return (
<Tabs>
<Tabs.Tab title="Overview" path="/" exact>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the exact thing can be deprecated under react-router 6 right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the react-router exact though, it's part of the Tabs implementation

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure but maybe we want to change the tab implementation.

<Tabs.Tab title="CI/CD" path="/ci-cd">
{entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION] && (
<GitHubActionsPlugin entity={entity} />
)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forgot circleci here :) yeah i know it's illustrative

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the GitHubActionsPlugin component should not be named Plugin, as a plugin is the whole package, this component is the GithubActionsPage rather, IIUC.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or is this the actual way to hook in a full plugin?


export const ComponentEntity = () => {
const entity = useEntity();
switch (entity.spec!.type) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question is if this switch is necessary - should we have to switch out / duplicate logic for choice of tabs, sidebar, etc per type? Maybe. Just wondering if it's the right "granularity".

)}
</Tabs.Tab>
)}
<Tabs.Tab title="Docs" path="/docs">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we want to have something similar to isCIAvailable here but isDocsAvailable or something and check the techdocs ref annotation to only show the Docs tab on entities that actually have docs?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, point being - since it's JSX, one can introduce whatever logic is necessary

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooor was that only for illustration purposes? xD

title: 'Entity',
});
export const entityRouteDefault = createRouteRef({
icon: NoIcon,
path: '/catalog/:kind/:optionalNamespaceAndName',
path: ':kind/:optionalNamespaceAndName',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The primary use-case for route-refs is linking into a plugin from other plugins or the app, doesn't this break that?

Btw, once we've settled the way we wanna do composition we want to have another look at route refs. Maybe they're not needed at all, but if they are we should convert the path to a method that takes params. In this case the type of the route ref would be something like RouteRef<[kind: string, optionalNamespaceAndName: string]>

@@ -89,7 +89,7 @@ const Root: FC<{}> = ({ children }) => (
<SidebarSearchField onSearch={handleSearch} />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="./" text="Home" />
<SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup a routeRef (ref is reserved 😅) prop for the SidebarItem is missing, we definitely want that.

We should preferably be able to generate a sidebar given a static config similar to this:

sidebar:
  items:
  - ref: catalog.catalog
  - ref: scaffolder.create
  - divider
  - ref: explore.explore
  - ref: techdocs.docs

</Tabs.Tab>
{isCIAvailable && (
<Tabs.Tab title="CI/CD" path="/ci-cd">
{entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION] && (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be tricky to generate an app based on static config if this kind of logic lives in the app. No need to fix as a part of this PR, but something we should look at after.

);
};

export const CatalogPlugin = ({ EntityPage }) => (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yuuuuuuup. In general I think we can come up with a couple of suffixes for people to use depending on what is being exported. For example *Page and *Card.

I'm also thinking that all of these exported components will be wrapped by the plugin, so that we can inject a plugin context, pick up APIs, and ensure that they're all dynamically loaded.

};

/**
* Always going to return an entity, or throw an error if not a descendant of a EntityProvider.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@freben - that's what you wanted, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. But I'm not sure about the type in there, looks complex :)

@shmidt-i shmidt-i marked this pull request as ready for review August 31, 2020 21:46
@shmidt-i shmidt-i linked an issue Aug 31, 2020 that may be closed by this pull request
@@ -26,3 +26,10 @@ This helps the community know what plugins are in development.

You can also use this process if you have an idea for a good plugin but you hope
that someone else will pick up the work.

## Integrate into the catalog service
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Service Catalog?

);

const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to move on top of EntityPage

const navigate = useNavigate();

React.Children.forEach(children, child => {
if (!React.isValidElement(child)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check if child type is actually Content

onChange={onTabChange}
/>
<Content>
<Grid container spacing={3}>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove Grid

}, [errorApi, navigate, error, loading, entity]);

if (!name) {
navigate('/catalog');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just /


type EntityLoadingStatus = {
entity?: Entity;
loading: boolean | null;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boolean


export const EntityContext = createContext<EntityLoadingStatus>({
entity: undefined as any,
loading: null,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true in the beginning

title: 'Entity',
});
export const entityRouteDefault = createRouteRef({
icon: NoIcon,
path: '/catalog/:kind/:optionalNamespaceAndName',
path: ':kind/:optionalNamespaceAndName/*',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove /*

@shmidt-i
Copy link
Contributor Author

shmidt-i commented Sep 1, 2020

Leave old AppRoutes as well

@shmidt-i shmidt-i merged commit 64ecf77 into master Sep 3, 2020
@shmidt-i shmidt-i deleted the shmidt-i/app-catalog-tabs-routes-everything-is-connected branch September 3, 2020 12:06
muffix pushed a commit to muffix/backstage that referenced this pull request Sep 4, 2020
Addresses the changes required after merging backstage#2076. The Sentry plugin
now defines a router and the widget will be displayed as a tab.
@awanlin awanlin mentioned this pull request Sep 15, 2023
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
7 participants