Skip to content

Commit

Permalink
Ability to add event metadata (#381)
Browse files Browse the repository at this point in the history
* Ability to add event metadata

* Close Dropdown on outside click

* Show (none) value in metadata breakdown

* Allow filtering for metadata key/val pairs

* Use correct clickhouse_ecto

* Better naming for meta filter

* Add tests

* Add changelog entry

* Remove change made for testing
  • Loading branch information
ukutaht authored Oct 28, 2020
1 parent c533562 commit 40900c7
Show file tree
Hide file tree
Showing 23 changed files with 377 additions and 53 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ All notable changes to this project will be documented in this file.

## [1.1.2] - Unreleased

### Added
- Ability to add event metadata plausible/analytics#381

### Changed
- Use alpine as base image to decrease Docker image size plausible/analytics#353

Expand Down
21 changes: 14 additions & 7 deletions assets/js/dashboard/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,22 @@ export function cancelAll() {
abortController = new AbortController()
}

function serializeFilters(filters) {
const cleaned = {}
Object.entries(filters).forEach(([key, val]) => val ? cleaned[key] = val : null);
return JSON.stringify(cleaned)
}

export function serializeQuery(query, extraQuery=[]) {
query = Object.assign({}, query, {
date: query.date ? formatISO(query.date) : undefined,
from: query.from ? formatISO(query.from) : undefined,
to: query.to ? formatISO(query.to) : undefined,
filters: query.filters ? JSON.stringify(query.filters) : undefined
}, ...extraQuery)
const queryObj = {}
if (query.period) { queryObj.period = query.period }
if (query.date) { queryObj.date = formatISO(query.date) }
if (query.from) { queryObj.from = formatISO(query.from) }
if (query.to) { queryObj.to = formatISO(query.to) }
if (query.filters) { queryObj.filters = serializeFilters(query.filters) }
Object.assign(queryObj, ...extraQuery)

return '?' + serialize(query)
return '?' + serialize(queryObj)
}

export function get(url, query, ...extraQuery) {
Expand Down
13 changes: 9 additions & 4 deletions assets/js/dashboard/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import { withRouter } from 'react-router-dom'
import {removeQueryParam} from './query'
import Datamap from 'datamaps'

function filterText(key, value) {
function filterText(key, value, query) {
if (key === "goal") {
return <span className="inline-block max-w-sm truncate">Completed goal <b>{value}</b></span>
}
if (key === "meta") {
const [metaKey, metaValue] = Object.entries(value)[0]
const eventName = query.filters["goal"] ? query.filters["goal"] : 'event'
return <span className="inline-block max-w-sm truncate">{eventName}.{metaKey} is <b>{metaValue}</b></span>
}
if (key === "source") {
return <span className="inline-block max-w-sm truncate">Source: <b>{value}</b></span>
}
Expand Down Expand Up @@ -41,14 +46,14 @@ function filterText(key, value) {
}
}

function renderFilter(history, [key, value]) {
function renderFilter(history, [key, value], query) {
function removeFilter() {
history.push({search: removeQueryParam(location.search, key)})
}

return (
<span key={key} title={value} className="inline-flex bg-white text-gray-700 shadow text-sm rounded py-2 px-3 mr-4">
{filterText(key, value)} <b className="ml-1 cursor-pointer" onClick={removeFilter}></b>
{filterText(key, value, query)} <b className="ml-1 cursor-pointer" onClick={removeFilter}></b>
</span>
)
}
Expand All @@ -61,7 +66,7 @@ function Filters({query, history, location}) {
if (appliedFilters.length > 0) {
return (
<div className="mt-4">
{ appliedFilters.map((filter) => renderFilter(history, filter)) }
{ appliedFilters.map((filter) => renderFilter(history, filter, query)) }
</div>
)
}
Expand Down
1 change: 1 addition & 0 deletions assets/js/dashboard/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function parseQuery(querystring, site) {
to: q.get('to') ? parseUTCDate(q.get('to')) : undefined,
filters: {
'goal': q.get('goal'),
'meta': JSON.parse(q.get('meta')),
'source': q.get('source'),
'utm_medium': q.get('utm_medium'),
'utm_source': q.get('utm_source'),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react';
import { Link } from 'react-router-dom'

import Bar from './bar'
import MoreLink from './more-link'
import numberFormatter from '../number-formatter'
import * as api from '../api'
import Bar from '../bar'
import MoreLink from '../more-link'
import MetaBreakdown from './meta-breakdown'
import numberFormatter from '../../number-formatter'
import * as api from '../../api'

export default class Conversions extends React.Component {
constructor(props) {
Expand Down Expand Up @@ -36,24 +37,29 @@ export default class Conversions extends React.Component {
query.set('goal', goalName)

return (
<Link to={{search: query.toString(), state: {scrollTop: true}}} style={{marginTop: '-26px'}} className="hover:underline block px-2">
<Link to={{search: query.toString()}} style={{marginTop: '-26px'}} className="hover:underline block px-2">
{ goalName }
</Link>
)
}
}

renderGoal(goal) {
const renderMeta = this.props.query.filters['goal'] == goal.name && goal.meta_keys

return (
<div className="flex items-center justify-between my-2 text-sm" key={goal.name}>
<div className="w-full h-8" style={{maxWidth: 'calc(100% - 14rem)'}}>
<Bar count={goal.count} all={this.state.goals} bg="bg-red-50" />
{this.renderGoalText(goal.name)}
</div>
<div>
<span className="font-medium inline-block w-20 text-right">{numberFormatter(goal.count)}</span>
<span className="font-medium inline-block w-36 text-right">{numberFormatter(goal.total_count)}</span>
<div className="my-2 text-sm" key={goal.name}>
<div className="flex items-center justify-between my-2">
<div className="w-full h-8 relative" style={{maxWidth: 'calc(100% - 14rem)'}}>
<Bar count={goal.count} all={this.state.goals} bg="bg-red-50" />
{this.renderGoalText(goal.name)}
</div>
<div>
<span className="font-medium inline-block w-20 text-right">{numberFormatter(goal.count)}</span>
<span className="font-medium inline-block w-36 text-right">{numberFormatter(goal.total_count)}</span>
</div>
</div>
{ renderMeta && <MetaBreakdown site={this.props.site} query={this.props.query} goal={goal} /> }
</div>
)
}
Expand Down
131 changes: 131 additions & 0 deletions assets/js/dashboard/stats/conversions/meta-breakdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React from 'react';
import { Link } from 'react-router-dom'

import Transition from "../../../transition.js";
import Bar from '../bar'
import numberFormatter from '../../number-formatter'
import * as api from '../../api'

export default class MetaBreakdown extends React.Component {
constructor(props) {
super(props)
this.handleClick = this.handleClick.bind(this)
const metaFilter = props.query.filters['meta']
console.log(metaFilter)
const metaKey = metaFilter ? Object.keys(metaFilter)[0] : props.goal.meta_keys[0]
this.state = {
loading: true,
dropdownOpen: false,
metaKey: metaKey
}
}

componentDidMount() {
this.fetchMetaBreakdown()
document.addEventListener('mousedown', this.handleClick, false);
}

componentWillUnmount() {
document.removeEventListener('mousedown', this.handleClick, false);
}

handleClick(e) {
if (this.dropDownNode && this.dropDownNode.contains(e.target)) return;
if (!this.state.dropdownOpen) return;

this.setState({dropdownOpen: false})
}

fetchMetaBreakdown() {
if (this.props.query.filters['goal']) {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/meta-breakdown/${encodeURIComponent(this.state.metaKey)}`, this.props.query)
.then((res) => this.setState({loading: false, breakdown: res}))
}
}

renderMetadataValue(value) {
const query = new URLSearchParams(window.location.search)
query.set('meta', JSON.stringify({[this.state.metaKey]: value.name}))

return (
<div className="flex items-center justify-between my-2" key={value.name}>
<div className="w-full h-8 relative" style={{maxWidth: 'calc(100% - 14rem)'}}>
<Bar count={value.count} all={this.state.breakdown} bg="bg-red-50" />
<Link to={{search: query.toString()}} style={{marginTop: '-26px'}} className="hover:underline block px-2">
{ value.name }
</Link>
</div>
<div>
<span className="font-medium inline-block w-20 text-right">{numberFormatter(value.count)}</span>
<span className="font-medium inline-block w-36 text-right">{numberFormatter(value.total_count)}</span>
</div>
</div>
)
}

changeMetaKey(newKey) {
this.setState({metaKey: newKey, loading: true, dropdownOpen: false}, this.fetchMetaBreakdown)
}

renderMetaKeyOption(key) {
const extraClass = key === this.state.metaKey ? 'font-medium text-gray-900' : 'hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900'

return (
<span onClick={this.changeMetaKey.bind(this, key)} key={key} className={`cursor-pointer block truncate px-4 py-2 text-sm leading-5 text-gray-700 ${extraClass}`}>
{key}
</span>
)
}

renderDropdown() {
return (
<div className="py-1">
{ this.props.goal.meta_keys.map(this.renderMetaKeyOption.bind(this)) }
</div>
)
}

toggleDropdown() {
this.setState({dropdownOpen: !this.state.dropdownOpen})
}

renderBody() {
if (this.state.loading) {
return <div className="px-4 py-2"><div className="loading sm mx-auto"><div></div></div></div>
} else {
return this.state.breakdown.map((metaValue) => this.renderMetadataValue(metaValue))
}
}

render() {
return (
<div className="w-full pl-6 mt-4">
<div className="relative">
Breakdown by
<button onClick={this.toggleDropdown.bind(this)} className="ml-1 inline-flex items-center rounded-md leading-5 font-bold text-gray-700 focus:outline-none transition ease-in-out duration-150 hover:text-gray-500 focus:border-blue-300 focus:shadow-outline-blue">
{ this.state.metaKey }
<svg className="mt-px h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
<Transition
show={this.state.dropdownOpen}
enter="transition ease-out duration-100 transform"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75 transform"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="z-10 origin-top-left absolute left-0 mt-2 w-64 rounded-md shadow-lg" ref={node => this.dropDownNode = node} >
<div className="rounded-md bg-white shadow-xs">
{ this.renderDropdown() }
</div>
</div>
</Transition>
</div>
{ this.renderBody() }
</div>
)
}
}
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ config :plausible, Plausible.ClickhouseRepo,
loggers: [Ecto.LogEntry],
url: System.get_env(
"CLICKHOUSE_DATABASE_URL",
"http://127.0.0.1:8123/plausible_test"
"http://127.0.0.1:8123/plausible_dev"
)

config :plausible,
Expand Down
3 changes: 3 additions & 0 deletions lib/plausible/event/clickhouse_schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ defmodule Plausible.ClickhouseEvent do
field :operating_system, :string
field :browser, :string

field :"meta.key", {:array, :string}
field :"meta.value", {:array, :string}

timestamps(inserted_at: :timestamp, updated_at: false)
end

Expand Down
Loading

0 comments on commit 40900c7

Please sign in to comment.