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

add RedirectAppLinks component in kibana_react #67595

Merged

Conversation

pgayvallet
Copy link
Contributor

@pgayvallet pgayvallet commented May 28, 2020

Summary

Fix #58751

Add a <RedirectAppLinks> component in kibana_react to help with cross-app linking.

The component will intercept any click performed on <a> children and call application.navigateToUrl to avoid performing a full page refresh when the link points to a valid kibana application route.

Checklist

@pgayvallet pgayvallet added this to Pending Review in kibana-core [DEPRECATED] via automation May 28, 2020
@pgayvallet pgayvallet added Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v7.9.0 v8.0.0 labels May 28, 2020
@elasticmachine
Copy link
Contributor

Pinging @elastic/kibana-platform (Team:Platform)

@@ -306,6 +306,7 @@ export class ApplicationService {
const relUrl = http.basePath.prepend(getAppUrl(availableMounters, appId, path));
return absolute ? relativeToAbsolute(relUrl) : relUrl;
},
parseAppUrl: (url) => parseAppUrl(url, http.basePath, this.apps),
Copy link
Contributor Author

@pgayvallet pgayvallet May 28, 2020

Choose a reason for hiding this comment

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

As the new RedirectCrossAppLinks component should ignore links pointing to the current application, I couldn't just use navigateToApp directly, and had to expose parseAppUrl to allow the consumer to check if the app from the link is, or not, the active app.

Exposing this API still makes sense anyway I think

