Skip to content
Snippets Groups Projects
Commit ae4f33bf authored by Tom Robinson's avatar Tom Robinson Committed by GitHub
Browse files

Merge pull request #5072 from metabase/release-0.24.0

Merge release-0.24.0 to master
parents 16414094 60487689
No related merge requests found
Showing
with 137 additions and 34 deletions
docs/users-guide/images/dashboards/FilterDashboards.png

10.4 KiB

docs/users-guide/images/drill-through/actions.png

39.7 KiB

docs/users-guide/images/drill-through/drill-through.png

69.9 KiB

docs/users-guide/images/drill-through/heading-actions.png

47.3 KiB

docs/users-guide/images/drill-through/inequality-filters.png

28 KiB

......@@ -4,15 +4,16 @@
* [What Metabase does](01-what-is-metabase.md)
* [The basics of database terminology](02-database-basics.md)
* [Asking questions in Metabase](03-asking-questions.md)
* [How to visualize the answers to questions](04-visualizing-results.md)
* [Sharing and organizing your saved questions](05-sharing-answers.md)
* [Creating dashboards](06-dashboards.md)
* [Adding filters to dashboards](07-dashboard-filters.md)
* [Creating charts with multiple series](08-multi-series-charting.md)
* [Using Pulses for daily emails](09-pulses.md)
* [Get answers in Slack with Metabot](10-metabot.md)
* [Some helpful tips on building your data model](11-data-model-reference.md)
* [Creating SQL Templates](12-sql-parameters.md)
* [Basic exploration in Metabase](03-basic-exploration.md)
* [Asking questions in Metabase](04-asking-questions.md)
* [How to visualize the answers to questions](05-visualizing-results.md)
* [Sharing and organizing your saved questions](06-sharing-answers.md)
* [Creating dashboards](07-dashboards.md)
* [Adding filters to dashboards](08-dashboard-filters.md)
* [Creating charts with multiple series](09-multi-series-charting.md)
* [Using Pulses for daily emails](10-pulses.md)
* [Get answers in Slack with Metabot](11-metabot.md)
* [Some helpful tips on building your data model](12-data-model-reference.md)
* [Creating SQL Templates](13-sql-parameters.md)
Let's get started with an overview of [What Metabase does](01-what-is-metabase.md).
......@@ -62,4 +62,7 @@ declare module "underscore" {
declare function chain<S>(obj: S): any;
declare function constant<S>(obj: S): () => S;
declare function isMatch(object: Object, properties: Object): boolean;
declare function identity<T>(o: T): T;
}
......@@ -20,6 +20,7 @@ const PermissionsEditor = ({ title = "Permissions", modal, admin, grid, onUpdate
action={onSave}
content={<PermissionsConfirm diff={diff} />}
triggerClasses={cx({ disabled: !isDirty })}
key="save"
>
<Button primary small={!modal}>Save Changes</Button>
</Confirm>;
......@@ -29,11 +30,12 @@ const PermissionsEditor = ({ title = "Permissions", modal, admin, grid, onUpdate
title="Discard changes?"
action={onCancel}
content="No changes to permissions will be made."
key="discard"
>
<Button small={!modal}>Cancel</Button>
</Confirm>
:
<Button small={!modal} onClick={onCancel}>Cancel</Button>;
<Button small={!modal} onClick={onCancel} key="cancel">Cancel</Button>;
return (
<LoadingAndErrorWrapper loading={!grid} className="flex-full flex flex-column">
......
......@@ -219,7 +219,7 @@ const AccessOptionList = ({ value, options, onChange }) =>
)}
</ul>
const EntityRowHeader = ({ entity, type }) =>
const EntityRowHeader = ({ entity, icon }) =>
<div
className="flex flex-column justify-center px1 pl4 ml2"
style={{
......@@ -227,7 +227,7 @@ const EntityRowHeader = ({ entity, type }) =>
}}
>
<div className="relative flex align-center">
<Icon name={type} className="absolute" style={{ left: -28 }} />
<Icon name={icon} className="absolute" style={{ left: -28 }} />
<h4>{entity.name}</h4>
</div>
{ entity.subtitle &&
......@@ -292,7 +292,7 @@ const PermissionsGrid = ({ className, grid, onUpdatePermission, entityId, groupI
}
renderRowHeader={({ rowIndex }) =>
<EntityRowHeader
type={grid.type}
icon={grid.icon}
entity={grid.entities[rowIndex]}
isFirstRow={rowIndex === 0}
isLastRow={rowIndex === grid.entities.length - 1}
......
......@@ -25,6 +25,7 @@ import {
updateSchemasPermission,
updateNativePermission,
diffPermissions,
inferAndUpdateEntityPermissions
} from "metabase/lib/permissions";
const getPermissions = (state) => state.admin.permissions.permissions;
......@@ -135,6 +136,36 @@ function getRawQueryWarningModal(permissions, groupId, entityId, value) {
}
}
// If the user is revoking an access to every single table of a database for a specific user group,
// warn the user that the access to raw queries will be revoked as well.
// This warning will only be shown if the user is editing the permissions of individual tables.
function getRevokingAccessToAllTablesWarningModal(database, permissions, groupId, entityId, value) {
if (value === "none" &&
getSchemasPermission(permissions, groupId, entityId) === "controlled" &&
getNativePermission(permissions, groupId, entityId) !== "none"
) {
// allTableEntityIds contains tables from all schemas
const allTableEntityIds = database.tables().map((table) => ({
databaseId: table.db_id,
schemaName: table.schema,
tableId: table.id
}));
// Show the warning only if user tries to revoke access to the very last table of all schemas
const afterChangesNoAccessToAnyTable = _.every(allTableEntityIds, (id) =>
getFieldsPermission(permissions, groupId, id) === "none" || _.isEqual(id, entityId)
);
if (afterChangesNoAccessToAnyTable) {
return {
title: "Revoke access to all tables?",
message: "This will also revoke this group's access to raw queries for this database.",
confirmButtonText: "Revoke access",
cancelButtonText: "Cancel"
};
}
}
}
const OPTION_GREEN = {
icon: "check",
iconColor: "#9CC177",
......@@ -217,6 +248,7 @@ export const getTablesPermissionsGrid = createSelector(
return {
type: "table",
icon: "table",
crumbs: database.schemaNames().length > 1 ? [
["Databases", "/admin/permissions/databases"],
[database.name, "/admin/permissions/databases/"+database.id+"/schemas"],
......@@ -237,12 +269,14 @@ export const getTablesPermissionsGrid = createSelector(
},
updater(groupId, entityId, value) {
MetabaseAnalytics.trackEvent("Permissions", "fields", value);
return updateFieldsPermission(permissions, groupId, entityId, value, metadata);
let updatedPermissions = updateFieldsPermission(permissions, groupId, entityId, value, metadata);
return inferAndUpdateEntityPermissions(updatedPermissions, groupId, entityId, metadata);
},
confirm(groupId, entityId, value) {
return [
getPermissionWarningModal(getFieldsPermission, "fields", defaultGroup, permissions, groupId, entityId, value),
getControlledDatabaseWarningModal(permissions, groupId, entityId)
getControlledDatabaseWarningModal(permissions, groupId, entityId),
getRevokingAccessToAllTablesWarningModal(database, permissions, groupId, entityId, value)
];
},
warning(groupId, entityId) {
......@@ -277,6 +311,7 @@ export const getSchemasPermissionsGrid = createSelector(
return {
type: "schema",
icon: "folder",
crumbs: [
["Databases", "/admin/permissions/databases"],
[database.name],
......@@ -293,7 +328,8 @@ export const getSchemasPermissionsGrid = createSelector(
},
updater(groupId, entityId, value) {
MetabaseAnalytics.trackEvent("Permissions", "tables", value);
return updateTablesPermission(permissions, groupId, entityId, value, metadata);
let updatedPermissions = updateTablesPermission(permissions, groupId, entityId, value, metadata);
return inferAndUpdateEntityPermissions(updatedPermissions, groupId, entityId, metadata);
},
postAction(groupId, { databaseId, schemaName }, value) {
if (value === "controlled") {
......@@ -335,6 +371,7 @@ export const getDatabasesPermissionsGrid = createSelector(
return {
type: "database",
icon: "database",
groups,
permissions: {
"schemas": {
......@@ -364,7 +401,7 @@ export const getDatabasesPermissionsGrid = createSelector(
},
confirm(groupId, entityId, value) {
return [
getPermissionWarningModal(getSchemasPermission, "schemas", defaultGroup, permissions, groupId, entityId, value)
getPermissionWarningModal(getSchemasPermission, "schemas", defaultGroup, permissions, groupId, entityId, value),
];
},
warning(groupId, entityId) {
......@@ -433,6 +470,7 @@ export const getCollectionsPermissionsGrid = createSelector(
return {
type: "collection",
icon: "collection",
groups,
permissions: {
"access": {
......
......@@ -15,10 +15,11 @@ import SettingsSetupList from "../components/SettingsSetupList.jsx";
import SettingsUpdatesForm from "../components/SettingsUpdatesForm.jsx";
import SettingsSingleSignOnForm from "../components/SettingsSingleSignOnForm.jsx";
import { prepareAnalyticsValue } from 'metabase/admin/settings/utils'
import _ from "underscore";
import cx from 'classnames';
import {
getSettings,
getSettingValues,
......@@ -82,8 +83,16 @@ export default class SettingsEditorApp extends Component {
}
this.refs.layout.setSaved();
let val = (setting.key === "report-timezone" || setting.type === "boolean") ? setting.value : "success";
MetabaseAnalytics.trackEvent("General Settings", setting.display_name || setting.key, val);
const value = prepareAnalyticsValue(setting);
MetabaseAnalytics.trackEvent(
"General Settings",
setting.display_name || setting.key,
value,
// pass the actual value if it's a number
typeof(value) === 'number' && value
);
} catch (error) {
let message = error && (error.message || (error.data && error.data.message));
this.refs.layout.setSaveError(message);
......
......@@ -47,7 +47,8 @@ const SECTIONS = [
...MetabaseSettings.get('timezones')
],
placeholder: "Select a timezone",
note: "Not all databases support timezones, in which case this setting won't take effect."
note: "Not all databases support timezones, in which case this setting won't take effect.",
allowValueCollection: true
},
{
key: "anon-tracking-enabled",
......@@ -249,19 +250,22 @@ const SECTIONS = [
key: "query-caching-min-ttl",
display_name: "Minimum Query Duration",
type: "number",
getHidden: (settings) => !settings["enable-query-caching"]
getHidden: (settings) => !settings["enable-query-caching"],
allowValueCollection: true
},
{
key: "query-caching-ttl-ratio",
display_name: "Cache Time-To-Live (TTL)",
display_name: "Cache Time-To-Live (TTL) multiplier",
type: "number",
getHidden: (settings) => !settings["enable-query-caching"]
getHidden: (settings) => !settings["enable-query-caching"],
allowValueCollection: true
},
{
key: "query-caching-max-kb",
display_name: "Max Cache Entry Size",
type: "number",
getHidden: (settings) => !settings["enable-query-caching"]
getHidden: (settings) => !settings["enable-query-caching"],
allowValueCollection: true
}
]
}
......
// in order to prevent collection of identifying information only fields
// that are explicitly marked as collectable or booleans should show the true value
export const prepareAnalyticsValue = (setting) =>
(setting.allowValueCollection || setting.type === "boolean")
? setting.value
: "success"
import { prepareAnalyticsValue } from './utils'
describe('prepareAnalyticsValue', () => {
const defaultSetting = { value: 120, type: 'number' }
const checkResult = (setting = defaultSetting, expected = "success") =>
expect(prepareAnalyticsValue(setting)).toEqual(expected)
it('should return a non identifying value by default ', () => {
checkResult()
})
it('should return the value of a setting marked collectable', () => {
checkResult({ ...defaultSetting, allowValueCollection: true }, defaultSetting.value)
})
it('should return the value of a setting with a type of "boolean" collectable', () => {
checkResult({ ...defaultSetting, type: 'boolean'}, defaultSetting.value)
})
})
......@@ -165,7 +165,7 @@ export default class DatabaseDetailsForm extends Component {
<div style={{maxWidth: "40rem"}} className="pt1">
Some database installations can only be accessed by connecting through an SSH bastion host.
This option also provides an extra layer of security when a VPN is not available.
Enabling this is usually slower than a dirrect connection.
Enabling this is usually slower than a direct connection.
</div>
</div>
</div>
......
......@@ -45,7 +45,7 @@ export default class AddToDashSelectDashModal extends Component {
addToDashboard = (dashboard: Dashboard) => {
// we send the user over to the chosen dashboard in edit mode with the current card added
this.props.onChangeLocation(Urls.dashboard(dashboard.id)+"?add="+this.props.card.id);
this.props.onChangeLocation(Urls.dashboard(dashboard.id, {addCardWithId: this.props.card.id}));
}
createDashboard = async(newDashboard: Dashboard) => {
......
......@@ -11,7 +11,7 @@ const DashCardParameterMapper = ({ dashcard }) =>
}
<div className="flex mx4 z1" style={{ justifyContent: "space-around" }}>
{[dashcard.card].concat(dashcard.series || []).map(card =>
<DashCardCardParameterMapper dashcard={dashcard} card={card} />
<DashCardCardParameterMapper key={`${dashcard.id},${card.id}`} dashcard={dashcard} card={card} />
)}
</div>
</div>
......
......@@ -188,7 +188,7 @@ export default class Dashboard extends Component<*, Props, State> {
}
return (
<LoadingAndErrorWrapper style={{ minHeight: "100%" }} className={cx("Dashboard flex-full", { "Dashboard--fullscreen": isFullscreen, "Dashboard--night": isNightMode})} loading={!dashboard} error={error}>
<LoadingAndErrorWrapper className={cx("Dashboard flex-full pb4", { "Dashboard--fullscreen": isFullscreen, "Dashboard--night": isNightMode})} loading={!dashboard} error={error}>
{() =>
<div className="full" style={{ overflowX: "hidden" }}>
<header className="DashboardHeader relative z2">
......
......@@ -16,6 +16,7 @@ import { getUserIsAdmin } from "metabase/selectors/user";
import * as dashboardActions from "../dashboard";
import {archiveDashboard} from "metabase/dashboards/dashboards"
import {parseHashOptions} from "metabase/lib/browser";
const mapStateToProps = (state, props) => {
return {
......@@ -34,8 +35,6 @@ const mapStateToProps = (state, props) => {
editingParameter: getEditingParameter(state, props),
parameters: getParameters(state, props),
parameterValues: getParameterValues(state, props),
addCardOnLoad: props.location.query.add ? parseInt(props.location.query.add) : null,
metadata: getMetadata(state)
}
}
......@@ -48,10 +47,25 @@ const mapDispatchToProps = {
onChangeLocation: push
}
type DashboardAppState = {
addCardOnLoad: number|null
}
@connect(mapStateToProps, mapDispatchToProps)
@title(({ dashboard }) => dashboard && dashboard.name)
export default class DashboardApp extends Component {
state: DashboardAppState = {
addCardOnLoad: null
};
componentWillMount() {
let options = parseHashOptions(window.location.hash);
if (options.add) {
this.setState({addCardOnLoad: parseInt(options.add)})
}
}
render() {
return <Dashboard {...this.props} />;
return <Dashboard addCardOnLoad={this.state.addCardOnLoad} {...this.props} />;
}
}
......@@ -97,8 +97,14 @@ export default (ComposedComponent: ReactClass<any>) =>
setValue("refresh", this.state.refreshPeriod);
setValue("fullscreen", this.state.isFullscreen);
setValue("theme", this.state.isNightMode ? "night" : null);
delete options.night; // DEPRECATED: options.night
// Delete the "add card to dashboard" parameter if it's present because we don't
// want to add the card again on page refresh. The `add` parameter is already handled in
// DashboardApp before this method is called.
delete options.add;
let hash = stringifyHashOptions(options);
hash = hash ? "#" + hash : "";
......@@ -106,7 +112,7 @@ export default (ComposedComponent: ReactClass<any>) =>
if (hash !== location.hash) {
replace({
pathname: location.pathname,
earch: location.search,
search: location.search,
hash
});
}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment