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

[Dashboard] Rebuild State Management #97941

Merged
merged 39 commits into from
Jun 7, 2021

Conversation

ThomThomson
Copy link
Contributor

@ThomThomson ThomThomson commented Apr 22, 2021

Summary

This rather large PR rebuilds and reorganizes the dashboard state management system. Most prominently, this PR finally removes all app state from the dashboard URL. This effort is also aimed at creating a better architecture and developer experience within the dashboard app to make debugging easier, provide a stable foundation for major upcoming features.

Fixes #21701
Fixes #92085
Fixes #98087
Fixes #99190
Addresses 2/3 line items from #91950

Architecture Diagram

Overall, removing URL syncing greatly simplified the architecture. The following diagram shows the entirety of dashboard state management.

Screen Shot 2021-05-03 at 9 39 47 PM

User Facing Changes

Full Unsaved State Persistence

The full state of your dashboard is now persisted in session storage, including options, query, filters etc. Previously, only the panel state was backed up there, causing any other unsaved state change to be lost when navigating away from the dashboard.

Click for Gifs

Before- options were not saved in the session storage. When leaving the dashboard and re-entering the options are reset to their defaults.
Before

After- options are saved to the session store along with all other pieces of dashboard state.
After

No more View Mode with Unsaved Changes

Now that the app state has been fully removed from the URL. the browser back / forward buttons should only work for navigation. This means that it is no longer possible to exit edit mode without a warning by pressing the back button. Because of this, we are again able to prevent the user from leaving edit mode when they have unsaved changes. This is a much simpler UX, and the unsaved changes warning from before 7.12 has been restored.

Click for images

Before- in 7.12 a third option was added to the switch to view mode dialog which would allow users to switch back to view mode while keeping their changes. This was necessary because of the URL state, but led to confusing and complicated UX.
Screen Shot 2021-05-03 at 10 33 59 PM

After- We have restored the pre 7.12 modal, with only two options:
Screen Shot 2021-05-03 at 10 34 08 PM

Better change checking for Panels

This PR changes dashboard panel state to a map instead of an array. This, combined with a better diffing method, means that the order of panels no longer matters in the diff. This cleans up a few inconsistencies with the unsaved changes message appearing when it shouldn't.

Click for Gifs

Before- Notice how even though the panels are in the exact same orientation that they were in before, the unsaved changes badge is still present. This particular order of moving panels 'reorders' them in the array, causing a diff.
Before Panels

After- The same steps were taken in the new version, but the diff only takes into account the positions of each panel. See how the unsaved changes badge disappears here.
After Panels

Better change checking for Options

This PR changes the change checking for options to allow for undefined or null values. Optional values would previously trip up the diff and cause unsaved changes to appear when it shouldn't.

Click for Gifs

Before- Even when the option was set back to its last saved state, the unsaved changes badge was still present
Opt Before

After- This issue has been fixed.
Opt After

Stay in Edit mode on save as

Saving a dashboard will now never boot you out of edit mode. Quick save used to be the only method that did this, which was inconsistent.

Click for Gif

May-03-2021 23-19-25

Silent Panel Migrations

Ever since 7.3, the versions that Panels were last saved under caused unnecessary diffing in the dashboard panels. This meant that any older version would always show unsaved changes even if a migration was unnecessary.

Now, because panels are compared as a map of panel states the version numbers will not get in the way and unsaved changes badge should only show due to direct user action.

Developer Experience Changes

  • One medium sized useEffect hook now handles all setup tasks
  • Redux Toolkit is now used to store dashboard state in memory
  • The code is now more functional, with similar functions grouped into files under /lib
  • Redux state is now the only source of truth. State is copied to the Dashboard Container and the Session storage
  • All dashboard state is treated the same. It is no longer half persisted in session storage and half persisted in the URL.
  • Change checking is done in one place, and is debounced to increase performance

Known Issues

The silent panel migrations feature listed above does not work with maps panels. If you load an older dashboard which has a map, (including any demo data dashboards) the unsaved changes badge will appear. This is due to an unrelated issue #98180

Checklist

Delete any items that are not applicable to this PR.

For maintainers

@ThomThomson ThomThomson force-pushed the feature/dashboardRedux branch from 9a42f37 to 46dbda5 Compare April 28, 2021 20:42
… multiple history records when removing state from URL
@ThomThomson ThomThomson force-pushed the feature/dashboardRedux branch 2 times, most recently from f0d596e to 593ba7c Compare May 4, 2021 00:21
@ThomThomson ThomThomson changed the title Feature/dashboard redux [Dashboard] Rebuild State Management May 4, 2021
@ThomThomson ThomThomson force-pushed the feature/dashboardRedux branch from 9351641 to c18a976 Compare May 4, 2021 22:56
@flash1293
Copy link
Contributor

It looks like this PR is changing the "sync color" mode for already saved dashboards. What do you think about treating all dashboards which are saved already as "sync color" enabled, but the flag will be disabled for newly created dashboards? This way we don't change the behavior for saved objects the user has already.

@ThomThomson ThomThomson added Feature:Dashboard Dashboard related features impact:high Addressing this issue will have a high level of impact on the quality/strength of our product. loe:x-large Extra Large Level of Effort release_note:fix Team:Presentation Presentation Team for Dashboard, Input Controls, and Canvas v7.14.0 v8.0.0 labels May 5, 2021
Copy link
Contributor

@nreese nreese left a comment

Choose a reason for hiding this comment

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

maps changes LGTM
code review

@ThomThomson
Copy link
Contributor Author

@elasticmachine merge upstream

@ThomThomson
Copy link
Contributor Author

@elasticmachine merge upstream

Copy link
Contributor

@clintandrewhall clintandrewhall left a comment

Choose a reason for hiding this comment

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

@ThomThomson, this is an incredibly impressive amount of work... I know it feels good to get this kind of tech debt off the table! It's really exciting.

I'm going to approve this PR, but I have to say, it's only from a literal and style perspective. It's really, really difficult to review the logic because of the sheer number of lines, but also the density line-by-line.

I'm erring on the side of getting it in so we can shake it out, rather than delaying to break it down further... but it makes me uncomfortable when I err on that side too often.

That said, what makes me confident in this PR is your obvious attention to detail and extensive tests. I wish all PRs I've reviewed had these!! ❤️

I know we've already discussed how this PR likely couldn't be split up, and that's fine. I think if the code were structured a bit differently, as I've suggested above, it would be easier for folks who haven't written it to understand the scope of the changes. Code comments help here, as well.

Let's get this important change in, but let's also talk more about how we can make Dashboard code smaller, more portable, grepable, and thus more "reviewable".

Great work, Devon!

dashboardId: getDashboardId(),
timeRange: shouldRestoreSearchSession
? data.query.timefilter.timefilter.getAbsoluteTime()
: data.query.timefilter.timefilter.getTime(),
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a common fixture in the code in this PR: passing in large objects and restating the chain. It makes the code harder to read, but also hard to review.

The current practice also means useEffect calls monitor the entire object for changes, rather than destructured portions drawn from props in the start of the render cycle. So it's a good habit to adopt in general.

Consider:

const { filterManager, queryString } = data.query;
const { timefilter } = data.query.timefilter;
// ...
timeRange: shouldRestoreSearchSession ? timefilter.getAbsoluteTime() : timefilter.getTime(),
// ...
filters: filterManager.getFilters,
query: queryString.formatQuery(appState.query),
// ...etc

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 particular example doesn't affect the useEffect, but I've reorganized it as suggested to be more clean.

DashboardState,
} from '../../types';

