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 2 commits
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 @@ -253,7 +252,16 @@ export class ExperimentView extends Component {
};
}

updateUrlWithViewState(diffState) {
ExperimentViewUtil.updateUrlWithViewState({
...this.props,
...this.state.persistedState,
...diffState,
});
}

setShowMultiColumns(value) {
this.updateUrlWithViewState({ showMultiColumns: value });
this.setState({
persistedState: new ExperimentViewPersistedState({
...this.state.persistedState,
Expand Down Expand Up @@ -341,6 +349,7 @@ export class ExperimentView extends Component {
};

handleColumnSelectionCheck = (categorizedUncheckedKeys) => {
this.updateUrlWithViewState({ 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,12 @@ export class ExperimentView extends Component {
const myModelVersionFilterInput = modelVersionFilterInput || this.props.modelVersionFilter;
const myStartTime = startTime || this.props.startTime;
try {
this.updateUrlWithViewState({
searchInput: mySearchInput,
orderByKey: myOrderByKey,
orderByAsc: myOrderByAsc,
startTime: myStartTime,
});
this.props.onSearch(
mySearchInput,
myLifecycleFilterInput,
Expand Down Expand Up @@ -1214,6 +1244,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 +1305,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 +1412,6 @@ export class ExperimentView extends Component {
});
return row;
});

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