Skip to content
Snippets Groups Projects
Commit e21c91b4 authored by Atte Keinänen's avatar Atte Keinänen Committed by GitHub
Browse files

Merge pull request #5790 from metabase/new-question-flow-p2

New question flow
parents d2e2749e 29d5b614
No related branches found
No related tags found
No related merge requests found
Showing
with 852 additions and 177 deletions
......@@ -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};
......
......@@ -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[]);
}
}
......@@ -18,4 +18,8 @@ export default class Metric extends Base {
aggregationClause(): Aggregation {
return ["METRIC", this.id];
}
isActive(): boolean {
return !!this.is_active;
}
}
/* @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;
}
}
......@@ -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";
......
@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);
......
......@@ -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;
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>&nbsp;of&nbsp;<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>
)
}
}
@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
......@@ -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);
......
......@@ -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 {
......
......@@ -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;
......
......@@ -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);
......
......@@ -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';
......
......@@ -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;
}
......
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>
);
}
}
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>
)
}
}
import React, { Component } from "react";
class Metrics extends Component {
render() {
return <div>Metrics</div>;
}
}
export default Metrics;
/* @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)
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)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment