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 share button in experiment view #4936

Merged
merged 22 commits into from Nov 24, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 2 additions & 3 deletions mlflow/server/js/src/common/utils/Utils.js
Expand Up @@ -711,10 +711,9 @@ class Utils {

static getSearchParamsFromUrl(search) {
const params = qs.parse(search, { ignoreQueryPrefix: true });
const str = JSON.stringify(params, function replaceUndefined(key, value) {
return value === undefined ? '' : value;
const str = JSON.stringify(params, function replaceUndefinedAndBools(key, value) {
return value === undefined ? '' : value === 'true' ? true : value === 'false' ? false : value;
marijncv marked this conversation as resolved.
Show resolved Hide resolved
});

return params ? JSON.parse(str) : [];
}

Expand Down
5 changes: 5 additions & 0 deletions mlflow/server/js/src/common/utils/Utils.test.js
Expand Up @@ -462,6 +462,7 @@ test('getSearchParamsFromUrl', () => {
const url1 = '?p=&q=&r=';
const url2 = '?';
const url3 = '?searchInput=some-Input';
const url4 = '?boolVal1=true&boolVal2=false';
expect(Utils.getSearchParamsFromUrl(url0)).toEqual({
searchInput: '',
});
Expand All @@ -470,6 +471,10 @@ test('getSearchParamsFromUrl', () => {
expect(Utils.getSearchParamsFromUrl(url3)).toEqual({
searchInput: 'some-Input',
});
expect(Utils.getSearchParamsFromUrl(url4)).toEqual({
boolVal1: true,
boolVal2: false,
});
});

test('getSearchUrlFromState', () => {
Expand Down
Expand Up @@ -83,8 +83,7 @@ export class ExperimentPage extends Component {
persistedState: {
searchInput: urlState.search === undefined ? '' : urlState.search,
orderByKey: urlState.orderByKey === undefined ? DEFAULT_ORDER_BY_KEY : urlState.orderByKey,
orderByAsc:
urlState.orderByAsc === undefined ? DEFAULT_ORDER_BY_ASC : urlState.orderByAsc === 'true',
orderByAsc: urlState.orderByAsc === undefined ? DEFAULT_ORDER_BY_ASC : urlState.orderByAsc,
startTime: urlState.startTime === undefined ? DEFAULT_START_TIME : urlState.startTime,
},
};
Expand Down Expand Up @@ -254,13 +253,6 @@ export class ExperimentPage extends Component {
modelVersionFilterInput,
startTime,
) => {
this.updateUrlWithSearchFilter({
searchInput,
orderByKey,
orderByAsc,
startTime,
});

this.setState(
{
lastRunsRefreshTime: Date.now(),
Expand Down Expand Up @@ -329,29 +321,6 @@ export class ExperimentPage extends Component {
return orderBy;
}

updateUrlWithSearchFilter({ searchInput, orderByKey, orderByAsc, startTime }) {
const state = {};
if (searchInput) {
state['search'] = searchInput;
}
if (startTime) {
state['startTime'] = startTime;
}
if (orderByKey) {
state['orderByKey'] = orderByKey;
}
// orderByAsc defaults to true, so only encode it if it is false.
if (orderByAsc === false) {
state['orderByAsc'] = orderByAsc;
}
const newUrl = `/experiments/${this.props.experimentId}/s?${Utils.getSearchUrlFromState(
state,
)}`;
if (newUrl !== this.props.history.location.pathname + this.props.history.location.search) {
this.props.history.push(newUrl);
}
}

renderExperimentView = (isLoading, shouldRenderError, requests) => {
let searchRunsError;
const getExperimentRequest = Utils.getRequestWithId(
Expand Down Expand Up @@ -381,6 +350,7 @@ export class ExperimentPage extends Component {
const experimentViewProps = {
experimentId: this.props.experimentId,
experiment: this.props.experiment,
location: this.props.location,
searchRunsRequestId: this.state.searchRunsRequestId,
modelVersionFilter: this.state.modelVersionFilter,
lifecycleFilter: this.state.lifecycleFilter,
Expand Down
@@ -1,5 +1,4 @@
import React from 'react';
import qs from 'qs';
import { shallow } from 'enzyme';
import { MemoryRouter as Router } from 'react-router-dom';

Expand All @@ -17,9 +16,9 @@ import {
PAGINATION_DEFAULT_STATE,
DEFAULT_ORDER_BY_KEY,
DEFAULT_ORDER_BY_ASC,
DEFAULT_START_TIME,
} from '../constants';

const BASE_PATH = '/experiments/17/s';
const EXPERIMENT_ID = '17';

jest.useFakeTimers();
Expand All @@ -39,12 +38,7 @@ beforeEach(() => {
loadMoreRunsApi = jest.fn(() => Promise.resolve());
searchForNewRuns = jest.fn(() => Promise.resolve());
location = {};

history = {};
history.location = {};
history.location.pathname = BASE_PATH;
history.location.search = '';
history.push = jest.fn();
});

const getExperimentPageMock = (additionalProps) => {
Expand All @@ -63,18 +57,17 @@ const getExperimentPageMock = (additionalProps) => {
);
};

function expectSearchState(historyEntry, state) {
const expectedPrefix = BASE_PATH + '?';
expect(historyEntry.startsWith(expectedPrefix)).toBe(true);
const search = historyEntry.substring(expectedPrefix.length);
const parsedHistory = qs.parse(search);
expect(parsedHistory).toEqual(state);
}

test('URL is empty for blank search', () => {
test('State and search params are correct for blank search', () => {
const wrapper = getExperimentPageMock();
wrapper.instance().onSearch('', 'Active', null, true, null);
expectSearchState(history.push.mock.calls[0][0], {});

expect(wrapper.state().persistedState.searchInput).toEqual('');
expect(wrapper.state().lifecycleFilter).toEqual('Active');
expect(wrapper.state().persistedState.orderByKey).toEqual(null);
expect(wrapper.state().persistedState.orderByAsc).toEqual(true);
expect(wrapper.state().modelVersionFilter).toEqual(null);
expect(wrapper.state().persistedState.startTime).toEqual(undefined);

const searchRunsCallParams = searchRunsApi.mock.calls[1][0];

expect(searchRunsCallParams.experimentIds).toEqual([EXPERIMENT_ID]);
Expand All @@ -83,25 +76,33 @@ test('URL is empty for blank search', () => {
expect(searchRunsCallParams.orderBy).toEqual([]);
});

test('URL can encode a complete search', () => {
test('State and search params are correct for complete search', () => {
const wrapper = getExperimentPageMock();
wrapper.instance().onSearch('metrics.metric0 > 3', 'Deleted', null, true, null, 'ALL');
expectSearchState(history.push.mock.calls[0][0], {
search: 'metrics.metric0 > 3',
startTime: 'ALL',
});

expect(wrapper.state().persistedState.searchInput).toEqual('metrics.metric0 > 3');
expect(wrapper.state().lifecycleFilter).toEqual('Deleted');
expect(wrapper.state().persistedState.orderByKey).toEqual(null);
expect(wrapper.state().persistedState.orderByAsc).toEqual(true);
expect(wrapper.state().modelVersionFilter).toEqual(null);
expect(wrapper.state().persistedState.startTime).toEqual('ALL');

const searchRunsCallParams = searchRunsApi.mock.calls[1][0];
expect(searchRunsCallParams.filter).toEqual('metrics.metric0 > 3');
expect(searchRunsCallParams.runViewType).toEqual(ViewType.DELETED_ONLY);
});

test('URL can encode order_by', () => {
test('State and search params are correct for search with order_by', () => {
const wrapper = getExperimentPageMock();
wrapper.instance().onSearch('', 'Active', 'my_key', false, null);
expectSearchState(history.push.mock.calls[0][0], {
orderByKey: 'my_key',
orderByAsc: 'false',
});

expect(wrapper.state().persistedState.searchInput).toEqual('');
expect(wrapper.state().lifecycleFilter).toEqual('Active');
expect(wrapper.state().persistedState.orderByKey).toEqual('my_key');
expect(wrapper.state().persistedState.orderByAsc).toEqual(false);
expect(wrapper.state().modelVersionFilter).toEqual(null);
expect(wrapper.state().persistedState.startTime).toEqual(undefined);

const searchRunsCallParams = searchRunsApi.mock.calls[1][0];
expect(searchRunsCallParams.filter).toEqual('');
expect(searchRunsCallParams.orderBy).toEqual(['my_key DESC']);
Expand All @@ -113,15 +114,17 @@ test('Loading state without any URL params', () => {
expect(state.persistedState.searchInput).toEqual('');
expect(state.persistedState.orderByKey).toBe(DEFAULT_ORDER_BY_KEY);
expect(state.persistedState.orderByAsc).toEqual(DEFAULT_ORDER_BY_ASC);
expect(state.persistedState.startTime).toEqual(DEFAULT_START_TIME);
});

test('Loading state with all URL params', () => {
location.search = 'params=a&metrics=b&search=c&orderByKey=d&orderByAsc=false';
location.search = 'search=c&orderByKey=d&orderByAsc=false&startTime=LAST_HOUR';
const wrapper = getExperimentPageMock();
const { state } = wrapper.instance();
expect(state.persistedState.searchInput).toEqual('c');
expect(state.persistedState.orderByKey).toEqual('d');
expect(state.persistedState.orderByAsc).toEqual(false);
expect(state.persistedState.startTime).toEqual('LAST_HOUR');
});

test('should render permission denied view when getExperiment yields permission error', () => {
Expand Down
Expand Up @@ -16,6 +16,7 @@ import {
Tooltip,
Typography,
Switch,
message,
} from 'antd';

import './ExperimentView.css';
Expand Down Expand Up @@ -90,8 +91,12 @@ export class ExperimentView extends Component {
this.getStartTimeColumnDisplayName = this.getStartTimeColumnDisplayName.bind(this);
this.onHandleStartTimeDropdown = this.onHandleStartTimeDropdown.bind(this);
this.handleDiffSwitchChange = this.handleDiffSwitchChange.bind(this);
const urlState = Utils.getSearchParamsFromUrl(this.props.location.search);
const store = ExperimentView.getLocalStore(this.props.experiment.experiment_id);
const persistedState = new ExperimentViewPersistedState(store.loadComponentState());
const persistedState = new ExperimentViewPersistedState({
...store.loadComponentState(),
...urlState,
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there a reason we need to store this in a state here? Can the parent parse the location and send down the parsed location as props so we don't need to maintain one more state.

Also assuming we need to maintain state here, we might need to correctly set the state again in getDerivedStateFromProps where we set the persistantState to new ExperimentViewPersistedState().toJSON() and the URL state is not considered.
https://github.com/mlflow/mlflow/pull/4936/files#diff-371936974df23203064a143d69e994cc78e7d7ef09a0b369044c0930218290f2R244

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can you clarify what you mean with one more state? In my view we are storing the same number of state variables as before the PR (the properties defined in ExperimentViewPersistedState). Only now we take into account the urlState when deciding what the values of the ExperimentViewPersistedState properties should be with order of precedence:

  1. urlState
  2. State persisted in localstorage
  3. Default values

I can update the PR such that the url query params are parsed in ExperimentPage and passed as a prop urlState to ExperimentView instead of location but we would still use it in the same way wrt to ExperimentViewPersistedState. Would that help?

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 regards to getDerivedStateFromProps, isn't the switching between ExperimentViewPersistedState handled by the update of the experiment prop in ExperimentView? Which in turn leads to loading in the new ExperimentViewPersistedState according to the above mentioned precedence in the constructor of ExperimentView.

I'm still quite new to React so maybe I'm reading the situation wrong 😊

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it would help to parse the URL in experimentPage and sending the state down which can be used as default state for the current values that we have.

Regarding the getDerivedStateFromProps, when the experiment changes, I don't think the constructor is called again. In that case, do we need the URL state to correctly update the ExperimentViewPersistedState correctly? We can also test this usecase by click the back button of the browser and seeing that the URL state changing, does that change the ExperimentViewPersistedState correctly as well?

const onboardingInformationStore = ExperimentView.getLocalStore(onboarding);
this.state = {
...ExperimentView.getDefaultUnpersistedState(),
Expand All @@ -108,14 +113,14 @@ export class ExperimentView extends Component {
onSearch: PropTypes.func.isRequired,
runInfos: PropTypes.arrayOf(PropTypes.instanceOf(RunInfo)).isRequired,
modelVersionsByRunUuid: PropTypes.object.isRequired,
experimentId: PropTypes.string.isRequired,
experiment: PropTypes.instanceOf(Experiment).isRequired,
history: PropTypes.any,

location: PropTypes.object,
// List of all parameter keys available in the runs we're viewing
paramKeyList: PropTypes.arrayOf(PropTypes.string).isRequired,
// List of all metric keys available in the runs we're viewing
metricKeyList: PropTypes.arrayOf(PropTypes.string).isRequired,

// List of list of params in all the visible runs
paramsList: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)).isRequired,
// List of list of metrics in all the visible runs
Expand All @@ -124,15 +129,12 @@ export class ExperimentView extends Component {
tagsList: PropTypes.arrayOf(PropTypes.object).isRequired,
// Object of experiment tags
experimentTags: PropTypes.object.isRequired,

// Input to the lifecycleFilter field
lifecycleFilter: PropTypes.string.isRequired,
modelVersionFilter: PropTypes.string.isRequired,

orderByKey: PropTypes.string,
orderByAsc: PropTypes.bool,
startTime: PropTypes.string,

// The initial searchInput
searchInput: PropTypes.string.isRequired,
searchRunsError: PropTypes.string,
Expand All @@ -141,7 +143,6 @@ export class ExperimentView extends Component {
handleLoadMoreRuns: PropTypes.func.isRequired,
loadingMore: PropTypes.bool.isRequired,
setExperimentTagApi: PropTypes.func.isRequired,

// If child runs should be nested under their parents
nestChildren: PropTypes.bool,
// ML-13038: Whether to force the compact view upon page load. Used only for testing;
Expand Down Expand Up @@ -172,8 +173,7 @@ export class ExperimentView extends Component {
};
}

/**
* Returns a LocalStorageStore instance that can be used to persist data associated with the
/* Returns a LocalStorageStore instance that can be used to persist data associated with the
* ExperimentView component (e.g. component state such as table sort settings), for the
* specified experiment.
*/
Expand All @@ -188,8 +188,7 @@ export class ExperimentView extends Component {
return true;
}

/**
* Returns true if search filter text was updated, e.g. if a user entered new text into the
/* Returns true if search filter text was updated, e.g. if a user entered new text into the
* param filter, metric filter, or search text boxes.
*/
filtersDidUpdate(prevState) {
Expand Down Expand Up @@ -254,6 +253,11 @@ export class ExperimentView extends Component {
}

setShowMultiColumns(value) {
ExperimentViewUtil.updateUrlWithViewState({
...this.props,
...this.state.persistedState,
showMultiColumns: value,
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there a reason we need to put updateUrlWithViewState as a pure function in ExperimentViewUtil? Can we just put in experimentView where we don't need to pass in props and state to that function, we just need to pass in the new URL state?

If we really need a pure function then we can create a wrapper function of updateUrlWithViewState which takes in the new diff URL state and passes that data to ExperimentViewUtil.updateUrlWithViewState along with props and state. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The main reason was actually that the linter started flagging that the ExperimentView.js file had more than 1500 lines. I've added a wrapper function in the latest commit. Curious to hear your thoughts!

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think since we discussed to maintain the URLState in experimentPage, updating the URLState can also be done in experimentPage as well. So we can move updateUrlWithViewState function to experimentPage where we can update the URL and passed the new parse state down to experimentView. Let me know what you think about that

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@sunishsheth2009 thanks for your comments. I've now moved the updateUrlWithViewState function to the ExperimentPage. Although, I feel I'm a bit stuck on dealing with the user changing the url manually (or pressing the back button in the browser). Can you provide some pointers (or suggestions on my branch) on how to do that gracefully?

I'll fix the failing tests after you feel my attempt is going in the right direction!

Copy link
Collaborator

Choose a reason for hiding this comment

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

Adding something like this in experiment page in getDerivedStateFromProps function might help with maintaining state.

if (props.location.search !== state.urlState) {
      return {
        lastRunsRefreshTime: Date.now(),
        numberOfNewRuns: 0,
        nextPageToken: null,
        persistedState: { ...getPersistedStateFromUrl(props.location.search) },
        urlState: props.location.search,
        searchRunsRequestId: getUUID(),
        ...PAGINATION_DEFAULT_STATE,
      };
    }

also the code for

const getPersistedStateFromUrl = (url) => {
 const urlState = Utils.getSearchParamsFromUrl(url);

 return {
   searchInput: urlState.search === undefined ? '' : urlState.search,
   orderByKey: urlState.orderByKey === undefined ? DEFAULT_ORDER_BY_KEY : urlState.orderByKey,
   orderByAsc:
     urlState.orderByAsc === undefined ? DEFAULT_ORDER_BY_ASC : urlState.orderByAsc === 'true',
   startTime: urlState.startTime === undefined ? DEFAULT_START_TIME : urlState.startTime,
 };
};

This can be reused in constructor as well

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the pointer @sunishsheth2009 ! Will give it another go.

With the goal of maintaining the state in ExperimentPage, wouldn't it make sense now to lift the urlState elements from ExperimentViewPersistedState back into ExperimentPagePersistedState, and pass in the needed values to ExperimentView as props?

Referring to:

  • showMultiColumns
  • categorizedUncheckedKeys
  • diffSwitchSelected
  • preSwitchCategorizedUncheckedKeys
  • postSwitchCategorizedUncheckedKeys

Having the urlState spread across the two persisted state objects is quite confusing to deal with from my perspective. Or maybe I'm still missing a piece of the puzzle.

In any case, will try and take some time over the weekend to work it out

Copy link
Collaborator

Choose a reason for hiding this comment

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

With the goal of maintaining the state in ExperimentPage, wouldn't it make sense now to lift the urlState elements from ExperimentViewPersistedState back into ExperimentPagePersistedState, and pass in the needed values to ExperimentView as props?

Yes that would be great! :) I like that idea of maintaining the state in one place so it can update it correctly.
Thank you. let me know if you need any help from my side

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks again for the pointer @sunishsheth2009, I've now updated the PR to maintain the urlState in ExperimentPage. In the end it became quite a refactor, and I've also (re-)enabled the storing of ExperimentPage state in localstorage. Curious to hear your what you think!

this.setState({
persistedState: new ExperimentViewPersistedState({
...this.state.persistedState,
Expand Down Expand Up @@ -341,6 +345,11 @@ export class ExperimentView extends Component {
};

handleColumnSelectionCheck = (categorizedUncheckedKeys) => {
ExperimentViewUtil.updateUrlWithViewState({
...this.props,
...this.state.persistedState,
categorizedUncheckedKeys,
});
this.setState({
persistedState: new ExperimentViewPersistedState({
...this.state.persistedState,
Expand Down Expand Up @@ -779,6 +788,21 @@ export class ExperimentView extends Component {
))}
</Select>
</Tooltip>
<Tooltip
title={this.props.intl.formatMessage({
defaultMessage: 'Share experiment view',
description: 'Label for the share experiment view button',
})}
>
<Button dataTestId='share-button' onClick={this.onShare}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<FormattedMessage
defaultMessage='Share'
description='String for the share button to share experiment view'
/>
</div>
</Button>
</Tooltip>
</Spacer>
}
right={
Expand Down Expand Up @@ -1055,6 +1079,14 @@ export class ExperimentView extends Component {
const myModelVersionFilterInput = modelVersionFilterInput || this.props.modelVersionFilter;
const myStartTime = startTime || this.props.startTime;
try {
ExperimentViewUtil.updateUrlWithViewState({
...this.props,
...this.state.persistedState,
searchInput: mySearchInput,
orderByKey: myOrderByKey,
orderByAsc: myOrderByAsc,
startTime: myStartTime,
});
this.props.onSearch(
mySearchInput,
myLifecycleFilterInput,
Expand Down Expand Up @@ -1214,6 +1246,16 @@ export class ExperimentView extends Component {
});
};

onShare = () => {
navigator.clipboard.writeText(window.location.href);
message.info(
this.props.intl.formatMessage({
defaultMessage: 'Experiment view copied to clipboard',
marijncv marked this conversation as resolved.
Show resolved Hide resolved
description: 'Content of the message after clicking the share experiment button',
}),
);
};

onClear = () => {
// When user clicks "Clear", preserve multicolumn toggle state but reset other persisted state
// attributes to their default values.
Expand Down Expand Up @@ -1265,9 +1307,7 @@ export class ExperimentView extends Component {
saveAs(blob, 'runs.csv');
};

/**
* Format a string for insertion into a CSV file.
*/
// Format a string for insertion into a CSV file.
static csvEscape(str) {
if (str === undefined) {
return '';
Expand Down Expand Up @@ -1374,7 +1414,6 @@ export class ExperimentView extends Component {
});
return row;
});

return ExperimentView.tableToCsv(columns, data);
}
}
Expand Down