diff --git a/.flowconfig b/.flowconfig index 2bc4b7201f3b3ddb72e5ba6ff10575b9d78543db..e27b923c11166afeb5834d3fbb0feb9aa99cd540 100644 --- a/.flowconfig +++ b/.flowconfig @@ -1,7 +1,6 @@ [ignore] .*/node_modules/react/node_modules/.* .*/node_modules/postcss-import/node_modules/.* -.*/node_modules/fixed-data-table/.* .*/node_modules/@kadira/storybook/node_modules/.* .*/node_modules/.*/\(lib\|test\).*\.json$ diff --git a/bin/version b/bin/version index f1a7f883f2d73d8b893a9132130924eff7b0b690..d74495d1e4ec7d45779f9188e4b0ff834e83d905 100755 --- a/bin/version +++ b/bin/version @@ -1,6 +1,6 @@ #!/usr/bin/env bash -VERSION="v0.21.0-snapshot" +VERSION="v0.21.0-rc2" # dynamically pull more interesting stuff from latest git commit HASH=$(git show-ref --head --hash=7 head) # first 7 letters of hash should be enough; that's what GitHub uses diff --git a/docs/administration-guide/01-managing-databases.md b/docs/administration-guide/01-managing-databases.md index 2458a780f520e7c4868dfbad228ef7d5617042ed..b25b3cc4739e5c0a5b2f53a39b39681541ddbc16 100644 --- a/docs/administration-guide/01-managing-databases.md +++ b/docs/administration-guide/01-managing-databases.md @@ -18,7 +18,7 @@ Now you’ll see a list of your databases. To connect another database to Metaba * Postgres * SQLite * SQL Server -* Driud +* Druid * Crate * [Oracle](databases/oracle.md) * [Vertica](databases/vertica.md) diff --git a/docs/administration-guide/05-setting-permissions.md b/docs/administration-guide/05-setting-permissions.md index a0918b25d57c508993a8b552eee97839c7d9f723..8baf031c208f7691921392e4fbeedea5f7d204d4 100644 --- a/docs/administration-guide/05-setting-permissions.md +++ b/docs/administration-guide/05-setting-permissions.md @@ -6,7 +6,7 @@ There are always going to be sensitive bits of information in your databases and Metabase uses a group-based approach to set permissions and restrictions on your databases and tables. At a high level, to set up permissions in your Metabase instance you’ll need to create one or more groups, add members to those groups, and then choose what level of database and SQL access those groups should have. -A user can be a member of multiple groups, and if one of the groups they’re in has access to a particular database, but another group they’re a member of does not, then they **will** have access to that database. +A user can be a member of multiple groups, and if one of the groups they’re in has access to a particular database or table, but another group they’re a member of does not, then they **will** have access to that database. ### Groups @@ -20,7 +20,7 @@ You’ll notice that you already have two default groups: Administrators and All You’ll also see that you’re a member of the **Administrators** group — that’s why you were able to go to the Admin Panel in the first place. So, to make someone an admin of Metabase you just need to add them to this group. Metabase admins can log into the Admin Panel and make changes there, and they always have unrestricted access to all data that you have in your Metabase instance. So be careful who you add to the Administrator group! -The **All Users** group is another special one. Every Metabase user is always a member of this group, though they can also be a member of as many other groups as you want. We recommend using the All Users group as a way to set default access levels for new Metabase users. If you have [Google single sign-on](09-single-sign-on.md) enabled, new users who join that way will be automatically added to the All Users group. +The **All Users** group is another special one. Every Metabase user is always a member of this group, though they can also be a member of as many other groups as you want. We recommend using the All Users group as a way to set default access levels for new Metabase users. If you have [Google single sign-on](09-single-sign-on.md) enabled, new users who join that way will be automatically added to the All Users group. (**Important note:** as we mentioned above, a user is given the *most permissive* setting she has for a given database/schema/table across *all* groups she is in. Because of that, it is important that your All Users group should never have *greater* access for an item than a group for which you're trying to restrict access — otherwise the more permissive setting will win out.) If you’ve set up the [Slack integration](08-setting-up-slack.md) and enabled [Metabot](../users-guide/10-metabot.md), you’ll also see a special **Metabot** group, which will allow you to restrict which questions your users will be able to access in Slack via Metabot. diff --git a/docs/users-guide/04-visualizing-results.md b/docs/users-guide/04-visualizing-results.md index 179b483a1951d1e929a45ff5b64fc70152b48c9a..0bce52900661543e6af9e8b237b3b3daee5266d8 100644 --- a/docs/users-guide/04-visualizing-results.md +++ b/docs/users-guide/04-visualizing-results.md @@ -5,49 +5,67 @@ While tables are useful for looking up information or finding specific numbers, In Metabase, an answer to a question can be visualized in a number of ways: * Number +* Progress bar * Table -* Line -* Bar -* Pie -* Area +* Line chart +* Bar chart +* Area chart +* Scatterplot or bubble chart +* Pie/donut chart * Map To change how the answer to your question is displayed, click on the Visualization dropdown menu beneath the question builder bar.  -If a particular visualization doesn’t really make sense for your answer, the format option will appear faded in the dropdown menu. +If a particular visualization doesn’t really make sense for your answer, the format option will appear grayed-out in the dropdown menu. You can still select a grayed-out option, though you might need to click on the chart options gear icon to make your selection work with your data. -Once a question is answered, you can save or download the answer, or add it to a dashboard. +Once a question is answered, you can save or download the answer, or add it to a dashboard or Pulse. -### Visualization options +### Visualization types and options Each visualization type has its own advanced options you can tweak. Just click the gear icon next to the visualization selector. Here's an overview of what you can do: #### Numbers -The options for numbers include adding prefixes or suffixes to your number (so you can do things like put a currency symbol in front or a percent at the end), setting the number of decimal places you want to include, and multiplying your result by a number (like if you want to multiply a decimal by 100 to make it look like a percent). +This option is for displaying a single number, nice and big. The options for numbers include adding character prefixes or suffixes to it (so you can do things like put a currency symbol in front or a percent at the end), setting the number of decimal places you want to include, and multiplying your result by a number (like if you want to multiply a decimal by 100 to make it look like a percent). + +#### Progress bars +Progress bars are for comparing a single number result to a goal value that you input. Open up the chart options for your progress bar to choose a goal for it, and Metabase will show you how far away your question's current result is from the goal. #### Tables -The table options allow you to hide and rearrange fields in the table you're looking at. +The Table option is good for looking at tabular data (duh), or for lists of things like users. The options allow you to hide and rearrange fields in the table you're looking at. #### Line, bar, and area charts +Line charts are best for displaying the trend of a number over time, especially when you have lots of x-axis values. Bar charts are great for displaying a metric grouped by a category (e.g., the number of users you have by country), and they can also be useful for showing a number over time if you have a smaller number of x-axis values (like orders per month this year). Area charts are useful when comparing the the proportions between two metrics over time. Both bar and area charts can be stacked. + These three charting types have very similar options, which are broken up into the following: -* **Data** — choose the fields you want to plot on your x and y axes. This also allows you to plot fields from unaggregated tables. -* **Display** — here's where you can make some cosmetic changes, like setting colors, and stacking bar or area charts. -* **Axes** — this is where you can hide axis markers or change their ranges. +* **Data** — choose the fields you want to plot on your x and y axes. This is mostly useful if your table or result set contains more than two columns, like if you're trying to graph fields from an unaggregated table. You can also add additional metric fields by clicking the `Add another series` link below the y-axis dropdown, or break your current metric out by an additional dimension by clicking the `Add a series breakout` link below the x-axis dropdown (note that you can't add an additional series breakout if you have more than one metric/series). +* **Display** — here's where you can make some cosmetic changes, like setting colors, and stacking bar or area charts. With line and area charts, you can also change the line style (line, curve, or step). We've also recently added the ability to create a goal line for your chart, and to configure how your chart deals with x-axis points that have missing y-axis values. +* **Axes** — this is where you can hide axis markers or change their ranges, and turn split axes on or off. You can also configure the way your axes are scaled, if you're into that kind of thing. * **Labels** — if you want to hide axis labels or customize them, here's where to go. -#### Pie charts -The options for pie charts let you choose which field to use as your measurement, and which one to use for the pie slices. You can also customize the pie chart's legend. +#### Scatterplots and bubble charts +Scatterplots are useful for visualizing the correlation between two variables, like comparing the age of your users vs. how many dollars they've spent on your products. To use a scatterplot, you'll need to ask a question that results in two numeric columns, like `Count of Orders grouped by Customer Age`. Alternatively, you can use a raw data table and select the two numeric fields you want to use in the chart options. + +If you have a third numeric field, you can also create a bubble chart. Select the Scatter visualization, then open up the chart options and select a field in the bubble size dropdown. This field will be used to determine the size of each bubble on your chart. For example, you could use a field that contains the number or count of items for the given x-y pair — i.e., larger bubbles for larger total dollar amounts spent on orders. + +Scatterplots and bubble charts also have similar chart options as line, bar, and area charts. + +#### Pie or donut charts +A pie or donut chart can be used when breaking out a metric by a single dimension, especially when the number of possible breakouts is small, like users by gender. If you have more than a few breakouts, like users by country, it's usually better to use a bar chart so that your users can more easily compare the relative sizes of each bar. + +The options for pie charts let you choose which field to use as your measurement, and which one to use for the dimension (i.e., the pie slices). You can also customize the pie chart's legend, whether or not to show each slice's percent of the whole in the legend, and the minimum size a slice needs to be in order for it to be displayed. #### Maps -When you select the Map visualization setting, Metabase will automatically try and pick the best kind of map to use based on the table or result you're currently looking at. Here are the maps that Metabase uses: +When you select the Map visualization setting, Metabase will automatically try and pick the best kind of map to use based on the table or result set you're currently looking at. Here are the maps that Metabase uses: + +* **United States Map** — Creating a map of the United States from your data requires your results to contain a column field with states. This lets you do things like visualize the count of your users broken out by state, with darker states representing more users. +* **Country Map** — To visualize your results in the format of a map of the world broken out by country, your result must contain a field with countries. (E.g., count of users by country.) +* **Pin Map** — If your table contains a latitude and longitude field, Metabase will try to display it as a pin map of the world. This will put one pin on the map for each row in your table, based on the latitude and longitude fields. You can try this with the Sample Dataset that's included in Metabase: start a new question and select the People table, use `raw data` for your view, and choose the Map option for your visualization. you'll see a map of the world, with each dot representing the latitude and longitude coordinates of a single person from the People table. -* **United States Map** — Creating a map of the United States from your data requires your results to contain a column field with states. This lets you do things like visualize the count of your users broken out by state. -* **Country Map** — To visualize your results in the format of a map of the world broken out by country, your result must contain a field with countries. -* **Pin Map** — If your table contains a latitude and longitude field, Metabase will try to display it as a pin map of the world. This will put one pin on the map for each row in your table, based on the latitude and longitude fields. +When you open up the Map options, you can manually switch between a region map (i.e., United States or world) and a pin map. If you're using a region map, you can also choose which field to use as the measurement, and which to use as the region (i.e. State or Country). -When you open up the Map options, you can manually switch between a region map (i.e., United States or world) and a pin map. (And don't worry — a flexible way to add custom maps of other countries and regions will be coming soon.) If you're using a region map, you can also choose which field to use as the measurement, and which to use as the region (i.e. State or Country). +Metabase now also allows administrators to add custom region maps via GeoJSON files through the Metabase Admin Panel. --- diff --git a/docs/users-guide/07-dashboard-filters.md b/docs/users-guide/07-dashboard-filters.md index d4965e39940f975e5c802bdf14da7693aadf891b..fde813da6856b156452c5daf647ebd9c2ec1a7ef 100644 --- a/docs/users-guide/07-dashboard-filters.md +++ b/docs/users-guide/07-dashboard-filters.md @@ -12,7 +12,13 @@ To add a filter to a dashboard, first enter dashboard editing mode, then click t  -You can choose from a number of filter types: time, location, ID, or other categories. The type of filter you choose will determine what the filter widget will look like, and will also determine what fields you’ll be able to filter your cards by. Let’s try a time filter, and then select the Month and Year option. +You can choose from a number of filter types: Time, Location, ID, or Other Categories. The type of filter you choose will determine what the filter widget will look like, and will also determine what fields you’ll be able to filter your cards by: +* **Time:** when picking a Time filter, you'll also be prompted to pick a specific type of filter widget: Month and Year, Quarter and Year, Single Date, Date Range, or Relative Date. Single Date and Date Range will provide a calendar widget, while the other options all provide slightly different dropdown interfaces for picking values. +* **Location:** there are four types of Location filters to choose from: City, State, ZIP or Postal Code, and Country. These will all show up as input box widgets unless the field(s) you're filtering contain fewer than 40 distinct possible values, in which case the widget will be a dropdown. +* **ID:** this filter provides a simple input box where you can type the ID of a user, order, etc. +* **Other Categories:** this is a flexible filter type that will let you create either a dropdown or input box to filter on any category field in your cards. Whether the filter widget is displayed as a dropdown or an input box is dependent on the field(s) you pick to filter on: if there are fewer than 40 distinct possible values for that field, you'll see a dropdown; otherwise you'll see an input box. (A future version of Metabase will include type-ahead search suggestions for the input box widget.) + +For our example, we'll select a Time filter, and then select the Month and Year option.  @@ -22,6 +28,10 @@ Now we’ve entered a new mode where we’ll need to wire up each card on our da So here’s what we’re doing — when we pick a month and year with our new filter, the filter needs to know which field in the card to filter on. For example, if we have a `Total Orders` card, and each order has a `Date Ordered` as well as a `Date Delivered`, we have to pick which of those fields to filter — do we want to see all the orders *placed* in January, or do we want to see all the orders *delivered* in January? So, for each card on our dashboard, we’ll pick a date field to connect to the filter. If one of your cards says there aren’t any valid fields, that just means that card doesn’t contain any fields that match the kind of filter you chose. +#### Filtering SQL-based cards +Note that if your dashboard includes cards that were created using the SQL/native query editor, you'll need to add a bit of additional markup to the SQL in those cards in order to use a dashboard filter on them. [Using SQL parameters](12-sql-parameters.md) + +  Before we click the `Done` button at the top of the screen, we can also customize the label of our new filter by clicking on the pencil icon next to it. We’ll type in a new label and hit enter. Now we’ll click `Done`, and then save the changes to our dashboard with the `Save` button. @@ -57,9 +67,9 @@ Here are a few tips to get the most out of dashboard filters: ### Some things to keep in mind - When you activate a dashboard filter, any card that isn’t wired up to the filter will fade out to indicate it’s not being filtered. If you activate more than one filter at the same time, cards will fade out unless they’re wired up to *every* active filter. -- If you have a card with multiple series on it that you want to use with a dashboard filter, then just make sure to select a filtering field for each of the series in the card. -- While connecting cards to a filter, you might see a warning message that says, `The values in this field don’t overlap with the values of any other fields you’ve chosen`. For example, maybe you selected the `Type of Pants` field for one card, but the `Types of Boats` field for another card; if you’re using those fields for the same filter, this is problematic because the filter would then give options to the user that don’t work for both cards. -- You can’t use a dashboard filter with a field in a question if that field is already being used in the definition of the question. For example, say you have a question called `Orders in January`, which counts all the orders and has a filter on the `Date Order Was Placed` field to only select the orders placed January — you can’t then connect a dashboard filter to the `Orders in January` card through the `Date Order Was Placed` field, because that field is already being used to filter the underlying question. +- If you have a card with multiple series on it that you want to use with a dashboard filter, then just make sure to select a field to be filtered for each of the series in the card. +- While connecting cards to a filter, you might see a warning message that says, `The values in this field don’t overlap with the values of any other fields you’ve chosen`. For example, maybe you selected the `Type of Pants` field for one card, but the `Types of Boats` field for another card; if you’re using those fields for the same filter, this is problematic because the filter would then give options to the user that wouldn't work for both cards (like, `Chinos, Jeans, Kayak, Slacks, Yacht`). Metabase prefers to prevent such silliness. +- You can’t use a dashboard filter with a field in a question if that field is already being used in the definition of the question. For example, say you have a question called `Orders in January`, which counts all the orders and has a filter on the `Date Order Was Placed` field to only select the orders placed January — you can’t then connect a dashboard filter to the `Orders in January` card through the `Date Order Was Placed` field, because that field is already being used to filter the underlying question's data. --- diff --git a/frontend/interfaces/icepick.js b/frontend/interfaces/icepick.js index 64baa5092de5ed3d4c94e1bfa7261470e5360dc4..3c120f77394a4cbe495e47064c84399657738997 100644 --- a/frontend/interfaces/icepick.js +++ b/frontend/interfaces/icepick.js @@ -10,6 +10,8 @@ declare module icepick { declare function assocIn<O:Object, K:Key, V:Value>(object: O, path: Array<K>, value: V): O; declare function updateIn<O:Object, K:Key, V:Value>(object: O, path: Array<K>, callback: ((value: V) => V)): O; + declare function merge<O:Object>(object: O, other: O): O; + // TODO: improve this declare function chain<O:Object>(object: O): any; } diff --git a/frontend/interfaces/underscore.js b/frontend/interfaces/underscore.js index 06749ba97a5b38ff73c671890c3a63cf128f4721..2093902740da8bae5394d7458a6b9d4bfb3c26c7 100644 --- a/frontend/interfaces/underscore.js +++ b/frontend/interfaces/underscore.js @@ -1,9 +1,9 @@ // type definitions for (some of) underscore declare module "underscore" { - declare function find<T>(list: T[], predicate: (val: T)=>boolean): ?T; - declare function findWhere<T>(list: T[], properties: {[key:string]: any}): ?T; - declare function findIndex<T>(list: T[], predicate: (val: T)=>boolean): number; + declare function find<T>(list: ?T[], predicate: (val: T)=>boolean): ?T; + declare function findWhere<T>(list: ?T[], properties: {[key:string]: any}): ?T; + declare function findIndex<T>(list: ?T[], predicate: (val: T)=>boolean): number; declare function clone<T>(obj: T): T; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx index 8ddb953c85288998bf1db04f94b5ca9a12d2f99b..38a618e35071c1095dba9e26cf3f05d85b2254dd 100644 --- a/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx +++ b/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx @@ -100,7 +100,14 @@ const getOptionUi = (option) => const GroupColumnHeader = ({ group, permissions, isLastColumn, isFirstColumn }) => <div className="absolute bottom left right"> - <h4 className="text-centered full my1">{ group.name }</h4> + <h4 className="text-centered full my1 flex layout-centered"> + { group.name } + { group.tooltip && + <Tooltip tooltip={group.tooltip} maxWidth="24em"> + <Icon className="ml1" name="question" /> + </Tooltip> + } + </h4> <div className="flex" style={getBorderStyles({ isLastColumn, isFirstColumn, isFirstRow: true, isLastRow: false })}> { permissions.map((permission, index) => <div key={permission.id} className="flex-full py1 border-column-divider" style={{ @@ -130,7 +137,7 @@ class GroupPermissionCell extends Component { constructor(props, context) { super(props, context); this.state = { - confirmText: null, + confirmations: null, confirmAction: null, hovered: false } @@ -138,22 +145,24 @@ class GroupPermissionCell extends Component { hoverEnter () { // only change the hover state if the group is not the admin // this helps indicate to users that the admin group is different - if (this.props.group.name !== "Admin" ) { + if (this.props.isEditable) { return this.setState({ hovered: true }); } return false } hoverExit () { - if (this.props.group.name !== "Admin" ) { + if (this.props.isEditable) { return this.setState({ hovered: false }); } return false } render() { const { permission, group, entity, onUpdatePermission } = this.props; + const { confirmations } = this.state; const value = permission.getter(group.id, entity.id); const options = permission.options(group.id, entity.id); + const warning = permission.warning && permission.warning(group.id, entity.id); let isEditable = this.props.isEditable && options.filter(option => option !== value).length > 0; @@ -166,9 +175,8 @@ class GroupPermissionCell extends Component { <Tooltip tooltip={getOptionUi(value).tooltip}> <div className={cx( - 'flex-full flex layout-centered', - { 'cursor-pointer' : group.name !== 'Admin' }, - { 'disabled' : group.name === 'Admin'} + 'flex-full flex layout-centered relative', + { 'cursor-pointer' : isEditable } )} style={{ borderColor: LIGHT_BORDER, @@ -183,15 +191,28 @@ class GroupPermissionCell extends Component { size={28} style={{ color: this.state.hovered ? '#fff' : getOptionUi(value).iconColor }} /> - { this.state.confirmText && + { confirmations && confirmations.length > 0 && <Modal> <ConfirmContent - {...this.state.confirmText} - onAction={this.state.confirmAction} - onClose={() => this.setState({ confirmText: null, confirmAction: null })} + {...confirmations[0]} + onAction={() => + // if it's the last one call confirmAction, otherwise remove the confirmation that was just confirmed + confirmations.length === 1 ? + this.setState({ confirmations: null, confirmAction: null }, this.state.confirmAction) + : + this.setState({ confirmations: confirmations.slice(1) }) + } + onCancel={() => this.setState({ confirmations: null, confirmAction: null })} /> </Modal> } + { warning && + <div className="absolute top right p1"> + <Tooltip tooltip={warning} maxWidth="24em"> + <Icon name="warning2" className="text-slate" /> + </Tooltip> + </div> + } </div> </Tooltip> } @@ -210,9 +231,9 @@ class GroupPermissionCell extends Component { postAction: permission.postAction }) } - let confirmText = permission.confirm && permission.confirm(group.id, entity.id, value); - if (confirmText) { - this.setState({ confirmText, confirmAction }); + let confirmations = (permission.confirm && permission.confirm(group.id, entity.id, value) || []).filter(c => c); + if (confirmations.length > 0) { + this.setState({ confirmations, confirmAction }); } else { confirmAction(); } diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js index f12b05850a86a0dad9fd73dd509be8a606780370..4fab573b5314812334a8947bbbf832be3636edd0 100644 --- a/frontend/src/metabase/admin/permissions/selectors.js +++ b/frontend/src/metabase/admin/permissions/selectors.js @@ -40,6 +40,17 @@ const getMetadata = createSelector( // reorder groups to be in this order const SPECIAL_GROUP_FILTERS = [isAdminGroup, isDefaultGroup, isMetaBotGroup].reverse(); +function getTooltipForGroup(group) { + if (isAdminGroup(group)) { + return "Administrators always have the highest level of acess to everything in Metabase." + } else if (isDefaultGroup(group)) { + return "Every Metabase user belongs to the All Users group. If you want to limit or restrict a group's access to something, make sure the All Users group has an equal or lower level of access."; + } else if (isMetaBotGroup(group)) { + return "Metabot is Metabase's Slack bot. You can choose what it has access to here."; + } + return null; +} + export const getGroups = createSelector( (state) => state.permissions.groups, (groups) => { @@ -50,7 +61,10 @@ export const getGroups = createSelector( orderedGroups.unshift(...orderedGroups.splice(index, 1)) } } - return orderedGroups; + return orderedGroups.map(group => ({ + ...group, + tooltip: getTooltipForGroup(group) + })) } ); @@ -62,6 +76,64 @@ export const getIsDirty = createSelector( export const getSaveError = (state) => state.permissions.saveError; + +// these are all the permission levels ordered by level of access +const PERM_LEVELS = ["write", "read", "all", "controlled", "none"]; +function hasGreaterPermissions(a, b) { + return (PERM_LEVELS.indexOf(a) - PERM_LEVELS.indexOf(b)) < 0 +} + +function getPermissionWarning(getter, entityType, defaultGroup, permissions, groupId, entityId, value) { + if (!defaultGroup || groupId === defaultGroup.id) { + return null; + } + let perm = value || getter(permissions, groupId, entityId); + let defaultPerm = getter(permissions, defaultGroup.id, entityId); + if (perm === "controlled" && defaultPerm === "controlled") { + return `The "${defaultGroup.name}" group may have access to a different set of ${entityType} than this group, which may give this group additional access to some ${entityType}.`; + } + if (hasGreaterPermissions(defaultPerm, perm)) { + return `The "${defaultGroup.name}" group has a higher level of access than this, which will override this setting. You should limit or revoke the "${defaultGroup.name}" group's access to this item.`; + } + return null; +} + +function getPermissionWarningModal(entityType, getter, defaultGroup, permissions, groupId, entityId, value) { + let permissionWarning = getPermissionWarning(entityType, getter, defaultGroup, permissions, groupId, entityId, value); + if (permissionWarning) { + return { + title: `${value === "controlled" ? "Limit" : "Revoke"} access even though "${defaultGroup.name}" has greater access?`, + message: permissionWarning, + confirmButtonText: (value === "controlled" ? "Limit" : "Revoke") + " access", + cancelButtonText: "Cancel" + }; + } +} + +function getControlledDatabaseWarningModal(permissions, groupId, entityId) { + if (getSchemasPermission(permissions, groupId, entityId) !== "controlled") { + return { + title: "Changing this database to limited access", + confirmButtonText: "Change", + cancelButtonText: "Cancel" + }; + } +} + +function getRawQueryWarningModal(permissions, groupId, entityId, value) { + if (value === "write" && + getNativePermission(permissions, groupId, entityId) !== "write" && + getSchemasPermission(permissions, groupId, entityId) !== "all" + ) { + return { + title: "Allow Raw Query Writing?", + message: "This will also change this group's data access to Unrestricted for this database.", + confirmButtonText: "Allow", + cancelButtonText: "Cancel" + }; + } +} + export const getTablesPermissionsGrid = createSelector( getMetadata, getGroups, getPermissions, getDatabaseId, getSchemaName, (metadata: Metadata, groups: Array<Group>, permissions: GroupsPermissions, databaseId: DatabaseId, schemaName: SchemaName) => { @@ -72,6 +144,7 @@ export const getTablesPermissionsGrid = createSelector( } const tables = database.tablesInSchema(schemaName || null); + const defaultGroup = _.find(groups, isDefaultGroup); return { type: "table", @@ -97,11 +170,13 @@ export const getTablesPermissionsGrid = createSelector( return updateFieldsPermission(permissions, groupId, entityId, value, metadata); }, confirm(groupId, entityId, value) { - if (getSchemasPermission(permissions, groupId, entityId) !== "controlled") { - return { - title: "Changing this database to limited access" - }; - } + return [ + getPermissionWarningModal(getFieldsPermission, "fields", defaultGroup, permissions, groupId, entityId, value), + getControlledDatabaseWarningModal(permissions, groupId, entityId) + ]; + }, + warning(groupId, entityId) { + return getPermissionWarning(getFieldsPermission, "fields", defaultGroup, permissions, groupId, entityId); } } }, @@ -128,6 +203,7 @@ export const getSchemasPermissionsGrid = createSelector( } const schemaNames = database.schemaNames(); + const defaultGroup = _.find(groups, isDefaultGroup); return { type: "schema", @@ -154,11 +230,13 @@ export const getSchemasPermissionsGrid = createSelector( } }, confirm(groupId, entityId, value) { - if (getSchemasPermission(permissions, groupId, entityId) !== "controlled") { - return { - title: "Changing this database to limited access" - }; - } + return [ + getPermissionWarningModal(getTablesPermission, "tables", defaultGroup, permissions, groupId, entityId, value), + getControlledDatabaseWarningModal(permissions, groupId, entityId) + ]; + }, + warning(groupId, entityId) { + return getPermissionWarning(getTablesPermission, "tables", defaultGroup, permissions, groupId, entityId); } } }, @@ -182,6 +260,7 @@ export const getDatabasesPermissionsGrid = createSelector( } const databases = metadata.databases(); + const defaultGroup = _.find(groups, isDefaultGroup); return { type: "database", @@ -211,6 +290,14 @@ export const getDatabasesPermissionsGrid = createSelector( } } }, + confirm(groupId, entityId, value) { + return [ + getPermissionWarningModal(getSchemasPermission, "schemas", defaultGroup, permissions, groupId, entityId, value) + ]; + }, + warning(groupId, entityId) { + return getPermissionWarning(getSchemasPermission, "schemas", defaultGroup, permissions, groupId, entityId); + } }, "native": { options(groupId, entityId) { @@ -228,15 +315,13 @@ export const getDatabasesPermissionsGrid = createSelector( return updateNativePermission(permissions, groupId, entityId, value, metadata); }, confirm(groupId, entityId, value) { - if (value === "write" && - getNativePermission(permissions, groupId, entityId) !== "write" && - getSchemasPermission(permissions, groupId, entityId) !== "all" - ) { - return { - title: "Allow Raw Query Writing", - message: "This will also change this group's data access to Unrestricted for this database." - }; - } + return [ + getPermissionWarningModal(getNativePermission, null, defaultGroup, permissions, groupId, entityId, value), + getRawQueryWarningModal(permissions, groupId, entityId, value) + ]; + }, + warning(groupId, entityId) { + return getPermissionWarning(getNativePermission, null, defaultGroup, permissions, groupId, entityId); } }, }, diff --git a/frontend/src/metabase/components/AddToDashSelectDashModal.jsx b/frontend/src/metabase/components/AddToDashSelectDashModal.jsx index 3fcc57eb75572893a554762ca8ceac4a29338391..ce1aeb97b15122fad0a28eb17d857e4e70319ae5 100644 --- a/frontend/src/metabase/components/AddToDashSelectDashModal.jsx +++ b/frontend/src/metabase/components/AddToDashSelectDashModal.jsx @@ -68,7 +68,7 @@ export default class AddToDashSelectDashModal extends Component { onClick={() => this.setState({ shouldCreateDashboard: true })} > <div - className="flex align-center absolute" + className="mt1 flex align-center absolute" style={ { right: 40 } } > <Icon name="add" size={16} /> diff --git a/frontend/src/metabase/components/ConfirmContent.jsx b/frontend/src/metabase/components/ConfirmContent.jsx index fcd21da1fab29c4d25889512372af0a46ff104c4..976172d370f7164e1ad901c57ac90e83552f909b 100644 --- a/frontend/src/metabase/components/ConfirmContent.jsx +++ b/frontend/src/metabase/components/ConfirmContent.jsx @@ -2,10 +2,21 @@ import React, { Component, PropTypes } from "react"; import ModalContent from "metabase/components/ModalContent.jsx"; -const ConfirmContent = ({ title, content, onClose, onAction, message = "Are you sure you want to do this?" }) => +const nop = () => {}; + +const ConfirmContent = ({ + title, + content, + message = "Are you sure you want to do this?", + onClose = nop, + onAction = nop, + onCancel = nop, + confirmButtonText = "Yes", + cancelButtonText = "No" +}) => <ModalContent title={title} - closeFn={onClose} + closeFn={() => { onCancel(); onClose(); }} > <div className="mx4">{content}</div> @@ -14,8 +25,8 @@ const ConfirmContent = ({ title, content, onClose, onAction, message = "Are you </div> <div className="Form-actions"> - <button className="Button Button--danger" onClick={() => { onAction(); onClose(); }}>Yes</button> - <button className="Button ml1" onClick={onClose}>No</button> + <button className="Button Button--danger" onClick={() => { onAction(); onClose(); }}>{confirmButtonText}</button> + <button className="Button ml1" onClick={() => { onCancel(); onClose(); }}>{cancelButtonText}</button> </div> </ModalContent> diff --git a/frontend/src/metabase/components/SaveQuestionModal.jsx b/frontend/src/metabase/components/SaveQuestionModal.jsx index adcca466084d6215bd827a8b412ecd8ab4f9e80b..4c4a015cb8bb4469d348e6915221b3a1a0410b2a 100644 --- a/frontend/src/metabase/components/SaveQuestionModal.jsx +++ b/frontend/src/metabase/components/SaveQuestionModal.jsx @@ -145,7 +145,7 @@ export default class SaveQuestionModal extends Component { onChange={(value) => this.onChange("saveType", value)} options={[ { name: `Replace original question, "${this.props.originalCard.name}"`, value: "overwrite" }, - { name: "Save as new question", value: "replace" }, + { name: "Save as new question", value: "create" }, ]} isVertical /> diff --git a/frontend/src/metabase/components/SortableItemList.jsx b/frontend/src/metabase/components/SortableItemList.jsx index c938818ef63f48f82e4aa9e26128a059b182da6a..00495171c6ff8fe3a56c83e9f629225452409b57 100644 --- a/frontend/src/metabase/components/SortableItemList.jsx +++ b/frontend/src/metabase/components/SortableItemList.jsx @@ -36,7 +36,7 @@ export default class SortableItemList extends Component { return ( <div className="SortableItemList"> <div className="flex align-center px2 pb3 border-bottom"> - <h5 className="text-bold text-uppercase text-grey-3 ml2 mr2">Sort by</h5> + <h5 className="text-bold text-uppercase text-grey-3 ml2 mt1 mr2">Sort by</h5> <Radio value={this.state.sort} options={["Last Modified", /*"Most Popular",*/ "Alphabetical Order"]} diff --git a/frontend/src/metabase/components/Value.jsx b/frontend/src/metabase/components/Value.jsx new file mode 100644 index 0000000000000000000000000000000000000000..eb1990bc45d20002d483480e32da4c09e8a211c5 --- /dev/null +++ b/frontend/src/metabase/components/Value.jsx @@ -0,0 +1,14 @@ +import React from "react"; + +import { formatValue } from "metabase/lib/formatting"; + +const Value = ({ value, ...options }) => { + let formatted = formatValue(value, { ...options, jsx: true }); + if (React.isValidElement(formatted)) { + return formatted; + } else { + return <span>{formatted}</span> + } +} + +export default Value; diff --git a/frontend/src/metabase/css/components/mb_data_table.css b/frontend/src/metabase/css/components/mb_data_table.css deleted file mode 100644 index 4fab07a1b854cff518c274f6ef3c913e8c1c06fd..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/css/components/mb_data_table.css +++ /dev/null @@ -1,80 +0,0 @@ - -.MB-DataTable-header:hover { - cursor: pointer; -} - -.MB-DataTable-header .Icon { opacity: 0; } -.MB-DataTable-header:hover .Icon, -.MB-DataTable-header--sorted .Icon { - opacity: 1; - transition: opacity .3s linear; -} - -.PagingButtons { - border: 1px solid #ddd; -} - -/* if the column is the one that is being sorted*/ -.MB-DataTable-header--sorted { - color: var(--brand-color); -} - -/* what follows is a war crime but such is the state of FE development */ -.MB-DataTable .public_fixedDataTable_main { - border-color: rgb(205, 205, 205); -} - -.MB-DataTable .public_fixedDataTableCell_main { - border-right: 1px solid #e8e8e8; - border-bottom: 1px solid #e8e8e8; - border-top: 1px solid transparent; - border-left: 1px solid transparent; -} - -.MB-DataTable .public_fixedDataTableCell_main:hover { - border-color: var(--brand-color); - color: var(--brand-color); -} - -.MB-DataTable .public_fixedDataTableRow_highlighted, -.MB-DataTable .public_fixedDataTableRow_highlighted .public_fixedDataTableCell_main { - background-color: #fff; -} - -.MB-DataTable .public_fixedDataTable_header, -.MB-DataTable .public_fixedDataTable_header .public_fixedDataTableCell_main { - background-color: #fff; - background-image: none; -} - -.MB-DataTable .public_fixedDataTable_header, -.MB-DataTable .public_fixedDataTable_header .public_fixedDataTableCell_main { - background-color: #fff; -} - -.MB-DataTable .public_fixedDataTable_header .public_fixedDataTableCell_main:hover { - border-color: #e8e8e8; -} - -/* this is so that our column calculation code works correctly */ -.MB-DataTable .public_fixedDataTableCell_cellContent { - display: block; -} - -/* cell overflow ellipsis */ -.MB-DataTable .cellData { - white-space: nowrap; - text-overflow: ellipsis; - overflow-x: hidden; -} - -/* needs to be display:block so .cellData doesn't overflow the width and not show ellipsis */ -/* only enable once the contentWidths have been computed (.MB-DataTable--ready) */ -/* only enable for the body rows (.public_fixedDataTable_bodyRow) */ -.MB-DataTable.MB-DataTable--ready .public_fixedDataTable_bodyRow .public_fixedDataTableCell_wrap1, -.MB-DataTable.MB-DataTable--ready .public_fixedDataTable_bodyRow .public_fixedDataTableCell_wrap2, -.MB-DataTable.MB-DataTable--ready .public_fixedDataTable_bodyRow .public_fixedDataTableCell_wrap3, -.MB-DataTable.MB-DataTable--ready .public_fixedDataTable_bodyRow .public_fixedDataTableCell_cellContent, -.MB-DataTable.MB-DataTable--ready .public_fixedDataTable_bodyRow .cellData { - display: block; -} diff --git a/frontend/src/metabase/css/core/colors.css b/frontend/src/metabase/css/core/colors.css index 3d491c211717d74354ca89a08a8f56247b526e6e..cec62039e13fd9e43867bbbcf32ff41c7460777d 100644 --- a/frontend/src/metabase/css/core/colors.css +++ b/frontend/src/metabase/css/core/colors.css @@ -50,7 +50,8 @@ } .bg-brand, -.bg-brand-hover:hover { background-color: var(--brand-color); } +.bg-brand-hover:hover, +.bg-brand-active:active { background-color: var(--brand-color); } /* success */ diff --git a/frontend/src/metabase/css/core/scroll.css b/frontend/src/metabase/css/core/scroll.css index aed81182ebf4d5da6679deefc6586a34c044e681..12fccedf309f12c5fae4b8165ebb6f78ca64419d 100644 --- a/frontend/src/metabase/css/core/scroll.css +++ b/frontend/src/metabase/css/core/scroll.css @@ -67,7 +67,7 @@ display: none; /* Safari and Chrome */ } -.scroll-hide-all * { +.scroll-hide-all, .scroll-hide-all * { -ms-overflow-style: none; /* IE 10+ */ overflow: -moz-scrollbars-none; /* Firefox */ } diff --git a/frontend/src/metabase/css/index.css b/frontend/src/metabase/css/index.css index b534fbdac1ac699414f85ef463432c403a3cd757..b65cc8ac418a170b29c684eec73f9f0891cab827 100644 --- a/frontend/src/metabase/css/index.css +++ b/frontend/src/metabase/css/index.css @@ -5,7 +5,6 @@ @import './components/form.css'; @import './components/header.css'; @import './components/icons.css'; -@import './components/mb_data_table.css'; @import './components/modal.css'; @import './components/popover.css'; @import './components/select.css'; diff --git a/frontend/src/metabase/css/query_builder.css b/frontend/src/metabase/css/query_builder.css index c1e86f73081bdc5f4608917b1d5ad253f9df71d4..da248e50891a699cfd8c20d8e5132ec0bffc726c 100644 --- a/frontend/src/metabase/css/query_builder.css +++ b/frontend/src/metabase/css/query_builder.css @@ -598,11 +598,6 @@ min-width: 25em; } -.MB-DataTable.MB-DataTable--pivot .public_fixedDataTableCell_main:first-child { - font-weight: bold; - border-right: 1px solid color(var(--base-grey) shade(40%)); -} - .List { padding: var(--padding-1); } diff --git a/frontend/src/metabase/css/vendor.css b/frontend/src/metabase/css/vendor.css index 29d32008ddb07f161efae2c048e6c20906cc6cbc..683d146355997d36209c9d394393e889d3f15a1e 100644 --- a/frontend/src/metabase/css/vendor.css +++ b/frontend/src/metabase/css/vendor.css @@ -3,6 +3,3 @@ /* z-index utils */ @import 'z-index/z-index.css'; - -/* react */ -@import 'fixed-data-table/dist/fixed-data-table.css'; diff --git a/frontend/src/metabase/dashboard/components/grid/GridItem.jsx b/frontend/src/metabase/dashboard/components/grid/GridItem.jsx index 699e53939d2af241e9505826fb65b9ac1529b62e..481cda2928d94c1408cdd3fa6cbc94ca4060149d 100644 --- a/frontend/src/metabase/dashboard/components/grid/GridItem.jsx +++ b/frontend/src/metabase/dashboard/components/grid/GridItem.jsx @@ -16,15 +16,15 @@ export default class GridItem extends Component { } onDragHandler(handlerName) { - return (e, {element, position}) => { + return (e, { node, x, y }) => { // react-draggle seems to return undefined/NaN occasionally, which breaks things - if (isNaN(position.clientX) || isNaN(position.clientY)) { + if (isNaN(x) || isNaN(y)) { return; } let { dragStartPosition, dragStartScrollTop } = this.state; if (handlerName === "onDragStart") { - dragStartPosition = position; + dragStartPosition = { x, y }; dragStartScrollTop = document.body.scrollTop this.setState({ dragStartPosition, dragStartScrollTop }); } @@ -33,8 +33,8 @@ export default class GridItem extends Component { let scrollTopDelta = document.body.scrollTop - dragStartScrollTop; // compute new position let pos = { - x: position.clientX - dragStartPosition.clientX, - y: position.clientY - dragStartPosition.clientY + scrollTopDelta, + x: x - dragStartPosition.x, + y: y - dragStartPosition.y + scrollTopDelta, }; if (handlerName === "onDragStop") { @@ -43,7 +43,7 @@ export default class GridItem extends Component { this.setState({ dragging: pos }); } - this.props[handlerName](this.props.i, {e, element, position: pos }); + this.props[handlerName](this.props.i, {e, node, position: pos }); }; } diff --git a/frontend/src/metabase/dashboard/components/parameters/widgets/DateRelativeWidget.jsx b/frontend/src/metabase/dashboard/components/parameters/widgets/DateRelativeWidget.jsx index b5a23be3048c78237d968d08117cfabf7ea41db0..83c57fdea34bd76a9b4affd9becb75434c02f862 100644 --- a/frontend/src/metabase/dashboard/components/parameters/widgets/DateRelativeWidget.jsx +++ b/frontend/src/metabase/dashboard/components/parameters/widgets/DateRelativeWidget.jsx @@ -1,9 +1,105 @@ import React, { Component, PropTypes } from "react"; -import RelativeDatePicker from "metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx"; - +import cx from "classnames"; import _ from "underscore"; +const SHORTCUTS = [ + { name: "Today", operator: ["=", "<", ">"], values: [["relative_datetime", "current"]]}, + { name: "Yesterday", operator: ["=", "<", ">"], values: [["relative_datetime", -1, "day"]]}, + { name: "Past 7 days", operator: "TIME_INTERVAL", values: [-7, "day"]}, + { name: "Past 30 days", operator: "TIME_INTERVAL", values: [-30, "day"]} +]; + +const RELATIVE_SHORTCUTS = { + "Last": [ + { name: "Week", operator: "TIME_INTERVAL", values: ["last", "week"]}, + { name: "Month", operator: "TIME_INTERVAL", values: ["last", "month"]}, + { name: "Year", operator: "TIME_INTERVAL", values: ["last", "year"]} + ], + "This": [ + { name: "Week", operator: "TIME_INTERVAL", values: ["current", "week"]}, + { name: "Month", operator: "TIME_INTERVAL", values: ["current", "month"]}, + { name: "Year", operator: "TIME_INTERVAL", values: ["current", "year"]} + ] +}; + +class PredefinedRelativeDatePicker extends Component { + constructor(props, context) { + super(props, context); + + _.bindAll(this, "isSelectedShortcut", "onSetShortcut"); + } + + static propTypes = { + filter: PropTypes.array.isRequired, + onFilterChange: PropTypes.func.isRequired + }; + + isSelectedShortcut(shortcut) { + let { filter } = this.props; + return ( + (Array.isArray(shortcut.operator) ? _.contains(shortcut.operator, filter[0]): filter[0] === shortcut.operator ) && + _.isEqual(filter.slice(2), shortcut.values) + ); + } + + onSetShortcut(shortcut) { + let { filter } = this.props; + let operator; + if (Array.isArray(shortcut.operator)) { + if (_.contains(shortcut.operator, filter[0])) { + operator = filter[0]; + } else { + operator = shortcut.operator[0]; + } + } else { + operator = shortcut.operator; + } + this.props.onFilterChange([operator, filter[1], ...shortcut.values]) + } + + render() { + return ( + <div className="p1 pt2"> + <section> + { SHORTCUTS.map((s, index) => + <span key={index} className={cx("inline-block half pb1", { "pr1": index % 2 === 0 })}> + <button + key={index} + className={cx("Button Button-normal Button--medium text-normal text-centered full", { "Button--purple": this.isSelectedShortcut(s) })} + onClick={() => this.onSetShortcut(s)} + > + {s.name} + </button> + </span> + )} + </section> + {Object.keys(RELATIVE_SHORTCUTS).map(sectionName => + <section key={sectionName}> + <div style={{}} className="border-bottom text-uppercase flex layout-centered mb2"> + <h6 style={{"position": "relative", "backgroundColor": "white", "top": "6px" }} className="px2"> + {sectionName} + </h6> + </div> + <div className="flex"> + { RELATIVE_SHORTCUTS[sectionName].map((s, index) => + <button + key={index} + data-ui-tag={"relative-date-shortcut-" + sectionName.toLowerCase() + "-" + s.name.toLowerCase()} + className={cx("Button Button-normal Button--medium flex-full mb1", { "Button--purple": this.isSelectedShortcut(s), "mr1": index !== RELATIVE_SHORTCUTS[sectionName].length - 1 })} + onClick={() => this.onSetShortcut(s)} + > + {s.name} + </button> + )} + </div> + </section> + )} + </div> + ); + } +} + // HACK: easiest way to get working with RelativeDatePicker const FILTERS = { "today": { @@ -63,7 +159,7 @@ export default class DateRelativeWidget extends Component { const { value, setValue, onClose } = this.props; return ( <div className="px1" style={{ maxWidth: 300 }}> - <RelativeDatePicker + <PredefinedRelativeDatePicker filter={FILTERS[value] ? FILTERS[value].mapping : [null, null]} onFilterChange={(filter) => { setValue(_.findKey(FILTERS, (f) => _.isEqual(f.mapping, filter))); diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index ff2fb912a348bb93a2555bfe3c801af9c1fead18..1ddb52efd31f36fe48c42f97c675b47f3ded6f76 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -149,6 +149,7 @@ export var ICON_PATHS = { attrs: { scale: 2 } }, sync: 'M16 2 A14 14 0 0 0 2 16 A14 14 0 0 0 16 30 A14 14 0 0 0 26 26 L 23.25 23 A10 10 0 0 1 16 26 A10 10 0 0 1 6 16 A10 10 0 0 1 16 6 A10 10 0 0 1 23.25 9 L19 13 L30 13 L30 2 L26 6 A14 14 0 0 0 16 2', + question: "M16,32 C24.836556,32 32,24.836556 32,16 C32,7.163444 24.836556,0 16,0 C7.163444,0 0,7.163444 0,16 C0,24.836556 7.163444,32 16,32 L16,32 Z M16,29.0909091 C8.77009055,29.0909091 2.90909091,23.2299095 2.90909091,16 C2.90909091,8.77009055 8.77009055,2.90909091 16,2.90909091 C23.2299095,2.90909091 29.0909091,8.77009055 29.0909091,16 C29.0909091,23.2299095 23.2299095,29.0909091 16,29.0909091 Z M12,9.56020942 C12.2727286,9.34380346 12.5694087,9.1413622 12.8900491,8.95287958 C13.2106896,8.76439696 13.5552807,8.59860455 13.9238329,8.45549738 C14.2923851,8.31239021 14.6885728,8.20069848 15.1124079,8.12041885 C15.5362429,8.04013921 15.9950835,8 16.4889435,8 C17.1818216,8 17.8065083,8.08725916 18.3630221,8.2617801 C18.919536,8.43630105 19.3931184,8.68586225 19.7837838,9.0104712 C20.1744491,9.33508016 20.4748147,9.7260012 20.6848894,10.1832461 C20.8949642,10.6404909 21,11.1483393 21,11.7068063 C21,12.2373499 20.9226052,12.6963331 20.7678133,13.0837696 C20.6130213,13.4712061 20.4176916,13.8080265 20.1818182,14.0942408 C19.9459448,14.3804552 19.6861194,14.6282712 19.4023342,14.8376963 C19.1185489,15.0471215 18.8495099,15.2408368 18.5952088,15.4188482 C18.3409078,15.5968595 18.1197798,15.773123 17.9318182,15.947644 C17.7438566,16.1221649 17.6240789,16.3176254 17.5724816,16.5340314 L17.2628993,18 L14.9189189,18 L14.6756757,16.3141361 C14.6167073,15.9720751 14.653562,15.6736487 14.7862408,15.4188482 C14.9189196,15.1640476 15.1013502,14.9336834 15.3335381,14.7277487 C15.565726,14.521814 15.8255514,14.3263535 16.1130221,14.1413613 C16.4004928,13.9563691 16.6695319,13.7574182 16.9201474,13.5445026 C17.1707629,13.3315871 17.3826773,13.0942421 17.5558968,12.8324607 C17.7291163,12.5706793 17.8157248,12.2582915 17.8157248,11.895288 C17.8157248,11.4764377 17.6701489,11.1431077 17.3789926,10.895288 C17.0878364,10.6474682 16.6879632,10.5235602 16.1793612,10.5235602 C15.7886958,10.5235602 15.462532,10.5619542 15.20086,10.6387435 C14.9391879,10.7155327 14.7143744,10.8010466 14.5264128,10.895288 C14.3384511,10.9895293 14.1744479,11.0750432 14.034398,11.1518325 C13.8943482,11.2286217 13.7543005,11.2670157 13.6142506,11.2670157 C13.2972957,11.2670157 13.0614258,11.1378721 12.9066339,10.8795812 L12,9.56020942 Z M14,22 C14,21.7192968 14.0511359,21.4580909 14.1534091,21.2163743 C14.2556823,20.9746577 14.3958324,20.7641335 14.5738636,20.5847953 C14.7518948,20.4054572 14.96212,20.2631584 15.2045455,20.1578947 C15.4469709,20.0526311 15.7121198,20 16,20 C16.2803044,20 16.5416655,20.0526311 16.7840909,20.1578947 C17.0265164,20.2631584 17.2386355,20.4054572 17.4204545,20.5847953 C17.6022736,20.7641335 17.7443177,20.9746577 17.8465909,21.2163743 C17.9488641,21.4580909 18,21.7192968 18,22 C18,22.2807032 17.9488641,22.5438584 17.8465909,22.7894737 C17.7443177,23.0350889 17.6022736,23.2475625 17.4204545,23.4269006 C17.2386355,23.6062387 17.0265164,23.7465882 16.7840909,23.8479532 C16.5416655,23.9493182 16.2803044,24 16,24 C15.7121198,24 15.4469709,23.9493182 15.2045455,23.8479532 C14.96212,23.7465882 14.7518948,23.6062387 14.5738636,23.4269006 C14.3958324,23.2475625 14.2556823,23.0350889 14.1534091,22.7894737 C14.0511359,22.5438584 14,22.2807032 14,22 Z", return:'M15.3040432,11.8500793 C22.1434689,13.0450349 27.291257,18.2496116 27.291257,24.4890512 C27.291257,25.7084278 27.0946472,26.8882798 26.7272246,28.0064033 L26.7272246,28.0064033 C25.214579,22.4825472 20.8068367,18.2141694 15.3040432,17.0604596 L15.3040432,25.1841972 L4.70874296,14.5888969 L15.3040432,3.99359668 L15.3040432,3.99359668 L15.3040432,11.8500793 Z', reference: { path: 'M15.9670388,2.91102126 L14.5202438,1.46422626 L14.5202438,13.9807372 C14.5202438,15.0873683 13.6272253,15.9844701 12.5215507,15.9844701 L2.89359,15.9844701 C2.16147687,15.9844701 1.446795,15.6184135 1.446795,14.5376751 L11.0747557,14.5376751 C12.1786034,14.5376751 13.0734488,13.6501624 13.0734488,12.5467556 L13.0734488,0 L2.17890813,0 C0,0 0,0 0,2.17890813 L0,14.5202438 C0,16.6991519 1.81285157,17.4312651 3.62570313,17.4312651 L13.9704736,17.4312651 C15.0731461,17.4312651 15.9670388,16.5448165 15.9670388,15.4275322 L15.9670388,2.91102126 Z', diff --git a/frontend/src/metabase/lib/redux.js b/frontend/src/metabase/lib/redux.js index d239455f54873717d7ff524ef57ca9459c25b910..0d04daac373851d8dbe6ed2b20909d0b3ca9262c 100644 --- a/frontend/src/metabase/lib/redux.js +++ b/frontend/src/metabase/lib/redux.js @@ -63,18 +63,9 @@ export function momentifyObjectsTimestamps(objects, keys) { return _.mapObject(objects, o => momentifyTimestamps(o, keys)); } -//filters out angular cruft in resource list -export const cleanResources = (resources) => resources - .filter(resource => resource.id !== undefined); - -//filters out angular cruft and turns into id indexed map -export const resourceListToMap = (resources) => cleanResources(resources) - .reduce((map, resource) => Object.assign({}, map, {[resource.id]: resource}), {}); - -//filters out angular cruft in resource -export const cleanResource = (resource) => Object.keys(resource) - .filter(key => key.charAt(0) !== "$") - .reduce((map, key) => Object.assign({}, map, {[key]: resource[key]}), {}); +// turns into id indexed map +export const resourceListToMap = (resources) => + resources.reduce((map, resource) => ({ ...map, [resource.id]: resource }), {}); export const fetchData = async ({ dispatch, diff --git a/frontend/src/metabase/lib/visualization_settings.js b/frontend/src/metabase/lib/visualization_settings.js index fe09dfbc1eccce6b1cd573d93d462b4be83c74df..fb2485b4566f3ef5fc1105d5a4be7547a8e1bc16 100644 --- a/frontend/src/metabase/lib/visualization_settings.js +++ b/frontend/src/metabase/lib/visualization_settings.js @@ -549,6 +549,8 @@ const SETTINGS = { columnNames: cols.reduce((o, col) => ({ ...o, [col.name]: getFriendlyName(col)}), {}) }) }, + "table.column_widths": { + }, "map.type": { title: "Map type", widget: ChartSettingSelect, diff --git a/frontend/src/metabase/meta/types/Dataset.js b/frontend/src/metabase/meta/types/Dataset.js index 9e821479af46eb0d431f63309239a9e3a1f82c16..5ad43b5b6ad1560d4bc3094cf251091c0eb433cc 100644 --- a/frontend/src/metabase/meta/types/Dataset.js +++ b/frontend/src/metabase/meta/types/Dataset.js @@ -1,12 +1,17 @@ /* @flow */ +import type { FieldId } from "./Field"; +import type { DatasetQuery } from "./Card"; + export type ColumnName = string; // TODO: incomplete export type Column = { + id: ?FieldId, name: ColumnName, display_name: string, base_type: string, + special_type: ?string } export type ISO8601Times = string; @@ -18,5 +23,6 @@ export type Dataset = { cols: Column[], columns: ColumnName[], rows: Row[] - } + }, + json_query: DatasetQuery } diff --git a/frontend/src/metabase/meta/types/Query.js b/frontend/src/metabase/meta/types/Query.js index d591acb55a7041905631844bd16504e475536efa..be77a68c43fb30d3a3970384e31e7fe4aee70d7f 100644 --- a/frontend/src/metabase/meta/types/Query.js +++ b/frontend/src/metabase/meta/types/Query.js @@ -38,7 +38,8 @@ export type StructuredQuery = { filter?: FilterClause, order_by?: OrderByClause, limit?: LimitClause, - expressions?: { [key: ExpressionName]: Expression } + expressions?: { [key: ExpressionName]: Expression }, + fields?: FieldsClause }; export type AggregationClause = @@ -97,3 +98,5 @@ export type ExpressionOperand = ConcreteField | NumericLiteral | Expression; export type Expression = [ExpressionOperator, ExpressionOperand, ExpressionOperand]; + +export type FieldsClause = FieldId[]; diff --git a/frontend/src/metabase/query_builder/components/AggregationWidget.jsx b/frontend/src/metabase/query_builder/components/AggregationWidget.jsx index f8f44f9e5e6a6a5fc6845e5b8b4d9e463e77ae2e..ceabaf5f32bbf6b796fee329f2c00fc6d39e57af 100644 --- a/frontend/src/metabase/query_builder/components/AggregationWidget.jsx +++ b/frontend/src/metabase/query_builder/components/AggregationWidget.jsx @@ -25,7 +25,7 @@ export default class AggregationWidget extends Component { } static propTypes = { - aggregation: PropTypes.array.isRequired, + aggregation: PropTypes.array, tableMetadata: PropTypes.object.isRequired, customFields: PropTypes.object, updateAggregation: PropTypes.func.isRequired @@ -48,7 +48,7 @@ export default class AggregationWidget extends Component { const fieldId = AggregationClause.getField(aggregation); let selectedAggregation = getAggregator(AggregationClause.getOperator(aggregation)); - if (!_.findWhere(tableMetadata.aggregation_options, { short: selectedAggregation.short })) { + if (selectedAggregation && !_.findWhere(tableMetadata.aggregation_options, { short: selectedAggregation.short })) { // if this table doesn't support the selected aggregation, prompt the user to select a different one selectedAggregation = null; } diff --git a/frontend/src/metabase/query_builder/components/FieldName.jsx b/frontend/src/metabase/query_builder/components/FieldName.jsx index c031be4c7a659ac870d5deb2c821e2552cf10513..cf6d52163cf9f383a3f8054d271f4ff5c5e1fb92 100644 --- a/frontend/src/metabase/query_builder/components/FieldName.jsx +++ b/frontend/src/metabase/query_builder/components/FieldName.jsx @@ -38,7 +38,7 @@ export default class FieldName extends Component { } // target field itself // using i.getIn to avoid exceptions when field is undefined - parts.push(<span key="field">{Query.getFieldPathName(fieldTarget.field.id, tableMetadata)}</span>); + parts.push(<span key="field">{Query.getFieldPathName(fieldTarget.field.id, fieldTarget.table)}</span>); // datetime-field unit if (fieldTarget.unit != null) { parts.push(<span key="unit">{": " + formatBucketing(fieldTarget.unit)}</span>); diff --git a/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx b/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx index 4a001d63002aadd4be7fa490dab6cb9cdaf9523d..8f228e593e9abf5c7738c44f2b2c1e49d9ea9796 100644 --- a/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx +++ b/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx @@ -39,7 +39,7 @@ export default class TimeGroupingPopover extends Component { static defaultProps = { groupingOptions: [ // "default", - // "minute", + "minute", "hour", "day", "week", diff --git a/frontend/src/metabase/query_builder/components/filters/DateOperatorSelector.jsx b/frontend/src/metabase/query_builder/components/filters/DateOperatorSelector.jsx index 59b403a30530a3ed6744ea173ab7346f2e012336..f57252073f756f2e10abb144eeee6d2bd179f3aa 100644 --- a/frontend/src/metabase/query_builder/components/filters/DateOperatorSelector.jsx +++ b/frontend/src/metabase/query_builder/components/filters/DateOperatorSelector.jsx @@ -35,7 +35,7 @@ export default class DateOperatorSelector extends Component { > <h3>{operator && titleCase(operator)}</h3> <Icon - name='chevrondown' + name={expanded ? 'chevronup' : 'chevrondown'} width="12" height="12" className="ml1" diff --git a/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx b/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx index b97632aa76b31575b791ce0c239e02fa71f49c8e..0a492020d0c8d166fd4f53d2ad6a511af88d7978 100644 --- a/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx +++ b/frontend/src/metabase/query_builder/components/filters/pickers/DatePicker.jsx @@ -62,7 +62,7 @@ class CurrentPicker extends Component { } -const getIntervals = ([op, field, value, unit]) => op === "TIME_INTERVAL" && typeof value === "number" ? Math.abs(value) : 1; +const getIntervals = ([op, field, value, unit]) => op === "TIME_INTERVAL" && typeof value === "number" ? Math.abs(value) : 30; const getUnit = ([op, field, value, unit]) => op === "TIME_INTERVAL" && unit ? unit : "day"; const getDate = (value) => typeof value === "string" && moment(value).isValid() ? value : moment().format("YYYY-MM-DD"); @@ -105,7 +105,7 @@ const OPERATORS = [ }, { name: "Between", - init: (filter) => ["BETWEEN", filter[1], null, null], + init: (filter) => ["BETWEEN", filter[1], getDate(filter[2]), getDate(filter[3])], test: ([op]) => op === "BETWEEN", widget: MultiDatePicker, }, diff --git a/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx b/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx index 469038ca4d18ffbd6ed42ad1621b8a206e726058..c66a5d47f4c7c904245aea3e588ad26095ac04d6 100644 --- a/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx +++ b/frontend/src/metabase/query_builder/components/filters/pickers/RelativeDatePicker.jsx @@ -1,10 +1,19 @@ import React, { Component, PropTypes } from "react"; -import { pluralize, titleCase } from "humanize-plus"; +import { pluralize, titleCase, capitalize } from "humanize-plus"; import cx from "classnames"; import Icon from "metabase/components/Icon"; import NumericInput from "./NumericInput.jsx"; +const PERIODS = [ + "minute", + "hour", + "day", + "week", + "month", + "year" +]; + export default class RelativeDatePicker extends Component { constructor () { super(); @@ -27,6 +36,7 @@ export default class RelativeDatePicker extends Component { <div className="px2"> <NumericInput className="input h3 mb2 border-purple" + data-ui-tag="relative-date-input" value={typeof intervals === "number" ? Math.abs(intervals) : intervals} onChange={(value) => onFilterChange([op, field, formatter(value), unit]) @@ -57,7 +67,7 @@ export const UnitPicker = ({ open, value, onChange, togglePicker, intervals, for > <h3>{pluralize(formatter(intervals) || 1, titleCase(value))}</h3> <Icon - name='chevrondown' + name={open ? 'chevronup' : 'chevrondown'} width="12" height="12" className="ml1" @@ -70,17 +80,17 @@ export const UnitPicker = ({ open, value, onChange, togglePicker, intervals, for overflow: 'hidden' }} > - { ['Minute', 'Hour', 'Day', 'Month', 'Year',].map((unit, index) => + { PERIODS.map((unit, index) => <li className={cx( 'List-item cursor-pointer p1', { 'List-item--selected': unit === value } )} key={index} - onClick={ () => onChange(unit.toLowerCase()) } + onClick={ () => onChange(unit) } > <h4 className="List-item-title"> - {pluralize(formatter(intervals) || 1, unit)} + {capitalize(pluralize(formatter(intervals) || 1, unit))} </h4> </li> ) diff --git a/frontend/src/metabase/redux/metadata.js b/frontend/src/metabase/redux/metadata.js index f46a19290f189f54656f45dc4b0a437e0767d132..b5084d489c176f1538f15513b120545744780d18 100644 --- a/frontend/src/metabase/redux/metadata.js +++ b/frontend/src/metabase/redux/metadata.js @@ -3,7 +3,6 @@ import { combineReducers, createThunkAction, resourceListToMap, - cleanResource, fetchData, updateData, } from "metabase/lib/redux"; @@ -60,11 +59,10 @@ export const updateMetric = createThunkAction(UPDATE_METRIC, function(metric) { const dependentRequestStatePaths = [['metadata', 'revisions', 'metric', metric.id]]; const putData = async () => { const updatedMetric = await MetricApi.update(metric); - const cleanMetric = cleanResource(updatedMetric); const existingMetrics = i.getIn(getState(), existingStatePath); const existingMetric = existingMetrics[metric.id]; - const mergedMetric = {...existingMetric, ...cleanMetric}; + const mergedMetric = {...existingMetric, ...updatedMetric}; return i.assoc(existingMetrics, mergedMetric.id, mergedMetric); }; @@ -136,11 +134,10 @@ export const updateSegment = createThunkAction(UPDATE_SEGMENT, function(segment) const dependentRequestStatePaths = [['metadata', 'revisions', 'segment', segment.id]]; const putData = async () => { const updatedSegment = await SegmentApi.update(segment); - const cleanSegment = cleanResource(updatedSegment); const existingSegments = i.getIn(getState(), existingStatePath); const existingSegment = existingSegments[segment.id]; - const mergedSegment = {...existingSegment, ...cleanSegment}; + const mergedSegment = {...existingSegment, ...updatedSegment}; return i.assoc(existingSegments, mergedSegment.id, mergedSegment); }; @@ -221,11 +218,10 @@ export const updateDatabase = createThunkAction(UPDATE_DATABASE, function(databa const slimDatabase = _.omit(database, "tables", "tables_lookup"); const updatedDatabase = await MetabaseApi.db_update(slimDatabase); - const cleanDatabase = cleanResource(updatedDatabase); const existingDatabases = i.getIn(getState(), existingStatePath); const existingDatabase = existingDatabases[database.id]; - const mergedDatabase = {...existingDatabase, ...cleanDatabase}; + const mergedDatabase = {...existingDatabase, ...updatedDatabase}; return i.assoc(existingDatabases, mergedDatabase.id, mergedDatabase); }; @@ -257,11 +253,10 @@ export const updateTable = createThunkAction(UPDATE_TABLE, function(table) { const updatedTable = await MetabaseApi.table_update(slimTable); - const cleanTable = cleanResource(updatedTable); const existingTables = i.getIn(getState(), existingStatePath); const existingTable = existingTables[table.id]; - const mergedTable = {...existingTable, ...cleanTable}; + const mergedTable = {...existingTable, ...updatedTable}; return i.assoc(existingTables, mergedTable.id, mergedTable); }; @@ -342,11 +337,10 @@ export const updateField = createThunkAction(UPDATE_FIELD, function(field) { const slimField = _.omit(field, "operators_lookup"); const fieldMetadata = await MetabaseApi.field_update(slimField); - const cleanField = cleanResource(fieldMetadata); const existingFields = i.getIn(getState(), existingStatePath); const existingField = existingFields[field.id]; - const mergedField = {...existingField, ...cleanField}; + const mergedField = {...existingField, ...fieldMetadata}; return i.assoc(existingFields, mergedField.id, mergedField); }; diff --git a/frontend/src/metabase/reference/reference.js b/frontend/src/metabase/reference/reference.js index 5adead1c49d17b44c151f990e3f6398f1896e920..179d489a6c5e128f65057d81403ebc34ab6f3dea 100644 --- a/frontend/src/metabase/reference/reference.js +++ b/frontend/src/metabase/reference/reference.js @@ -4,7 +4,6 @@ import { handleActions, createAction, createThunkAction, - cleanResource, fetchData } from 'metabase/lib/redux'; @@ -18,8 +17,7 @@ export const fetchGuide = createThunkAction(FETCH_GUIDE, (reload = false) => { const requestStatePath = ["reference", 'guide']; const existingStatePath = requestStatePath; const getData = async () => { - const guide = await GettingStartedApi.get(); - return cleanResource(guide); + return await GettingStartedApi.get(); }; return await fetchData({ diff --git a/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx b/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx index 6d26a3a2e9f9c63368f64aeee00a0d7eca3927bc..ad9d0c1817b5c136d80f3e8c094ea6b849622aaf 100644 --- a/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx +++ b/frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx @@ -22,7 +22,7 @@ const QUERY_BUILDER_STEPS = [ getModalTarget: () => qs(".GuiBuilder-data"), getModal: (props) => <div className="text-centered"> - <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/table.png" width={157} /> + <RetinaImage id="QB-TutorialTableImg" className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/table.png" width={157} /> <h3>Start by picking the table with the data that you have a question about.</h3> <p>Go ahead and select the "Orders" table from the dropdown menu.</p> </div>, @@ -44,7 +44,13 @@ const QUERY_BUILDER_STEPS = [ getModalTarget: () => qs(".GuiBuilder-filtered-by"), getModal: (props) => <div className="text-centered"> - <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/funnel.png" width={135} /> + <RetinaImage + className="mb2" + forceOriginalDimensions={false} + id="QB-TutorialFunnelImg" + src="/app/img/qb_tutorial/funnel.png" + width={135} + /> <h3>Filter your data to get just what you want.</h3> <p>Click the plus button and select the "Created At" field.</p> </div>, @@ -57,9 +63,9 @@ const QUERY_BUILDER_STEPS = [ }, { getPortalTarget: () => qs(".GuiBuilder-filtered-by"), - getPageFlagText: () => "This will let us select only orders that were created this year", - getPageFlagTarget: () => qs('[data-ui-tag="relative-date-shortcut-this-year"]'), - shouldAllowEvent: (e) => qs('[data-ui-tag="relative-date-shortcut-this-year"]').contains(e.target) + getPageFlagText: () => "Here we can pick how many days we want to see data for, try 10", + getPageFlagTarget: () => qs('[data-ui-tag="relative-date-input"]'), + shouldAllowEvent: (e) => qs('[data-ui-tag="relative-date-input"]').contains(e.target) }, { getPortalTarget: () => qs(".GuiBuilder-filtered-by"), @@ -71,7 +77,13 @@ const QUERY_BUILDER_STEPS = [ getModalTarget: () => qs(".Query-section-aggregation"), getModal: (props) => <div className="text-centered"> - <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/calculator.png" width={115} /> + <RetinaImage + className="mb2" + forceOriginalDimensions={false} + id="QB-TutorialCalculatorImg" + src="/app/img/qb_tutorial/calculator.png" + width={115} + /> <h3>Here's where you can choose to add or average your data, count the number of rows in the table, or just view the raw data.</h3> <p>Try it: click on <strong>Raw Data</strong> to change it to <strong>Count of rows</strong> so we can count how many orders there are in this table.</p> </div>, @@ -87,7 +99,13 @@ const QUERY_BUILDER_STEPS = [ getModalTarget: () => qs(".Query-section-breakout"), getModal: (props) => <div className="text-centered"> - <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/banana.png" width={232} /> + <RetinaImage + className="mb2" + forceOriginalDimensions={false} + id="QB-TutorialBananaImg" + src="/app/img/qb_tutorial/banana.png" + width={232} + /> <h3>Add a grouping to break out your results by category, day, month, and more.</h3> <p>Let's do it: click on <strong>Add a grouping</strong>, and choose <strong>Created At: by Week</strong>.</p> </div>, @@ -109,7 +127,13 @@ const QUERY_BUILDER_STEPS = [ getModalTarget: () => qs(".RunButton"), getModal: (props) => <div className="text-centered"> - <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/rocket.png" width={217} /> + <RetinaImage + className="mb2" + forceOriginalDimensions={false} + id="QB-TutorialRocketImg" + src="/app/img/qb_tutorial/rocket.png" + width={217} + /> <h3>Run Your Query.</h3> <p>You're doing so well! Click <strong>Run query</strong> to get your results!</p> </div>, @@ -120,7 +144,13 @@ const QUERY_BUILDER_STEPS = [ getModalTarget: () => qs(".VisualizationSettings"), getModal: (props) => <div className="text-centered"> - <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/chart.png" width={160} /> + <RetinaImage + className="mb2" + forceOriginalDimensions={false} + id="QB-TutorialChartImg" + src="/app/img/qb_tutorial/chart.png" + width={160} + /> <h3>You can view your results as a chart instead of a table.</h3> <p>Everbody likes charts! Click the <strong>Visualization</strong> dropdown and select <strong>Line</strong>.</p> </div>, @@ -135,7 +165,12 @@ const QUERY_BUILDER_STEPS = [ getPortalTarget: () => true, getModal: (props) => <div className="text-centered"> - <RetinaImage className="mb2" forceOriginalDimensions={false} src="/app/img/qb_tutorial/boat.png" width={190} /> + <RetinaImage + className="mb2" + forceOriginalDimensions={false} + id="QB-TutorialBoatImg" + src="/app/img/qb_tutorial/boat.png" width={190} + /> <h3>Well done!</h3> <p>That's all! If you still have questions, check out our <a className="link" target="_blank" href="http://www.metabase.com/docs/latest/users-guide/start">User's Guide</a>. Have fun exploring your data!</p> <a className="Button Button--primary" onClick={props.onNext}>Thanks!</a> diff --git a/frontend/src/metabase/visualizations/PieChart.jsx b/frontend/src/metabase/visualizations/PieChart.jsx index 6fd442cc8336a750ddad9671a740b137db25abe7..4490dc65694a03c06ddea37dc3844e9a04b79974 100644 --- a/frontend/src/metabase/visualizations/PieChart.jsx +++ b/frontend/src/metabase/visualizations/PieChart.jsx @@ -100,7 +100,7 @@ export default class PieChart extends Component { } let legendTitles = slices.map(slice => [ - slice.key === "Other" ? slice.key : formatDimension(slice.key, false), + slice.key === "Other" ? slice.key : formatDimension(slice.key, true), settings["pie.show_legend_perecent"] ? formatPercent(slice.percentage) : undefined ]); let legendColors = slices.map(slice => slice.color); diff --git a/frontend/src/metabase/visualizations/Table.jsx b/frontend/src/metabase/visualizations/Table.jsx index 5617c45087a617afbce1ff43de0dec710563d02e..fa9c2708f758be12725500f054505f4fd2ef691a 100644 --- a/frontend/src/metabase/visualizations/Table.jsx +++ b/frontend/src/metabase/visualizations/Table.jsx @@ -40,6 +40,14 @@ export default class Bar extends Component { } } + cellClicked = (rowIndex, columnIndex, ...args) => { + this.props.cellClickedFn(rowIndex, this.state.columnIndexes[columnIndex], ...args); + } + + cellIsClickable = (rowIndex, columnIndex, ...args) => { + return this.props.cellIsClickableFn(rowIndex, this.state.columnIndexes[columnIndex], ...args); + } + _updateData({ data, settings }) { if (settings["table.pivot"]) { this.setState({ @@ -47,23 +55,24 @@ export default class Bar extends Component { }); } else { const { cols, rows, columns } = data; - const colIndexes = settings["table.columns"] + const columnIndexes = settings["table.columns"] .filter(f => f.enabled) .map(f => _.findIndex(cols, (c) => c.name === f.name)) .filter(i => i >= 0 && i < cols.length); this.setState({ data: { - cols: colIndexes.map(i => cols[i]), - columns: colIndexes.map(i => columns[i]), - rows: rows.map(row => colIndexes.map(i => row[i])) + cols: columnIndexes.map(i => cols[i]), + columns: columnIndexes.map(i => columns[i]), + rows: rows.map(row => columnIndexes.map(i => row[i])) }, + columnIndexes }); } } render() { - const { card, cellClickedFn, setSortFn, isDashboard, settings } = this.props; + const { card, cellClickedFn, cellIsClickableFn, setSortFn, isDashboard, settings } = this.props; const { data } = this.state; const sort = card.dataset_query.query && card.dataset_query.query.order_by || null; const isPivoted = settings["table.pivot"]; @@ -75,7 +84,8 @@ export default class Bar extends Component { isPivoted={isPivoted} sort={sort} setSortFn={isPivoted ? undefined : setSortFn} - cellClickedFn={isPivoted ? undefined : cellClickedFn} + cellClickedFn={(!cellClickedFn || isPivoted) ? undefined : this.cellClicked} + cellIsClickableFn={(!cellIsClickableFn || isPivoted) ? undefined : this.cellIsClickable} /> ); } diff --git a/frontend/src/metabase/visualizations/TableInteractive.css b/frontend/src/metabase/visualizations/TableInteractive.css new file mode 100644 index 0000000000000000000000000000000000000000..fd07f661939a3007812ce5b59f3484223ede6b6a --- /dev/null +++ b/frontend/src/metabase/visualizations/TableInteractive.css @@ -0,0 +1,77 @@ +.PagingButtons { + border: 1px solid #ddd; +} + +.TableInteractive-headerCellData { + font-weight: 700; +} + +.TableInteractive-headerCellData:hover { + cursor: pointer; +} + +.TableInteractive-headerCellData .Icon { opacity: 0; } +.TableInteractive-headerCellData:hover .Icon, +.TableInteractive-headerCellData--sorted .Icon { + opacity: 1; + transition: opacity .3s linear; +} + +/* if the column is the one that is being sorted*/ +.TableInteractive-headerCellData--sorted { + color: var(--brand-color); +} + +/* what follows is a war crime but such is the state of FE development */ +.TableInteractive { + border: 1px solid rgb(205, 205, 205); +} +.TableInteractive-header { + border-bottom: 1px solid rgb(205, 205, 205); + border-right: 1px solid rgb(205, 205, 205); + box-sizing: border-box; +} + +.TableInteractive .TableInteractive-cellWrapper { + border-right: 1px solid #e8e8e8; + border-bottom: 1px solid #e8e8e8; + border-top: 1px solid transparent; + border-left: 1px solid transparent; + + padding: 8px; + overflow: hidden; + display: flex; + align-items: center; +} + +.TableInteractive.TableInteractive--pivot .TableInteractive-cellWrapper--firstColumn { + border-right: 1px solid rgb(205, 205, 205); +} + +.TableInteractive .TableInteractive-cellWrapper:hover { + border-color: var(--brand-color); + color: var(--brand-color); +} + +.TableInteractive .TableInteractive-header, +.TableInteractive .TableInteractive-header .TableInteractive-cellWrapper { + background-color: #fff; + background-image: none; +} + +.TableInteractive .TableInteractive-header, +.TableInteractive .TableInteractive-header .TableInteractive-cellWrapper { + background-color: #fff; +} + +.TableInteractive .TableInteractive-header .TableInteractive-cellWrapper:hover { + border-color: #e8e8e8; +} + +/* cell overflow ellipsis */ +.TableInteractive .cellData { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow-x: hidden; +} diff --git a/frontend/src/metabase/visualizations/TableInteractive.jsx b/frontend/src/metabase/visualizations/TableInteractive.jsx index 55fc0e40a0d5a88b1ff816ed5c5b0090144ec38c..285afc8fec1f63d9dddad7c145b972622fca79e4 100644 --- a/frontend/src/metabase/visualizations/TableInteractive.jsx +++ b/frontend/src/metabase/visualizations/TableInteractive.jsx @@ -1,32 +1,42 @@ import React, { Component, PropTypes } from "react"; import ReactDOM from "react-dom"; -import { Table, Column } from "fixed-data-table"; +import "./TableInteractive.css"; import Icon from "metabase/components/Icon.jsx"; + +import Value from "metabase/components/Value.jsx"; import QuickFilterPopover from "metabase/query_builder/components/QuickFilterPopover.jsx"; import MetabaseAnalytics from "metabase/lib/analytics"; -import { formatValue, capitalize } from "metabase/lib/formatting"; +import { capitalize } from "metabase/lib/formatting"; +import { getFriendlyName } from "metabase/visualizations/lib/utils"; import _ from "underscore"; import cx from "classnames"; +import ExplicitSize from "metabase/components/ExplicitSize.jsx"; +import { Grid, ScrollSync } from "react-virtualized"; +import Draggable from "react-draggable"; + +const HEADER_HEIGHT = 50; +const ROW_HEIGHT = 35; +const MIN_COLUMN_WIDTH = ROW_HEIGHT; +const RESIZE_HANDLE_WIDTH = 5; + +@ExplicitSize export default class TableInteractive extends Component { constructor(props, context) { super(props, context); this.state = { - width: 0, - height: 0, popover: null, columnWidths: [], contentWidths: null }; + this.columnHasResized = {}; - _.bindAll(this, "onClosePopover", "rowGetter", "cellRenderer", "columnResized"); - - this.isColumnResizing = false; + _.bindAll(this, "onClosePopover", "cellRenderer", "tableHeaderRenderer"); } static propTypes = { @@ -45,79 +55,135 @@ export default class TableInteractive extends Component { }; componentWillMount() { - this.componentWillReceiveProps(this.props); + // for measuring cells: + this._div = document.createElement("div"); + this._div.className = "TableInteractive"; + this._div.style.display = "inline-block" + this._div.style.position = "absolute" + this._div.style.visibility = "hidden" + this._div.style.zIndex = -1 + document.body.appendChild(this._div); + + this._measure(); } - componentWillReceiveProps(newProps) { - if (JSON.stringify(this.props.data && this.props.data.cols) !== JSON.stringify(newProps.data && newProps.data.cols)) { - this.setState({ - columnWidths: newProps.data.cols ? newProps.data.cols.map(col => 0) : [], // content cells don't wrap so this is fine - contentWidths: null - }); + componentWillUnmount() { + if (this._div) { + this._div.parentNode.removeChild(this._div); } } - componentDidMount() { - this.calculateSizing(this.state); + componentWillReceiveProps(newProps) { + if (JSON.stringify(this.props.data && this.props.data.cols) !== JSON.stringify(newProps.data && newProps.data.cols)) { + this.resetColumnWidths(); + } } shouldComponentUpdate(nextProps, nextState) { - // this is required because we don't pass in the containing element size as a property :-/ - // if size changes don't update yet because state will change in a moment - this.calculateSizing(nextState); - - // compare props (excluding card) and state to determine if we should re-render - // NOTE: this is essentially the same as React.addons.PureRenderMixin but - // we currently need to recalculate the container size here. + const PROP_KEYS = ["width", "height", "settings", "data"]; + // compare specific props and state to determine if we should re-render return ( - !_.isEqual({ ...this.props, card: null }, { ...nextProps, card: null }) || + !_.isEqual(_.pick(this.props, PROP_KEYS), _.pick(nextProps, PROP_KEYS)) || !_.isEqual(this.state, nextState) ); } componentDidUpdate() { if (!this.state.contentWidths) { - let tableElement = ReactDOM.findDOMNode(this.refs.table); - let contentWidths = []; - let rowElements = tableElement.querySelectorAll(".fixedDataTableRowLayout_rowWrapper"); - for (var rowIndex = 0; rowIndex < rowElements.length; rowIndex++) { - let cellElements = rowElements[rowIndex].querySelectorAll(".public_fixedDataTableCell_cellContent"); - for (var cellIndex = 0; cellIndex < cellElements.length; cellIndex++) { - contentWidths[cellIndex] = Math.max(contentWidths[cellIndex] || 0, cellElements[cellIndex].offsetWidth); - } - } - this.setState({ contentWidths }, () => this.calculateColumnWidths(this.props.data.cols)); + this._measure(); } } - calculateColumnWidths(cols) { + resetColumnWidths() { + this.setState({ + columnWidths: [], + contentWidths: null + }); + this.columnHasResized = {}; + this.props.onUpdateVisualizationSettings({ "table.column_widths": [] }); + } + + _measure() { + const { data: { cols } } = this.props; + + let contentWidths = cols.map((col, index) => + this._measureColumn(index) + ); + let columnWidths = cols.map((col, index) => { - if (this.state.contentWidths) { - return Math.min(this.state.contentWidths[index] + 1, 300); // + 1 to make sure it doen't wrap? + if (this.columnNeedsResize) { + if (this.columnNeedsResize[index] && !this.columnHasResized[index]) { + this.columnHasResized[index] = true; + return contentWidths[index] + 1; // + 1 to make sure it doen't wrap? + } else if (this.state.columnWidths[index]) { + return this.state.columnWidths[index]; + } } else { - return 300; + return contentWidths[index] + 1; } }); - this.setState({ columnWidths }); + + delete this.columnNeedsResize; + + this.setState({ contentWidths, columnWidths }, this.recomputeGridSize); } - calculateSizing(prevState, force) { - var element = ReactDOM.findDOMNode(this); + _measureColumn(columnIndex) { + const { data: { rows } } = this.props; + let width = MIN_COLUMN_WIDTH; - // account for padding of our parent - var style = window.getComputedStyle(element.parentElement, null); - var paddingTop = Math.ceil(parseFloat(style.getPropertyValue("padding-top"))); - var paddingLeft = Math.ceil(parseFloat(style.getPropertyValue("padding-left"))); - var paddingRight = Math.ceil(parseFloat(style.getPropertyValue("padding-right"))); + // measure column header + width = Math.max(width, this._measureCell(this.tableHeaderRenderer({ columnIndex }))); - var width = element.parentElement.offsetWidth - paddingLeft - paddingRight; - var height = element.parentElement.offsetHeight - paddingTop; + // measure up to 10 non-nil cells + let remaining = 10; + for (let rowIndex = 0; rowIndex < rows.length && remaining > 0; rowIndex++) { + if (rows[rowIndex][columnIndex] != null) { + const cellWidth = this._measureCell(this.cellRenderer({ rowIndex, columnIndex })); + width = Math.max(width, cellWidth); + remaining--; + } + } - if (width !== prevState.width || height !== prevState.height || force) { - this.setState({ width, height }); + return width; + } + + _measureCell(cell) { + ReactDOM.unstable_renderSubtreeIntoContainer(this, cell, this._div); + + // 2px for border? + const width = this._div.clientWidth + 2; + + ReactDOM.unmountComponentAtNode(this._div); + + return width; + } + + recomputeGridSize = () => { + if (this.header && this.grid) { + this.header.recomputeGridSize(); + this.grid.recomputeGridSize(); } } + recomputeColumnSizes = _.debounce(() => { + this.setState({ contentWidths: null }) + }, 100) + + onCellResize(columnIndex) { + this.columnNeedsResize = this.columnNeedsResize || {} + this.columnNeedsResize[columnIndex] = true; + this.recomputeColumnSizes(); + } + + onColumnResize(columnIndex, width) { + const { settings } = this.props; + let columnWidthsSetting = settings["table.column_widths"] ? settings["table.column_widths"].slice() : []; + columnWidthsSetting[columnIndex] = Math.max(MIN_COLUMN_WIDTH, width); + this.props.onUpdateVisualizationSettings({ "table.column_widths": columnWidthsSetting }); + setTimeout(() => this.recomputeGridSize(), 1); + } + isSortable() { return (this.props.setSortFn !== undefined); } @@ -137,21 +203,11 @@ export default class TableInteractive extends Component { this.setState({ popover: null }); } - rowGetter(rowIndex) { - var row = { - hasPopover: this.state.popover && this.state.popover.rowIndex === rowIndex || false - }; - for (var i = 0; i < this.props.data.rows[rowIndex].length; i++) { - row[i] = this.props.data.rows[rowIndex][i]; - } - return row; - } - - showPopover(rowIndex, cellDataKey) { + showPopover(rowIndex, columnIndex) { this.setState({ popover: { rowIndex: rowIndex, - cellDataKey: cellDataKey + columnIndex: columnIndex } }); } @@ -160,134 +216,153 @@ export default class TableInteractive extends Component { this.setState({ popover: null }); } - cellRenderer(cellData, cellDataKey, rowData, rowIndex, columnData, width) { - const column = this.props.data.cols[cellDataKey]; - cellData = cellData != null ? formatValue(cellData, { column: column, jsx: true }) : null; - - var key = 'cl'+rowIndex+'_'+cellDataKey; - if (this.props.cellIsClickableFn(rowIndex, cellDataKey)) { + cellRenderer({ key, style, rowIndex, columnIndex }) { + const { data: { cols, rows }} = this.props; + const column = cols[columnIndex]; + const cellData = rows[rowIndex][columnIndex]; + if (this.props.cellIsClickableFn(rowIndex, columnIndex)) { return ( - <a key={key} className="link cellData" onClick={this.cellClicked.bind(this, rowIndex, cellDataKey)}>{cellData}</a> + <div + key={key} style={style} + className={cx("TableInteractive-cellWrapper cellData", { + "TableInteractive-cellWrapper--firstColumn": columnIndex === 0 + })} + onClick={this.cellClicked.bind(this, rowIndex, columnIndex)} + > + <Value className="link" value={cellData} column={column} onResize={this.onCellResize.bind(this, columnIndex)} /> + </div> ); } else { - var popover = null; - if (this.state.popover && this.state.popover.rowIndex === rowIndex && this.state.popover.cellDataKey === cellDataKey) { - popover = ( - <QuickFilterPopover - column={this.props.data.cols[this.state.popover.cellDataKey]} - onFilter={this.popoverFilterClicked.bind(this, rowIndex, cellDataKey)} - onClose={this.onClosePopover} - /> - ); - } + const { popover } = this.state; const isFilterable = column.source !== "aggregation"; return ( - <div key={key} className={cx({ "cursor-pointer": isFilterable })} onClick={isFilterable && this.showPopover.bind(this, rowIndex, cellDataKey)}> - <span className="cellData">{cellData}</span> - {popover} + <div + key={key} style={style} + className={cx("TableInteractive-cellWrapper cellData", { + "TableInteractive-cellWrapper--firstColumn": columnIndex === 0, + "cursor-pointer": isFilterable + })} + onClick={isFilterable && this.showPopover.bind(this, rowIndex, columnIndex)} + > + <Value value={cellData} column={column} onResize={this.onCellResize.bind(this, columnIndex)} /> + { popover && popover.rowIndex === rowIndex && popover.columnIndex === columnIndex && + <QuickFilterPopover + column={cols[this.state.popover.columnIndex]} + onFilter={this.popoverFilterClicked.bind(this, rowIndex, columnIndex)} + onClose={this.onClosePopover} + /> + } </div> ); } } - columnResized(width, idx) { - var tableColumnWidths = this.state.columnWidths.slice(); - tableColumnWidths[idx] = width; - this.setState({ - columnWidths: tableColumnWidths - }); - this.isColumnResizing = false; - } - - tableHeaderRenderer(columnIndex) { - var column = this.props.data.cols[columnIndex], - colVal = (column && column.display_name && String(column.display_name)) || - (column && column.name && String(column.name)) || ""; + tableHeaderRenderer({ key, style, columnIndex }) { + const { sort, data: { cols }} = this.props; + const isSortable = this.isSortable(); + const column = cols[columnIndex]; + let columnTitle = getFriendlyName(column); if (column.unit && column.unit !== "default") { - colVal += ": " + capitalize(column.unit.replace(/-/g, " ")) + columnTitle += ": " + capitalize(column.unit.replace(/-/g, " ")) } - - if (!colVal && this.props.isPivoted && columnIndex !== 0) { - colVal = "Unset"; + if (!columnTitle && this.props.isPivoted && columnIndex !== 0) { + columnTitle = "Unset"; } - var headerClasses = cx('MB-DataTable-header cellData align-center', { - 'MB-DataTable-header--sorted': (this.props.sort && (this.props.sort[0][0] === column.id)), - }); - - // set the initial state of the sorting indicator chevron - var sortChevron = (<Icon name="chevrondown" size={8}></Icon>); - - if(this.props.sort && this.props.sort[0][1] === 'ascending') { - sortChevron = (<Icon name="chevronup" size={8}></Icon>); - } - - if (this.isSortable()) { - return ( - <div key={columnIndex} className={headerClasses} onClick={this.setSort.bind(this, column)}> - <span> - {colVal} - </span> - <span className="ml1"> - {sortChevron} - </span> + return ( + <div + key={key} + style={{ ...style, overflow: "visible" /* ensure resize handle is visible */ }} + className={cx("TableInteractive-cellWrapper TableInteractive-headerCellData", { + "TableInteractive-cellWrapper--firstColumn": columnIndex === 0, + "TableInteractive-headerCellData--sorted": (sort && sort[0] && sort[0][0] === column.id), + })} + > + <div + className={cx("cellData", { "cursor-pointer": isSortable })} + onClick={isSortable && this.setSort.bind(this, column)} + > + {columnTitle} + {isSortable && + <Icon + className="Icon ml1" + name={sort && sort[0] && sort[0][1] === "ascending" ? "chevronup" : "chevrondown"} + size={8} + /> + } </div> - ); - } else { - return ( - <span className={headerClasses}> - {colVal} - </span> - ); - } + <Draggable + axis="x" + bounds={{ left: RESIZE_HANDLE_WIDTH }} + position={{ x: this.getColumnWidth({ index: columnIndex }), y: 0 }} + onStop={(e, { x }) => { + this.onColumnResize(columnIndex, x)} + } + > + <div + className="bg-brand-hover bg-brand-active" + style={{ zIndex: 99, position: "absolute", width: RESIZE_HANDLE_WIDTH, top: 0, bottom: 0, left: -RESIZE_HANDLE_WIDTH - 1, cursor: "ew-resize" }} + /> + </Draggable> + </div> + ) } - render() { - if(!this.props.data) { - return false; - } - - var tableColumns = this.props.data.cols.map((column, idx) => { - var colVal = (column && column.display_name && String(column.display_name)) || - (column && column.name && String(column.name)) || ""; - var colWidth = this.state.columnWidths[idx]; + getColumnWidth = ({ index }) => { + const { settings } = this.props; + const { columnWidths } = this.state; + const columnWidthsSetting = settings["table.column_widths"] || []; + return columnWidthsSetting[index] || columnWidths[index] || MIN_COLUMN_WIDTH; + } - if (!colWidth) { - colWidth = 75; - } + render() { + const { width, height, data: { cols, rows }, className } = this.props; - return ( - <Column - key={'col_' + idx} - className="MB-DataTable-column" - width={colWidth} - isResizable={true} - headerRenderer={this.tableHeaderRenderer.bind(this, idx)} - cellRenderer={this.cellRenderer} - dataKey={idx} - label={colVal}> - </Column> - ); - }); + if (!width || !height) { + return <div className={className} />; + } return ( - <span className={cx('MB-DataTable', { 'MB-DataTable--pivot': this.props.isPivoted, 'MB-DataTable--ready': this.state.contentWidths })}> - <Table - ref="table" - rowHeight={35} - rowGetter={this.rowGetter} - rowsCount={this.props.data.rows.length} - width={this.state.width} - height={this.state.height} - headerHeight={50} - isColumnResizing={this.isColumnResizing} - onColumnResizeEndCallback={this.columnResized} - allowCellsRecycling={true} - > - {tableColumns} - </Table> - </span> + <ScrollSync> + {({ clientHeight, clientWidth, onScroll, scrollHeight, scrollLeft, scrollTop, scrollWidth }) => + <div className={cx(className, 'TableInteractive relative', { 'TableInteractive--pivot': this.props.isPivoted, 'TableInteractive--ready': this.state.contentWidths })}> + <canvas className="spread" style={{ pointerEvents: "none", zIndex: 999 }} width={width} height={height} /> + <Grid + ref={(ref) => this.header = ref} + style={{ top: 0, left: 0, right: 0, height: HEADER_HEIGHT, position: "absolute", overflow: "hidden" }} + className="TableInteractive-header scroll-hide-all" + width={width || 0} + height={HEADER_HEIGHT} + rowCount={1} + rowHeight={HEADER_HEIGHT} + // HACK: there might be a better way to do this, but add a phantom padding cell at the end to ensure scroll stays synced if main content scrollbars are visible + columnCount={cols.length + 1} + columnWidth={(props) => props.index < cols.length ? this.getColumnWidth(props) : 50} + cellRenderer={(props) => props.columnIndex < cols.length ? this.tableHeaderRenderer(props) : null} + onScroll={({ scrollLeft }) => onScroll({ scrollLeft })} + scrollLeft={scrollLeft} + tabIndex={null} + /> + <Grid + ref={(ref) => this.grid = ref} + style={{ top: HEADER_HEIGHT, left: 0, right: 0, bottom: 0, position: "absolute" }} + className="" + width={width} + height={height - HEADER_HEIGHT} + columnCount={cols.length} + columnWidth={this.getColumnWidth} + rowCount={rows.length} + rowHeight={ROW_HEIGHT} + cellRenderer={this.cellRenderer} + onScroll={({ scrollLeft }) => onScroll({ scrollLeft })} + scrollLeft={scrollLeft} + tabIndex={null} + overscanRowCount={20} + /> + </div> + } + </ScrollSync> ); } } diff --git a/frontend/src/metabase/visualizations/TableSimple.jsx b/frontend/src/metabase/visualizations/TableSimple.jsx index d661336cab2cdc16431933d59cb71db65966bc31..cb26c420fb679d52b905222d47f4ae814d48331c 100644 --- a/frontend/src/metabase/visualizations/TableSimple.jsx +++ b/frontend/src/metabase/visualizations/TableSimple.jsx @@ -75,7 +75,7 @@ export default class TableSimple extends Component { <thead ref="header"> <tr> {cols.map((col, colIndex) => - <th key={colIndex} className={cx("MB-DataTable-header cellData text-brand-hover", { "MB-DataTable-header--sorted": sortColumn === colIndex })} onClick={() => this.setSort(colIndex)}> + <th key={colIndex} className={cx("TableInteractive-headerCellData cellData text-brand-hover", { "TableInteractive-headerCellData--sorted": sortColumn === colIndex })} onClick={() => this.setSort(colIndex)}> <div className="relative"> <Icon name={sortDescending ? "chevrondown" : "chevronup"} diff --git a/frontend/src/metabase/visualizations/components/ChartTooltip.jsx b/frontend/src/metabase/visualizations/components/ChartTooltip.jsx index 09d9aac7eb1af4626a128d6c962038fe66181990..139d7b1af60213105164d7500b4412a4ba07f8fb 100644 --- a/frontend/src/metabase/visualizations/components/ChartTooltip.jsx +++ b/frontend/src/metabase/visualizations/components/ChartTooltip.jsx @@ -1,8 +1,8 @@ import React, { Component, PropTypes } from "react"; import TooltipPopover from "metabase/components/TooltipPopover.jsx" +import Value from "metabase/components/Value.jsx"; -import { formatValue } from "metabase/lib/formatting"; import { getFriendlyName } from "metabase/visualizations/lib/utils"; export default class ChartTooltip extends Component { @@ -40,23 +40,21 @@ export default class ChartTooltip extends Component { <tbody> { Array.isArray(hovered.data) ? hovered.data.map(({ key, value, col }, index) => - <tr key={index}> - <td className="text-light text-right">{key}:</td> - <td className="pl1 text-bold text-left"> - { col ? - formatValue(value, { column: col, jsx: true, majorWidth: 0 }) - : - value - } - </td> - </tr> + <TooltipRow + key={index} + name={key} + value={value} + column={col} + /> ) : [["key", 0], ["value", 1]].map(([propName, colIndex]) => - <tr key={propName} className=""> - <td className="text-light text-right">{getFriendlyName(s.data.cols[colIndex])}:</td> - <td className="pl1 text-bold text-left">{formatValue(hovered.data[propName], { column: s.data.cols[colIndex], jsx: true, majorWidth: 0 })}</td> - </tr> + <TooltipRow + key={propName} + name={getFriendlyName(s.data.cols[colIndex])} + value={hovered.data[propName]} + column={s.data.cols[colIndex]} + /> ) } </tbody> @@ -65,3 +63,15 @@ export default class ChartTooltip extends Component { ); } } + +const TooltipRow = ({ name, value, column }) => + <tr> + <td className="text-light text-right">{name}:</td> + <td className="pl1 text-bold text-left"> + { React.isValidElement(value) ? + value + : + <Value value={value} column={column} majorWidth={0} /> + } + </td> + </tr> diff --git a/frontend/test/e2e/admin/datamodel.spec.js b/frontend/test/e2e/admin/datamodel.spec.js index e8215354b6290074146de737ae43b95d2bb99f7b..5bb160930a65796a32b19fc6f2014d58afdae1c9 100644 --- a/frontend/test/e2e/admin/datamodel.spec.js +++ b/frontend/test/e2e/admin/datamodel.spec.js @@ -61,7 +61,7 @@ describeE2E("admin/datamodel", () => { await waitForElementAndClick(driver, ".GuiBuilder-filtered-by a"); await waitForElementAndClick(driver, "#FilterPopover .List-item:nth-child(4)>a"); const addFilterButton = findElement(driver, "#FilterPopover .Button.disabled"); - await waitForElementAndClick(driver, "#OperatorSelector .Button.Button-normal.Button--medium:nth-child(3)"); + await waitForElementAndClick(driver, "#OperatorSelector .Button.Button-normal.Button--medium:nth-child(2)"); await waitForElementAndSendKeys(driver, "#FilterPopover input.border-purple", 'gmail'); expect(await addFilterButton.isEnabled()).toBe(true); await addFilterButton.click(); diff --git a/frontend/test/e2e/query_builder/tutorial.spec.js b/frontend/test/e2e/query_builder/tutorial.spec.js index d1a4b6183421ecf2ddd1aa8eb874bb79a85be8d0..e21619af931f45d28154811fc7de2b23b11ed12e 100644 --- a/frontend/test/e2e/query_builder/tutorial.spec.js +++ b/frontend/test/e2e/query_builder/tutorial.spec.js @@ -34,7 +34,7 @@ describeE2E("tutorial", () => { await screenshot(driver, "screenshots/setup-tutorial-qb.png"); await waitForElementAndClick(driver, ".Modal .Button.Button--primary"); - await waitForElement(driver, "img[src='/app/img/qb_tutorial/table.png']"); + await waitForElement(driver, "#QB-TutorialTableImg"); // a .Modal-backdrop element blocks clicks for a while during transition? await waitForElementRemoved(driver, '.Modal-backdrop'); await waitForElementAndClick(driver, ".GuiBuilder-data a"); @@ -50,32 +50,33 @@ describeE2E("tutorial", () => { await waitForElementAndClick(driver, "#TablePicker .List-item:first-child>a"); // select filters - await waitForElement(driver, "img[src='/app/img/qb_tutorial/funnel.png']"); + await waitForElement(driver, "#QB-TutorialFunnelImg"); await waitForElementAndClick(driver, ".GuiBuilder-filtered-by .Query-section:not(.disabled) a"); await waitForElementAndClick(driver, "#FilterPopover .List-item:first-child>a"); - await waitForElementAndClick(driver, ".Button[data-ui-tag='relative-date-shortcut-this-year']"); + await waitForElementAndClick(driver, "input[data-ui-tag='relative-date-input']"); + await waitForElementAndSendKeys(driver, "#FilterPopover input.border-purple", '10'); await waitForElementAndClick(driver, ".Button[data-ui-tag='add-filter']:not(.disabled)"); // select aggregations - await waitForElement(driver, "img[src='/app/img/qb_tutorial/calculator.png']"); + await waitForElement(driver, "#QB-TutorialCalculatorImg"); await waitForElementAndClick(driver, "#Query-section-aggregation"); await waitForElementAndClick(driver, "#AggregationPopover .List-item:nth-child(2)>a"); // select breakouts - await waitForElement(driver, "img[src='/app/img/qb_tutorial/banana.png']"); + await waitForElement(driver, "#QB-TutorialBananaImg"); await waitForElementAndClick(driver, ".Query-section.Query-section-breakout>div"); await waitForElementAndClick(driver, "#BreakoutPopover .List-item:first-child .Field-extra>a"); - await waitForElementAndClick(driver, "#TimeGroupingPopover .List-item:nth-child(3)>a"); + await waitForElementAndClick(driver, "#TimeGroupingPopover .List-item:nth-child(4)>a"); // run query - await waitForElement(driver, "img[src='/app/img/qb_tutorial/rocket.png']"); + await waitForElement(driver, "#QB-TutorialRocketImg"); await waitForElementAndClick(driver, ".Button.RunButton"); // wait for query to complete - await waitForElement(driver, "img[src='/app/img/qb_tutorial/chart.png']", 20000); + await waitForElement(driver, "#QB-TutorialChartImg", 20000); // switch visualization await waitForElementAndClick(driver, "#VisualizationTrigger"); @@ -84,7 +85,7 @@ describeE2E("tutorial", () => { await waitForElementAndClick(driver, "#VisualizationPopover li:nth-child(4)"); // end tutorial - await waitForElement(driver, "img[src='/app/img/qb_tutorial/boat.png']"); + await waitForElement(driver, "#QB-TutorialBoatImg"); await waitForElementAndClick(driver, ".Modal .Button.Button--primary"); await waitForElementAndClick(driver, ".PopoverBody .Button.Button--primary"); diff --git a/frontend/test/unit/lib/utils.spec.js b/frontend/test/unit/lib/utils.spec.js index 74dd116c3970a82c9ceb20d6b94959090e54230d..c9384a0307d4a7d979d59d93840483def23a4918 100644 --- a/frontend/test/unit/lib/utils.spec.js +++ b/frontend/test/unit/lib/utils.spec.js @@ -53,7 +53,7 @@ describe('utils', () => { }); describe("compareVersions", () => { - fit ("should compare versions correctly", () => { + it ("should compare versions correctly", () => { let expected = [ "0.0.9", "0.0.10-snapshot", diff --git a/frontend/test/unit/query_builder/components/FieldName.spec.js b/frontend/test/unit/query_builder/components/FieldName.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0e0c8c062d5168603a27e3339073ec5d724571ef --- /dev/null +++ b/frontend/test/unit/query_builder/components/FieldName.spec.js @@ -0,0 +1,45 @@ + +import React from "react"; +import ReactDOM from "react-dom"; +import { renderIntoDocument } from "react-addons-test-utils"; + +import FieldName from "metabase/query_builder/components/FieldName.jsx"; + +const TABLE_A = { + fields: [ + { id: 1, display_name: "Foo" }, + { id: 2, display_name: "Baz", parent_id: 1 } + ] +} +const TABLE_B = { + fields: [ + { id: 3, display_name: "Bar", target: { table: TABLE_A } } + ] +} + +describe("FieldName", () => { + it("should render regular field correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={1} tableMetadata={TABLE_A}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("Foo"); + }); + it("should render local field correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={["field-id", 1]} tableMetadata={TABLE_A}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("Foo"); + }); + it("should render foreign key correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={["fk->", 3, 1]} tableMetadata={TABLE_B}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("BarFoo"); + }); + it("should render datetime correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={["datetime-field", 1, "week"]} tableMetadata={TABLE_A}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("Foo: Week"); + }); + it("should render nested field correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={2} tableMetadata={TABLE_A}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("Foo: Baz"); + }); + it("should render nested fk field correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={["fk->", 3, 2]} tableMetadata={TABLE_B}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("BarFoo: Baz"); + }); +}); diff --git a/package.json b/package.json index 7bb4291740a3bdeba0858195ecbc8b251ef45a52..c549711f9ddec6fc1e38496e0b1fc3deccce1083 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "d3": "^3.5.17", "dc": "^2.0.0-beta.32", "diff": "^2.2.1", - "fixed-data-table": "^0.6.0", "history": "^3.0.0", "humanize-plus": "^1.8.1", "icepick": "^1.1.0", @@ -37,14 +36,14 @@ "react-addons-shallow-compare": "^15.2.1", "react-ansi-style": "^1.0.0", "react-dom": "^15.2.1", - "react-draggable": "^1.1.3", + "react-draggable": "^2.2.3", "react-redux": "^4.4.5", "react-resizable": "^1.0.1", "react-retina-image": "^2.0.0", "react-router": "^2.6.0", "react-router-redux": "^4.0.5", "react-sortable": "^1.0.1", - "react-virtualized": "^8.0.12", + "react-virtualized": "^8.6.0", "recompose": "^0.20.2", "redux": "^3.5.2", "redux-actions": "^0.9.1", @@ -123,7 +122,7 @@ "webpack-postcss-tools": "^1.1.1" }, "scripts": { - "dev" : "yarn && concurrently --kill-others -p name -n 'backend,frontend' -c 'blue,green' 'lein ring server' 'yarn run build-hot'", + "dev": "yarn && concurrently --kill-others -p name -n 'backend,frontend' -c 'blue,green' 'lein ring server' 'yarn run build-hot'", "lint": "eslint --ext .js --ext .jsx --max-warnings 0 frontend/src", "flow": "flow check", "test": "karma start frontend/test/karma.conf.js --single-run", diff --git a/project.clj b/project.clj index a3aeb88bfab40ee54b4123b497e42118215a471b..7fc9ee5432f44fb082dd77c8d613ef311daa2fb6 100644 --- a/project.clj +++ b/project.clj @@ -44,7 +44,7 @@ "v3-rev135-1.22.0"] [com.google.apis/google-api-services-bigquery ; Google BigQuery Java Client Library "v2-rev324-1.22.0"] - [com.h2database/h2 "1.4.192"] ; embedded SQL database + [com.h2database/h2 "1.4.193"] ; embedded SQL database [com.mattbertolini/liquibase-slf4j "2.0.0"] ; Java Migrations lib [com.mchange/c3p0 "0.9.5.2"] ; connection pooling library [com.novemberain/monger "3.1.0"] ; MongoDB Driver diff --git a/src/metabase/api/permissions.clj b/src/metabase/api/permissions.clj index 5a1b928fe2addb01ebc633c72fabbc88dd0b6f7f..091a91e323a4d67be4cb4a8ac273fb39be21e0ba 100644 --- a/src/metabase/api/permissions.clj +++ b/src/metabase/api/permissions.clj @@ -77,20 +77,34 @@ ;;; | PERMISSIONS GROUP ENDPOINTS | ;;; +------------------------------------------------------------------------------------------------------------------------------------------------------+ +(defn- group-id->num-members + "Return a map of `PermissionsGroup` ID -> number of members in the group. + (This doesn't include entries for empty groups.)" + [] + (into {} (for [{:keys [group_id members]} (db/query {:select [[:pgm.group_id :group_id] [:%count.pgm.id :members]] + :from [[:permissions_group_membership :pgm]] + :left-join [[:core_user :user] [:= :pgm.user_id :user.id]] + :where [:= :user.is_active true] + :group-by [:pgm.group_id]})] + {group_id members}))) + +(defn- ordered-groups + "Return a sequence of ordered `PermissionsGroups`, including the `MetaBot` group only if MetaBot is enabled." + [] + (db/select PermissionsGroup + {:where (if (metabot/metabot-enabled) + true + [:not= :id (u/get-id (group/metabot))]) + :order-by [:%lower.name]})) + (defendpoint GET "/group" - "Fetch all `PermissionsGroups`." + "Fetch all `PermissionsGroups`, including a count of the number of `:members` in that group." [] (check-superuser) - (db/query {:select [:pg.id :pg.name [:%count.pgm.id :members]] - :from [[:permissions_group :pg]] - :left-join [[:permissions_group_membership :pgm] [:= :pg.id :pgm.group_id] - [:core_user :user] [:= :pgm.user_id :user.id]] - :where [:and [:= :user.is_active true] - (if (metabot/metabot-enabled) - true - [:not= :pg.id (:id (group/metabot))])] - :group-by [:pg.id :pg.name] - :order-by [:%lower.pg.name]})) + (let [group-id->members (group-id->num-members)] + (for [group (ordered-groups)] + (assoc group :members (or (group-id->members (u/get-id group)) + 0))))) (defendpoint GET "/group/:id" "Fetch the details for a certain permissions group." diff --git a/src/metabase/driver/generic_sql/query_processor.clj b/src/metabase/driver/generic_sql/query_processor.clj index f8ad39baa28ed657c8cb83e19195cef834d24182..98df73108f4d91546485229d74a43c1ed72f4522 100644 --- a/src/metabase/driver/generic_sql/query_processor.clj +++ b/src/metabase/driver/generic_sql/query_processor.clj @@ -18,6 +18,7 @@ [metabase.util.honeysql-extensions :as hx]) (:import java.sql.Timestamp java.util.Date + clojure.lang.Keyword (metabase.query_processor.interface AgFieldRef DateTimeField DateTimeValue @@ -65,7 +66,8 @@ nil (formatted [_] nil) Number (formatted [this] this) String (formatted [this] this) - honeysql.types.SqlCall (formatted [this] this) + Keyword (formatted [this] this) ; HoneySQL fn calls and keywords (e.g. `:%count.*`) are + honeysql.types.SqlCall (formatted [this] this) ; already converted to HoneySQL so just return them as-is Expression (formatted [{:keys [operator args]}] diff --git a/src/metabase/driver/mongo.clj b/src/metabase/driver/mongo.clj index addce073530d27f4a23e4bb9f0291e937a87fe2d..6ebee0cef2d8340681fd80d1139fc3bc96dcadaf 100644 --- a/src/metabase/driver/mongo.clj +++ b/src/metabase/driver/mongo.clj @@ -192,6 +192,9 @@ :display-name "Database password" :type :password :placeholder "******"} + {:name "authdb" + :display-name "Authentication Database" + :placeholder "Optional database to use when authenticating"} {:name "ssl" :display-name "Use a secure connection (SSL)?" :type :boolean diff --git a/src/metabase/driver/mongo/util.clj b/src/metabase/driver/mongo/util.clj index b4dfcb5b5836c853c792124f7cf3bb303e8763d9..52e93eb088a8ac62476bae4d5fe998e8b6a0ea44 100644 --- a/src/metabase/driver/mongo/util.clj +++ b/src/metabase/driver/mongo/util.clj @@ -43,7 +43,7 @@ "Run F with a new connection (bound to `*mongo-connection*`) to DATABASE. Don't use this directly; use `with-mongo-connection`." [f database] - (let [{:keys [dbname host port user pass ssl] + (let [{:keys [dbname host port user pass ssl authdb] :or {port 27017, pass "", ssl false}} (cond (string? database) {:dbname database} (:dbname (:details database)) (:details database) ; entire Database obj @@ -53,9 +53,12 @@ user) pass (when (seq pass) pass) + authdb (if (seq authdb) + authdb + dbname) server-address (mg/server-address host port) credentials (when user - (mcred/create user dbname pass)) + (mcred/create user authdb pass)) connect (partial mg/connect server-address (build-connection-options :ssl? ssl)) conn (if credentials (connect credentials) diff --git a/src/metabase/models/field.clj b/src/metabase/models/field.clj index 1a4e27f507f74017fb6967f12379d644f3c99d93..4d3830a35b81d193715e7d9e60ae26dd38aa09de 100644 --- a/src/metabase/models/field.clj +++ b/src/metabase/models/field.clj @@ -106,7 +106,7 @@ (:fk_target_field_id field))] (:fk_target_field_id field))) id->target-field (u/key-by :id (when (seq target-field-ids) - (db/select Field :id [:in target-field-ids])))] + (filter i/can-read? (db/select Field :id [:in target-field-ids]))))] (for [field fields :let [target-id (:fk_target_field_id field)]] (assoc field :target (id->target-field target-id))))) diff --git a/src/metabase/models/permissions.clj b/src/metabase/models/permissions.clj index b30d433f3b2432a3349dc4c7244b72a7358bc392..e39c160e2197a79ecfb7457bb18d0377818306d7 100644 --- a/src/metabase/models/permissions.clj +++ b/src/metabase/models/permissions.clj @@ -311,7 +311,7 @@ (db/cascade-delete! Permissions where)))) (defn revoke-permissions! - "Revoke permissions for GROUP-OR-ID to object with PATH-COMPONENTS." + "Revoke all permissions for GROUP-OR-ID to object with PATH-COMPONENTS, *including* related permissions." [group-or-id & path-components] (delete-related-permissions! group-or-id (apply object-path path-components))) @@ -345,7 +345,7 @@ (grant-permissions! group-or-id (native-readwrite-path database-id))) (defn revoke-db-schema-permissions! - "Remove all permissions entires for a DB and any child objects. + "Remove all permissions entires for a DB and *any* child objects. This does *not* revoke native permissions; use `revoke-native-permssions!` to do that." [group-or-id database-id] ;; TODO - if permissions for this DB are DB root entries like `/db/1/` won't this end up removing our native perms? @@ -375,10 +375,10 @@ :none (revoke-permissions! group-id db-id schema table-id))) (s/defn ^:private ^:always-validate update-schema-perms! [group-id :- s/Int, db-id :- s/Int, schema :- s/Str, new-schema-perms :- SchemaPermissionsGraph] - (revoke-permissions! group-id db-id schema) (cond - (= new-schema-perms :all) (grant-permissions! group-id db-id schema) - (= new-schema-perms :none) nil + (= new-schema-perms :all) (do (revoke-permissions! group-id db-id schema) ; clear out any existing related permissions + (grant-permissions! group-id db-id schema)) ; then grant full perms for the schema + (= new-schema-perms :none) (revoke-permissions! group-id db-id schema) (map? new-schema-perms) (doseq [[table-id table-perms] new-schema-perms] (update-table-perms! group-id db-id schema table-id table-perms)))) @@ -400,10 +400,10 @@ (when-let [new-native-perms (:native new-db-perms)] (update-native-permissions! group-id db-id new-native-perms)) (when-let [schemas (:schemas new-db-perms)] - (revoke-db-schema-permissions! group-id db-id) (cond - (= schemas :all) (grant-permissions-for-all-schemas! group-id db-id) - (= schemas :none) nil + (= schemas :all) (do (revoke-db-schema-permissions! group-id db-id) + (grant-permissions-for-all-schemas! group-id db-id)) + (= schemas :none) (revoke-db-schema-permissions! group-id db-id) (map? schemas) (doseq [schema (keys schemas)] (update-schema-perms! group-id db-id schema (get-in new-db-perms [:schemas schema])))))) @@ -444,13 +444,13 @@ [old new] (data/diff (:groups old-graph) (:groups new-graph))] (when (or (seq old) (seq new)) (log/debug (format "Changing permissions: ðŸ”\nFROM:\n%s\nTO:\n%s" - (u/pprint-to-str 'magenta old) - (u/pprint-to-str 'blue new))) + (u/pprint-to-str 'magenta old) + (u/pprint-to-str 'blue new))) (check-revision-numbers old-graph new-graph) (db/transaction - (doseq [group-id (keys new)] - (update-group-permissions! group-id (get new group-id))) - (save-perms-revision! (:revision old-graph) old new))))) + (doseq [group-id (keys new)] + (update-group-permissions! group-id (get new group-id))) + (save-perms-revision! (:revision old-graph) old new))))) ;; The following arity is provided soley for convenience for tests/REPL usage ([ks new-value] {:pre [(sequential? ks)]} diff --git a/src/metabase/query_processor/expand.clj b/src/metabase/query_processor/expand.clj index 2ad8ad88d633ad97846c05f86e9e34454432da32..11dcaf31528bb4434baa8e33b7db953014fe8b45 100644 --- a/src/metabase/query_processor/expand.clj +++ b/src/metabase/query_processor/expand.clj @@ -51,7 +51,7 @@ [id :- su/IntGreaterThanZero] (i/map->FieldPlaceholder {:field-id id})) -(s/defn ^:private ^:always-validate field :- i/AnyField +(s/defn ^:private ^:always-validate field :- i/AnyFieldOrExpression "Generic reference to a `Field`. F can be an integer Field ID, or various other forms like `fk->` or `aggregation`." [f] (if (integer? f) @@ -116,7 +116,13 @@ (defn- field-or-expression [f] (if (instance? Expression f) - (update f :args (partial map field-or-expression)) ; recursively call field-or-expression on all the args of the expression + ;; recursively call field-or-expression on all the args inside the expression unless they're numbers + ;; plain numbers are always assumed to be numeric literals here; you must use MBQL '98 `:field-id` syntax to refer to Fields inside an expression <3 + (update f :args #(for [arg %] + (if (number? arg) + arg + (field-or-expression arg)))) + ;; otherwise if it's not an Expression it's a a (field f))) (s/defn ^:private ^:always-validate ag-with-field :- i/Aggregation [ag-type f] diff --git a/src/metabase/query_processor/interface.clj b/src/metabase/query_processor/interface.clj index e4d6010e22b282656f150548dbccc44dcd9900f7..0e95c8e4e8fb027ea8c0ab33ee01f03ee896a048 100644 --- a/src/metabase/query_processor/interface.clj +++ b/src/metabase/query_processor/interface.clj @@ -185,10 +185,10 @@ args :- [(s/cond-pre (s/recursive #'RValue) (s/recursive #'Aggregation))]]) -(def AnyField +(def AnyFieldOrExpression "Schema for a `FieldPlaceholder`, `AgRef`, or `Expression`." (s/named (s/cond-pre ExpressionRef Expression FieldPlaceholderOrAgRef) - "Valid field, ag field reference, or expression reference.")) + "Valid field, ag field reference, expression, or expression reference.")) (def LiteralDatetimeString @@ -302,7 +302,7 @@ (def OrderBy "Schema for top-level `order-by` clause in an MBQL query." - (s/named {:field AnyField + (s/named {:field AnyFieldOrExpression :direction OrderByDirection} "Valid order-by subclause")) @@ -317,7 +317,7 @@ "Schema for an MBQL query." {(s/optional-key :aggregation) [Aggregation] (s/optional-key :breakout) [FieldPlaceholderOrExpressionRef] - (s/optional-key :fields) [AnyField] + (s/optional-key :fields) [AnyFieldOrExpression] (s/optional-key :filter) Filter (s/optional-key :limit) su/IntGreaterThanZero (s/optional-key :order-by) [OrderBy] diff --git a/src/metabase/query_processor/parameters.clj b/src/metabase/query_processor/parameters.clj index 396b70f779ef7e912cf68cc5a39abbb71fe51fab..af2793c722d002bb37962d3773ac7966516f9e64 100644 --- a/src/metabase/query_processor/parameters.clj +++ b/src/metabase/query_processor/parameters.clj @@ -46,7 +46,7 @@ :start (t/first-day-of-the-month dt)}) (defn- year-range [^DateTime dt] - {:end (t/last-day-of-the-month (.withMonthOfYear dt DateTimeConstants/DECEMBER)) + {:end (t/last-day-of-the-month (.withMonthOfYear dt DateTimeConstants/DECEMBER)) :start (t/first-day-of-the-month (.withMonthOfYear dt DateTimeConstants/JANUARY))}) (defn- absolute-date->range diff --git a/test/metabase/api/permissions_test.clj b/test/metabase/api/permissions_test.clj index 989a16b1f2812c3085d97ed4926c8c893b997682..e0a242cc6e7e05b91959c93c316089b3d791b77e 100644 --- a/test/metabase/api/permissions_test.clj +++ b/test/metabase/api/permissions_test.clj @@ -1,28 +1,39 @@ (ns metabase.api.permissions-test - "Tests for `api/permissions` endpoints." + "Tests for `/api/permissions` endpoints." (:require [expectations :refer :all] - [metabase.test.data.users :as tu] - [metabase.models.permissions-group :as group] + [metabase.models.permissions-group :refer [PermissionsGroup], :as group] + [metabase.test.data.users :as test-users] + [metabase.test.util :as tu] [metabase.util :as u])) -;; GET /group +;; GET /permissions/group ;; Should *not* include inactive users in the counts. ;; It should also *not* include the MetaBot group because MetaBot should *not* be enabled +(defn- fetch-groups [] + (test-users/delete-temp-users!) + (set ((test-users/user->client :crowberto) :get 200 "permissions/group"))) + (expect #{{:id (u/get-id (group/all-users)), :name "All Users", :members 3} {:id (u/get-id (group/admin)), :name "Administrators", :members 1}} - (do - (tu/delete-temp-users!) - (set ((tu/user->client :crowberto) :get 200 "permissions/group")))) + (fetch-groups)) + +;; The endpoint should however return empty groups! +(tu/expect-with-temp [PermissionsGroup [group]] + #{{:id (u/get-id (group/all-users)), :name "All Users", :members 3} + {:id (u/get-id (group/admin)), :name "Administrators", :members 1} + (assoc (into {} group) :members 0)} + (fetch-groups)) + -;; GET /group/:id +;; GET /permissions/group/:id ;; Should *not* include inactive users (expect - #{{:first_name "Crowberto", :last_name "Corv", :email "crowberto@metabase.com", :user_id (tu/user->id :crowberto), :membership_id true} - {:first_name "Lucky", :last_name "Pigeon", :email "lucky@metabase.com", :user_id (tu/user->id :lucky), :membership_id true} - {:first_name "Rasta", :last_name "Toucan", :email "rasta@metabase.com", :user_id (tu/user->id :rasta), :membership_id true}} + #{{:first_name "Crowberto", :last_name "Corv", :email "crowberto@metabase.com", :user_id (test-users/user->id :crowberto), :membership_id true} + {:first_name "Lucky", :last_name "Pigeon", :email "lucky@metabase.com", :user_id (test-users/user->id :lucky), :membership_id true} + {:first_name "Rasta", :last_name "Toucan", :email "rasta@metabase.com", :user_id (test-users/user->id :rasta), :membership_id true}} (do - (tu/delete-temp-users!) - (set (for [member (:members ((tu/user->client :crowberto) :get 200 (str "permissions/group/" (u/get-id (group/all-users)))))] + (test-users/delete-temp-users!) + (set (for [member (:members ((test-users/user->client :crowberto) :get 200 (str "permissions/group/" (u/get-id (group/all-users)))))] (update member :membership_id (complement nil?)))))) diff --git a/test/metabase/api/table_test.clj b/test/metabase/api/table_test.clj index 22d5efbaf65fbcf4e86dc6d362fa8fe8f2afb6c6..9665142f2149ccc33fc3c60a5f3140ea8654797e 100644 --- a/test/metabase/api/table_test.clj +++ b/test/metabase/api/table_test.clj @@ -326,6 +326,25 @@ :created_at $})) ((user->client :rasta) :get 200 (format "table/%d/query_metadata" (id :users)))) +;; Check that FK fields belonging to Tables we don't have permissions for don't come back as hydrated `:target`(#3867) +(expect + #{{:name "id", :target false} + {:name "fk", :target false}} + ;; create a temp DB with two tables; table-2 has an FK to table-1 + (tu/with-temp* [Database [db] + Table [table-1 {:db_id (u/get-id db)}] + Table [table-2 {:db_id (u/get-id db)}] + Field [table-1-id {:table_id (u/get-id table-1), :name "id", :base_type :type/Integer, :special_type :type/PK}] + Field [table-2-id {:table_id (u/get-id table-2), :name "id", :base_type :type/Integer, :special_type :type/PK}] + Field [table-2-fk {:table_id (u/get-id table-2), :name "fk", :base_type :type/Integer, :special_type :type/FK, :fk_target_field_id (u/get-id table-1-id)}]] + ;; grant permissions only to table-2 + (perms/revoke-permissions! (perms-group/all-users) (u/get-id db)) + (perms/grant-permissions! (perms-group/all-users) (u/get-id db) (:schema table-2) (u/get-id table-2)) + ;; metadata for table-2 should show all fields for table-2, but the FK target info shouldn't be hydrated + (set (for [field (:fields ((user->client :rasta) :get 200 (format "table/%d/query_metadata" (u/get-id table-2))))] + (-> (select-keys field [:name :target]) + (update :target boolean)))))) + ;; ## PUT /api/table/:id (tu/expect-with-temp [Table [table {:rows 15}]] diff --git a/test/metabase/driver/druid_test.clj b/test/metabase/driver/druid_test.clj index 78730377ddbfebeaa4fe960e3c63eb6f41c2e042..3cb08f21eb73411193c1aa1ecb05a10dea137b13 100644 --- a/test/metabase/driver/druid_test.clj +++ b/test/metabase/driver/druid_test.clj @@ -182,3 +182,23 @@ (ql/aggregation (ql/+ (ql/max $venue_price) (ql/min (ql/- $venue_price $id)))) (ql/breakout $venue_price))) + +;; aggregation w/o field +(expect-with-engine :druid + [["1" 222.0] + ["2" 616.0] + ["3" 116.0] + ["4" 50.0]] + (druid-query-returning-rows + (ql/aggregation (ql/+ 1 (ql/count))) + (ql/breakout $venue_price))) + +;; aggregation with math inside the aggregation :scream_cat: +(expect-with-engine :druid + [["1" 442.0] + ["2" 1845.0] + ["3" 460.0] + ["4" 245.0]] + (druid-query-returning-rows + (ql/aggregation (ql/sum (ql/+ $venue_price 1))) + (ql/breakout $venue_price))) diff --git a/test/metabase/models/permissions_test.clj b/test/metabase/models/permissions_test.clj index c6ad55f07388e9c3d009d1fefbaf1efb2bf97a3b..fb20af01d0a7f7fd4c16e687962223ddae6f369e 100644 --- a/test/metabase/models/permissions_test.clj +++ b/test/metabase/models/permissions_test.clj @@ -1,6 +1,10 @@ (ns metabase.models.permissions-test (:require [expectations :refer :all] - [metabase.models.permissions :as perms])) + (metabase.models [permissions :as perms] + [permissions-group :refer [PermissionsGroup]]) + [metabase.test.data :as data] + [metabase.test.util :as tu] + [metabase.util :as u])) ;;; ------------------------------------------------------------ valid-object-path? ------------------------------------------------------------ @@ -502,3 +506,19 @@ ;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+ ;;; | TODO - Permissions Graph Tests | ;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+ + +(defn- test-data-graph [group] + (get-in (perms/graph) [:groups (u/get-id group) (data/id) :schemas "PUBLIC"])) + +;; Test that setting partial permissions for a table retains permissions for other tables -- #3888 +(tu/expect-with-temp [PermissionsGroup [group]] + [{(data/id :categories) :none, (data/id :checkins) :none, (data/id :users) :none, (data/id :venues) :all} + {(data/id :categories) :all, (data/id :checkins) :none, (data/id :users) :none, (data/id :venues) :all}] + (do + ;; first, graph permissions only for VENUES + (perms/grant-permissions! group (perms/object-path (data/id) "PUBLIC" (data/id :venues))) + [(test-data-graph group) + ;; next, grant permissions via `update-graph!` for CATEGORIES as well. Make sure permissions for VENUES are retained (#3888) + (do + (perms/update-graph! [(u/get-id group) (data/id) :schemas "PUBLIC" (data/id :categories)] :all) + (test-data-graph group))])) diff --git a/test/metabase/query_processor_test/expression_aggregations_test.clj b/test/metabase/query_processor_test/expression_aggregations_test.clj index 3b3ab6996800032b6b8d5ac6c882e2763d02570c..b0e855f6735b0e9d187505d08cef8ac25e189715 100644 --- a/test/metabase/query_processor_test/expression_aggregations_test.clj +++ b/test/metabase/query_processor_test/expression_aggregations_test.clj @@ -134,3 +134,25 @@ (ql/aggregation (ql/+ (ql/max $price) (ql/min (ql/- $price $id)))) (ql/breakout $price))))) + +;; aggregation w/o field +(datasets/expect-with-engines (engines-that-support :expression-aggregations) + [[1 23] + [2 60] + [3 14] + [4 7]] + (format-rows-by [int int] + (rows (data/run-query venues + (ql/aggregation (ql/+ 1 (ql/count))) + (ql/breakout $price))))) + +;; aggregation with math inside the aggregation :scream_cat: +(datasets/expect-with-engines (engines-that-support :expression-aggregations) + [[1 44] + [2 177] + [3 52] + [4 30]] + (format-rows-by [int int] + (rows (data/run-query venues + (ql/aggregation (ql/sum (ql/+ $price 1))) + (ql/breakout $price))))) diff --git a/webpack.config.js b/webpack.config.js index e3b78d1ba6d6c508ba3677bead04d9dd191504a5..315736b950e135e322fce097a5ad47d04a92395e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -139,8 +139,6 @@ var config = module.exports = { 'ace/snippets/pgsql': __dirname + '/node_modules/ace-builds/src-min-noconflict/snippets/pgsql.js', 'ace/snippets/sqlserver': __dirname + '/node_modules/ace-builds/src-min-noconflict/snippets/sqlserver.js', 'ace/snippets/json': __dirname + '/node_modules/ace-builds/src-min-noconflict/snippets/json.js', - // react - 'fixed-data-table': __dirname + '/node_modules/fixed-data-table/dist/fixed-data-table.min.js', // misc 'moment': __dirname + '/node_modules/moment/min/moment.min.js', 'tether': __dirname + '/node_modules/tether/dist/js/tether.min.js', diff --git a/yarn.lock b/yarn.lock index 78111001b4e5732720ab740d7a4e782d01eb3f22..96befc4141c2cc3b27c2cf9df919f54d3598b50f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,5 +1,7 @@ # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 + + "@kadira/react-split-pane@^1.4.0": version "1.4.7" resolved "https://registry.yarnpkg.com/@kadira/react-split-pane/-/react-split-pane-1.4.7.tgz#6d753d4a9fe62fe82056e323a6bcef7f026972b5" @@ -66,11 +68,15 @@ winston "^2.1.1" ws "^1.0.1" +Base64@~0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/Base64/-/Base64-0.2.1.tgz#ba3a4230708e186705065e66babdd4c35cf60028" + abbrev@1, abbrev@1.0.x: version "1.0.9" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135" -accepts@~1.3.3, accepts@1.3.3: +accepts@1.3.3, accepts@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca" dependencies: @@ -95,14 +101,14 @@ acorn@^4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.3.tgz#1a3e850b428e73ba6b09d1cc527f5aaad4d03ef1" -adm-zip@~0.4.3: - version "0.4.7" - resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.7.tgz#8606c2cbf1c426ce8c8ec00174447fd49b6eafc1" - adm-zip@0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.4.tgz#a61ed5ae6905c3aea58b3a657d25033091052736" +adm-zip@~0.4.3: + version "0.4.7" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.7.tgz#8606c2cbf1c426ce8c8ec00174447fd49b6eafc1" + after@0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/after/-/after-0.8.1.tgz#ab5d4fb883f596816d3515f8f791c0af486dd627" @@ -301,14 +307,18 @@ async-each@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" -async@^0.9.0, async@~0.9.0: - version "0.9.2" - resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" +async@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/async/-/async-1.4.0.tgz#35f86f83c59e0421d099cd9a91d8278fb578c00d" -async@^1.3.0, async@^1.4.0, async@^1.5.0, async@1.x: +async@1.x, async@^1.3.0, async@^1.4.0, async@^1.5.0: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" +async@^0.9.0, async@~0.9.0: + version "0.9.2" + resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" + async@~0.2.6, async@~0.2.9: version "0.2.10" resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" @@ -317,10 +327,6 @@ async@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/async/-/async-1.0.0.tgz#f8fc04ca3a13784ade9e1641af98578cfbd647a9" -async@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/async/-/async-1.4.0.tgz#35f86f83c59e0421d099cd9a91d8278fb578c00d" - asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1048,7 +1054,7 @@ babel-register@^6.11.6, babel-register@^6.16.0: path-exists "^1.0.0" source-map-support "^0.4.2" -babel-runtime@^6.0.0, babel-runtime@^6.11.6, babel-runtime@^6.2.0, babel-runtime@^6.5.0, babel-runtime@^6.9.0, babel-runtime@^6.9.1, babel-runtime@^6.9.2, babel-runtime@6.x.x: +babel-runtime@6.x.x, babel-runtime@^6.0.0, babel-runtime@^6.11.6, babel-runtime@^6.2.0, babel-runtime@^6.5.0, babel-runtime@^6.9.0, babel-runtime@^6.9.1, babel-runtime@^6.9.2: version "6.11.6" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.11.6.tgz#6db707fef2d49c49bfa3cb64efdb436b518b8222" dependencies: @@ -1096,6 +1102,10 @@ backo2@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" +balanced-match@0.1.0, balanced-match@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.1.0.tgz#b504bd05869b39259dd0c5efc35d843176dccc4a" + balanced-match@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.2.1.tgz#7bc658b4bed61eee424ad74f75f5c3e2c4df3cc7" @@ -1104,31 +1114,23 @@ balanced-match@^0.4.1, balanced-match@^0.4.2: version "0.4.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" -balanced-match@~0.1.0, balanced-match@0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.1.0.tgz#b504bd05869b39259dd0c5efc35d843176dccc4a" - base64-arraybuffer@0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8" -base64-js@^1.0.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1" - base64-js@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" -Base64@~0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/Base64/-/Base64-0.2.1.tgz#ba3a4230708e186705065e66babdd4c35cf60028" +base64-js@^1.0.2: + version "1.2.0" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1" base64id@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/base64id/-/base64id-0.1.0.tgz#02ce0fdeee0cef4f40080e1e73e834f0b1bfce3f" -batch@^0.5.3, batch@0.5.3: +batch@0.5.3, batch@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/batch/-/batch-0.5.3.tgz#3f3414f380321743bfc1042f9a83ff1d5824d464" @@ -1187,6 +1189,10 @@ block-stream@*: dependencies: inherits "~2.0.0" +bluebird@2.9.6: + version "2.9.6" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.9.6.tgz#1fc3a6b1685267dc121b5ec89b32ce069d81ab7d" + bluebird@^2.9.27: version "2.11.0" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" @@ -1195,10 +1201,6 @@ bluebird@^3.0.5, bluebird@^3.3.3, bluebird@^3.3.4, bluebird@^3.4.1: version "3.4.6" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.6.tgz#01da8d821d87813d158967e743d5fe6c62cf8c0f" -bluebird@2.9.6: - version "2.9.6" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.9.6.tgz#1fc3a6b1685267dc121b5ec89b32ce069d81ab7d" - body-parser@^1.12.4: version "1.15.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.15.2.tgz#d7578cf4f1d11d5f6ea804cef35dc7a7ff6dae67" @@ -1368,16 +1370,6 @@ center-align@^0.1.1: align-text "^0.1.3" lazy-cache "^1.0.3" -chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" - chalk@0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.5.1.tgz#663b3a648b68b55d04690d49167aa837858f2174" @@ -1398,6 +1390,16 @@ chalk@1.1.1: strip-ansi "^3.0.0" supports-color "^2.0.0" +chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + change-case@3.0.x: version "3.0.0" resolved "https://registry.yarnpkg.com/change-case/-/change-case-3.0.0.tgz#6c9c8e35f8790870a82b6b0745be8c3cbef9b081" @@ -1456,7 +1458,7 @@ clap@^1.0.9: dependencies: chalk "^1.1.3" -classnames@^2.1.3, classnames@^2.2.0, classnames@^2.2.3, classnames@^2.2.5: +classnames@^2.1.3, classnames@^2.2.3, classnames@^2.2.5: version "2.2.5" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d" @@ -1514,7 +1516,7 @@ code-point-at@^1.0.0: dependencies: number-is-nan "^1.0.0" -color-convert@^0.5.3, color-convert@0.5.x: +color-convert@0.5.x, color-convert@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd" @@ -1522,19 +1524,13 @@ color-convert@^1.3.0: version "1.5.0" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.5.0.tgz#7a2b4efb4488df85bca6443cb038b7100fbe7de1" -color-name@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.1.tgz#4b1415304cf50028ea81643643bd82ea05803689" - color-name@1.0.x: version "1.0.1" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.0.1.tgz#6b34b2b29b7716013972b0b9d5bedcfbb6718df8" -color-string@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991" - dependencies: - color-name "^1.0.0" +color-name@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.1.tgz#4b1415304cf50028ea81643643bd82ea05803689" color-string@0.2.x: version "0.2.4" @@ -1542,6 +1538,12 @@ color-string@0.2.x: dependencies: color-name "1.0.x" +color-string@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991" + dependencies: + color-name "^1.0.0" + color@^0.10.1: version "0.10.1" resolved "https://registry.yarnpkg.com/color/-/color-0.10.1.tgz#c04188df82a209ddebccecdacd3ec320f193739f" @@ -1579,26 +1581,20 @@ colormin@^1.0.5: css-color-names "0.0.4" has "^1.0.1" -colors@^1.1.0, colors@~1.1.2, colors@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" - colors@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" +colors@1.1.2, colors@^1.1.0, colors@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" + combined-stream@^1.0.5, combined-stream@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" dependencies: delayed-stream "~1.0.0" -commander@^2.8.1, commander@^2.9.0, commander@~2.9.0, commander@2.9.x: - version "2.9.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" - dependencies: - graceful-readlink ">= 1.0.0" - commander@2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.6.0.tgz#9df7e52fb2a0cb0fb89058ee80c3104225f37e1d" @@ -1609,6 +1605,12 @@ commander@2.8.x: dependencies: graceful-readlink ">= 1.0.0" +commander@2.9.x, commander@^2.8.1, commander@^2.9.0, commander@~2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" + dependencies: + graceful-readlink ">= 1.0.0" + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -1744,14 +1746,14 @@ cross-spawn-async@^1.0.1: lru-cache "^2.6.5" which "^1.1.1" -crossfilter@^1.3.12: - version "1.3.12" - resolved "https://registry.yarnpkg.com/crossfilter/-/crossfilter-1.3.12.tgz#147d7236a98c45c69f78bdc3a99d6fb00f70930c" - crossfilter2@~1.3: version "1.3.14" resolved "https://registry.yarnpkg.com/crossfilter2/-/crossfilter2-1.3.14.tgz#c45bd8d335f6c91accbac26eda203377f195f680" +crossfilter@^1.3.12: + version "1.3.12" + resolved "https://registry.yarnpkg.com/crossfilter/-/crossfilter-1.3.12.tgz#147d7236a98c45c69f78bdc3a99d6fb00f70930c" + cryptiles@2.x.x: version "2.0.5" resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" @@ -1885,11 +1887,9 @@ cycle@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" -d@^0.1.1, d@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" - dependencies: - es5-ext "~0.10.2" +d3@^3, d3@^3.5.17, d3@^3.5.x: + version "3.5.17" + resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8" d@1: version "1.0.0" @@ -1897,9 +1897,11 @@ d@1: dependencies: es5-ext "^0.10.9" -d3@^3, d3@^3.5.17, d3@^3.5.x: - version "3.5.17" - resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8" +d@^0.1.1, d@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" + dependencies: + es5-ext "~0.10.2" dashdash@^1.12.0: version "1.14.0" @@ -1929,16 +1931,16 @@ dc@^2.0.0-beta.32: crossfilter2 "~1.3" d3 "^3" -debug@^2.1.1, debug@^2.2.0, debug@~2.2.0, debug@2, debug@2.2.0: +debug@0.7.4, debug@~0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-0.7.4.tgz#06e1ea8082c2cb14e39806e22e2f6f757f92af39" + +debug@2, debug@2.2.0, debug@^2.1.1, debug@^2.2.0, debug@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da" dependencies: ms "0.7.1" -debug@~0.7.4, debug@0.7.4: - version "0.7.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-0.7.4.tgz#06e1ea8082c2cb14e39806e22e2f6f757f92af39" - decamelize@^1.0.0, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -2065,14 +2067,14 @@ domain-browser@^1.1.1: version "1.1.7" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc" -domelementtype@~1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b" - domelementtype@1: version "1.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.0.tgz#b17aed82e8ab59e52dd9c19b1756e0fc187204c2" +domelementtype@~1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b" + domhandler@2.1: version "2.1.0" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.1.0.tgz#d2646f5e57f6c3bab11cf6cb05d3c0acf7412594" @@ -2240,14 +2242,6 @@ es5-shim@^4.5.9: version "4.5.9" resolved "https://registry.yarnpkg.com/es5-shim/-/es5-shim-4.5.9.tgz#2a1e2b9e583ff5fed0c20a3ee2cbf3f75230a5c0" -es6-iterator@~0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-0.1.3.tgz#d6f58b8c4fc413c249b4baa19768f8e4d7c8944e" - dependencies: - d "~0.1.1" - es5-ext "~0.10.5" - es6-symbol "~2.0.1" - es6-iterator@2: version "2.0.0" resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.0.tgz#bd968567d61635e33c0b80727613c9cb4b096bac" @@ -2256,6 +2250,14 @@ es6-iterator@2: es5-ext "^0.10.7" es6-symbol "3" +es6-iterator@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-0.1.3.tgz#d6f58b8c4fc413c249b4baa19768f8e4d7c8944e" + dependencies: + d "~0.1.1" + es5-ext "~0.10.5" + es6-symbol "~2.0.1" + es6-map@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.4.tgz#a34b147be224773a4d7da8072794cefa3632b897" @@ -2285,6 +2287,13 @@ es6-shim@^0.35.1: version "0.35.1" resolved "https://registry.yarnpkg.com/es6-shim/-/es6-shim-0.35.1.tgz#a23524009005b031ab4a352ac196dfdfd1144ab7" +es6-symbol@3, es6-symbol@~3.1, es6-symbol@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.0.tgz#94481c655e7a7cad82eba832d97d5433496d7ffa" + dependencies: + d "~0.1.1" + es5-ext "~0.10.11" + es6-symbol@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-2.0.1.tgz#761b5c67cfd4f1d18afb234f691d678682cb3bf3" @@ -2292,13 +2301,6 @@ es6-symbol@~2.0.1: d "~0.1.1" es5-ext "~0.10.5" -es6-symbol@~3.1, es6-symbol@~3.1.0, es6-symbol@3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.0.tgz#94481c655e7a7cad82eba832d97d5433496d7ffa" - dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - es6-template-strings@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/es6-template-strings/-/es6-template-strings-2.0.1.tgz#b166c6a62562f478bb7775f6ca96103a599b4b2c" @@ -2425,7 +2427,7 @@ espree@^3.3.1: acorn "^4.0.1" acorn-jsx "^3.0.0" -esprima@^2.6.0, esprima@^2.7.1, esprima@2.7.x: +esprima@2.7.x, esprima@^2.6.0, esprima@^2.7.1: version "2.7.3" resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" @@ -2463,7 +2465,7 @@ event-emitter@~0.3.4: d "~0.1.1" es5-ext "~0.10.7" -eventemitter3@^1.1.1, eventemitter3@1.x.x: +eventemitter3@1.x.x, eventemitter3@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508" @@ -2566,7 +2568,7 @@ express@^4.13.3: utils-merge "1.0.0" vary "~1.1.0" -extend@^3.0.0, extend@~3.0.0, extend@3: +extend@3, extend@^3.0.0, extend@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.0.tgz#5a474353b9f3353ddd8176dfd37b91c83a46f1d4" @@ -2719,10 +2721,6 @@ fined@^1.0.1: lodash.pick "^4.2.1" parse-filepath "^1.0.1" -fixed-data-table@^0.6.0: - version "0.6.3" - resolved "https://registry.yarnpkg.com/fixed-data-table/-/fixed-data-table-0.6.3.tgz#e0bba85bc84a065dea71f0fb3a46d61441ad81e8" - flagged-respawn@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-0.3.2.tgz#ff191eddcd7088a675b2610fffc976be9b8074b5" @@ -2736,7 +2734,7 @@ flat-cache@^1.2.1: graceful-fs "^4.1.2" write "^0.2.1" -flatten@^1.0.2, flatten@1.0.2: +flatten@1.0.2, flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" @@ -2923,7 +2921,7 @@ glob-parent@^2.0.0: dependencies: is-glob "^2.0.0" -glob@^5.0.14, glob@^5.0.15, glob@^5.0.5, glob@5.0.x: +glob@5.0.x, glob@^5.0.14, glob@^5.0.15, glob@^5.0.5: version "5.0.15" resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" dependencies: @@ -3120,7 +3118,7 @@ hoek@2.x.x: version "2.16.3" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" -hoist-non-react-statics@^1.0.0, hoist-non-react-statics@^1.0.3, hoist-non-react-statics@^1.0.5, hoist-non-react-statics@^1.2.0, hoist-non-react-statics@1.2.0, hoist-non-react-statics@1.x.x: +hoist-non-react-statics@1.2.0, hoist-non-react-statics@1.x.x, hoist-non-react-statics@^1.0.0, hoist-non-react-statics@^1.0.3, hoist-non-react-statics@^1.0.5, hoist-non-react-statics@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" @@ -3234,7 +3232,7 @@ icepick@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/icepick/-/icepick-1.3.0.tgz#e4942842ed8f9ee778d7dd78f7e36627f49fdaef" -iconv-lite@~0.4.13, iconv-lite@0.4.13: +iconv-lite@0.4.13, iconv-lite@~0.4.13: version "0.4.13" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" @@ -3311,7 +3309,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@^2.0.1, inherits@~2.0.0, inherits@~2.0.1, inherits@2: +inherits@2, inherits@^2.0.1, inherits@~2.0.0, inherits@~2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" @@ -3353,7 +3351,7 @@ interpret@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.1.tgz#d579fb7f693b858004947af39fa0db49f795602c" -invariant@^2.0.0, invariant@^2.2.0, invariant@^2.2.1, invariant@2.x.x: +invariant@2.x.x, invariant@^2.0.0, invariant@^2.2.0, invariant@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.1.tgz#b097010547668c7e337028ebe816ebe36c8a8d54" dependencies: @@ -3579,14 +3577,14 @@ is@~0.2.6: version "0.2.7" resolved "https://registry.yarnpkg.com/is/-/is-0.2.7.tgz#3b34a2c48f359972f35042849193ae7264b63562" -isarray@^1.0.0, isarray@~1.0.0, isarray@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - isarray@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" +isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + isbinaryfile@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.1.tgz#6e99573675372e841a0520c036b41513d783e79e" @@ -3608,7 +3606,7 @@ isomorphic-fetch@^2.1.1, isomorphic-fetch@^2.2.1: node-fetch "^1.0.1" whatwg-fetch ">=0.10.0" -isstream@~0.1.2, isstream@0.1.x: +isstream@0.1.x, isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -3620,7 +3618,7 @@ istanbul-instrumenter-loader@^0.2.0: loader-utils "0.x.x" object-assign "4.x.x" -istanbul@^0.4.0, istanbul@0.x.x: +istanbul@0.x.x, istanbul@^0.4.0: version "0.4.5" resolved "https://registry.yarnpkg.com/istanbul/-/istanbul-0.4.5.tgz#65c7d73d4c4da84d4f3ac310b918fb0b8033733b" dependencies: @@ -3702,7 +3700,7 @@ js-tokens@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-2.0.0.tgz#79903f5563ee778cc1162e6dcf1a0027c97f9cb5" -js-yaml@^3.5.1, js-yaml@~3.6.1, js-yaml@3.x: +js-yaml@3.x, js-yaml@^3.5.1, js-yaml@~3.6.1: version "3.6.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.6.1.tgz#6e5fe67d8b205ce4d22fad05b7781e8dadcc4b30" dependencies: @@ -3745,14 +3743,14 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" -json3@^3.3.2, json3@3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" - json3@3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/json3/-/json3-3.2.6.tgz#f6efc93c06a04de9aec53053df2559bb19e2038b" +json3@3.3.2, json3@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" + json5@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/json5/-/json5-0.4.0.tgz#054352e4c4c80c86c0923877d449de176a732c8d" @@ -3981,7 +3979,7 @@ load-json-file@^1.0.0: pinkie-promise "^2.0.0" strip-bom "^2.0.0" -loader-utils@^0.2.11, loader-utils@^0.2.12, loader-utils@^0.2.15, loader-utils@^0.2.3, loader-utils@^0.2.5, loader-utils@^0.2.7, loader-utils@~0.2.2, loader-utils@~0.2.5, loader-utils@0.2.x, loader-utils@0.x.x: +loader-utils@0.2.x, loader-utils@0.x.x, loader-utils@^0.2.11, loader-utils@^0.2.12, loader-utils@^0.2.15, loader-utils@^0.2.3, loader-utils@^0.2.5, loader-utils@^0.2.7, loader-utils@~0.2.2, loader-utils@~0.2.5: version "0.2.16" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.16.tgz#f08632066ed8282835dff88dfb52704765adee6d" dependencies: @@ -4084,11 +4082,7 @@ lodash.isarray@^3.0.0: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" -lodash.isempty@^4.2.1: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" - -lodash.isempty@4.2.1: +lodash.isempty@4.2.1, lodash.isempty@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.2.1.tgz#6015160e77116db88bb612df42adf7f200abec77" dependencies: @@ -4173,18 +4167,14 @@ lodash.words@^3.0.0: dependencies: lodash._root "^3.0.0" -lodash@^3.7.0, lodash@^3.8.0, lodash@3.10.1: +lodash@3.10.1, lodash@^3.7.0, lodash@^3.8.0: version "3.10.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" -lodash@^4.0.0, lodash@^4.11.2, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.16.2, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0: +lodash@^4.0.0, lodash@^4.11.2, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.16.2, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.5.1: version "4.16.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.16.4.tgz#01ce306b9bad1319f2a5528674f88297aeb70127" -lodash@^4.5.1: - version "4.17.2" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.2.tgz#34a3055babe04ce42467b607d700072c7ff6bf42" - log-symbols@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" @@ -4225,14 +4215,14 @@ lower-case@^1.1.0, lower-case@^1.1.1, lower-case@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.3.tgz#c92393d976793eee5ba4edb583cf8eae35bd9bfb" -lru-cache@^2.6.5: - version "2.7.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" - lru-cache@2.2.x: version "2.2.4" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.2.4.tgz#6c658619becf14031d0d0b594b16042ce4dc063d" +lru-cache@^2.6.5: + version "2.7.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" + lru-queue@0.1: version "0.1.0" resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" @@ -4353,16 +4343,20 @@ mime-types@^2.1.11, mime-types@~2.1.11, mime-types@~2.1.7: dependencies: mime-db "~1.24.0" -mime@^1.2.11, mime@^1.3.4, mime@1.3.4: +mime@1.3.4, mime@^1.2.11, mime@^1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" -minimatch@^3.0.0, minimatch@^3.0.2, "minimatch@2 || 3": +"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774" dependencies: brace-expansion "^1.0.0" +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" @@ -4371,11 +4365,7 @@ minimist@~0.0.1: version "0.0.10" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" -minimist@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - -mkdirp@^0.5.0, mkdirp@^0.5.1, "mkdirp@>=0.5 0", mkdirp@~0.5.0, mkdirp@~0.5.1, mkdirp@0.5.x: +mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" dependencies: @@ -4389,11 +4379,7 @@ mobx@^2.3.4: version "2.6.0" resolved "https://registry.yarnpkg.com/mobx/-/mobx-2.6.0.tgz#0ae83a20488b92d10d4ca326e18fe78a5ab7cb36" -moment@^2.11.2: - version "2.17.0" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.17.0.tgz#a4c292e02aac5ddefb29a6eed24f51938dd3b74f" - -moment@2.14.1: +moment@2.14.1, moment@^2.11.2: version "2.14.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.14.1.tgz#b35b27c47e57ed2ddc70053d6b07becdb291741c" @@ -4547,7 +4533,7 @@ node.flow@1.2.3: dependencies: node.extend "1.0.8" -nopt@~3.0.1, nopt@3.x: +nopt@3.x, nopt@~3.0.1: version "3.0.6" resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" dependencies: @@ -4620,7 +4606,7 @@ oauth-sign@~0.8.1: version "0.8.2" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" -object-assign@^4.0.1, object-assign@^4.1.0, object-assign@4.x.x: +object-assign@4.x.x, object-assign@^4.0.1, object-assign@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" @@ -4678,7 +4664,7 @@ on-headers@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.1.tgz#928f5d0f470d49342651ea6794b0857c100693f7" -once@^1.3.0, once@^1.3.1, once@1.x: +once@1.x, once@^1.3.0, once@^1.3.1: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" dependencies: @@ -4702,7 +4688,7 @@ open@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/open/-/open-0.0.5.tgz#42c3e18ec95466b6bf0dc42f3a2945c3f0cad8fc" -optimist@^0.6.1, optimist@~0.6.0, optimist@~0.6.1, optimist@0.6.1: +optimist@0.6.1, optimist@^0.6.1, optimist@~0.6.0, optimist@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" dependencies: @@ -5471,26 +5457,26 @@ pump@^1.0.0: end-of-stream "^1.1.0" once "^1.3.1" -punycode@^1.2.4: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - punycode@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" +punycode@^1.2.4: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + q@^1.0.1, q@^1.1.2: version "1.4.1" resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e" -qs@^6.1.0, qs@^6.2.0, qs@~6.2.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.1.tgz#ce03c5ff0935bc1d9d69a9f14cbd18e568d67625" - qs@6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.0.tgz#3b7848c03c2dece69a9522b0fae8c4126d745f3b" +qs@^6.1.0, qs@^6.2.0, qs@~6.2.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.1.tgz#ce03c5ff0935bc1d9d69a9f14cbd18e568d67625" + query-string@^3.0.0: version "3.0.3" resolved "https://registry.yarnpkg.com/query-string/-/query-string-3.0.3.tgz#ae2e14b4d05071d4e9b9eb4873c35b0dcd42e638" @@ -5508,7 +5494,7 @@ querystring-es3@~0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" -querystring@^0.2.0, querystring@0.2.0: +querystring@0.2.0, querystring@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" @@ -5571,18 +5557,18 @@ react-ansi-style@^1.0.0: version "15.3.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.3.2.tgz#c46b0aa5380d7b838e7a59c4a7beff2ed315531f" -react-draggable@^1.1.3: - version "1.3.7" - resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-1.3.7.tgz#729bde4bd1e717c395b228c775e837695d50c2b2" - dependencies: - classnames "^2.2.0" - react-draggable@^2.1.0: version "2.2.2" resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-2.2.2.tgz#80932da5ad81795bdfd141e846d7a9309680e270" dependencies: classnames "^2.2.5" +react-draggable@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-2.2.3.tgz#17628cb8aaefed639d38e0021b978a685d80b08b" + dependencies: + classnames "^2.2.5" + react-fuzzy@^0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/react-fuzzy/-/react-fuzzy-0.2.3.tgz#4fa08729524cd491e2b589509e8d2de7e3adfb9e" @@ -5681,9 +5667,9 @@ react-sortable@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/react-sortable/-/react-sortable-1.1.0.tgz#022360e2ebee6a75c81fc2b48debb37c28ce7566" -react-virtualized@^8.0.12: - version "8.0.13" - resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-8.0.13.tgz#7d2ad1efd4eedd02e57e19847a876876d232508a" +react-virtualized@^8.6.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-8.6.0.tgz#b54402ad5c75d3cd15481dc822de7f816d57da22" dependencies: babel-runtime "^6.11.6" classnames "^2.2.3" @@ -5718,6 +5704,15 @@ read-pkg@^1.0.0: normalize-package-data "^2.3.2" path-type "^1.0.0" +readable-stream@1.0, readable-stream@~1.0.2: + version "1.0.34" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + readable-stream@^1.0.27-1, readable-stream@^1.1.13: version "1.1.14" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" @@ -5727,7 +5722,7 @@ readable-stream@^1.0.27-1, readable-stream@^1.1.13: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@^2.0.0, "readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@~2.1.4: +readable-stream@^2.0.0, "readable-stream@^2.0.0 || ^1.1.13", readable-stream@~2.1.4: version "2.1.5" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0" dependencies: @@ -5739,16 +5734,7 @@ readable-stream@^2.0.0, "readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2. string_decoder "~0.10.x" util-deprecate "~1.0.1" -readable-stream@~1.0.2, readable-stream@1.0: - version "1.0.34" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - -readable-stream@~2.0.0, readable-stream@~2.0.5: +readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@~2.0.0, readable-stream@~2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" dependencies: @@ -5957,7 +5943,7 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" -request@^2.58.0, request@^2.64.0, request@^2.65.0, request@^2.67.0, request@^2.74.0, request@2.x: +request@2.x, request@^2.58.0, request@^2.64.0, request@^2.65.0, request@^2.67.0, request@^2.74.0: version "2.75.0" resolved "https://registry.yarnpkg.com/request/-/request-2.75.0.tgz#d2b8268a286da13eaa5d01adf5d18cc90f657d93" dependencies: @@ -6009,7 +5995,7 @@ resolve-from@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" -resolve@^1.1.6, resolve@^1.1.7, resolve@1.1.x: +resolve@1.1.x, resolve@^1.1.6, resolve@^1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" @@ -6042,7 +6028,7 @@ right-align@^0.1.1: dependencies: align-text "^0.1.1" -rimraf@^2.2.8, rimraf@^2.3.2, rimraf@^2.3.3, rimraf@^2.4.4, rimraf@^2.5.4, rimraf@~2.5.0, rimraf@~2.5.1, rimraf@2: +rimraf@2, rimraf@^2.2.8, rimraf@^2.3.2, rimraf@^2.3.3, rimraf@^2.4.4, rimraf@^2.5.4, rimraf@~2.5.0, rimraf@~2.5.1: version "2.5.4" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.5.4.tgz#96800093cbf1a0c86bd95b4625467535c29dfa04" dependencies: @@ -6109,14 +6095,14 @@ sauce-connect-launcher@^0.15.1: lodash "3.10.1" rimraf "2.4.3" -sax@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" - sax@0.6.x: version "0.6.1" resolved "https://registry.yarnpkg.com/sax/-/sax-0.6.1.tgz#563b19c7c1de892e09bfc4f2fc30e3c27f0952b9" +sax@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" + screenfull@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-3.0.2.tgz#9fbcce07bad4c680a8f90f2bfc9c41788af55d0c" @@ -6141,14 +6127,14 @@ semver-truncate@^1.0.0: dependencies: semver "^5.3.0" +"semver@2 || 3 || 4 || 5", semver@^5.0.1, semver@^5.1.0, semver@^5.3.0, semver@~5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" + semver@^4.0.3, semver@^4.3.3, semver@~4.3.3: version "4.3.6" resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.6.tgz#300bc6e0e86374f7ba61068b5b1ecd57fc6532da" -semver@^5.0.1, semver@^5.1.0, semver@^5.3.0, semver@~5.3.0, "semver@2 || 3 || 4 || 5": - version "5.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" - semver@~5.0.1: version "5.0.3" resolved "https://registry.yarnpkg.com/semver/-/semver-5.0.3.tgz#77466de589cd5d3c95f138aa78bc569a3cb5d27a" @@ -6355,13 +6341,19 @@ source-map-support@~0.2.8: dependencies: source-map "0.1.32" -source-map@^0.1.41, source-map@~0.1.7, source-map@0.1.x: +source-map@0.1.32: + version "0.1.32" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.32.tgz#c8b6c167797ba4740a8ea33252162ff08591b266" + dependencies: + amdefine ">=0.0.4" + +source-map@0.1.x, source-map@^0.1.41, source-map@~0.1.7: version "0.1.43" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" dependencies: amdefine ">=0.0.4" -source-map@^0.4.4, source-map@~0.4.1, source-map@~0.4.2, source-map@0.4.x: +source-map@0.4.x, source-map@^0.4.4, source-map@~0.4.1, source-map@~0.4.2: version "0.4.4" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" dependencies: @@ -6377,12 +6369,6 @@ source-map@~0.2.0: dependencies: amdefine ">=0.0.4" -source-map@0.1.32: - version "0.1.32" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.32.tgz#c8b6c167797ba4740a8ea33252162ff08591b266" - dependencies: - amdefine ">=0.0.4" - spawn-default-shell@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/spawn-default-shell/-/spawn-default-shell-1.1.0.tgz#095439d44c4b7c0aff56a53929fbaab87878e7c6" @@ -6454,10 +6440,6 @@ strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" -string_decoder@~0.10.25, string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - string-width@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" @@ -6482,6 +6464,10 @@ string.prototype.padstart@^3.0.0: es-abstract "^1.4.3" function-bind "^1.0.2" +string_decoder@~0.10.25, string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + stringstream@~0.0.4: version "0.0.5" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" @@ -6565,7 +6551,7 @@ symbol-observable@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.3.tgz#0fdb005e84f346a899d492beba23068b32d1525a" -systemjs-builder@^0.15.20, systemjs-builder@0.15.32: +systemjs-builder@0.15.32, systemjs-builder@^0.15.20: version "0.15.32" resolved "https://registry.yarnpkg.com/systemjs-builder/-/systemjs-builder-0.15.32.tgz#66795f104792b0302eba40950f29ed53a791cc3e" dependencies: @@ -6585,7 +6571,7 @@ systemjs-builder@^0.15.20, systemjs-builder@0.15.32: traceur "0.0.105" uglify-js "^2.6.1" -systemjs@^0.19.39, systemjs@0.19.39: +systemjs@0.19.39, systemjs@^0.19.39: version "0.19.39" resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-0.19.39.tgz#e513e6f91a25a37b8b607c51c7989ee0d67b9356" dependencies: @@ -6771,9 +6757,9 @@ ua-parser-js@^0.7.9: version "0.7.10" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.10.tgz#917559ddcce07cbc09ece7d80495e4c268f4ef9f" -uglify-js@^2.6, uglify-js@^2.6.1: - version "2.7.3" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.7.3.tgz#39b3a7329b89f5ec507e344c6e22568698ef4868" +uglify-js@2.6.x, uglify-js@^2.6, uglify-js@^2.6.1, uglify-js@~2.6.0: + version "2.6.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.6.4.tgz#65ea2fb3059c9394692f15fed87c2b36c16b9adf" dependencies: async "~0.2.6" source-map "~0.5.1" @@ -6788,15 +6774,6 @@ uglify-js@~2.3: optimist "~0.3.5" source-map "~0.1.7" -uglify-js@~2.6.0, uglify-js@2.6.x: - version "2.6.4" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.6.4.tgz#65ea2fb3059c9394692f15fed87c2b36c16b9adf" - dependencies: - async "~0.2.6" - source-map "~0.5.1" - uglify-to-browserify "~1.0.0" - yargs "~3.10.0" - uglify-to-browserify@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" @@ -6831,7 +6808,7 @@ uniqs@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" -unpipe@~1.0.0, unpipe@1.0.0: +unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" @@ -6855,16 +6832,16 @@ url-join@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/url-join/-/url-join-0.0.1.tgz#1db48ad422d3402469a87f7d97bdebfe4fb1e3c8" -url-parse@^1.1.1: - version "1.1.6" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.1.6.tgz#ab8ff5aea1388071961255e2236147c52ca5fc48" +url-parse@1.0.x: + version "1.0.5" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.0.5.tgz#0854860422afdcfefeb6c965c662d4800169927b" dependencies: querystringify "0.0.x" requires-port "1.0.x" -url-parse@1.0.x: - version "1.0.5" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.0.5.tgz#0854860422afdcfefeb6c965c662d4800169927b" +url-parse@^1.1.1: + version "1.1.6" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.1.6.tgz#ab8ff5aea1388071961255e2236147c52ca5fc48" dependencies: querystringify "0.0.x" requires-port "1.0.x" @@ -6896,7 +6873,7 @@ util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" -util@~0.10.3, util@0.10.3: +util@0.10.3, util@~0.10.3: version "0.10.3" resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" dependencies: @@ -7119,6 +7096,10 @@ winston@^2.1.1: pkginfo "0.3.x" stack-trace "0.0.x" +wordwrap@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" + wordwrap@^1.0.0, wordwrap@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" @@ -7127,10 +7108,6 @@ wordwrap@~0.0.2: version "0.0.3" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" -wordwrap@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" - wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -7141,7 +7118,7 @@ write@^0.2.1: dependencies: mkdirp "^0.5.1" -ws@^1.0.1, ws@1.1.1: +ws@1.1.1, ws@^1.0.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.1.tgz#082ddb6c641e85d4bb451f03d52f06eabdb1f018" dependencies: @@ -7169,7 +7146,7 @@ xml2js@0.4.4: sax "0.6.x" xmlbuilder ">=1.0.0" -xmlbuilder@>=1.0.0, xmlbuilder@8.2.2: +xmlbuilder@8.2.2, xmlbuilder@>=1.0.0: version "8.2.2" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773" @@ -7201,4 +7178,3 @@ yeast@0.1.2: z-index@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/z-index/-/z-index-0.0.1.tgz#4f3d257a36869dabd990572b70494291cb3eab8f" -