interface DashboardDiffCommon {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: type DashboardDiffCommon = Record<string, unknown>;

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've seen this written both ways. Is there any reason to use one over the other?

newInput: DashboardContainerInput
) => {
return commonDiffFilters<DashboardContainerInput>(
(originalInput as unknown) as DashboardDiffCommonFilters,
Copy link
Contributor

@clintandrewhall clintandrewhall May 26, 2021

Choose a reason for hiding this comment

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

This kind of cast usually indicates a "code smell". It's hard to know in this case, though.

There are a lot of casts in this PR, which is a bit concerning.

Copy link
Contributor Author

@ThomThomson ThomThomson May 28, 2021

Choose a reason for hiding this comment

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

This is a result of me perhaps over-DRY-ing this section. I'm casting this just within this file in order to apply the same function (diffing the filters) to two different kinds of dashboard state.

The main thing that I'm proud of here is treating the diffing of both types of dashboard state the same, and diffing them in the same file. In the past this was done in two totally different ways, in different files. In the future, I'd like to simplify this even further by combining DashboardState and DashboardContainerInput into one type.

currentState: DashboardState;
timefilter: TimefilterContract;
saveOptions: SavedDashboardSaveOpts;
toasts: NotificationsStart['toasts'];
Copy link
Contributor

Choose a reason for hiding this comment

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

This just reminds me we need to get our service abstractions into Dashboard. :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Definitely! That is the next tech-debt item to tackle in dashboard for sure!

savedDashboard.title = currentState.title;
savedDashboard.description = currentState.description;
savedDashboard.timeRestore = currentState.timeRestore;
savedDashboard.optionsJSON = JSON.stringify(currentState.options);
Copy link
Contributor

@clintandrewhall clintandrewhall May 26, 2021

Choose a reason for hiding this comment

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

nit: you might consider the following to reduce visual repetition/noise. It also makes what you're using and what you're replacing very clear line-by-line:

const { title, description, timeRestore, options } = currentState;
savedDashboard = {
  ...savedDashboard,
  title,
  description,
  timeRestore,
  optionsJSON: JSON.stringify(options),
};

Copy link
Contributor Author

@ThomThomson ThomThomson May 28, 2021

Choose a reason for hiding this comment

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

This is very old code which was only moved in this PR. Also, since savedDashboard is a class instance, the spread and reassign technique doesn't work here. That said, I've done a bit of cleanup here to make a little nicer to look at.


const newSearchSessionId: string | undefined = (() => {
// do not update session id if this is irrelevant state change to prevent excessive searches
if (!shouldRefetch) return;
Copy link
Contributor

Choose a reason for hiding this comment

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

eslint should have caught/fixed this... do you have it enabled for files in your IDE?

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 if this is a rule or not - I've seen it used a bunch of times.

// do not update session id if this is irrelevant state change to prevent excessive searches
if (!shouldRefetch) return;

const sessionApi = services.data.search.session;
Copy link
Contributor

Choose a reason for hiding this comment

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

👏🏻

'expandedPanelId',
'isFullScreenMode',
'isEmbeddedExternally',
];
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Very cool!

data: { query: queryService, search },
} = services;
const { filterManager, queryString, timefilter } = queryService;
const { timefilter: timefilterService } = timefilter;
Copy link
Contributor

Choose a reason for hiding this comment

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

👏🏻

value: string;
operator: string;
index: string;
export interface DashboardAppServices {
Copy link
Contributor

Choose a reason for hiding this comment

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

We should consider structuring this by concern, (though service abstractions may solve this entirely)... we've got event handlers mixed with components mixed with entire service API collections...?

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 totally agree about restructuring this. I am saving the refactor of everything services related for our next tech-debt removal session! Very excited to clean this up.

Copy link
Contributor Author

@ThomThomson ThomThomson left a comment

Choose a reason for hiding this comment

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

@clintandrewhall While I've been making improvements to the dashboard code, it is still extremely dense, and can be overcomplicated at times. This is a step towards fixing that, but there are still plenty more changes to be made.

Additionally, I've certainly been known to value conciseness over readability, and in PRs of this size, that can be killer for reviewers. I've fixed most of your suggestions, and have also made some bigger changes to the structure for simplicities' sake, and added or fleshed out many of the code comments around the dashboard build process. Take a look at the comments below to see what I've changed.

Beyond the simplifications I've done - the services abstraction will absolutely help to mitigate some of this complexity, and further down the line I'd like to move all of the dashboard state inside the embeddable, so increase portability of the system.

/**
* Unpack services
*/
const services = useKibana<DashboardAppServices>().services;
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, I've moved the unpacking of the services outside the useEffect. This has the downside of adding far more individual items to the deps array, but stops the useEffect from monitoring unnecessary services.

* The dashboard build context is a collection of all of the services and props required in subsequent steps to build the dashboard
* from the dashboardId. This build context doesn't contain any extrenuous services.
*/
const dashboardBuildContext: DashboardBuildContext = {
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've changed the dashboard build context to include all required services unpacked, rather than as part of a services object. This means no more "restating the chain" in the functions down the line.

});

/**
* Handle the Incoming Embeddable Part 1:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added some more thorough comments explaining how the incoming embeddable is added to the dashboard. This can be quite confusing.

@kibanamachine
Copy link
Contributor

💚 Build Succeeded

Metrics [docs]

Module Count

Fewer modules leads to a faster build time

id before after diff
dashboard 218 265 +47

Public APIs missing comments

Total count of every public API that lacks a comment. Target amount is 0. Run node scripts/build_api_docs --plugin [yourplugin] --stats comments for more detailed information.

id before after diff
dashboard 132 133 +1

Async chunks

Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app

id before after diff
dashboard 140.0KB 221.4KB +81.5KB

Page load bundle

Size of the bundles that are downloaded on every page load. Target size is below 100kb

id before after diff
dashboard 327.9KB 325.8KB -2.2KB
embeddable 179.0KB 179.1KB +48.0B
total -2.1KB
Unknown metric groups

API count

id before after diff
dashboard 144 145 +1

History

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature:Dashboard Dashboard related features Feature:Embedding Embedding content via iFrame impact:high Addressing this issue will have a high level of impact on the quality/strength of our product. loe:x-large Extra Large Level of Effort release_note:fix Team:Presentation Presentation Team for Dashboard, Input Controls, and Canvas v7.14.0 v8.0.0
Projects
None yet
8 participants