Comment on lines 37 to 45
it('intercept click events on children link elements', () => {
let event: MouseEvent;

const component = mount(
<div
onClick={(e) => {
event = e;
}}
>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I feel like these enzyme based tests are as good as actual FTR test, as the only difference is that they are using jsdoc instead of the actual browser implementation. It also allow more precise testing, as we can collect the event to perform verifications, which would not be possible on FTR tests.

Tell me if you think that adding FTR tests is still necessary.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nope. Seems fine to me. Can be covered by FTR when in use by actual applications.

@pgayvallet pgayvallet marked this pull request as ready for review May 28, 2020 15:16
@pgayvallet pgayvallet requested review from a team as code owners May 28, 2020 15:16
@pgayvallet pgayvallet added the release_note:skip Skip the PR/issue when compiling release notes label May 28, 2020
Copy link
Contributor

@joshdover joshdover left a comment

Choose a reason for hiding this comment

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

Is there any UI we could drop this into now for testing? Maybe the SavedObjects UI?

src/plugins/kibana_react/public/app_links/utils.ts Outdated Show resolved Hide resolved

import { getClosestLink } from './utils';

const createBranch = (...tags: string[]): HTMLElement[] => {
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be simpler to just use raw html strings. I prefer this because it eliminates the possibility of there being a bug in this setup code.

const element = document.createElement('div')
element.innerHTML = `<div><a></a></div>`;

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 utility method has the advantage to return the created branch elements as an array

const [, container, target] = createBranch('A', 'DIV', 'SPAN');

Using plain innerHTML would force to manually access each individual node of the branch in the tests. I think the decreased readability is a biggest downside than a bug risk in the createBranch method?

I could also change to something like

const element = document.createElement('a')
element.innerHTML = `<div><span></span></div>`;
const [, container, target] = getLeftmostBranch(element);

but it's still 3 lines versus one (and getLeftmostBranch would have a logic very close to createBranch anyway)

wdyt?

!hasActiveModifierKey(e) // ignore clicks with modifier keys
) {
const appInfo = parseAppUrl(link.href);
if (appInfo && appInfo.app !== currentAppId) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Remind me again why we can't call this for links within the same app?

Copy link
Contributor

Choose a reason for hiding this comment

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

It'd be nice if this solution worked for all links

Copy link
Contributor Author

@pgayvallet pgayvallet Jun 2, 2020

Choose a reason for hiding this comment

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

It would work for intra-app links. It's just an opinionated decision, based on the fact that most (all) out apps are currently using react-router and the Link component. Handling links to the active link could/would result in a click on the link be handled both by the Link click handler and ours, resulting on performing the history.push twice. (Also the name of our component is redirect**Cross**AppLinks

But I'm unsure about that TBH, maybe we should just decide than any app links should be handled and document that using Link inside RedirectCrossAppLinks should be avoided? wdyt @joshdover @Dosant ?

Copy link
Contributor Author

@pgayvallet pgayvallet Jun 2, 2020

Choose a reason for hiding this comment

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

Looking at https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/Link.js#L49, the Link onClick does prevent default, so clicking on a Link inside our component would juste trigger the Link's navigation, and not ours.

We are probably fine to remove the currentApp check then. Which also means we can use the component for the navlinks.

Should the component be renamed to RedirectAppLinks then?

Copy link
Contributor

Choose a reason for hiding this comment

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

based on the fact that most (all) out apps are currently using react-router and the Link component

I'm not sure this is true? In my rudimentary search (git grep "import.*Link.*from 'react-router*" -- {src,x-pack}/plugins) I only found that beats_management and uptime plugins were using this component. I think the main reason devs are not using this component is that you can't really use it with EuiButton or EuiLink.

We are probably fine to remove the currentApp check then. Which also means we can use the component for the navlinks.

Should the component be renamed to RedirectAppLinks then?

If it works, than a rename makes sense to me 👍

@pgayvallet
Copy link
Contributor Author

Is there any UI we could drop this into now for testing? Maybe the SavedObjects UI?

I added it to the SOM table, however as the management plugin is still in legacy mode (the KP management plugin is still just a bridge to the legacy plugin), all links are still triggering full page refresh (however with actual debugging I saw that it worked, but it's not enough).

Not sure where we could wire this for testing. @Dosant maybe you have an idea?

@Dosant
Copy link
Contributor

Dosant commented Jun 2, 2020

@pgayvallet

I added it to the SOM table, however as the management plugin is still in legacy mode (the KP management plugin is still just a bridge to the legacy plugin), all links are still triggering full page refresh (however with actual debugging I saw that it worked, but it's not enough).
Not sure where we could wire this for testing. @Dosant maybe you have an idea?

How about our examples? https://github.com/elastic/kibana/tree/master/examples
There are 3 state management demo apps and we could try to link them together?
Like a footer in the bottom of each demo app with links to other state management demo apps?

There are also functional tests for those demos, so we could cover that navigation in function test

to run with examples use: yarn start --run-examples

@pgayvallet
Copy link
Contributor Author

Is there any UI we could drop this into now for testing? Maybe the SavedObjects UI?

Remind me again why we can't call this for links within the same app?

Another option, if we get rid of this non-same-app check: We could also wire it directly to the nav by removing the current custom onClick handler in src/core/public/chrome/ui/header/nav_link.tsx

@pgayvallet
Copy link
Contributor Author

pgayvallet commented Jun 3, 2020

Adapted and renamed the component to no longer exclude the current app.

We could also wire it directly to the nav by removing the current custom onClick handler in src/core/public/chrome/ui/header/nav_link.tsx

Actually we can't, as the component lives in the kibana_react plugin and the header in core, and we can't import plugin code in core.

@pgayvallet
Copy link
Contributor Author

Due to the lack of any obvious place to do it, I ended up adding FTR tests instead of wiring the feature to some existing links.

PR should be in its final state now.

// see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/12239
const target = e.target as HTMLElement;

const link = getClosestLink(target, container);
Copy link
Contributor

Choose a reason for hiding this comment

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

Could use built-in Element.closest() method?

Suggested change
const link = getClosestLink(target, container);
const link = target.closest('.containerClass a');

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure, due to browser support: https://caniuse.com/#search=closest?
Unfortunately I don't have a browserstack account to ensure this API is properly shimmed by corejs

Do we have a usable account somewhere?

Copy link
Contributor

Choose a reason for hiding this comment

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

We are discounting IE11 support in v7.9, so it's not a problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That requires quite a bit of changes in the handler unit tests as it can no longer pass down the container for testing, but I guess using native APIs should be encouraged.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hum. it seems that our version of jsdom doesnt supports closest, which is strange as it's supposed to be available for quite a long time now.

    TypeError: target.closest is not a function

      41 |     const target = e.target as HTMLElement;
      42 |
    > 43 |     const link = target.closest(`.${containerClass} a`) as HTMLAnchorElement;
console.log(target, target.parentElement, target.closest, target.parentElement.closest)

// HTMLAnchorElement {} HTMLDivElement {...} undefined undefined

Copy link
Contributor Author

@pgayvallet pgayvallet Jun 4, 2020

Choose a reason for hiding this comment

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

Confirmed: the jsdom version that (our version of) jest is using (node_modules/jest-environment-jsdom/node_modules/jsdom/lib/jsdom/living/nodes/Element-impl.js) is a 11.5.1, which does not implements closest...

Latest jest does have a version that implements it though (https://github.com/facebook/jest/blob/master/packages/jest-environment-jsdom/package.json)

Copy link
Contributor

Choose a reason for hiding this comment

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

That's a shame. Let's keep the current implementation then. We can add a comment to switch to native API later.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will add a comment for when #58095 lands.

Copy link
Contributor

@patrykkopycinski patrykkopycinski Jun 8, 2020

Choose a reason for hiding this comment

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

Unfortunately, I wasn't able to bring the most recent version of jsdom in that PR, because seems a combination of jest@25 with jsdom>=14 got serious performance degradation and we cannot bump to jest@26, because it requires typescript>=3.8, so I hope @restrry PR gets merged soon and I will give it a try to jest@26 and jsdom@16

navigateToUrl: ApplicationStart['navigateToUrl'];
}

export const createCrossAppClickHandler = ({
Copy link
Contributor

@mshustov mshustov Jun 4, 2020

Choose a reason for hiding this comment

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

This method doesn't contain any logic to enforce the cross-app navigation. Does it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good catch, this one wasn't properly renamed when we decided to get rid of the cross-app limitation.

* Utility component that will intercept click events on children anchor (`<a>`) elements to call
* `application.navigateToUrl` with the link's href. This will trigger SPA friendly navigation
* when the link points to a valid kibana app.
*
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we recommend using it on the topmost level of the component tree?

decodeURIComponent(
url.format({
protocol: 'http:',
hostname: process.env.TEST_KIBANA_HOST || 'localhost',
Copy link
Contributor

@mshustov mshustov Jun 4, 2020

Choose a reason for hiding this comment

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

Optional: our tests shouldn't rely on the test environment too much since we do not have control over it. We can match against some parts of browser URL instead:

const currentUrl = await browser.getCurrentUrl();
const parsedCurrentUrl = Url.parse(currentUrl);
expect(parsedCurrentUrl.pathname).to.eql('/app/applink_end/some-path');
expect(parsedCurrentUrl.hash).to.eql('#/some/hash');

return browser.executeAsync<boolean>(async (cb) => {
const reloaded = window.__nonReloadedFlag !== true;
cb(reloaded);
return reloaded;
Copy link
Contributor

Choose a reason for hiding this comment

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

not needed?

Copy link
Contributor Author

@pgayvallet pgayvallet Jun 4, 2020

Choose a reason for hiding this comment

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

Unfortunately It is, as least for TS not to complains, because our executeAsync signature is broken (and so is webdriver's but in a different way), but I got tired of adding the comment on every browser.executeAsync tbh.

public async executeAsync<R>(
fn: string | ((...args: any[]) => Promise<R>),
...args: any[]
): Promise<R> {

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree. But TS doesn't work well with WebDriver implementation when a callback is always the last argument passed in a function.
We could change executeAsync signature to accept 1 additional argument at max:

async executeAsync<T = any>(
  fn: <T>(cb: (value: T) => void) => void | Promise<void>,
): Promise<T>;
async executeAsync<T = any, Context extends Record<string, any> = Record<string, any>>(
  fn: <T>(context: Context, cb: (value: T) => void) => void | Promise<void>,
  context: Context,
): Promise<T>;

There are 13 tests with executeAsync in the code base, it doesn't look like a significant effort. @elastic/kibana-qa Any objections we do this change in a separate PR?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'll defer to @dmlemeshko on this.

Copy link
Member

Choose a reason for hiding this comment

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

@elastic/kibana-qa Any objections we do this change in a separate PR?

No problem from my side.

const browser = getService('browser');
const testSubjects = getService('testSubjects');

const setNonReloadedFlag = async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: here and below unnecessary async


beforeEach(() => {
application = applicationServiceMock.createStartContract();
application.parseAppUrl.mockReturnValue({ app: 'targetApp' });
Copy link
Contributor

Choose a reason for hiding this comment

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

It doesn't appear this is used anymore? Can we remove this API now?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It isn't. I feel like I'll have to reimplement it soon enough by doing so, but you're probably right, I should remove it from this PR.

@kibanamachine
Copy link
Contributor

💚 Build Succeeded

History

To update your PR or re-run it, just comment with:
@elasticmachine merge upstream

@pgayvallet
Copy link
Contributor Author

@Dosant (or anyone else from @elastic/kibana-app-arch) as kibana_react is under your ownership, could we get a review on this PR?

Copy link
Contributor

@Dosant Dosant left a comment

Choose a reason for hiding this comment

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

Gave it a try with following scenarios:

  1. Cross link between different KP example apps without page refresh
  2. Wrapped uiActions context menu with it and checked that link are working without page refresh

One thing to keep in mind, is that consumer have to add this wrapper for every react root. (Guess this is just like react works)

e.g.

<RedirectCrossAppLinks>
  <App>
      <a href />
     <... native js>
          ReactDOM.render(<RedirectCrossAppLinks><a href /></RedirectCrossAppLinks>)
     </ ... native js>     
  </App>
</RedirectCrossAppLinks>

A bit sad that had to explicitly pass applicationStart to use it and in that nested case above ⬆️ this is a bit of a burden

@pgayvallet
Copy link
Contributor Author

One thing to keep in mind, is that consumer have to add this wrapper for every react root. (Guess this is just like react works)

Yea, unfortunately this is how react works. Roots are strictly isolated in react world. There is no alternative that wouldn't have the same limitation as my previous PR using a global handler.

A bit sad that had to explicitly pass applicationStart

Other alternative would have been to use a context, but you would have to pass the application service to the context the same way, so it doesnt change much and just add a layer of complexity imho (and that wouldnt work with 'nested' react roots either)

@pgayvallet pgayvallet merged commit b7057b7 into elastic:master Jun 12, 2020
kibana-core [DEPRECATED] automation moved this from Pending Review to Done (7.9) Jun 12, 2020
pgayvallet added a commit to pgayvallet/kibana that referenced this pull request Jun 12, 2020
* implements RedirectCrossAppLinks component

* update doc

* review comments

* use `RedirectCrossAppLinks` in SOM SO table page

* update snapshots due to merge

* do not filter current app

* rename component

* fix snapshots

* add FTR tests

* review comments

* remove the `parseAppUrl` unused core API

* fix snapshots

* fix test plugin ts version

* add newline
pgayvallet added a commit that referenced this pull request Jun 12, 2020
* implements RedirectCrossAppLinks component

* update doc

* review comments

* use `RedirectCrossAppLinks` in SOM SO table page

* update snapshots due to merge

* do not filter current app

* rename component

* fix snapshots

* add FTR tests

* review comments

* remove the `parseAppUrl` unused core API

* fix snapshots

* fix test plugin ts version

* add newline
@jasonrhodes
Copy link
Member

Just noting we should make sure to add these types of updates to existing docs when possible. Here's a ticket to address: #72432

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
release_note:skip Skip the PR/issue when compiling release notes Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v7.9.0 v8.0.0
Projects
Development

Successfully merging this pull request may close these issues.

Improve cross-app linking in Platform
10 participants