diff --git a/frontend/interfaces/underscore.js b/frontend/interfaces/underscore.js index 8b3511ac04ba9054b193152dff50dbb5dc1a3333..4c9edd5d120f3bcb168833766c6b521c0803d665 100644 --- a/frontend/interfaces/underscore.js +++ b/frontend/interfaces/underscore.js @@ -21,6 +21,11 @@ declare module "underscore" { declare function map<T, U>(a: T[], iteratee: (val: T, n?: number)=>U): U[]; declare function map<K, T, U>(a: {[key:K]: T}, iteratee: (val: T, k?: K)=>U): U[]; + declare function mapObject( + object: Object, + iteratee: (val: any, key: string) => Object, + context?: mixed + ): Object; declare function object<T>(a: Array<[string, T]>): {[key:string]: T}; diff --git a/frontend/src/metabase-lib/lib/metadata/Metadata.js b/frontend/src/metabase-lib/lib/metadata/Metadata.js index 5442aafd20ee5003da201a58dbd676ed2d606026..c4ccb35410635c8f582584c180d8cb1d9d52d7c7 100644 --- a/frontend/src/metabase-lib/lib/metadata/Metadata.js +++ b/frontend/src/metabase-lib/lib/metadata/Metadata.js @@ -38,4 +38,9 @@ export default class Metadata extends Base { // $FlowFixMe return (Object.values(this.metrics): Metric[]); } + + segmentsList(): Metric[] { + // $FlowFixMe + return (Object.values(this.segments): Segment[]); + } } diff --git a/frontend/src/metabase-lib/lib/metadata/Metric.js b/frontend/src/metabase-lib/lib/metadata/Metric.js index 10b69a7fd99808139b476657f20049aa119b0318..915b6c27dc656ca01e680b3193da32e60460f345 100644 --- a/frontend/src/metabase-lib/lib/metadata/Metric.js +++ b/frontend/src/metabase-lib/lib/metadata/Metric.js @@ -18,4 +18,8 @@ export default class Metric extends Base { aggregationClause(): Aggregation { return ["METRIC", this.id]; } + + isActive(): boolean { + return !!this.is_active; + } } diff --git a/frontend/src/metabase-lib/lib/metadata/Segment.js b/frontend/src/metabase-lib/lib/metadata/Segment.js index 26f3effae4fa05e6321dc9eb4cb989c5dee2771c..9c1bfad6c6b2697b41f8728584ca7d850f28e9de 100644 --- a/frontend/src/metabase-lib/lib/metadata/Segment.js +++ b/frontend/src/metabase-lib/lib/metadata/Segment.js @@ -1,9 +1,9 @@ /* @flow weak */ import Base from "./Base"; -import Question from "../Question"; import Database from "./Database"; import Table from "./Table"; +import type { FilterClause } from "metabase/meta/types/Query"; /** * Wrapper class for a segment. Belongs to a {@link Database} and possibly a {@link Table} @@ -15,8 +15,11 @@ export default class Segment extends Base { database: Database; table: Table; - newQuestion(): Question { - // $FlowFixMe - return new Question(); + filterClause(): FilterClause { + return ["SEGMENT", this.id]; + } + + isActive(): boolean { + return !!this.is_active; } } diff --git a/frontend/src/metabase-lib/lib/queries/NativeQuery.js b/frontend/src/metabase-lib/lib/queries/NativeQuery.js index 7beee3058bfbe1024242ec068f196ba4d6b89691..19690b60e46e989a7544f45b5a4c7cfe41b2232d 100644 --- a/frontend/src/metabase-lib/lib/queries/NativeQuery.js +++ b/frontend/src/metabase-lib/lib/queries/NativeQuery.js @@ -16,7 +16,7 @@ import { getEngineNativeRequiresTable } from "metabase/lib/engine"; -import { chain, getIn, assocIn } from "icepick"; +import { chain, assoc, getIn, assocIn } from "icepick"; import _ from "underscore"; import type { @@ -93,6 +93,21 @@ export default class NativeQuery extends AtomicQuery { /* Methods unique to this query type */ + /** + * @returns a new query with the provided Database set. + */ + setDatabase(database: Database): NativeQuery { + if (database.id !== this.databaseId()) { + // TODO: this should reset the rest of the query? + return new NativeQuery( + this._originalQuestion, + assoc(this.datasetQuery(), "database", database.id) + ); + } else { + return this; + } + } + hasWritePermission(): boolean { const database = this.database(); return database != null && database.native_permissions === "write"; diff --git a/frontend/src/metabase/components/SearchHeader.css b/frontend/src/metabase/components/SearchHeader.css index f053bae23692cbfa6121066151dac76d9380ad05..95e1e4440d4a04bbb9627e048f6dfe8ff56cf40d 100644 --- a/frontend/src/metabase/components/SearchHeader.css +++ b/frontend/src/metabase/components/SearchHeader.css @@ -1,9 +1,5 @@ @import '../questions/Questions.css'; -:local(.searchHeader) { - composes: flex align-center from "style"; -} - :local(.searchIcon) { color: var(--muted-color); } @@ -12,6 +8,7 @@ composes: borderless from "style"; color: var(--title-color); font-size: 20px; + width: 100%; } :local(.searchBox)::-webkit-input-placeholder { color: var(--subtitle-color); diff --git a/frontend/src/metabase/components/SearchHeader.jsx b/frontend/src/metabase/components/SearchHeader.jsx index ccd13419418a580b8f348d047bc75520414686e1..f087cff31b2649657bf62bb7bfd82184dce61fcf 100644 --- a/frontend/src/metabase/components/SearchHeader.jsx +++ b/frontend/src/metabase/components/SearchHeader.jsx @@ -2,13 +2,11 @@ import React from "react"; import PropTypes from "prop-types"; import S from "./SearchHeader.css"; - import Icon from "metabase/components/Icon.jsx"; - import cx from "classnames"; -const SearchHeader = ({ searchText, setSearchText }) => - <div className={S.searchHeader}> +const SearchHeader = ({ searchText, setSearchText, autoFocus, inputRef, resetSearchText }) => + <div className="flex align-center"> <Icon className={S.searchIcon} name="search" size={18} /> <input className={cx("input bg-transparent", S.searchBox)} @@ -16,12 +14,25 @@ const SearchHeader = ({ searchText, setSearchText }) => placeholder="Filter this list..." value={searchText} onChange={(e) => setSearchText(e.target.value)} + autoFocus={!!autoFocus} + ref={inputRef || (() => {})} /> + { resetSearchText && searchText !== "" && + <Icon + name="close" + className="cursor-pointer text-grey-2" + size={18} + onClick={resetSearchText} + /> + } </div> SearchHeader.propTypes = { searchText: PropTypes.string.isRequired, setSearchText: PropTypes.func.isRequired, + autoFocus: PropTypes.bool, + inputRef: PropTypes.func, + resetSearchText: PropTypes.func }; export default SearchHeader; diff --git a/frontend/src/metabase/containers/EntitySearch.jsx b/frontend/src/metabase/containers/EntitySearch.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5fb33bdfaa0042b5466bb547606fe901addf798a --- /dev/null +++ b/frontend/src/metabase/containers/EntitySearch.jsx @@ -0,0 +1,479 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' +import { push } from "react-router-redux"; +import _ from "underscore"; +import cx from "classnames"; + +import SearchHeader from "metabase/components/SearchHeader"; + +import { caseInsensitiveSearch } from "metabase/lib/string"; +import Icon from "metabase/components/Icon"; +import EmptyState from "metabase/components/EmptyState"; +import { Link } from "react-router"; +import { KEYCODE_DOWN, KEYCODE_ENTER, KEYCODE_UP } from "metabase/lib/keyboard"; + +const PAGE_SIZE = 10 + +const SEARCH_GROUPINGS = [ + { + name: "Name", + icon: null, + // Name grouping is a no-op grouping so always put all results to same group with identifier `0` + groupBy: () => 0, + // Setting name to null hides the group header in SearchResultsGroup component + getGroupName: () => null + }, + { + name: "Table", + icon: "table2", + groupBy: (entity) => entity.table.id, + getGroupName: (entity) => entity.table.display_name + }, + { + name: "Database", + icon: "database", + groupBy: (entity) => entity.table.db.id, + getGroupName: (entity) => entity.table.db.name + }, + { + name: "Creator", + icon: "mine", + groupBy: (entity) => entity.creator.id, + getGroupName: (entity) => entity.creator.common_name + }, +] +const DEFAULT_SEARCH_GROUPING = SEARCH_GROUPINGS[0] + +type Props = { + title: string, + // Sorted list of entities like segments or metrics + entities: any[], + getUrlForEntity: (any) => void +} + +export default class EntitySearch extends Component { + searchHeaderInput: ?HTMLButtonElement + props: Props + + constructor(props) { + super(props); + this.state = { + filteredEntities: props.entities, + currentGrouping: DEFAULT_SEARCH_GROUPING, + searchText: "" + }; + } + + componentWillReceiveProps = (nextProps) => { + this.applyFiltersForEntities(nextProps.entities) + } + + setSearchText = (searchText) => { + this.setState({ searchText }, this.applyFiltersAfterFilterChange) + } + + resetSearchText = () => { + this.setSearchText("") + this.searchHeaderInput.focus() + } + + applyFiltersAfterFilterChange = () => this.applyFiltersForEntities(this.props.entities) + + applyFiltersForEntities = (entities) => { + const { searchText } = this.state; + + if (searchText !== "") { + const filteredEntities = entities.filter(({ name, description }) => + caseInsensitiveSearch(name, searchText) + ) + + this.setState({ filteredEntities }) + } + else { + this.setState({ filteredEntities: entities }) + } + } + + setGrouping = (grouping) => { + this.setState({ currentGrouping: grouping }) + this.searchHeaderInput.focus() + } + + // Returns an array of groups based on current grouping. The groups are sorted by their name. + // Entities inside each group aren't separately sorted as EntitySearch expects that the `entities` + // is already in the desired order. + getGroups = () => { + const { currentGrouping, filteredEntities } = this.state; + + return _.chain(filteredEntities) + .groupBy(currentGrouping.groupBy) + .pairs() + .map(([groupId, entitiesInGroup]) => ({ + groupName: currentGrouping.getGroupName(entitiesInGroup[0]), + entitiesInGroup + })) + .sortBy(({ groupName }) => groupName !== null && groupName.toLowerCase()) + .value() + } + + render() { + const { title, getUrlForEntity } = this.props; + const { searchText, currentGrouping, filteredEntities } = this.state; + + const hasUngroupedResults = !currentGrouping.icon && filteredEntities.length > 0 + + return ( + <div className="bg-slate-extra-light full Entity-search"> + <div className="wrapper wrapper--small pt4 pb4"> + <div className="flex mb4 align-center" style={{ height: "50px" }}> + <Icon + className="Entity-search-back-button shadowed cursor-pointer text-grey-4 mr2 flex align-center circle p2 bg-white transition-background transition-color" + style={{ + border: "1px solid #DCE1E4", + boxShadow: "0 2px 4px 0 #DCE1E4" + }} + name="backArrow" + onClick={ () => window.history.back() } + /> + <div className="text-centered flex-full"> + <h2>{title}</h2> + </div> + </div> + <div> + <SearchGroupingOptions + currentGrouping={currentGrouping} + setGrouping={this.setGrouping} + /> + <div + className={cx("bg-white bordered", { "rounded": !hasUngroupedResults }, { "rounded-top": hasUngroupedResults })} + style={{ padding: "5px 15px" }} + > + <SearchHeader + searchText={searchText} + setSearchText={this.setSearchText} + autoFocus + inputRef={el => this.searchHeaderInput = el} + resetSearchText={this.resetSearchText} + /> + </div> + { filteredEntities.length > 0 && + <GroupedSearchResultsList + groupingIcon={currentGrouping.icon} + groups={this.getGroups()} + getUrlForEntity={getUrlForEntity} + /> + } + { filteredEntities.length === 0 && + <div className="mt4"> + <EmptyState + message={ + <div className="mt4"> + <h3 className="text-grey-5">No results found</h3> + <p className="text-grey-4">Try adjusting your filter to find what you’re + looking for.</p> + </div> + } + image="/app/img/empty_question" + imageHeight="213px" + imageClassName="mln2" + smallDescription + /> + </div> + } + </div> + </div> + </div> + ) + } +} + +export const SearchGroupingOptions = ({ currentGrouping, setGrouping }) => + <div className="Entity-search-grouping-options"> + <h3 className="mb3">View by</h3> + <ul> + { SEARCH_GROUPINGS.map((groupingOption) => + <SearchGroupingOption + key={groupingOption.name} + grouping={groupingOption} + active={currentGrouping === groupingOption} + setGrouping={setGrouping} + /> + )} + </ul> + </div> + +export class SearchGroupingOption extends Component { + props: { + grouping: any, + active: boolean, + setGrouping: (any) => boolean + } + + onSetGrouping = () => { + this.props.setGrouping(this.props.grouping) + } + + render() { + const { grouping, active } = this.props; + + return ( + <li + className={cx( + "my2 cursor-pointer text-uppercase text-small text-green-saturated-hover", + {"text-grey-4": !active}, + {"text-green-saturated": active} + )} + onClick={this.onSetGrouping} + > + {grouping.name} + </li> + ) + } +} + +export class GroupedSearchResultsList extends Component { + props: { + groupingIcon: string, + groups: any, + getUrlForEntity: (any) => void, + } + + state = { + highlightedItemIndex: 0, + // `currentPages` is used as a map-like structure for storing the current pagination page for each group. + // If a given group has no value in currentPages, then it is assumed to be in the first page (`0`). + currentPages: {} + } + + componentDidMount() { + window.addEventListener("keydown", this.onKeyDown, true); + } + + componentWillUnmount() { + window.removeEventListener("keydown", this.onKeyDown, true); + } + + componentWillReceiveProps() { + this.setState({ + highlightedItemIndex: 0, + currentPages: {} + }) + } + + /** + * Returns the count of currently visible entities for each result group. + */ + getVisibleEntityCounts() { + const { groups } = this.props; + const { currentPages } = this.state + return groups.map((group, index) => + Math.min(PAGE_SIZE, group.entitiesInGroup.length - (currentPages[index] || 0) * PAGE_SIZE) + ) + } + + onKeyDown = (e) => { + const { highlightedItemIndex } = this.state + + if (e.keyCode === KEYCODE_UP) { + this.setState({ highlightedItemIndex: Math.max(0, highlightedItemIndex - 1) }) + e.preventDefault(); + } else if (e.keyCode === KEYCODE_DOWN) { + const visibleEntityCount = this.getVisibleEntityCounts().reduce((a, b) => a + b) + this.setState({ highlightedItemIndex: Math.min(highlightedItemIndex + 1, visibleEntityCount - 1) }) + e.preventDefault(); + } + } + + /** + * Returns `{ groupIndex, itemIndex }` which describes that which item in which group is currently highlighted. + * Calculates it based on current visible entities (as pagination affects which entities are visible on given time) + * and the current highlight index that is modified with up and down arrow keys + */ + getHighlightPosition() { + const { highlightedItemIndex } = this.state + const visibleEntityCounts = this.getVisibleEntityCounts() + + let entitiesInPreviousGroups = 0 + for (let groupIndex = 0; groupIndex < visibleEntityCounts.length; groupIndex++) { + const visibleEntityCount = visibleEntityCounts[groupIndex] + const indexInCurrentGroup = highlightedItemIndex - entitiesInPreviousGroups + + if (indexInCurrentGroup <= visibleEntityCount - 1) { + return { groupIndex, itemIndex: indexInCurrentGroup } + } + + entitiesInPreviousGroups += visibleEntityCount + } + } + + /** + * Sets the current pagination page by finding the group that match the `entities` list of entities + */ + setCurrentPage = (entities, page) => { + const { groups } = this.props; + const { currentPages } = this.state; + const groupIndex = groups.findIndex((group) => group.entitiesInGroup === entities) + + this.setState({ + highlightedItemIndex: 0, + currentPages: { + ...currentPages, + [groupIndex]: page + } + }) + } + + render() { + const { groupingIcon, groups, getUrlForEntity } = this.props; + const { currentPages } = this.state; + + const highlightPosition = this.getHighlightPosition(groups) + + return ( + <div className="full"> + {groups.map(({ groupName, entitiesInGroup }, groupIndex) => + <SearchResultsGroup + key={groupIndex} + groupName={groupName} + groupIcon={groupingIcon} + entities={entitiesInGroup} + getUrlForEntity={getUrlForEntity} + highlightItemAtIndex={groupIndex === highlightPosition.groupIndex ? highlightPosition.itemIndex : undefined} + currentPage={currentPages[groupIndex] || 0} + setCurrentPage={this.setCurrentPage} + /> + )} + </div> + ) + } +} + +export const SearchResultsGroup = ({ groupName, groupIcon, entities, getUrlForEntity, highlightItemAtIndex, currentPage, setCurrentPage }) => + <div> + { groupName !== null && + <div className="flex align-center bg-slate-almost-extra-light bordered mt3 px3 py2"> + <Icon className="mr1" style={{color: "#BCC5CA"}} name={groupIcon}/> + <h4>{groupName}</h4> + </div> + } + <SearchResultsList + entities={entities} + getUrlForEntity={getUrlForEntity} + highlightItemAtIndex={highlightItemAtIndex} + currentPage={currentPage} + setCurrentPage={setCurrentPage} + /> + </div> + + +class SearchResultsList extends Component { + props: { + entities: any[], + getUrlForEntity: () => void, + highlightItemAtIndex?: number, + currentPage: number, + setCurrentPage: (entities, number) => void + } + + state = { + page: 0 + } + + getPaginationSection = (start, end, entityCount) => { + const { entities, currentPage, setCurrentPage } = this.props + + const currentEntitiesText = start === end ? `${start + 1}` : `${start + 1}-${end + 1}` + const isInBeginning = start === 0 + const isInEnd = end + 1 >= entityCount + + return ( + <li className="py1 px3 flex justify-end align-center"> + <span className="text-bold">{ currentEntitiesText }</span> of <span + className="text-bold">{entityCount}</span> + <span + className={cx( + "mx1 flex align-center justify-center rounded", + { "cursor-pointer bg-grey-2 text-white": !isInBeginning }, + { "bg-grey-0 text-grey-1": isInBeginning } + )} + style={{width: "22px", height: "22px"}} + onClick={() => !isInBeginning && setCurrentPage(entities, currentPage - 1)}> + <Icon name="chevronleft" size={14}/> + </span> + <span + className={cx( + "flex align-center justify-center rounded", + { "cursor-pointer bg-grey-2 text-white": !isInEnd }, + { "bg-grey-0 text-grey-2": isInEnd } + )} + style={{width: "22px", height: "22px"}} + onClick={() => !isInEnd && setCurrentPage(entities, currentPage + 1)}> + <Icon name="chevronright" size={14}/> + </span> + </li> + ) + } + render() { + const { currentPage, entities, getUrlForEntity, highlightItemAtIndex } = this.props + + const showPagination = PAGE_SIZE < entities.length + + let start = PAGE_SIZE * currentPage; + let end = Math.min(entities.length - 1, PAGE_SIZE * (currentPage + 1) - 1); + const entityCount = entities.length; + + const entitiesInCurrentPage = entities.slice(start, end + 1) + + return ( + <ol className="Entity-search-results-list flex-full bg-white border-left border-right border-bottom rounded-bottom"> + {entitiesInCurrentPage.map((entity, index) => + <SearchResultListItem key={index} entity={entity} getUrlForEntity={getUrlForEntity} highlight={ highlightItemAtIndex === index } /> + )} + {showPagination && this.getPaginationSection(start, end, entityCount)} + </ol> + ) + } +} + +@connect(null, { onChangeLocation: push }) +export class SearchResultListItem extends Component { + props: { + entity: any, + getUrlForEntity: (any) => void, + highlight?: boolean, + + onChangeLocation: (string) => void + } + + componentDidMount() { + window.addEventListener("keydown", this.onKeyDown, true); + } + componentWillUnmount() { + window.removeEventListener("keydown", this.onKeyDown, true); + } + /** + * If the current search result entity is highlighted via arrow keys, then we want to + * let the press of Enter to navigate to that entity + */ + onKeyDown = (e) => { + const { highlight, entity, getUrlForEntity, onChangeLocation } = this.props; + if (highlight && e.keyCode === KEYCODE_ENTER) { + onChangeLocation(getUrlForEntity(entity)) + } + } + + render() { + const { entity, highlight, getUrlForEntity } = this.props; + + return ( + <li> + <Link + className={cx("no-decoration flex py2 px3 cursor-pointer bg-slate-extra-light-hover border-bottom", { "bg-grey-0": highlight })} + to={getUrlForEntity(entity)} + > + <h4 className="text-brand flex-full mr1"> { entity.name } </h4> + </Link> + </li> + ) + } +} diff --git a/frontend/src/metabase/css/containers/entity_search.css b/frontend/src/metabase/css/containers/entity_search.css new file mode 100644 index 0000000000000000000000000000000000000000..ac8bf588aae4d1ddd87d61ec22b919bd81281230 --- /dev/null +++ b/frontend/src/metabase/css/containers/entity_search.css @@ -0,0 +1,34 @@ +@media screen and (--breakpoint-min-md) { + .Entity-search-back-button { + position: absolute; + margin-left: -150px; + } + + .Entity-search-grouping-options { + position: absolute; + margin-left: -150px; + margin-top: 22px; + } +} + + +@media screen and (--breakpoint-max-md) { + .Entity-search-grouping-options { + display: flex; + align-items: center; + } + .Entity-search-grouping-options > h3 { + margin-bottom: 0; + margin-right: 20px; + } + .Entity-search-grouping-options > ul { + display: flex; + } + .Entity-search-grouping-options > ul > li { + margin-right: 10px; + } +} + +.Entity-search input { + width: 100%; +} \ No newline at end of file diff --git a/frontend/src/metabase/css/core/colors.css b/frontend/src/metabase/css/core/colors.css index b9087cd8cc26ea6650d10a4c1220c32971632efc..9e8a624b5498fae7f50116517dc8032d1945397d 100644 --- a/frontend/src/metabase/css/core/colors.css +++ b/frontend/src/metabase/css/core/colors.css @@ -22,10 +22,12 @@ --orange-color: #F9A354; --purple-color: #A989C5; --green-color: #9CC177; + --green-saturated-color: #84BB4C; --dark-color: #4C545B; --error-color: #EF8C8C; --slate-color: #9BA5B1; --slate-light-color: #DFE8EA; + --slate-almost-extra-light-color: #EDF2F5; --slate-extra-light-color: #F9FBFC; } @@ -106,6 +108,11 @@ color: var(--green-color); } +.text-green-saturated, +.text-green-saturated-hover:hover { + color: var(--green-saturated-color); +} + .text-orange, .text-orange-hover:hover { color: var(--orange-color); @@ -148,7 +155,9 @@ .bg-slate { background-color: var(--slate-color); } .bg-slate-light { background-color: var(--slate-light-color); } +.bg-slate-almost-extra-light { background-color: var(--slate-almost-extra-light-color);} .bg-slate-extra-light { background-color: var(--slate-extra-light-color); } +.bg-slate-extra-light-hover:hover { background-color: var(--slate-extra-light-color); } .text-dark, :local(.text-dark) { color: var(--dark-color); diff --git a/frontend/src/metabase/css/core/grid.css b/frontend/src/metabase/css/core/grid.css index 7bcfcaf96f764161f2bf5a7727f358fbd6506bc7..dea70d422fdff7506ac610ce7d390aaed2dd6bc2 100644 --- a/frontend/src/metabase/css/core/grid.css +++ b/frontend/src/metabase/css/core/grid.css @@ -239,6 +239,9 @@ .large-Grid--guttersXXl > .Grid-cell { padding: 5em 0 0 5em; } + .large-Grid--normal > .Grid-cell { + flex: 1; + } } .Grid-cell.Cell--1of3 { diff --git a/frontend/src/metabase/css/core/layout.css b/frontend/src/metabase/css/core/layout.css index 1636716bf5f9e41b0d0ed84ccd01376956ff450e..d5a68a934806232f4d24d661f872086ddbbcdfd4 100644 --- a/frontend/src/metabase/css/core/layout.css +++ b/frontend/src/metabase/css/core/layout.css @@ -34,6 +34,10 @@ .block, :local(.block) { display: block; } +@media screen and (--breakpoint-min-lg) { +.lg-block { display: block; } +} + .inline, :local(.inline) { display: inline; } @@ -76,6 +80,18 @@ } } +@media screen and (--breakpoint-min-lg) { + .wrapper.lg-wrapper--trim { + max-width: var(--lg-width); + } +} + +@media screen and (--breakpoint-min-xl) { + .wrapper.lg-wrapper--trim { + max-width: var(--xl-width); + } +} + /* fully fit the parent element - use as a base for app-y pages like QB or settings */ .spread, :local(.spread) { position: absolute; diff --git a/frontend/src/metabase/css/core/rounded.css b/frontend/src/metabase/css/core/rounded.css index 3d27f218a99693f0b7ac25c85682c36f9b647dff..7a49794c7255b4f326248535dd5fa1fc15b7c781 100644 --- a/frontend/src/metabase/css/core/rounded.css +++ b/frontend/src/metabase/css/core/rounded.css @@ -6,6 +6,10 @@ border-radius: var(--default-border-radius); } +.rounded-med, :local(.rounded-med) { + border-radius: var(--med-border-radius); +} + .rounded-top { border-top-left-radius: var(--default-border-radius); border-top-right-radius: var(--default-border-radius); diff --git a/frontend/src/metabase/css/index.css b/frontend/src/metabase/css/index.css index 95ee56f34e5a58cfb35ebc7acac7d39ba1e5011a..af07ba3c870211f867dedc021fda7cc648deb92b 100644 --- a/frontend/src/metabase/css/index.css +++ b/frontend/src/metabase/css/index.css @@ -12,6 +12,8 @@ @import './components/select.css'; @import './components/table.css'; +@import './containers/entity_search.css'; + @import './admin.css'; @import './card.css'; @import './dashboard.css'; diff --git a/frontend/src/metabase/lib/redux.js b/frontend/src/metabase/lib/redux.js index 81d58aa3ca77f0ba9c8ba73ab71e58c12c0b2836..18ed1a8b088419dc7ec2cd511b5f869127761921 100644 --- a/frontend/src/metabase/lib/redux.js +++ b/frontend/src/metabase/lib/redux.js @@ -65,11 +65,15 @@ export const fetchData = async ({ const existingData = getIn(getState(), existingStatePath); const statePath = requestStatePath.concat(['fetch']); try { - const requestState = getIn(getState(), ["requests", ...statePath]); + const requestState = getIn(getState(), ["requests", "states", ...statePath]); if (!requestState || requestState.error || reload) { dispatch(setRequestState({ statePath, state: "LOADING" })); const data = await getData(); - dispatch(setRequestState({ statePath, state: "LOADED" })); + + // NOTE Atte Keinänen 8/23/17: + // Dispatch `setRequestState` after clearing the call stack because we want to the actual data to be updated + // before we notify components via `state.requests.fetches` that fetching the data is completed + setTimeout(() => dispatch(setRequestState({ statePath, state: "LOADED" })), 0); return data; } diff --git a/frontend/src/metabase/new_query/components/NewQueryOption.jsx b/frontend/src/metabase/new_query/components/NewQueryOption.jsx index 41931adcbf60f44436dbb46bbc07e4b618311bb5..cc1503208943485327ebba2c5521f91bf407bc93 100644 --- a/frontend/src/metabase/new_query/components/NewQueryOption.jsx +++ b/frontend/src/metabase/new_query/components/NewQueryOption.jsx @@ -1,12 +1,13 @@ import React, { Component } from "react"; import cx from "classnames"; +import { Link } from "react-router"; export default class NewQueryOption extends Component { props: { image: string, title: string, description: string, - onClick: () => void + to: string }; state = { @@ -14,19 +15,20 @@ export default class NewQueryOption extends Component { }; render() { - const { width, image, title, description, onClick } = this.props; + const { width, image, title, description, to } = this.props; const { hover } = this.state; return ( - <div - className="bg-white p3 align-center bordered rounded cursor-pointer transition-all text-centered text-brand-light" + <Link + className="block no-decoration bg-white px3 pt4 align-center bordered rounded cursor-pointer transition-all text-centered" style={{ + boxSizing: "border-box", boxShadow: hover ? "0 3px 8px 0 rgba(220,220,220,0.50)" : "0 1px 3px 0 rgba(220,220,220,0.50)", - height: "310px" + height: 340 }} onMouseOver={() => this.setState({hover: true})} onMouseLeave={() => this.setState({hover: false})} - onClick={onClick} + to={to} > <div className="flex align-center layout-centered" style={{ height: "160px" }}> <img @@ -36,11 +38,11 @@ export default class NewQueryOption extends Component { /> </div> - <div className="text-grey-2 text-normal mt2 mb2 text-paragraph" style={{lineHeight: "1.5em"}}> - <h2 className={cx("transition-all", {"text-grey-5": !hover}, {"text-brand": hover})}>{title}</h2> - <p className={"text-grey-4"}>{description}</p> + <div className="text-normal mt2 mb2 text-paragraph" style={{lineHeight: "1.25em"}}> + <h2 className={cx("transition-all", {"text-brand": hover})}>{title}</h2> + <p className={"text-grey-4 text-small"}>{description}</p> </div> - </div> + </Link> ); } } diff --git a/frontend/src/metabase/new_query/containers/MetricSearch.jsx b/frontend/src/metabase/new_query/containers/MetricSearch.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fe74845f673a315356328b7256e5fd0336732f55 --- /dev/null +++ b/frontend/src/metabase/new_query/containers/MetricSearch.jsx @@ -0,0 +1,97 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' +import { fetchMetrics, fetchDatabases } from "metabase/redux/metadata"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import EntitySearch from "metabase/containers/EntitySearch"; +import { getMetadata, getMetadataFetched } from "metabase/selectors/metadata"; +import _ from 'underscore' + +import type { Metric } from "metabase/meta/types/Metric"; +import type Metadata from "metabase-lib/lib/metadata/Metadata"; +import EmptyState from "metabase/components/EmptyState"; + +import type { StructuredQuery } from "metabase/meta/types/Query"; +import { getCurrentQuery } from "metabase/new_query/selectors"; +import { resetQuery } from '../new_query' + +const mapStateToProps = state => ({ + query: getCurrentQuery(state), + metadata: getMetadata(state), + metadataFetched: getMetadataFetched(state) +}) +const mapDispatchToProps = { + fetchMetrics, + fetchDatabases, + resetQuery +} + +@connect(mapStateToProps, mapDispatchToProps) +export default class MetricSearch extends Component { + props: { + getUrlForQuery: (StructuredQuery) => void, + query: StructuredQuery, + metadata: Metadata, + metadataFetched: any, + fetchMetrics: () => void, + fetchDatabases: () => void, + resetQuery: () => void, + } + + componentDidMount() { + this.props.fetchDatabases() // load databases if not loaded yet + this.props.fetchMetrics(true) // metrics may change more often so always reload them + this.props.resetQuery(); + } + + getUrlForMetric = (metric: Metric) => { + const updatedQuery = this.props.query + .setDatabase(metric.table.db) + .setTable(metric.table) + .addAggregation(metric.aggregationClause()) + + return this.props.getUrlForQuery(updatedQuery); + } + + render() { + const { metadataFetched, metadata } = this.props; + const isLoading = !metadataFetched.metrics || !metadataFetched.databases + + return ( + <LoadingAndErrorWrapper loading={isLoading}> + {() => { + const sortedActiveMetrics = _.chain(metadata.metricsList()) + .filter((metric) => metric.isActive()) + .sortBy(({name}) => name.toLowerCase()) + .value() + + if (sortedActiveMetrics.length > 0) { + return ( + <EntitySearch + title="Which metric?" + // TODO Atte Keinänen 8/22/17: If you call `/api/table/:id/table_metadata` it returns + // all metrics (also retired ones) and is missing `is_active` prop. Currently this + // filters them out but we should definitely update the endpoints in the upcoming metadata API refactoring. + entities={sortedActiveMetrics} + getUrlForEntity={this.getUrlForMetric} + /> + ) + } else { + return ( + <div className="mt2 flex-full flex align-center justify-center bg-slate-extra-light"> + <EmptyState + message={<span>Defining common metrics for your team makes it even easier to ask questions</span>} + image="/app/img/metrics_illustration" + action="How to create metrics" + link="http://www.metabase.com/docs/latest/administration-guide/07-segments-and-metrics.html" + className="mt2" + imageClassName="mln2" + /> + </div> + ) + } + }} + </LoadingAndErrorWrapper> + ) + } +} + diff --git a/frontend/src/metabase/new_query/containers/Metrics.jsx b/frontend/src/metabase/new_query/containers/Metrics.jsx deleted file mode 100644 index d1b1a464c1aa7e31e549e775a41ef5fa526a9d84..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/new_query/containers/Metrics.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import React, { Component } from "react"; - -class Metrics extends Component { - render() { - return <div>Metrics</div>; - } -} - -export default Metrics; diff --git a/frontend/src/metabase/new_query/containers/NewQuery.jsx b/frontend/src/metabase/new_query/containers/NewQuery.jsx deleted file mode 100644 index 8f1db2d0c682124e320391715db009738581b079..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/new_query/containers/NewQuery.jsx +++ /dev/null @@ -1,143 +0,0 @@ -/* @flow */ - -import React, { Component } from 'react' -import { connect } from 'react-redux' - -import { fetchDatabases, fetchTableMetadata } from 'metabase/redux/metadata' -import { resetQuery, updateQuery } from '../new_query' - -import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; - -import Table from "metabase-lib/lib/metadata/Table"; -import Database from "metabase-lib/lib/metadata/Database"; -import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery" -import type { TableId } from "metabase/meta/types/Table"; -import Metadata from "metabase-lib/lib/metadata/Metadata"; -import { getMetadata, getTables } from "metabase/selectors/metadata"; -import NewQueryOption from "metabase/new_query/components/NewQueryOption"; -import NativeQuery from "metabase-lib/lib/queries/NativeQuery"; -import { getCurrentQuery, getPlainNativeQuery } from "metabase/new_query/selectors"; -import Query from "metabase-lib/lib/queries/Query"; - -const mapStateToProps = state => ({ - query: getCurrentQuery(state), - plainNativeQuery: getPlainNativeQuery(state), - metadata: getMetadata(state), - tables: getTables(state) -}) - -const mapDispatchToProps = { - fetchDatabases, - fetchTableMetadata, - resetQuery, - updateQuery -} - - -type Props = { - // Component parameters - onComplete: (StructuredQuery) => void, - - // Properties injected with redux connect - query: StructuredQuery, - plainNativeQuery: NativeQuery, - - resetQuery: () => void, - updateQuery: (Query) => void, - - fetchDatabases: () => void, - fetchTableMetadata: (TableId) => void, - - metadata: Metadata -} - -export class NewQuery extends Component { - props: Props - - componentWillMount() { - this.props.fetchDatabases(); - this.props.resetQuery(); - } - - startGuiQuery = (database: Database) => { - this.props.onComplete(this.props.query); - } - - startNativeQuery = (table: Table) => { - this.props.onComplete(this.props.plainNativeQuery); - } - - // NOTE: Not in the first iteration yet! - // - // showMetricSearch = () => { - // - // } - // - // showSegmentSearch = () => { - // - // } - // - // startMetricQuery = (metric: Metric) => { - // this.props.fetchTableMetadata(metric.table().id); - // - // this.props.updateQuery( - // this.props.query - // .setDatabase(metric.database) - // .setTable(metric.table) - // .addAggregation(metric.aggregationClause()) - // ) - // this.props.onComplete(updatedQuery); - // } - - render() { - const { query } = this.props - - if (!query) { - return <LoadingAndErrorWrapper loading={true}/> - } - - return ( - <div className="flex-full full ml-auto mr-auto px1 mt4 mb2 align-center" - style={{maxWidth: "800px"}}> - <ol className="flex-full Grid Grid--guttersXl Grid--full small-Grid--1of2"> - - {/*<li className="Grid-cell"> - <NewQueryOption - image="/app/img/questions_illustration" - title="Metrics" - description="See data over time, as a map, or pivoted to help you understand trends or changes." - /> - </li> - <li className="Grid-cell"> - <NewQueryOption - image="/app/img/list_illustration" - title="Segments" - description="Explore tables and see what’s going on underneath your charts." - width={180} - /> - </li>*/} - - <li className="Grid-cell"> - {/*TODO: Move illustrations to the new location in file hierarchy. At the same time put an end to the equal-size-@2x ridicule. */} - <NewQueryOption - image="/app/img/custom_question" - title="New question" - description="Use the simple query builder to see trends, lists of things, or to create your own metrics." - onClick={this.startGuiQuery} - /> - </li> - <li className="Grid-cell"> - <NewQueryOption - image="/app/img/sql_illustration@2x" - title="SQL" - description="For more complicated questions, you can write your own SQL." - onClick={this.startNativeQuery} - /> - </li> - </ol> - </div> - ) - } -} - -export default connect(mapStateToProps, mapDispatchToProps)(NewQuery) diff --git a/frontend/src/metabase/new_query/containers/NewQueryOptions.jsx b/frontend/src/metabase/new_query/containers/NewQueryOptions.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2fd3912bfb1277fab9cf140839949d4c99870361 --- /dev/null +++ b/frontend/src/metabase/new_query/containers/NewQueryOptions.jsx @@ -0,0 +1,137 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' + +import { + fetchDatabases, + fetchMetrics, + fetchSegments, +} from 'metabase/redux/metadata' + +import { resetQuery } from '../new_query' + +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery" +import Metadata from "metabase-lib/lib/metadata/Metadata"; +import { getMetadata, getMetadataFetched } from "metabase/selectors/metadata"; +import NewQueryOption from "metabase/new_query/components/NewQueryOption"; +import NativeQuery from "metabase-lib/lib/queries/NativeQuery"; +import { getCurrentQuery, getPlainNativeQuery } from "metabase/new_query/selectors"; +import { getUserIsAdmin } from "metabase/selectors/user"; + +const mapStateToProps = state => ({ + query: getCurrentQuery(state), + plainNativeQuery: getPlainNativeQuery(state), + metadata: getMetadata(state), + metadataFetched: getMetadataFetched(state), + isAdmin: getUserIsAdmin(state) +}) + +const mapDispatchToProps = { + fetchDatabases, + fetchMetrics, + fetchSegments, + resetQuery +} + +type Props = { + // Component parameters + getUrlForQuery: (StructuredQuery) => void, + metricSearchUrl: string, + segmentSearchUrl: string, + + // Properties injected with redux connect + query: StructuredQuery, + plainNativeQuery: NativeQuery, + metadata: Metadata, + isAdmin: boolean, + + resetQuery: () => void, + + fetchDatabases: () => void, + fetchMetrics: () => void, + fetchSegments: () => void, +} + +export class NewQueryOptions extends Component { + props: Props + + componentWillMount() { + this.props.fetchDatabases() + this.props.fetchMetrics() + this.props.fetchSegments() + + this.props.resetQuery(); + } + + getGuiQueryUrl = () => { + return this.props.getUrlForQuery(this.props.query); + } + + getNativeQueryUrl = () => { + return this.props.getUrlForQuery(this.props.plainNativeQuery); + } + + render() { + const { query, metadata, metadataFetched, isAdmin, metricSearchUrl, segmentSearchUrl } = this.props + + if (!query || (!isAdmin && (!metadataFetched.metrics || !metadataFetched.segments))) { + return <LoadingAndErrorWrapper loading={true}/> + } + + const showMetricOption = isAdmin || metadata.metricsList().length > 0 + const showSegmentOption = isAdmin || metadata.segmentsList().length > 0 + const showCustomInsteadOfNewQuestionText = showMetricOption || showSegmentOption + + return ( + <div className="bg-slate-extra-light full-height flex"> + <div className="wrapper wrapper--trim lg-wrapper--trim xl-wrapper--trim flex-full px1 mt4 mb2 align-center"> + <div className="flex align-center justify-center" style={{minHeight: "100%"}}> + <ol className="flex-full Grid Grid--guttersXl Grid--full small-Grid--1of2 large-Grid--normal"> + { showMetricOption && + <li className="Grid-cell"> + <NewQueryOption + image="/app/img/questions_illustration" + title="Metrics" + description="See data over time, as a map, or pivoted to help you understand trends or changes." + to={metricSearchUrl} + /> + </li> + } + { showSegmentOption && + <li className="Grid-cell"> + <NewQueryOption + image="/app/img/list_illustration" + title="Segments" + description="Explore tables and see what’s going on underneath your charts." + width={180} + to={segmentSearchUrl} + /> + </li> + } + <li className="Grid-cell"> + {/*TODO: Move illustrations to the new location in file hierarchy. At the same time put an end to the equal-size-@2x ridicule. */} + <NewQueryOption + image="/app/img/query_builder_illustration" + title={ showCustomInsteadOfNewQuestionText ? "Custom" : "New question"} + description="Use the simple query builder to see trends, lists of things, or to create your own metrics." + width={180} + to={this.getGuiQueryUrl} + /> + </li> + <li className="Grid-cell"> + <NewQueryOption + image="/app/img/sql_illustration" + title="SQL" + description="For more complicated questions, you can write your own SQL." + to={this.getNativeQueryUrl} + /> + </li> + </ol> + </div> + </div> + </div> + ) + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(NewQueryOptions) diff --git a/frontend/src/metabase/new_query/containers/SegmentSearch.jsx b/frontend/src/metabase/new_query/containers/SegmentSearch.jsx new file mode 100644 index 0000000000000000000000000000000000000000..aee20268390a2927f7cee6e41b2cf94bc795de7f --- /dev/null +++ b/frontend/src/metabase/new_query/containers/SegmentSearch.jsx @@ -0,0 +1,98 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' +import _ from 'underscore' + +import { fetchDatabases, fetchSegments } from "metabase/redux/metadata"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import EntitySearch from "metabase/containers/EntitySearch"; +import { getMetadata, getMetadataFetched } from "metabase/selectors/metadata"; + +import Metadata from "metabase-lib/lib/metadata/Metadata"; +import type { Segment } from "metabase/meta/types/Segment"; +import EmptyState from "metabase/components/EmptyState"; + +import type { StructuredQuery } from "metabase/meta/types/Query"; +import { getCurrentQuery } from "metabase/new_query/selectors"; +import { resetQuery } from '../new_query' + +const mapStateToProps = state => ({ + query: getCurrentQuery(state), + metadata: getMetadata(state), + metadataFetched: getMetadataFetched(state) +}) +const mapDispatchToProps = { + fetchSegments, + fetchDatabases, + resetQuery +} + +@connect(mapStateToProps, mapDispatchToProps) +export default class SegmentSearch extends Component { + props: { + getUrlForQuery: (StructuredQuery) => void, + query: StructuredQuery, + metadata: Metadata, + metadataFetched: any, + fetchSegments: () => void, + fetchDatabases: () => void, + resetQuery: () => void + } + + componentDidMount() { + this.props.fetchDatabases() // load databases if not loaded yet + this.props.fetchSegments(true) // segments may change more often so always reload them + this.props.resetQuery(); + } + + getUrlForSegment = (segment: Segment) => { + const updatedQuery = this.props.query + .setDatabase(segment.table.database) + .setTable(segment.table) + .addFilter(segment.filterClause()) + + return this.props.getUrlForQuery(updatedQuery); + } + + render() { + const { metadataFetched, metadata } = this.props; + + const isLoading = !metadataFetched.segments || !metadataFetched.databases + + return ( + <LoadingAndErrorWrapper loading={isLoading}> + {() => { + // TODO Atte Keinänen 8/22/17: If you call `/api/table/:id/table_metadata` it returns + // all segments (also retired ones) and they are missing both `is_active` and `creator` props. Currently this + // filters them out but we should definitely update the endpoints in the upcoming metadata API refactoring. + const sortedActiveSegments = _.chain(metadata.segmentsList()) + .filter((segment) => segment.isActive()) + .sortBy(({name}) => name.toLowerCase()) + .value() + + if (sortedActiveSegments.length > 0) { + return <EntitySearch + title="Which segment?" + entities={sortedActiveSegments} + getUrlForEntity={this.getUrlForSegment} + /> + } else { + return ( + <div className="mt2 flex-full flex align-center justify-center bg-slate-extra-light"> + <EmptyState + message={<span>Defining common segments for your team makes it even easier to ask questions</span>} + image="/app/img/segments_illustration" + action="How to create segments" + link="http://www.metabase.com/docs/latest/administration-guide/07-segments-and-metrics.html" + className="mt2" + imageClassName="mln2" + /> + </div> + ) + } + }} + </LoadingAndErrorWrapper> + ) + } + +} + diff --git a/frontend/src/metabase/new_query/containers/Segments.jsx b/frontend/src/metabase/new_query/containers/Segments.jsx deleted file mode 100644 index 41c947aea47b6a76da07aaff956c220bfc1317c9..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/new_query/containers/Segments.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import React, { Component } from "react"; -import { connect } from "react-redux"; -import { Link } from "react-router"; - -import { serializeCardForUrl } from "metabase/lib/card"; - -import { fetchSegments } from "metabase/redux/metadata"; -import { getSegments } from "metabase/selectors/metadata"; - -const mapStateToProps = state => ({ - segments: Object.values(getSegments(state)) -}); - -const mapDispatchToProps = { - fetchSegments -}; - -@connect(mapStateToProps, mapDispatchToProps) -class Segments extends Component { - componentWillMount() { - this.props.fetchSegments(); - } - render() { - const { segments } = this.props; - return ( - <div> - <div> - <Link to="question/new"> - Back - </Link> - <h2>Which segment?</h2> - </div> - <ol> - {segments.map(segment => ( - <li key={segment.id}> - <Link to={serializeCardForUrl(segment.definition)}> - {segment.name} - </Link> - </li> - ))} - </ol> - </div> - ); - } -} - -export default Segments; diff --git a/frontend/src/metabase/new_query/new_query.js b/frontend/src/metabase/new_query/new_query.js index 3bc383098ee35549ee7722942c85ca0b5393e377..bb911dde0bb581ebfcc9faabd83524befc833d31 100644 --- a/frontend/src/metabase/new_query/new_query.js +++ b/frontend/src/metabase/new_query/new_query.js @@ -4,7 +4,7 @@ */ import { handleActions, combineReducers } from "metabase/lib/redux"; -import StructuredQuery, { STRUCTURED_QUERY_TEMPLATE } from "metabase-lib/lib/queries/StructuredQuery"; +import { STRUCTURED_QUERY_TEMPLATE } from "metabase-lib/lib/queries/StructuredQuery"; import type { DatasetQuery } from "metabase/meta/types/Card"; /** @@ -17,21 +17,12 @@ export function resetQuery() { } } -export const UPDATE_QUERY = "metabase/new_query/UPDATE_QUERY"; -export function updateQuery(updatedQuery: StructuredQuery) { - return function(dispatch, getState) { - dispatch.action(UPDATE_QUERY, updatedQuery.datasetQuery()) - } -} - /** * The current query that we are creating */ -// TODO Atte Keinänen 6/12/17: Test later how Flow typing with redux-actions could work best for our reducers // something like const query = handleActions<DatasetQuery>({ const datasetQuery = handleActions({ [RESET_QUERY]: (state, { payload }): DatasetQuery => payload, - [UPDATE_QUERY]: (state, { payload }): DatasetQuery => payload }, STRUCTURED_QUERY_TEMPLATE); export default combineReducers({ diff --git a/frontend/src/metabase/new_query/new_query.spec.js b/frontend/src/metabase/new_query/new_query.spec.js deleted file mode 100644 index 579632e7012a3a060b8fd7e786b3f93847ac8f7f..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/new_query/new_query.spec.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Would possibly be nice to test the whole action-reducer-selector loop in here - */ -describe("New query flow", () => { - it("temporary placeholder test", () => { - expect(true).toEqual(true); - }) -}); diff --git a/frontend/src/metabase/new_query/router_wrappers.js b/frontend/src/metabase/new_query/router_wrappers.js index 78105c7886b88f27f301eacfa343d81a3f715777..f0111005249c7c198c88b4a63a54d2e84a108076 100644 --- a/frontend/src/metabase/new_query/router_wrappers.js +++ b/frontend/src/metabase/new_query/router_wrappers.js @@ -2,12 +2,55 @@ import React, { Component } from "react"; import { connect } from "react-redux"; import { push } from "react-router-redux"; -import NewQuery from "metabase/new_query/containers/NewQuery"; +import NewQueryOptions from "./containers/NewQueryOptions"; +import SegmentSearch from "./containers/SegmentSearch"; +import MetricSearch from "./containers/MetricSearch"; @connect(null, { onChangeLocation: push }) export class NewQuestionStart extends Component { + getUrlForQuery = (query) => { + return query.question().getUrl() + } + render() { - return <NewQuery onComplete={(query) => this.props.onChangeLocation(query.question().getUrl())} /> + return ( + <NewQueryOptions + getUrlForQuery={this.getUrlForQuery} + metricSearchUrl="/question/new/metric" + segmentSearchUrl="/question/new/segment" + /> + ) } } +@connect(null, { onChangeLocation: push }) +export class NewQuestionMetricSearch extends Component { + getUrlForQuery = (query) => { + return query.question().getUrl() + } + + render() { + return ( + <MetricSearch + getUrlForQuery={this.getUrlForQuery} + defaultStep={"metricSearch"} + /> + ) + } +} + +@connect(null, { onChangeLocation: push }) +export class NewQuestionSegmentSearch extends Component { + getUrlForQuery = (query) => { + return query.question().getUrl() + } + + render() { + return ( + <SegmentSearch + getUrlForQuery={this.getUrlForQuery} + defaultStep={"segmentSearch"} + /> + ) + } +} diff --git a/frontend/src/metabase/new_query/selectors.js b/frontend/src/metabase/new_query/selectors.js index 417b1967d21ad7a9e8499acc1386367b2667a236..c47c9ed47dcd67a37414eee22fd0c3d4e3ef4a57 100644 --- a/frontend/src/metabase/new_query/selectors.js +++ b/frontend/src/metabase/new_query/selectors.js @@ -16,6 +16,16 @@ export const getCurrentQuery = state => { } export const getPlainNativeQuery = state => { + const metadata = getMetadata(state) const question = Question.create({ metadata: getMetadata(state) }) - return new NativeQuery(question) + const databases = metadata.databasesList().filter(db => !db.is_saved_questions) + + // If we only have a single database, then default to that + // (native query editor doesn't currently show the db selector if there is only one database available) + if (databases.length === 1) { + return new NativeQuery(question).setDatabase(databases[0]) + } else { + return new NativeQuery(question) + } + } \ No newline at end of file diff --git a/frontend/src/metabase/redux/metadata.js b/frontend/src/metabase/redux/metadata.js index 13507ac73a21210007a32993c94719b157f9d0fc..63b445f0ae103acdf1554702c03468282ec40b3f 100644 --- a/frontend/src/metabase/redux/metadata.js +++ b/frontend/src/metabase/redux/metadata.js @@ -81,7 +81,6 @@ export const updateMetricImportantFields = createThunkAction(UPDATE_METRIC_IMPOR }; }); - export const FETCH_SEGMENTS = "metabase/metadata/FETCH_SEGMENTS"; export const fetchSegments = createThunkAction(FETCH_SEGMENTS, (reload = false) => { return async (dispatch, getState) => { diff --git a/frontend/src/metabase/redux/requests.js b/frontend/src/metabase/redux/requests.js index da294bc72d4fdee7c4e2e378390ff7a018aa388d..958d8be731aaa47aa8f99cd2e05c816f214073cc 100644 --- a/frontend/src/metabase/redux/requests.js +++ b/frontend/src/metabase/redux/requests.js @@ -1,15 +1,17 @@ /* @flow weak */ import { handleActions, createAction } from "metabase/lib/redux"; -import { assocIn } from "icepick"; +import { getIn, assocIn } from "icepick"; +import { combineReducers } from "redux"; -const SET_REQUEST_STATE = "metabase/requests/SET_REQUEST_STATE"; +export const SET_REQUEST_STATE = "metabase/requests/SET_REQUEST_STATE"; const CLEAR_REQUEST_STATE = "metabase/requests/CLEAR_REQUEST_STATE"; export const setRequestState = createAction(SET_REQUEST_STATE); export const clearRequestState = createAction(CLEAR_REQUEST_STATE); -export default handleActions({ +// For a given state path, returns the current request state ("LOADING", "LOADED" or a request error) +export const states = handleActions({ [SET_REQUEST_STATE]: { next: (state, { payload }) => assocIn( state, @@ -25,3 +27,25 @@ export default handleActions({ ) } }, {}); + +// For given state path, returns true if the data has been successfully fetched at least once +export const fetched = handleActions({ + [SET_REQUEST_STATE]: { + next: (state, {payload}) => { + const isFetch = payload.statePath[payload.statePath.length - 1] === "fetch" + + if (isFetch) { + const statePathWithoutFetch = payload.statePath.slice(0, -1) + return assocIn( + state, + statePathWithoutFetch, + getIn(state, statePathWithoutFetch) || payload.state === "LOADED" + ) + } else { + return state + } + } + } +}, {}) + +export default combineReducers({ states, fetched }) diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index eb6a83bf053502996cd4793aeef966892ae872c7..796e82e84bd9ff6554dec75c2b66c1ef957c6b1a 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -42,7 +42,7 @@ import SetupApp from "metabase/setup/containers/SetupApp.jsx"; import UserSettingsApp from "metabase/user/containers/UserSettingsApp.jsx"; // new question -import { NewQuestionStart } from "metabase/new_query/router_wrappers"; +import { NewQuestionStart, NewQuestionMetricSearch, NewQuestionSegmentSearch } from "metabase/new_query/router_wrappers"; // admin containers import DatabaseListApp from "metabase/admin/databases/containers/DatabaseListApp.jsx"; @@ -200,10 +200,10 @@ export const getRoutes = (store) => <Route path="/question"> <IndexRoute component={QueryBuilder} /> { /* NEW QUESTION FLOW */ } - <Route path="new"> + <Route path="new" title="New Question"> <IndexRoute component={NewQuestionStart} /> - {/*<Route path="metrics" component={NewQuestionMetrics} />*/} - {/*<Route path="segments" component={NewQuestionSegments} />*/} + <Route path="metric" title="Metrics" component={NewQuestionMetricSearch} /> + <Route path="segment" title="Segments" component={NewQuestionSegmentSearch} /> </Route> </Route> <Route path="/question/:cardId" component={QueryBuilder} /> diff --git a/frontend/src/metabase/selectors/metadata.js b/frontend/src/metabase/selectors/metadata.js index 6cb138df2fd51060ca77156a2e87904acc0395d9..1b6bda217a42d1feccf73be2e8a207faacc9aec5 100644 --- a/frontend/src/metabase/selectors/metadata.js +++ b/frontend/src/metabase/selectors/metadata.js @@ -27,6 +27,7 @@ export const getNormalizedFields = state => state.metadata.fields; export const getNormalizedMetrics = state => state.metadata.metrics; export const getNormalizedSegments = state => state.metadata.segments; +export const getMetadataFetched = state => state.requests.fetched.metadata || {} // TODO: these should be denomalized but non-cylical, and only to the same "depth" previous "tableMetadata" was, e.x. // @@ -68,6 +69,7 @@ export const getMetadata = createSelector( meta.fields = copyObjects(meta, fields, Field) meta.segments = copyObjects(meta, segments, Segment) meta.metrics = copyObjects(meta, metrics, Metric) + // meta.loaded = getLoadedStatuses(requestStates) hydrateList(meta.databases, "tables", meta.tables); diff --git a/frontend/test/home/HomepageApp.integ.spec.js b/frontend/test/home/HomepageApp.integ.spec.js index 74a65737c1dbc867aa3020c9ae3d0520ab67f191..3aecd44370ed11f83553f856bdd3f9c21022260f 100644 --- a/frontend/test/home/HomepageApp.integ.spec.js +++ b/frontend/test/home/HomepageApp.integ.spec.js @@ -15,7 +15,6 @@ import { import { delay } from 'metabase/lib/promise'; import HomepageApp from "metabase/home/containers/HomepageApp"; -import { createMetric, createSegment } from "metabase/admin/datamodel/datamodel"; import { FETCH_ACTIVITY } from "metabase/home/actions"; import { QUERY_COMPLETED } from "metabase/query_builder/actions"; @@ -23,22 +22,33 @@ import Activity from "metabase/home/components/Activity"; import ActivityItem from "metabase/home/components/ActivityItem"; import ActivityStory from "metabase/home/components/ActivityStory"; import Scalar from "metabase/visualizations/visualizations/Scalar"; +import { CardApi, MetricApi, SegmentApi } from "metabase/services"; describe("HomepageApp", () => { + let questionId = null; + let segmentId = null; + let metricId = null; + beforeAll(async () => { await login() // Create some entities that will show up in the top of activity feed // This test doesn't care if there already are existing items in the feed or not // Delays are required for having separable creation times for each entity - await createSavedQuestion(unsavedOrderCountQuestion) + questionId = (await createSavedQuestion(unsavedOrderCountQuestion)).id() await delay(100); - await createSegment(orders_past_30_days_segment); + segmentId = (await SegmentApi.create(orders_past_30_days_segment)).id; await delay(100); - await createMetric(vendor_count_metric); + metricId = (await MetricApi.create(vendor_count_metric)).id; await delay(100); }) + afterAll(async () => { + await MetricApi.delete({ metricId, revision_message: "Let's exterminate this metric" }) + await SegmentApi.delete({ segmentId, revision_message: "Let's exterminate this segment" }) + await CardApi.delete({ cardId: questionId }) + }) + describe("activity feed", async () => { it("shows the expected list of activity", async () => { const store = await createTestStore() diff --git a/frontend/test/lib/redux.unit.spec.js b/frontend/test/lib/redux.unit.spec.js index 723be7003d9532c4bbe7ab2465969fd5abac683e..df8cb0cfc85e5d287c573f089af7d564ec19b4bf 100644 --- a/frontend/test/lib/redux.unit.spec.js +++ b/frontend/test/lib/redux.unit.spec.js @@ -3,6 +3,8 @@ import { updateData } from 'metabase/lib/redux'; +import { delay } from "metabase/lib/promise" + describe("Metadata", () => { const getDefaultArgs = ({ existingData = 'data', @@ -17,7 +19,7 @@ describe("Metadata", () => { requestStatePath = statePath, existingStatePath = statePath, getState = () => ({ - requests: { test: { path: { fetch: requestState, update: requestState } } }, + requests: { states: { test: { path: { fetch: requestState, update: requestState } } } }, test: { path: existingData } }), dispatch = jasmine.createSpy('dispatch'), @@ -39,15 +41,15 @@ describe("Metadata", () => { const args = getDefaultArgs({}); describe("fetchData()", () => { - it("should return new data if request hasn't been made", async (done) => { + it("should return new data if request hasn't been made", async () => { const argsDefault = getDefaultArgs({}); const data = await fetchData(argsDefault); + await delay(10); expect(argsDefault.dispatch.calls.count()).toEqual(2); expect(data).toEqual(args.newData); - done(); }); - it("should return existing data if request has been made", async (done) => { + it("should return existing data if request has been made", async () => { const argsLoading = getDefaultArgs({requestState: args.requestStateLoading}); const dataLoading = await fetchData(argsLoading); expect(argsLoading.dispatch.calls.count()).toEqual(0); @@ -57,21 +59,20 @@ describe("Metadata", () => { const dataLoaded = await fetchData(argsLoaded); expect(argsLoaded.dispatch.calls.count()).toEqual(0); expect(dataLoaded).toEqual(args.existingData); - done(); }); - it("should return new data if previous request ended in error", async (done) => { + it("should return new data if previous request ended in error", async () => { const argsError = getDefaultArgs({requestState: args.requestStateError}); const dataError = await fetchData(argsError); + await delay(10); expect(argsError.dispatch.calls.count()).toEqual(2); expect(dataError).toEqual(args.newData); - done(); }); // FIXME: this seems to make jasmine ignore the rest of the tests // is an exception bubbling up from fetchData? why? // how else to test return value in the catch case? - xit("should return existing data if request fails", async (done) => { + it("should return existing data if request fails", async () => { const argsFail = getDefaultArgs({getData: () => Promise.reject('error')}); try{ @@ -82,12 +83,11 @@ describe("Metadata", () => { catch(error) { return; } - done(); }); }); describe("updateData()", () => { - it("should return new data regardless of previous request state", async (done) => { + it("should return new data regardless of previous request state", async () => { const argsDefault = getDefaultArgs({}); const data = await updateData(argsDefault); expect(argsDefault.dispatch.calls.count()).toEqual(2); @@ -107,16 +107,14 @@ describe("Metadata", () => { const dataError = await updateData(argsError); expect(argsError.dispatch.calls.count()).toEqual(2); expect(dataError).toEqual(args.newData); - done(); }); - // FIXME: same problem as fetchData() case - xit("should return existing data if request fails", async (done) => { + it("should return existing data if request fails", async () => { const argsFail = getDefaultArgs({putData: () => {throw new Error('test')}}); - const data = await fetchData(argsFail); + const data = await updateData(argsFail); + await delay(10) expect(argsFail.dispatch.calls.count()).toEqual(2); expect(data).toEqual(args.existingData); - done(); }); }); }); diff --git a/frontend/test/query_builder/components/dataref/MetricPane.integ.spec.js b/frontend/test/query_builder/components/dataref/MetricPane.integ.spec.js index 0ace79bee1ab15a91f50f3ba391e41fd2e29ddfe..44dfe0f771ecdac37ef7f3520c146b48cc5cef60 100644 --- a/frontend/test/query_builder/components/dataref/MetricPane.integ.spec.js +++ b/frontend/test/query_builder/components/dataref/MetricPane.integ.spec.js @@ -13,20 +13,21 @@ import { delay } from "metabase/lib/promise" import QueryBuilder from "metabase/query_builder/containers/QueryBuilder"; import DataReference from "metabase/query_builder/components/dataref/DataReference"; import { vendor_count_metric } from "__support__/sample_dataset_fixture"; -import { createMetric } from "metabase/admin/datamodel/datamodel"; import { FETCH_TABLE_METADATA } from "metabase/redux/metadata"; import QueryDefinition from "metabase/query_builder/components/dataref/QueryDefinition"; import QueryButton from "metabase/components/QueryButton"; import Scalar from "metabase/visualizations/visualizations/Scalar"; import * as Urls from "metabase/lib/urls"; +import { MetricApi } from "metabase/services"; describe("MetricPane", () => { let store = null; let queryBuilder = null; + let metricId = null; beforeAll(async () => { await login(); - await createMetric(vendor_count_metric); + metricId = (await MetricApi.create(vendor_count_metric)).id; store = await createTestStore() store.pushPath(Urls.plainQuestion()); @@ -34,6 +35,9 @@ describe("MetricPane", () => { await store.waitForActions([INITIALIZE_QB]); }) + afterAll(async () => { + await MetricApi.delete({ metricId, revision_message: "Let's exterminate this metric" }) + }) // NOTE: These test cases are intentionally stateful // (doing the whole app rendering thing in every single test case would probably slow things down) diff --git a/frontend/test/query_builder/query_builder.integ.spec.js b/frontend/test/query_builder/query_builder.integ.spec.js index 69cb6d8c10789474b70fbb601ddc18c1b043923a..7e237152a215c49177e48b638c5d9ebdaf06c39f 100644 --- a/frontend/test/query_builder/query_builder.integ.spec.js +++ b/frontend/test/query_builder/query_builder.integ.spec.js @@ -34,7 +34,10 @@ import { deleteFieldDimension, updateFieldDimension, updateFieldValues, - FETCH_TABLE_METADATA + FETCH_TABLE_METADATA, + FETCH_METRICS, + FETCH_SEGMENTS, + FETCH_DATABASES } from "metabase/redux/metadata"; import FieldList, { DimensionPicker } from "metabase/query_builder/components/FieldList"; import FilterPopover from "metabase/query_builder/components/filters/FilterPopover"; @@ -65,6 +68,15 @@ import NewQueryOption from "metabase/new_query/components/NewQueryOption"; import { RESET_QUERY } from "metabase/new_query/new_query"; import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; import NativeQuery from "metabase-lib/lib/queries/NativeQuery"; +import EntitySearch, { + SearchGroupingOption, SearchResultListItem, + SearchResultsGroup +} from "metabase/containers/EntitySearch"; +import { MetricApi, SegmentApi } from "metabase/services"; +import AggregationWidget from "metabase/query_builder/components/AggregationWidget"; +import { SET_REQUEST_STATE } from "metabase/redux/requests"; +import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor"; +import DataSelector from "metabase/query_builder/components/DataSelector"; const REVIEW_PRODUCT_ID = 32; const REVIEW_RATING_ID = 33; @@ -98,6 +110,26 @@ describe("QueryBuilder", () => { * Simple tests for seeing if the query builder renders without errors */ describe("for new questions", async () => { + let metricId = null; + let segmentId = null; + + beforeAll(async () => { + // TODO: Move these test metric/segment definitions to a central place + const metricDef = {name: "A Metric", description: "For testing new question flow", table_id: 1,show_in_getting_started: true, + definition: {database: 1, query: {aggregation: ["count"]}}} + const segmentDef = {name: "A Segment", description: "For testing new question flow", table_id: 1, show_in_getting_started: true, + definition: {database: 1, query: {filter: ["abc"]}}} + + // Needed for question creation flow + metricId = (await MetricApi.create(metricDef)).id; + segmentId = (await SegmentApi.create(segmentDef)).id; + }) + + afterAll(async () => { + await MetricApi.delete({ metricId, revision_message: "The lifetime of this metric was just a few seconds" }) + await SegmentApi.delete({ segmentId, revision_message: "Sadly this segment didn't enjoy a long life either" }) + }) + it("redirects /question to /question/new", async () => { const store = await createTestStore() store.pushPath("/question"); @@ -110,33 +142,106 @@ describe("QueryBuilder", () => { store.pushPath(Urls.newQuestion()); const app = mount(store.getAppContainer()); - await store.waitForActions([RESET_QUERY]); + await store.waitForActions([RESET_QUERY, FETCH_METRICS, FETCH_SEGMENTS]); + await store.waitForActions([SET_REQUEST_STATE]); - expect(app.find(NewQueryOption).length).toBe(2) + expect(app.find(NewQueryOption).length).toBe(4) }); - it("lets you start a custom gui query", async () => { + it("lets you start a custom gui question", async () => { const store = await createTestStore() store.pushPath(Urls.newQuestion()); const app = mount(store.getAppContainer()); - await store.waitForActions([RESET_QUERY]); + await store.waitForActions([RESET_QUERY, FETCH_METRICS, FETCH_SEGMENTS]); + await store.waitForActions([SET_REQUEST_STATE]); - click(app.find(NewQueryOption).first()) + click(app.find(NewQueryOption).filterWhere((c) => c.prop('title') === "Custom")) await store.waitForActions(INITIALIZE_QB, UPDATE_URL, LOAD_METADATA_FOR_CARD); expect(getQuery(store.getState()) instanceof StructuredQuery).toBe(true) }) - // Something doesn't work in tests when opening the native query editor :/ - xit("lets you start a custom native query", async () => { + it("lets you start a custom native question", async () => { + // Don't render Ace editor in tests because it uses many DOM methods that aren't supported by jsdom + // see also parameters.integ.js for more notes about Ace editor testing + NativeQueryEditor.prototype.loadAceEditor = () => {} + const store = await createTestStore() store.pushPath(Urls.newQuestion()); const app = mount(store.getAppContainer()); - await store.waitForActions([RESET_QUERY]); + await store.waitForActions([RESET_QUERY, FETCH_METRICS, FETCH_SEGMENTS, FETCH_DATABASES]); + await store.waitForActions([SET_REQUEST_STATE]); - click(app.find(NewQueryOption).last()) + click(app.find(NewQueryOption).filterWhere((c) => c.prop('title') === "SQL")) await store.waitForActions(INITIALIZE_QB); expect(getQuery(store.getState()) instanceof NativeQuery).toBe(true) + + // No database selector visible because in test environment we should + // only have a single database + expect(app.find(DataSelector).length).toBe(0) + + // The name of the database should be displayed + expect(app.find(NativeQueryEditor).text()).toMatch(/Sample Dataset/) + }) + + it("lets you start a question from a metric", async () => { + const store = await createTestStore() + + store.pushPath(Urls.newQuestion()); + const app = mount(store.getAppContainer()); + await store.waitForActions([RESET_QUERY, FETCH_METRICS, FETCH_SEGMENTS]); + await store.waitForActions([SET_REQUEST_STATE]); + + click(app.find(NewQueryOption).filterWhere((c) => c.prop('title') === "Metrics")) + await store.waitForActions(FETCH_DATABASES); + await store.waitForActions([SET_REQUEST_STATE]); + expect(store.getPath()).toBe("/question/new/metric") + + const entitySearch = app.find(EntitySearch) + const viewByCreator = entitySearch.find(SearchGroupingOption).last() + expect(viewByCreator.text()).toBe("Creator"); + click(viewByCreator) + + const group = entitySearch.find(SearchResultsGroup) + expect(group.prop('groupName')).toBe("Bobby Tables") + + const metricSearchResult = group.find(SearchResultListItem) + .filterWhere((item) => /A Metric/.test(item.text())) + click(metricSearchResult.childAt(0)) + + await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]); + expect( + app.find(AggregationWidget).find(".View-section-aggregation").text() + ).toBe("A Metric") + }) + + it("lets you start a question from a segment", async () => { + const store = await createTestStore() + + store.pushPath(Urls.newQuestion()); + const app = mount(store.getAppContainer()); + await store.waitForActions([RESET_QUERY, FETCH_METRICS, FETCH_SEGMENTS]); + await store.waitForActions([SET_REQUEST_STATE]); + + click(app.find(NewQueryOption).filterWhere((c) => c.prop('title') === "Segments")) + await store.waitForActions(FETCH_DATABASES); + await store.waitForActions([SET_REQUEST_STATE]); + expect(store.getPath()).toBe("/question/new/segment") + + const entitySearch = app.find(EntitySearch) + const viewByTable = entitySearch.find(SearchGroupingOption).at(1) + expect(viewByTable.text()).toBe("Table"); + click(viewByTable) + + const group = entitySearch.find(SearchResultsGroup) + .filterWhere((group) => group.prop('groupName') === "Orders") + + const metricSearchResult = group.find(SearchResultListItem) + .filterWhere((item) => /A Segment/.test(item.text())) + click(metricSearchResult.childAt(0)) + + await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]); + expect(app.find(FilterWidget).find(".Filter-section-value").text()).toBe("A Segment") }) }); @@ -737,7 +842,6 @@ describe("QueryBuilder", () => { const firstRowCells = table.find("tbody tr").first().find("td"); expect(firstRowCells.length).toBe(2); - // lat-long formatting should be improved when it comes to trailing zeros expect(firstRowCells.first().text()).toBe("90° S – 80° S"); const countCell = firstRowCells.last(); diff --git a/resources/frontend_client/app/img/custom_question.png b/resources/frontend_client/app/img/custom_question.png deleted file mode 100644 index dfba328409c9a7bd759cdc26acfe4a1e08ba1b22..0000000000000000000000000000000000000000 Binary files a/resources/frontend_client/app/img/custom_question.png and /dev/null differ diff --git a/resources/frontend_client/app/img/custom_question@2x.png b/resources/frontend_client/app/img/custom_question@2x.png deleted file mode 100644 index dfba328409c9a7bd759cdc26acfe4a1e08ba1b22..0000000000000000000000000000000000000000 Binary files a/resources/frontend_client/app/img/custom_question@2x.png and /dev/null differ diff --git a/resources/frontend_client/app/img/empty_question.png b/resources/frontend_client/app/img/empty_question.png new file mode 100644 index 0000000000000000000000000000000000000000..7ba003aa07eed085e6916aaac85a3abbae98b412 Binary files /dev/null and b/resources/frontend_client/app/img/empty_question.png differ diff --git a/resources/frontend_client/app/img/empty_question@2x.png b/resources/frontend_client/app/img/empty_question@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..cb56281af1d6921c7e9e68b0c7b206e20ae1d8ac Binary files /dev/null and b/resources/frontend_client/app/img/empty_question@2x.png differ diff --git a/resources/frontend_client/app/img/list_illustration.png b/resources/frontend_client/app/img/list_illustration.png index 28d872b66634f934a5bb2551c8d7f6c43bacf808..c791c75f8a35d3a791eeb33c961c25bf2d5ef641 100644 Binary files a/resources/frontend_client/app/img/list_illustration.png and b/resources/frontend_client/app/img/list_illustration.png differ diff --git a/resources/frontend_client/app/img/list_illustration@2x.png b/resources/frontend_client/app/img/list_illustration@2x.png index 28d872b66634f934a5bb2551c8d7f6c43bacf808..368e777feb0014735e3802eb5c16b69beeaabd6e 100644 Binary files a/resources/frontend_client/app/img/list_illustration@2x.png and b/resources/frontend_client/app/img/list_illustration@2x.png differ diff --git a/resources/frontend_client/app/img/metrics_illustration.png b/resources/frontend_client/app/img/metrics_illustration.png new file mode 100644 index 0000000000000000000000000000000000000000..ba198cad7cfdf41a62a4eef66414265227a902e9 Binary files /dev/null and b/resources/frontend_client/app/img/metrics_illustration.png differ diff --git a/resources/frontend_client/app/img/metrics_illustration@2x.png b/resources/frontend_client/app/img/metrics_illustration@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ba198cad7cfdf41a62a4eef66414265227a902e9 Binary files /dev/null and b/resources/frontend_client/app/img/metrics_illustration@2x.png differ diff --git a/resources/frontend_client/app/img/query_builder_illustration.png b/resources/frontend_client/app/img/query_builder_illustration.png new file mode 100644 index 0000000000000000000000000000000000000000..b387d07336b7f8acaf239856b070ee65607a6a5d Binary files /dev/null and b/resources/frontend_client/app/img/query_builder_illustration.png differ diff --git a/resources/frontend_client/app/img/query_builder_illustration@2x.png b/resources/frontend_client/app/img/query_builder_illustration@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..abdd2e6a466bfea142f75bd8a9d1f55a9a598b41 Binary files /dev/null and b/resources/frontend_client/app/img/query_builder_illustration@2x.png differ diff --git a/resources/frontend_client/app/img/segments_illustration.png b/resources/frontend_client/app/img/segments_illustration.png new file mode 100644 index 0000000000000000000000000000000000000000..9a53a05472a929a5400c796847fbd6e83f0133fc Binary files /dev/null and b/resources/frontend_client/app/img/segments_illustration.png differ diff --git a/resources/frontend_client/app/img/segments_illustration@2x.png b/resources/frontend_client/app/img/segments_illustration@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9a53a05472a929a5400c796847fbd6e83f0133fc Binary files /dev/null and b/resources/frontend_client/app/img/segments_illustration@2x.png differ diff --git a/resources/frontend_client/app/img/sql_illustration.png b/resources/frontend_client/app/img/sql_illustration.png index 4bc10a2e8eeccc7fc9d0eb2b3d8c4fde43c17b9c..0b9f73d73e9364d9517c58a068916f370b2b5061 100644 Binary files a/resources/frontend_client/app/img/sql_illustration.png and b/resources/frontend_client/app/img/sql_illustration.png differ diff --git a/resources/frontend_client/app/img/sql_illustration@2x.png b/resources/frontend_client/app/img/sql_illustration@2x.png index 4bc10a2e8eeccc7fc9d0eb2b3d8c4fde43c17b9c..80d90e26238fd7ad506526e637e13a0568b0a082 100644 Binary files a/resources/frontend_client/app/img/sql_illustration@2x.png and b/resources/frontend_client/app/img/sql_illustration@2x.png differ