Add filter groups (#1167)

* Add filter groups

* New flow for filters

* Visual consistency

* Mobile improvements to dropdown

* Do not let filters wrap on a new line

* Fix country filter

* Add mix format to pre-commit configuration

* Make eslint happy

* Fix formatting

* Use transition from headlessUI
This commit is contained in:
Uku Taht 2021-07-21 16:50:26 +03:00 committed by GitHub
parent 2d413129a7
commit 0de89bad82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 755 additions and 694 deletions

View File

@ -26,3 +26,8 @@ repos:
- eslint-plugin-jsx-a11y@6.4.1
- eslint-plugin-react@7.21.5
- eslint-plugin-react-hooks@4.2.0
- repo: https://gitlab.com/jvenom/elixir-pre-commit-hooks
rev: v1.0.0
hooks:
- id: mix-format

View File

@ -5,7 +5,7 @@
"extends": ["airbnb", "prettier"],
"plugins": ["prettier"],
"rules": {
"max-len": [2, {"code": 100}],
"max-len": [0, {"code": 120}],
"prettier/prettier": [2],
"react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
"react/destructuring-assignment": [0],
@ -13,6 +13,9 @@
"max-classes-per-file": [0],
"react/jsx-one-expression-per-line": [0],
"react/self-closing-comp": [0],
"no-unused-expressions": [1, { "allowShortCircuit": true }]
"no-unused-expressions": [1, { "allowShortCircuit": true }],
"no-unused-vars": [2, { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }],
"jsx-a11y/click-events-have-key-events": [0],
"jsx-a11y/no-static-element-interactions": [0]
}
}

View File

@ -315,3 +315,16 @@ iframe[hidden] {
.pagination-link[disabled] {
@apply cursor-default bg-gray-100 dark:bg-gray-300 pointer-events-none;
}
.flatpickr-calendar.static.open {
top: 12px;
}
@media (max-width: 768px) {
.flatpickr-wrapper {
position: absolute!important;
right: 0!important;
left: 0!important;
}
}

View File

@ -2,6 +2,7 @@ import React, {useState, useCallback} from 'react'
import {useCombobox} from 'downshift'
import classNames from 'classnames'
import debounce from 'debounce-promise'
import { ChevronDownIcon } from '@heroicons/react/solid'
function selectInputText(e) {
e.target.select()
@ -27,7 +28,6 @@ function Spinner() {
export default function SearchSelect(props) {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [initialLoadComplete, setInitialLoadComplete] = useState(false)
function fetchOptions({inputValue, isOpen}) {
setLoading(isOpen)
@ -60,11 +60,9 @@ export default function SearchSelect(props) {
props.onInput(changes.inputValue)
},
initialSelectedItem: props.initialSelectedItem,
onIsOpenChange: ({inputValue}) => {
if (!initialLoadComplete) {
fetchOptions({inputValue: inputValue, isOpen: true}).then(() => {
setInitialLoadComplete(true)
})
onIsOpenChange: ({isOpen, inputValue}) => {
if (isOpen) {
fetchOptions({inputValue, isOpen})
}
}
})
@ -79,11 +77,11 @@ export default function SearchSelect(props) {
}
return (
<div className="mt-1 relative">
<div className="ml-2 relative w-full">
<div className="relative rounded-md shadow-sm" {...getToggleButtonProps()} {...getComboboxProps()}>
<input {...getInputProps({onKeyDown: keydown})} onFocus={selectInputText} placeholder={props.placeholder} type="text" className={classNames('w-full pr-10 border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-200 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:bg-gray-900 dark:text-gray-300 block', {'cursor-pointer': inputValue === '' && !isOpen})} />
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
{ !loading && <ChevronDown /> }
{ !loading && <ChevronDownIcon className="h-4 w-4 text-gray-500" /> }
{ loading && <Spinner /> }
</div>
</div>

View File

@ -0,0 +1,91 @@
import React from "react";
import {
shiftDays,
shiftMonths,
formatISO,
nowForSite,
parseUTCDate,
isBefore,
isAfter,
} from "./date";
import { QueryButton } from "./query";
function renderArrow(query, site, period, prevDate, nextDate) {
const insertionDate = parseUTCDate(site.insertedAt);
const disabledLeft = isBefore(
parseUTCDate(prevDate),
insertionDate,
period
);
const disabledRight = isAfter(
parseUTCDate(nextDate),
nowForSite(site),
period
);
const leftClasses = `flex items-center px-2 border-r border-gray-300 rounded-l
dark:border-gray-500 dark:text-gray-100 ${
disabledLeft ? "bg-gray-300 dark:bg-gray-950" : "hover:bg-gray-100 dark:hover:bg-gray-900"
}`;
const rightClasses = `flex items-center px-2 rounded-r dark:text-gray-100 ${
disabledRight ? "bg-gray-300 dark:bg-gray-950" : "hover:bg-gray-100 dark:hover:bg-gray-900"
}`;
return (
<div className="flex rounded shadow bg-white mr-4 cursor-pointer dark:bg-gray-800">
<QueryButton
to={{ date: prevDate }}
query={query}
className={leftClasses}
disabled={disabledLeft}
>
<svg
className="feather h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</QueryButton>
<QueryButton
to={{ date: nextDate }}
query={query}
className={rightClasses}
disabled={disabledRight}
>
<svg
className="feather h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</QueryButton>
</div>
);
}
export default function DatePickerArrows({site, query}) {
if (query.period === "month") {
const prevDate = formatISO(shiftMonths(query.date, -1));
const nextDate = formatISO(shiftMonths(query.date, 1));
return renderArrow(query, site, "month", prevDate, nextDate);
} if (query.period === "day") {
const prevDate = formatISO(shiftDays(query.date, -1));
const nextDate = formatISO(shiftDays(query.date, 1));
return renderArrow(query, site, "day", prevDate, nextDate);
}
return null
}

View File

@ -1,6 +1,8 @@
import React from "react";
import React, { Fragment } from "react";
import { withRouter } from "react-router-dom";
import Flatpickr from "react-flatpickr";
import classNames from 'classnames'
import { ChevronDownIcon } from '@heroicons/react/solid'
import {
shiftDays,
shiftMonths,
@ -17,7 +19,7 @@ import {
isBefore,
isAfter,
} from "./date";
import Transition from "../transition";
import { Transition } from '@headlessui/react'
import { navigateToQuery, QueryLink, QueryButton } from "./query";
class DatePicker extends React.Component {
@ -26,7 +28,7 @@ class DatePicker extends React.Component {
this.handleKeydown = this.handleKeydown.bind(this);
this.handleClick = this.handleClick.bind(this);
this.openCalendar = this.openCalendar.bind(this);
this.open = this.open.bind(this);
this.toggle = this.toggle.bind(this);
this.state = { mode: "menu", open: false };
}
@ -148,137 +150,9 @@ class DatePicker extends React.Component {
return 'Realtime'
}
renderArrow(period, prevDate, nextDate) {
const insertionDate = parseUTCDate(this.props.site.insertedAt);
const disabledLeft = isBefore(
parseUTCDate(prevDate),
insertionDate,
period
);
const disabledRight = isAfter(
parseUTCDate(nextDate),
nowForSite(this.props.site),
period
);
const leftClasses = `flex items-center px-2 border-r border-gray-300 rounded-l
dark:border-gray-500 dark:text-gray-100 ${
disabledLeft ? "bg-gray-300 dark:bg-gray-950" : "hover:bg-gray-200 dark:hover:bg-gray-900"
}`;
const rightClasses = `flex items-center px-2 rounded-r dark:text-gray-100 ${
disabledRight ? "bg-gray-300 dark:bg-gray-950" : "hover:bg-gray-200 dark:hover:bg-gray-900"
}`;
return (
<div className="flex rounded shadow bg-white mr-4 cursor-pointer dark:bg-gray-800">
<QueryButton
to={{ date: prevDate }}
query={this.props.query}
className={leftClasses}
disabled={disabledLeft}
>
<svg
className="feather h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</QueryButton>
<QueryButton
to={{ date: nextDate }}
query={this.props.query}
className={rightClasses}
disabled={disabledRight}
>
<svg
className="feather h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</QueryButton>
</div>
);
}
renderArrows() {
const { query } = this.props;
if (query.period === "month") {
const prevDate = formatISO(shiftMonths(query.date, -1));
const nextDate = formatISO(shiftMonths(query.date, 1));
return this.renderArrow("month", prevDate, nextDate);
} if (query.period === "day") {
const prevDate = formatISO(shiftDays(query.date, -1));
const nextDate = formatISO(shiftDays(query.date, 1));
return this.renderArrow("day", prevDate, nextDate);
}
}
open() {
this.setState({ mode: "menu", open: true });
}
renderDropDown() {
return (
<div
className="relative"
style={{ height: "35.5px", width: "190px" }}
ref={(node) => (this.dropDownNode = node)}
>
<div
onClick={this.open}
onKeyPress={this.open}
className="flex items-center justify-between rounded bg-white dark:bg-gray-800 shadow px-4
pr-3 py-2 leading-tight cursor-pointer text-sm font-medium text-gray-800
dark:text-gray-200 h-full hover:bg-gray-200 dark:hover:bg-gray-900"
tabIndex="0"
role="button"
aria-haspopup="true"
aria-expanded="false"
aria-controls="datemenu"
>
<span className="mr-2">{this.timeFrameText()}</span>
<svg
className="text-indigo-500 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<Transition
show={this.state.open}
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"
>
{this.renderDropDownContent()}
</Transition>
</div>
);
toggle() {
const newMode = this.state.mode === 'calendar' && !this.state.open ? 'menu' : this.state.mode
this.setState({ mode: newMode, open: !this.state.open });
}
close() {
@ -314,7 +188,7 @@ class DatePicker extends React.Component {
to={{from: false, to: false, period, ...opts}}
onClick={this.close.bind(this)}
query={this.props.query}
className={`${boldClass } px-4 py-2 md:text-sm leading-tight hover:bg-gray-200
className={`${boldClass } px-4 py-2 text-sm leading-tight hover:bg-gray-100 hover:text-gray-900
dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 flex items-center justify-between`}
>
{text}
@ -328,11 +202,10 @@ class DatePicker extends React.Component {
return (
<div
id="datemenu"
className="absolute mt-2 rounded shadow-md z-10"
style={{width: '235px', right: '-5px'}}
className="absolute w-full left-0 right-0 md:w-56 md:absolute md:top-auto md:left-auto md:right-0 mt-2 origin-top-right z-10"
>
<div
className="rounded bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5
className="rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5
font-medium text-gray-800 dark:text-gray-200"
>
<div className="py-1">
@ -359,7 +232,7 @@ class DatePicker extends React.Component {
<span
onClick={() => this.setState({mode: 'calendar'}, this.openCalendar)}
onKeyPress={() => this.setState({mode: 'calendar'}, this.openCalendar)}
className="px-4 py-2 md:text-sm leading-tight hover:bg-gray-100
className="px-4 py-2 text-sm leading-tight hover:bg-gray-100
dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100
cursor-pointer flex items-center justify-between"
tabIndex="0"
@ -379,19 +252,21 @@ class DatePicker extends React.Component {
const insertionDate = new Date(this.props.site.insertedAt);
const dayBeforeCreation = insertionDate - 86400000;
return (
<Flatpickr
id="calendar"
options={{
mode: 'range',
maxDate: 'today',
minDate: dayBeforeCreation,
showMonths: 1,
static: true,
animate: true}}
ref={calendar => this.calendar = calendar}
className="invisible"
onChange={this.setCustomDate.bind(this)}
/>
<div className="h-0">
<Flatpickr
id="calendar"
options={{
mode: 'range',
maxDate: 'today',
minDate: dayBeforeCreation,
showMonths: 1,
static: true,
animate: true}}
ref={calendar => this.calendar = calendar}
className="invisible"
onChange={this.setCustomDate.bind(this)}
/>
</div>
)
}
}
@ -432,9 +307,41 @@ class DatePicker extends React.Component {
render() {
return (
<div className="flex justify-end ml-auto pl-2">
{ this.renderArrows() }
{ this.renderDropDown() }
<div
className={classNames('md:relative', this.props.className)}
ref={(node) => (this.dropDownNode = node)}
>
<div
onClick={this.toggle}
onKeyPress={this.toggle}
className="flex items-center justify-between rounded bg-white dark:bg-gray-800 shadow px-2 md:px-3
py-2 leading-tight cursor-pointer text-xs md:text-sm text-gray-800
dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900"
tabIndex="0"
role="button"
aria-haspopup="true"
aria-expanded="false"
aria-controls="datemenu"
>
<span className="mr-1 md:mr-2">
{this.props.leadingText}
<span className="font-medium">{this.timeFrameText()}</span>
</span>
<ChevronDownIcon className="hidden sm:inline-block h-4 w-4 md:h-5 md:w-5 text-gray-500" />
</div>
<Transition
show={this.state.open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{this.renderDropDownContent()}
</Transition>
</div>
);
}

View File

@ -1,29 +1,173 @@
import React from 'react';
import { withRouter, Link } from 'react-router-dom'
import { countFilters, formattedFilters, navigateToQuery } from './query'
import React, { Fragment, useState } from 'react';
import { Link, withRouter } from 'react-router-dom'
import { AdjustmentsIcon, PlusIcon, XIcon, PencilIcon } from '@heroicons/react/solid'
import classNames from 'classnames'
import Datamap from 'datamaps'
import Transition from "../transition.js";
import { Menu, Transition } from '@headlessui/react'
import { appliedFilters, navigateToQuery, formattedFilters } from './query'
import { FILTER_GROUPS, formatFilterGroup, filterGroupForFilter } from './stats/modals/filter'
function removeFilter(key, history, query) {
const newOpts = {
[key]: false
}
if (key === 'goal') { newOpts.props = false }
navigateToQuery(
history,
query,
newOpts
)
}
function clearAllFilters(history, query) {
const newOpts = Object.keys(query.filters).reduce((acc, red) => ({ ...acc, [red]: false }), {});
navigateToQuery(
history,
query,
newOpts
);
}
function filterText(key, value, query) {
if (key === "goal") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Completed goal <b>{value}</b></span>
}
if (key === "props") {
const [metaKey, metaValue] = Object.entries(value)[0]
const eventName = query.filters.goal ? query.filters.goal : 'event'
return <span className="inline-block max-w-2xs md:max-w-xs truncate">{eventName}.{metaKey} is <b>{metaValue}</b></span>
}
if (key === "source") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Source: <b>{value}</b></span>
}
if (key === "utm_medium") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">UTM medium: <b>{value}</b></span>
}
if (key === "utm_source") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">UTM source: <b>{value}</b></span>
}
if (key === "utm_campaign") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">UTM campaign: <b>{value}</b></span>
}
if (key === "referrer") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Referrer: <b>{value}</b></span>
}
if (key === "screen") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Screen size: <b>{value}</b></span>
}
if (key === "browser") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Browser: <b>{value}</b></span>
}
if (key === "browser_version") {
const browserName = query.filters.browser ? query.filters.browser : 'Browser'
return <span className="inline-block max-w-2xs md:max-w-xs truncate">{browserName}.Version: <b>{value}</b></span>
}
if (key === "os") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Operating System: <b>{value}</b></span>
}
if (key === "os_version") {
const osName = query.filters.os ? query.filters.os : 'OS'
return <span className="inline-block max-w-2xs md:max-w-xs truncate">{osName}.Version: <b>{value}</b></span>
}
if (key === "country") {
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
const selectedCountry = allCountries.find((c) => c.id === value) || {properties: {name: value}};
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Country: <b>{selectedCountry.properties.name}</b></span>
}
if (key === "page") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Page: <b>{value}</b></span>
}
if (key === "entry_page") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Entry Page: <b>{value}</b></span>
}
if (key === "exit_page") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Exit Page: <b>{value}</b></span>
}
throw new Error(`Unknown filter: ${key}`)
}
function renderDropdownFilter(site, history, [key, value], query) {
if (key === 'props') {
return (
<Menu.Item key={key}>
<div className="px-4 sm:py-2 py-3 md:text-sm leading-tight flex items-center justify-between" key={key + value}>
{filterText(key, value, query)}
<b title={`Remove filter: ${formattedFilters[key]}`} className="ml-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500" onClick={() => removeFilter(key, history, query)}></b>
</div>
</Menu.Item>
)
}
return (
<Menu.Item key={key}>
<div className="px-3 md:px-4 sm:py-2 py-3 md:text-sm leading-tight flex items-center justify-between" key={key + value}>
<Link
title={`Edit filter: ${formattedFilters[key]}`}
to={{ pathname: `/${encodeURIComponent(site.domain)}/filter/${filterGroupForFilter(key)}`, search: window.location.search }}
className="group flex w-full justify-between items-center"
>
{filterText(key, value, query)}
<PencilIcon className="w-4 h-4 ml-1 cursor-pointer group-hover:text-indigo-700 dark:group-hover:text-indigo-500" />
</Link>
<b title={`Remove filter: ${formattedFilters[key]}`} className="ml-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500" onClick={() => removeFilter(key, history, query)}>
<XIcon className="w-4 h-4" />
</b>
</div>
</Menu.Item>
)
}
function filterDropdownOption(site, option) {
return (
<Menu.Item key={option}>
{({ active }) => (
<Link
to={{ pathname: `/${encodeURIComponent(site.domain)}/filter/${option}`, search: window.location.search }}
className={classNames(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-800 dark:text-gray-300',
'block px-4 py-2 text-sm font-medium'
)}
>
{formatFilterGroup(option)}
</Link>
)}
</Menu.Item>
)
}
function DropdownContent({history, site, query, wrapped}) {
const [addingFilter, setAddingFilter] = useState(false);
if (wrapped === 0 || addingFilter) {
return Object.keys(FILTER_GROUPS).map((option) => filterDropdownOption(site, option))
}
return (
<>
<div className="border-b border-gray-200 dark:border-gray-500 px-4 sm:py-2 py-3 md:text-sm leading-tight hover:text-indigo-700 dark:hover:text-indigo-500 hover:cursor-pointer" onClick={() => setAddingFilter(true)}>
+ Add filter
</div>
{appliedFilters(query).map((filter) => renderDropdownFilter(site, history, filter, query))}
<div className="border-t border-gray-200 dark:border-gray-500 px-4 sm:py-2 py-3 md:text-sm leading-tight hover:text-indigo-700 dark:hover:text-indigo-500 hover:cursor-pointer" onClick={() => clearAllFilters(history, query)}>
Clear All Filters
</div>
</>
)
}
class Filters extends React.Component {
constructor(props) {
super(props);
this.state = {
dropdownOpen: false,
wrapped: 1, // 0=unwrapped, 1=waiting to check, 2=wrapped
viewport: 1080
viewport: 1080,
};
this.appliedFilters = Object.keys(props.query.filters)
.map((key) => [key, props.query.filters[key]])
.filter(([key, value]) => !!value);
this.renderDropDown = this.renderDropDown.bind(this);
this.renderDropDownContent = this.renderDropDownContent.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleResize = this.handleResize.bind(this);
this.rewrapFilters = this.rewrapFilters.bind(this);
this.renderFilterList = this.renderFilterList.bind(this);
this.handleKeyup = this.handleKeyup.bind(this)
}
@ -32,19 +176,16 @@ class Filters extends React.Component {
window.addEventListener('resize', this.handleResize, false);
document.addEventListener('keyup', this.handleKeyup);
this.rewrapFilters();
this.handleResize();
this.rewrapFilters();
}
componentDidUpdate(prevProps, prevState) {
const { query } = this.props;
const { viewport, wrapped } = this.state;
this.appliedFilters = Object.keys(query.filters)
.map((key) => [key, query.filters[key]])
.filter(([key, value]) => !!value)
if (JSON.stringify(query) !== JSON.stringify(prevProps.query) || viewport !== prevState.viewport) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ wrapped: 1 });
}
@ -60,40 +201,42 @@ class Filters extends React.Component {
}
handleKeyup(e) {
const { query, history } = this.props
const {query, history} = this.props
if (e.ctrlKey || e.metaKey || e.altKey) return
if (e.key === 'Escape') {
this.clearAllFilters(history, query)
clearAllFilters(history, query)
}
}
handleResize() {
this.setState({ viewport: window.innerWidth || 639 });
this.setState({ viewport: window.innerWidth || 639});
}
handleClick(e) {
if (this.dropDownNode && this.dropDownNode.contains(e.target)) return;
this.setState({ dropdownOpen: false });
};
// Checks if the filter container is wrapping items
rewrapFilters() {
let currItem, prevItem, items = document.getElementById('filters');
const { wrapped } = this.state;
const items = document.getElementById('filters');
const { wrapped, viewport } = this.state;
// Always wrap on mobile
if (appliedFilters(this.props.query).length > 0 && viewport <= 768) {
this.setState({ wrapped: 2 })
return;
}
this.setState({ wrapped: 0 });
// Don't rewrap if we're already properly wrapped, there are no DOM children, or there is only filter
if (wrapped !== 1 || !items || this.appliedFilters.length === 1) {
if (wrapped !== 1 || !items || appliedFilters(this.props.query).length === 1) {
return;
};
let prevItem = null;
// For every filter DOM Node, check if its y value is higher than the previous (this indicates a wrap)
[...(items.childNodes)].forEach(item => {
currItem = item.getBoundingClientRect();
const currItem = item.getBoundingClientRect();
if (prevItem && prevItem.top < currItem.top) {
this.setState({ wrapped: 2 });
}
@ -101,206 +244,106 @@ class Filters extends React.Component {
});
};
filterText(key, value, query) {
const negated = value[0] == '!' && ['page', 'entry_page', 'exit_page'].includes(key)
value = negated ? value.slice(1) : value
if (key === "goal") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Completed goal <b>{value}</b></span>
}
if (key === "props") {
const [metaKey, metaValue] = Object.entries(value)[0]
const eventName = query.filters["goal"] ? query.filters["goal"] : 'event'
return <span className="inline-block max-w-2xs md:max-w-xs truncate">{eventName}.{metaKey} is <b>{metaValue}</b></span>
}
if (key === "source") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Source is <b>{value}</b></span>
}
if (key === "utm_medium") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">UTM medium is <b>{value}</b></span>
}
if (key === "utm_source") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">UTM source is <b>{value}</b></span>
}
if (key === "utm_campaign") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">UTM campaign is <b>{value}</b></span>
}
if (key === "referrer") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Referrer URL is <b>{value}</b></span>
}
if (key === "screen") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Screen size is <b>{value}</b></span>
}
if (key === "browser") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Browser is <b>{value}</b></span>
}
if (key === "browser_version") {
const browserName = query.filters["browser"] ? query.filters["browser"] : 'Browser'
return <span className="inline-block max-w-2xs md:max-w-xs truncate">{browserName} Version is <b>{value}</b></span>
}
if (key === "os") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Operating System is <b>{value}</b></span>
}
if (key === "os_version") {
const osName = query.filters["os"] ? query.filters["os"] : 'OS'
return <span className="inline-block max-w-2xs md:max-w-xs truncate">{osName} Version is <b>{value}</b></span>
}
if (key === "country") {
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
const selectedCountry = allCountries.find((c) => c.id === value) || { properties: { name: value } };
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Country is <b>{selectedCountry.properties.name}</b></span>
}
if (key === "page") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Page is{negated ? ' not' : ''} <b>{value}</b></span>
}
if (key === "entry_page") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Entry Page is{negated ? ' not' : ''} <b>{value}</b></span>
}
if (key === "exit_page") {
return <span className="inline-block max-w-2xs md:max-w-xs truncate">Exit Page is{negated ? ' not' : ''} <b>{value}</b></span>
}
}
removeFilter(key, history, query) {
const newOpts = {
[key]: false
}
if (key === 'goal') { newOpts.props = false }
navigateToQuery(
history,
query,
newOpts
)
}
renderDropdownFilter(history, [key, value], query) {
if ('props' == key) {
return (
<div className="px-4 sm:py-2 py-3 md:text-sm leading-tight flex items-center justify-between" key={key + value}>
{this.filterText(key, value, query)}
<b title={`Remove filter: ${formattedFilters[key]}`} className="ml-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500" onClick={() => this.removeFilter(key, history, query)}></b>
</div>
)
}
return (
<div className="px-3 md:px-4 sm:py-2 py-3 md:text-sm leading-tight flex items-center justify-between" key={key + value}>
<Link
title={`Edit filter: ${formattedFilters[key]}`}
to={{ pathname: `/${encodeURIComponent(this.props.site.domain)}/filter/${key}`, search: window.location.search }}
className="group flex w-full justify-between items-center"
>
{this.filterText(key, value, query)}
<svg className="ml-1 cursor-pointer group-hover:text-indigo-700 dark:group-hover:text-indigo-500 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
</Link>
<b title={`Remove filter: ${formattedFilters[key]}`} className="ml-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500" onClick={() => this.removeFilter(key, history, query)}></b>
</div>
)
}
renderListFilter(history, [key, value], query) {
return (
<span key={key} title={value} className="flex bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 shadow text-sm rounded mr-2 items-center">
{'props' == key ? (
{key === 'props' ? (
<span className="flex w-full h-full items-center py-2 pl-3">
{this.filterText(key, value, query)}
{filterText(key, value, query)}
</span>
) : (
<>
<Link title={`Edit filter: ${formattedFilters[key]}`} className="filter-list-text flex w-full h-full items-center py-2 pl-3" to={{ pathname: `/${encodeURIComponent(this.props.site.domain)}/filter/${key}`, search: window.location.search }}>
{this.filterText(key, value, query)}
<Link title={`Edit filter: ${formattedFilters[key]}`} className="flex w-full h-full items-center py-2 pl-3" to={{ pathname: `/${encodeURIComponent(this.props.site.domain)}/filter/${filterGroupForFilter(key)}`, search: window.location.search }}>
{filterText(key, value, query)}
</Link>
<span className="filter-list-edit hidden h-full w-full px-2 cursor-pointer text-indigo-700 dark:text-indigo-500 items-center">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="1 1 23 23" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
</span>
</>
)}
<span title={`Remove filter: ${formattedFilters[key]}`} className="filter-list-remove flex h-full w-full px-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500 items-center" onClick={() => this.removeFilter(key, history, query)}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path></svg>
<span title={`Remove filter: ${formattedFilters[key]}`} className="flex h-full w-full px-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500 items-center" onClick={() => removeFilter(key, history, query)}>
<XIcon className="w-4 h-4" />
</span>
</span>
)
}
clearAllFilters(history, query) {
const newOpts = Object.keys(query.filters).reduce((acc, red) => ({ ...acc, [red]: false }), {});
navigateToQuery(
history,
query,
newOpts
);
}
renderDropDownContent() {
const { viewport } = this.state;
const { history, query, site } = this.props;
renderDropdownButton() {
if (this.state.wrapped === 2) {
return (
<>
<AdjustmentsIcon className="-ml-1 mr-1 h-4 w-4" aria-hidden="true" />
{appliedFilters(this.props.query).length} Filters
</>
)
}
return (
<div className="absolute mt-2 rounded shadow-md z-10" style={{ width: viewport <= 768 ? '320px' : '350px', right: '-5px' }} ref={node => this.dropDownNode = node}>
<div className="rounded bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 font-medium text-gray-800 dark:text-gray-200 flex flex-col">
<Link to={`/${encodeURIComponent(site.domain)}/filter${window.location.search}`} className="group border-b flex border-gray-200 dark:border-gray-500 px-4 sm:py-2 py-3 md:text-sm leading-tight hover:text-indigo-700 dark:hover:text-indigo-500 hover:cursor-pointer">
<svg className="mr-2 h-4 w-4 text-gray-500 dark:text-gray-200 group-hover:text-indigo-700 dark:group-hover:text-indigo-500 hover:cursor-pointer" fill="none" stroke="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
Add Filter
</Link>
{this.appliedFilters.map((filter) => this.renderDropdownFilter(history, filter, query))}
<div className="border-t border-gray-200 dark:border-gray-500 px-4 sm:py-2 py-3 md:text-sm leading-tight hover:text-indigo-700 dark:hover:text-indigo-500 hover:cursor-pointer" onClick={() => this.clearAllFilters(history, query)}>
Clear All Filters
</div>
</div>
</div>
<>
<PlusIcon className="-ml-1 mr-1 h-4 w-4 md:h-5 md:w-5" aria-hidden="true" />
Add filter
</>
)
}
renderDropDown() {
const { history, query, site } = this.props;
return (
<div id="filters" className='ml-auto'>
<div className="relative" style={{ height: '35.5px', width: '100px' }}>
<div onClick={() => this.setState((state) => ({ dropdownOpen: !state.dropdownOpen }))} className="flex items-center justify-between rounded bg-white dark:bg-gray-800 shadow px-4 pr-3 py-2 leading-tight cursor-pointer text-sm font-medium text-gray-800 dark:text-gray-200 h-full">
<span className="mr-2">Filters</span>
<svg className="text-indigo-500 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<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"
>
{this.renderDropDownContent()}
</Transition>
</div>
</div>
<Menu as="div" className="md:relative ml-auto">
{({ open }) => (
<>
<div>
<Menu.Button className="flex items-center text-xs md:text-sm font-medium leading-tight px-3 py-2 cursor-pointer ml-auto text-gray-500 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900 rounded">
{this.renderDropdownButton()}
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute w-full left-0 right-0 md:w-72 md:absolute md:top-auto md:left-auto md:right-0 mt-2 origin-top-right z-10"
>
<div
className="rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5
font-medium text-gray-800 dark:text-gray-200"
>
<DropdownContent history={history} query={query} site={site} wrapped={this.state.wrapped} />
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
);
}
renderFilterList() {
const { history, query, site } = this.props;
const { history, query } = this.props;
return (
<div id="filters" className="flex flex-grow pl-2 flex-wrap">
{(this.appliedFilters.map((filter) => this.renderListFilter(history, filter, query)))}
if (this.state.wrapped !== 2) {
return (
<div id="filters">
{(appliedFilters(query).map((filter) => this.renderListFilter(history, filter, query)))}
</div>
);
}
<Link to={`/${encodeURIComponent(site.domain)}/filter${window.location.search}`} className={`inline-flex items-center text-sm font-medium px-4 py-2 mr-2 cursor-pointer ml-auto text-gray-500 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900 rounded`}>
<svg className="mr-1 w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M5 4a1 1 0 00-2 0v7.268a2 2 0 000 3.464V16a1 1 0 102 0v-1.268a2 2 0 000-3.464V4zM11 4a1 1 0 10-2 0v1.268a2 2 0 000 3.464V16a1 1 0 102 0V8.732a2 2 0 000-3.464V4zM16 3a1 1 0 011 1v7.268a2 2 0 010 3.464V16a1 1 0 11-2 0v-1.268a2 2 0 010-3.464V4a1 1 0 011-1z"></path></svg>
Filter
</Link>
</div>
);
return null
}
render() {
const { wrapped, viewport } = this.state;
if (this.appliedFilters.length > 0 && (wrapped === 2 || viewport <= 768)) {
return this.renderDropDown();
}
return this.renderFilterList();
return (
<>
{ this.renderFilterList() }
{ this.renderDropDown() }
</>
)
}
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import Datepicker from './datepicker'
import DatepickerArrows from './datepicker-arrows'
import SiteSwitcher from './site-switcher'
import Filters from './filters'
import CurrentVisitors from './stats/current-visitors'
@ -13,6 +14,16 @@ import Conversions from './stats/conversions'
import { withPinnedHeader } from './pinned-header-hoc';
class Historical extends React.Component {
constructor(props) {
super(props)
this.state = {mobileFiltersOpen: false}
this.toggleMobileFilters = this.toggleMobileFilters.bind(this)
}
toggleMobileFilters() {
this.setState({mobileFiltersOpen: !this.state.mobileFiltersOpen})
}
renderConversions() {
if (this.props.site.hasGoals) {
return (
@ -29,14 +40,17 @@ class Historical extends React.Component {
return (
<div className="mb-12">
<div id="stats-container-top"></div>
<div className={`${navClass} top-0 sm:py-3 py-1 z-9 ${this.props.stuck && !this.props.site.embedded ? 'z-10 fullwidth-shadow bg-gray-50 dark:bg-gray-850' : ''}`}>
<div className="items-center w-full sm:flex">
<div className="flex items-center w-full mb-2 sm:mb-0">
<div className={`${navClass} top-0 sm:py-3 py-2 z-9 ${this.props.stuck && !this.props.site.embedded ? 'z-10 fullwidth-shadow bg-gray-50 dark:bg-gray-850' : ''}`}>
<div className="items-center w-full flex">
<div className="flex items-center w-full">
<SiteSwitcher site={this.props.site} loggedIn={this.props.loggedIn} currentUserRole={this.props.currentUserRole} />
<CurrentVisitors timer={this.props.timer} site={this.props.site} query={this.props.query} />
<Filters site={this.props.site} query={this.props.query} history={this.props.history} />
<Filters className="flex" site={this.props.site} query={this.props.query} history={this.props.history} mobileFiltersOpen={this.state.mobileFiltersOpen} />
</div>
<div className="flex ml-auto pl-2">
<DatepickerArrows site={this.props.site} query={this.props.query} />
<Datepicker className="w-28 sm:w-36 md:w-44" site={this.props.site} query={this.props.query} />
</div>
<Datepicker site={this.props.site} query={this.props.query} />
</div>
</div>
<VisitorGraph site={this.props.site} query={this.props.query} />

View File

@ -44,13 +44,10 @@ export function parseQuery(querystring, site) {
}
}
export function countFilters(query) {
let count = 0;
for (const filter of Object.values(query.filters)) {
if (filter) count++;
}
return count;
export function appliedFilters(query) {
return Object.keys(query.filters)
.map((key) => [key, query.filters[key]])
.filter(([key, value]) => !!value);
}
function generateQueryString(data) {

View File

@ -1,8 +1,6 @@
import React from 'react';
import Datepicker from './datepicker'
import SiteSwitcher from './site-switcher'
import Filters from './filters'
import StickyNav from './pinned-header-hoc';
import CurrentVisitors from './stats/current-visitors'
import VisitorGraph from './stats/visitor-graph'
import Sources from './stats/sources'
@ -10,9 +8,8 @@ import Pages from './stats/pages/'
import Countries from './stats/countries'
import Devices from './stats/devices'
import Conversions from './stats/conversions'
import { withPinnedHeader } from './pinned-header-hoc';
class Realtime extends React.Component {
export default class Realtime extends React.Component {
renderConversions() {
if (this.props.site.hasGoals) {
return (
@ -28,16 +25,7 @@ class Realtime extends React.Component {
return (
<div className="mb-12">
<div id="stats-container-top"></div>
<div className={`${navClass} top-0 sm:py-3 py-1 z-9 ${this.props.stuck && !this.props.site.embedded ? 'z-10 fullwidth-shadow bg-gray-50 dark:bg-gray-850' : ''}`}>
<div className="items-center justify-between w-full sm:flex">
<div className="flex items-center w-full mb-2 sm:mb-0">
<SiteSwitcher site={this.props.site} loggedIn={this.props.loggedIn} />
<Filters site={this.props.site} query={this.props.query} history={this.props.history} />
</div>
<Datepicker site={this.props.site} query={this.props.query} />
</div>
</div>
<StickyNav site={this.props.site} query={this.props.query} timer={this.props.timer} />
<VisitorGraph site={this.props.site} query={this.props.query} timer={this.props.timer} />
<div className="items-start justify-between block w-full md:flex">
<Sources site={this.props.site} query={this.props.query} timer={this.props.timer} />
@ -53,5 +41,3 @@ class Realtime extends React.Component {
)
}
}
export default withPinnedHeader(Realtime, '#stats-container-top');

View File

@ -51,7 +51,7 @@ export default function Router({site, loggedIn, currentUserRole}) {
<Route path="/:domain/countries">
<CountriesModal site={site} />
</Route>
<Route path={["/:domain/filter/:field", "/:domain/filter"]}>
<Route path={["/:domain/filter/:field"]}>
<FilterModal site={site} />
</Route>
</Switch>

View File

@ -1,5 +1,5 @@
import React from 'react';
import Transition from "../transition.js";
import { Transition } from '@headlessui/react'
export default class SiteSwitcher extends React.Component {
constructor() {
@ -126,7 +126,7 @@ export default class SiteSwitcher extends React.Component {
renderArrow() {
if (this.props.loggedIn) {
return (
<svg className="-mr-1 ml-2 h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<svg className="-mr-1 ml-1 md:ml-2 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>
)
@ -137,10 +137,10 @@ export default class SiteSwitcher extends React.Component {
const hoverClass = this.props.loggedIn ? 'hover:text-gray-500 dark:hover:text-gray-200 focus:border-blue-300 focus:ring ' : 'cursor-default'
return (
<div className="relative inline-block text-left z-10 mr-4">
<button onClick={this.toggle.bind(this)} className={`inline-flex items-center text-lg w-full rounded-md py-2 leading-5 font-bold text-gray-700 dark:text-gray-300 focus:outline-none transition ease-in-out duration-150 ${hoverClass}`}>
<div className="relative inline-block text-left z-10 mr-2 sm:mr-4">
<button onClick={this.toggle.bind(this)} className={`inline-flex items-center md:text-lg w-full rounded-md py-2 leading-5 font-bold text-gray-700 dark:text-gray-300 focus:outline-none transition ease-in-out duration-150 ${hoverClass}`}>
<img src={`https://icons.duckduckgo.com/ip3/${this.props.site.domain}.ico`} onError={(e)=>{e.target.onerror = null; e.target.src="https://icons.duckduckgo.com/ip3/placeholder.ico"}} referrerPolicy="no-referrer" className="inline w-4 mr-2 align-middle" />
<img src={`https://icons.duckduckgo.com/ip3/${this.props.site.domain}.ico`} onError={(e)=>{e.target.onerror = null; e.target.src="https://icons.duckduckgo.com/ip3/placeholder.ico"}} referrerPolicy="no-referrer" className="inline w-4 mr-1 md:mr-2 align-middle" />
{this.props.site.domain}
{this.renderArrow()}
</button>

View File

@ -1,7 +1,7 @@
import React from 'react';
import * as api from '../api'
import { Link } from 'react-router-dom'
import { countFilters } from '../query';
import { appliedFilters } from '../query';
export default class CurrentVisitors extends React.Component {
constructor(props) {
@ -20,7 +20,7 @@ export default class CurrentVisitors extends React.Component {
}
render() {
if (countFilters(this.props.query) !== 0) { return null }
if (appliedFilters(this.props.query) !== []) { return null }
const query = new URLSearchParams(window.location.search)
query.set('period', 'realtime')
@ -28,8 +28,8 @@ export default class CurrentVisitors extends React.Component {
const { currentVisitors } = this.state;
if (currentVisitors !== null) {
return (
<Link to={{search: query.toString()}} className="block ml-2 mr-auto text-sm font-bold text-gray-500 dark:text-gray-300">
<svg className="inline w-2 mr-2 text-green-500 fill-current" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<Link to={{search: query.toString()}} className="block ml-1 md:ml-2 mr-auto text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300">
<svg className="inline w-2 mr-1 md:mr-2 text-green-500 fill-current" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8"/>
</svg>
{currentVisitors} current visitor{currentVisitors === 1 ? '' : 's'}

View File

@ -1,65 +1,84 @@
import React from "react";
import { withRouter, Redirect } from 'react-router-dom'
import React, { Fragment } from "react";
import { withRouter } from 'react-router-dom'
import classNames from 'classnames'
import Datamap from 'datamaps'
import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/solid'
import SearchSelect from '../../components/search-select'
import Modal from './modal'
import { parseQuery, formattedFilters, navigateToQuery } from '../../query'
import Transition from "../../../transition";
import { parseQuery, formattedFilters } from '../../query'
import * as api from '../../api'
import {apiPath, sitePath} from '../../url'
function getFilterValue(selectedFilter, query) {
const negated = !!query.filters[selectedFilter] && query.filters[selectedFilter][0] === '!'
let filterValue = negated ? query.filters[selectedFilter].slice(1) : (query.filters[selectedFilter] || "")
export const FILTER_GROUPS = {
'page': ['page'],
'source': ['source', 'referrer'],
'country': ['country'],
'screen': ['screen'],
'browser': ['browser', 'browser_version'],
'os': ['os', 'os_version'],
'utm': ['utm_medium', 'utm_source', 'utm_campaign'],
'entry_page': ['entry_page'],
'exit_page': ['exit_page'],
'goal': ['goal']
}
if (selectedFilter == 'country') {
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
const selectedCountry = allCountries.find((c) => c.id === filterValue) || { properties: { name: filterValue } };
filterValue = selectedCountry.properties.name
}
function getCountryName(ISOCode) {
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
const selectedCountry = allCountries.find((c) => c.id === ISOCode);
return selectedCountry && selectedCountry.properties.name
}
return {filterValue, negated}
function getFormState(filterGroup, query) {
return FILTER_GROUPS[filterGroup].reduce((result, filter) => {
let filterValue = query.filters[filter] || ''
const type = filterValue[0] === '!' ? 'is_not' : 'is'
if (filter === 'country') filterValue = getCountryName(filterValue)
return Object.assign(result, {[filter]: {value: filterValue, type}})
}, {})
}
function withIndefiniteArticle(word) {
if (word.startsWith('UTM')) {
return 'a ' + word
} else if (['a', 'e', 'i', 'o', 'u'].some((vowel) => word.toLowerCase().startsWith(vowel))) {
return 'an ' + word
} else {
return 'a ' + word
return `a ${ word}`
} if (['a', 'e', 'i', 'o', 'u'].some((vowel) => word.toLowerCase().startsWith(vowel))) {
return `an ${ word}`
}
return `a ${ word}`
}
const SECONDARY_FILTERS = {
'browser': 'browser_version',
'os': 'os_version',
'source': 'referrer',
export function formatFilterGroup(filterGroup) {
if (filterGroup === 'utm') {
return 'UTM tags'
}
return formattedFilters[filterGroup]
}
const SECONDARY_TO_PRIMARY = Object.keys(SECONDARY_FILTERS)
.reduce((res, key) => Object.assign(res, {[SECONDARY_FILTERS[key]]: key}), {});
export function filterGroupForFilter(filter) {
const map = Object.entries(FILTER_GROUPS).reduce((filterToGroupMap, [group, filtersInGroup]) => {
const filtersToAdd = {}
filtersInGroup.forEach((filterInGroup) => {
filtersToAdd[filterInGroup] = group
})
function getVersionFilter(forFilter) {
return SECONDARY_FILTERS[forFilter]
return { ...filterToGroupMap, ...filtersToAdd}
}, {})
return map[filter] || filter
}
class FilterModal extends React.Component {
constructor(props) {
super(props)
const query = parseQuery(props.location.search, props.site)
let selectedFilter = this.props.match.params.field || 'page'
let secondaryFilter;
const selectedFilterGroup = this.props.match.params.field || 'page'
const formState = getFormState(selectedFilterGroup, query)
if (Object.values(SECONDARY_FILTERS).includes(selectedFilter)) {
selectedFilter = SECONDARY_TO_PRIMARY[selectedFilter]
}
secondaryFilter = SECONDARY_FILTERS[selectedFilter]
this.state = Object.assign({selectedFilter, query}, getFilterValue(selectedFilter, query), {secondaryFilterValue: query.filters[secondaryFilter] || ''})
this.handleKeydown = this.handleKeydown.bind(this)
this.handleSubmit = this.handleSubmit.bind(this)
this.state = {selectedFilterGroup, query, formState}
}
componentDidMount() {
@ -71,176 +90,200 @@ class FilterModal extends React.Component {
}
handleKeydown(e) {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing || e.keyCode === 229) return
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing || e.keyCode === 229) {
return
}
if (e.target.tagName == 'BODY' && e.key == 'Enter') {
if (e.target.tagName === 'BODY' && e.key === 'Enter') {
this.handleSubmit()
}
}
negationSupported(filter) {
return ['page', 'entry_page', 'exit_page'].includes(filter)
handleSubmit() {
const { formState } = this.state;
const filters = Object.entries(formState).reduce((res, [filterKey, {type, value}]) => {
let finalFilterValue = (type === 'is_not' ? '!' : '') + value.trim()
if (filterKey === 'country') {
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
const selectedCountry = allCountries.find((c) => c.properties.name === finalFilterValue) || { id: finalFilterValue };
finalFilterValue = selectedCountry.id
}
res.push({filter: filterKey, value: finalFilterValue})
return res
}, [])
this.selectFiltersAndCloseModal(filters)
}
fetchOptions(input) {
const {query, selectedFilter} = this.state
const updatedQuery = { ...query, filters: { ...query.filters, [selectedFilter]: null } }
if (selectedFilter === 'country') {
const matchedCountries = Datamap.prototype.worldTopo.objects.world.geometries.filter(c => c.properties.name.toLowerCase().includes(input.trim().toLowerCase()))
const matches = matchedCountries.map(c => c.id)
return api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/suggestions/country`, updatedQuery, { q: matches })
.then((res) => {
return res.map(code => matchedCountries.filter(c => c.id == code)[0].properties.name)
})
} else {
return api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/suggestions/${selectedFilter}`, updatedQuery, { q: input.trim() })
onInput(filterName) {
return (val) => {
this.setState(prevState => ({formState: Object.assign(prevState.formState, {
[filterName]: Object.assign(prevState.formState[filterName], {value: val})
})}))
}
}
onInput(val) {
this.setState({filterValue: val})
setFilterType(filterName, newType) {
this.setState(prevState => ({formState: Object.assign(prevState.formState, {
[filterName]: Object.assign(prevState.formState[filterName], {type: newType})
})}))
}
renderSearchSelector() {
const {selectedFilter, filterValue} = this.state
return (
<SearchSelect
key={selectedFilter}
fetchOptions={this.fetchOptions.bind(this)}
initialSelectedItem={filterValue}
onInput={this.onInput.bind(this)}
placeholder={`Select ${withIndefiniteArticle(formattedFilters[selectedFilter])}`}
/>
)
}
fetchSecondaryOptions(filterName) {
const {query, selectedFilter} = this.state
fetchOptions(filter) {
return (input) => {
const {filterValue} = this.state
const updatedQuery = { ...query, filters: { ...query.filters, [selectedFilter]: filterValue, [filterName]: null } }
return api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/suggestions/${filterName}`, updatedQuery, { q: input.trim() })
}
}
onSecondaryInput(val) {
this.setState({secondaryFilterValue: val})
}
renderVersionSelector() {
const {selectedFilter, filterValue, secondaryFilterValue} = this.state
const secondaryFilter = SECONDARY_FILTERS[selectedFilter]
if (secondaryFilter) {
return (
<SearchSelect
key={selectedFilter + filterValue + secondaryFilter}
fetchOptions={this.fetchSecondaryOptions(secondaryFilter)}
initialSelectedItem={secondaryFilterValue}
onInput={this.onSecondaryInput.bind(this)}
placeholder={`${formattedFilters[secondaryFilter]} (optional)`}
/>
const {query, formState} = this.state
const formFilters = Object.fromEntries(
Object.entries(formState).map(([k, v]) => [k, v.value])
)
const updatedQuery = {...query, filters: { ...query.filters, ...formFilters, [filter]: null }}
if (filter === 'country') {
const matchedCountries = Datamap.prototype.worldTopo.objects.world.geometries.filter(c => c.properties.name.toLowerCase().includes(input.trim().toLowerCase()))
const matches = matchedCountries.map(c => c.id)
return api.get(apiPath(this.props.site, '/suggestions/country'), updatedQuery, { q: matches })
.then((res) => res.map(code => matchedCountries.filter(c => c.id === code)[0].properties.name))
}
return api.get(apiPath(this.props.site, `/suggestions/${filter}`), updatedQuery, { q: input.trim() })
}
}
selectedFilterType(filter) {
return this.state.formState[filter].type
}
isDisabled() {
return Object.entries(this.state.formState).every(([_key, {value: val}]) => !val)
}
selectFiltersAndCloseModal(filters) {
const queryString = new URLSearchParams(window.location.search)
for (const entry of filters) {
filters.forEach((entry) => {
if (entry.value) {
queryString.set(entry.filter, entry.value)
} else {
queryString.delete(entry.filter)
}
}
})
this.props.history.replace({pathname: `/${encodeURIComponent(this.props.site.domain)}`, search: queryString.toString()})
this.props.history.replace({pathname: sitePath(this.props.site), search: queryString.toString()})
}
handleSubmit() {
const { selectedFilter, negated, filterValue, secondaryFilterValue } = this.state;
renderFilterInputs() {
return FILTER_GROUPS[this.state.selectedFilterGroup].map((filter) => (
<div className="mt-4" key={filter}>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">{ formattedFilters[filter] }</div>
<div className="flex items-start mt-1">
{ this.renderFilterTypeSelector(filter) }
let finalFilterValue = (this.negationSupported(selectedFilter) && negated ? '!' : '') + filterValue.trim()
if (selectedFilter == 'country') {
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
const selectedCountry = allCountries.find((c) => c.properties.name === finalFilterValue) || { id: finalFilterValue };
finalFilterValue = selectedCountry.id
}
<SearchSelect
key={filter}
fetchOptions={this.fetchOptions(filter)}
initialSelectedItem={this.state.formState[filter].value}
onInput={this.onInput(filter)}
placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`}
/>
</div>
const filters = [{filter: selectedFilter, value: finalFilterValue}]
const secondaryFilter = SECONDARY_FILTERS[selectedFilter]
if (secondaryFilter) {
filters.push({filter: secondaryFilter, value: secondaryFilterValue.trim()})
}
this.selectFiltersAndCloseModal(filters)
</div>
))
}
updateSelectedFilter(e) {
this.setState(Object.assign({selectedFilter: e.target.value}, getFilterValue(e.target.value, this.state.query)))
renderFilterTypeSelector(filterName) {
return (
<Menu as="div" className="relative inline-block text-left">
{({ open }) => (
<>
<div className="w-24">
<Menu.Button className="inline-flex justify-between items-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500">
{ this.selectedFilterType(filterName) }
<ChevronDownIcon className="-mr-2 ml-2 h-4 w-4 text-gray-500" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
static
className="z-10 origin-top-left absolute left-0 mt-2 w-24 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="py-1">
<Menu.Item>
{({ active }) => (
<span
onClick={() => this.setFilterType(filterName, 'is')}
className={classNames(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
'cursor-pointer block px-4 py-2 text-sm'
)}
>
is
</span>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<span
onClick={() => this.setFilterType(filterName, 'is_not')}
className={classNames(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
'cursor-pointer block px-4 py-2 text-sm'
)}
>
is not
</span>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
)
}
renderBody() {
const { selectedFilter, negated, filterValue, secondaryFilterValue, query } = this.state;
const editableFilters = Object.keys(this.state.query.filters).filter(filter => !['props'].concat(Object.values(SECONDARY_FILTERS)).includes(filter))
const { selectedFilterGroup, query } = this.state;
return (
<>
<h1 className="text-xl font-bold dark:text-gray-100">{query.filters[selectedFilter] || query.filters[SECONDARY_FILTERS[selectedFilter]] ? 'Edit' : 'Add'} Filter</h1>
<h1 className="text-xl font-bold dark:text-gray-100">Filter by {formatFilterGroup(selectedFilterGroup)}</h1>
<div className="my-4 border-b border-gray-300"></div>
<div className="mt-4 border-b border-gray-300"></div>
<main className="modal__content">
<form className="flex flex-col" id="filter-form" onSubmit={this.handleSubmit}>
<select
value={selectedFilter}
className="my-2 block w-full pr-10 border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-200 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:bg-gray-900 dark:text-gray-300 cursor-pointer"
placeholder="Select a Filter"
onChange={this.updateSelectedFilter.bind(this)}
>
<option disabled value="" className="hidden">Select a Filter</option>
{editableFilters.map(filter => <option key={filter} value={filter}>{formattedFilters[filter]}</option>)}
</select>
{this.negationSupported(selectedFilter) && (
<div className="my-4 flex items-center">
<label className="text-gray-700 dark:text-gray-300 text-sm cursor-pointer">
<input
type="checkbox"
className="bg-gray-100 dark:bg-gray-900 text-indigo-600 border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-200 mr-2 relative inline-flex flex-shrink-0 h-6 w-8 border-1 rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none"
checked={negated}
name="exclude"
onChange={(e) => this.setState({ negated: e.target.checked })}
/>
Exclude pages matching this filter
</label>
</div>
)}
{this.renderSearchSelector()}
{this.renderVersionSelector()}
<form className="flex flex-col" id="filter-form" onSubmit={this.handleSubmit.bind(this)}>
{this.renderFilterInputs()}
<div className="mt-6 flex items-center justify-start">
<button
type="submit"
disabled={filterValue.trim().length === 0 && secondaryFilterValue.trim().length === 0}
className="button"
disabled={this.isDisabled()}
>
{query.filters[selectedFilter] || query.filters[SECONDARY_FILTERS[selectedFilter]] ? 'Update' : 'Add'} Filter
Save Filter
</button>
{query.filters[selectedFilter] && (
{query.filters[selectedFilterGroup] && (
<button
type="button"
className="ml-2 button px-4 flex bg-red-500 dark:bg-red-500 hover:bg-red-600 dark:hover:bg-red-700 items-center"
onClick={() => {
this.selectFiltersAndCloseModal([{filter: selectedFilter, value: null}, {filter: SECONDARY_FILTERS[selectedFilter], value: null}])
this.selectFiltersAndCloseModal([{filter: selectedFilterGroup, value: null}])
}}
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
@ -256,11 +299,13 @@ class FilterModal extends React.Component {
}
renderHints() {
if (['page', 'entry_page', 'exit_page'].includes(this.state.selectedFilter)) {
if (['page', 'entry_page', 'exit_page'].includes(this.state.selectedFilterGroup)) {
return (
<p className="mt-6 text-xs text-gray-500">Hint: You can use double asterisks to match any character e.g. /blog**</p>
)
}
return null
}
render() {

View File

@ -1,7 +1,6 @@
import React from "react";
import { Link, withRouter } from 'react-router-dom'
import Transition from "../../../transition.js";
import FadeIn from '../../fade-in'
import Modal from './modal'
import * as api from '../../api'

View File

@ -389,7 +389,7 @@ export default class VisitorGraph extends React.Component {
render() {
return (
<LazyLoader onVisible={this.onVisible}>
<div className="relative w-full mt-2 bg-white rounded shadow-xl dark:bg-gray-825 main-graph">
<div className="relative w-full bg-white rounded shadow-xl dark:bg-gray-825 main-graph">
{ this.state.loading && <div className="graph-inner"><div className="pt-24 mx-auto loading sm:pt-32 md:pt-48"><div></div></div></div> }
{ this.renderInner() }
</div>

View File

@ -0,0 +1,7 @@
export function apiPath(site, path) {
return `/api/stats/${encodeURIComponent(site.domain)}${path}`
}
export function sitePath(site, path) {
return `/${encodeURIComponent(site.domain)}${path}`
}

View File

@ -1,107 +0,0 @@
// https://gist.github.com/adamwathan/3b9f3ad1a285a2d1b482769aeb862467
import { CSSTransition as ReactCSSTransition } from 'react-transition-group'
import React, { useRef, useEffect, useContext } from 'react'
const TransitionContext = React.createContext({
parent: {},
})
function useIsInitialRender() {
const isInitialRender = useRef(true)
useEffect(() => {
isInitialRender.current = false
}, [])
return isInitialRender.current
}
function CSSTransition({
show,
enter = '',
enterFrom = '',
enterTo = '',
leave = '',
leaveFrom = '',
leaveTo = '',
appear,
children,
}) {
const enterClasses = enter.split(' ').filter((s) => s.length)
const enterFromClasses = enterFrom.split(' ').filter((s) => s.length)
const enterToClasses = enterTo.split(' ').filter((s) => s.length)
const leaveClasses = leave.split(' ').filter((s) => s.length)
const leaveFromClasses = leaveFrom.split(' ').filter((s) => s.length)
const leaveToClasses = leaveTo.split(' ').filter((s) => s.length)
function addClasses(node, classes) {
classes.length && node.classList.add(...classes)
}
function removeClasses(node, classes) {
classes.length && node.classList.remove(...classes)
}
return (
<ReactCSSTransition
appear={appear}
unmountOnExit
in={show}
addEndListener={(node, done) => {
node.addEventListener('transitionend', done, false)
}}
onEnter={(node) => {
addClasses(node, [...enterClasses, ...enterFromClasses])
}}
onEntering={(node) => {
removeClasses(node, enterFromClasses)
addClasses(node, enterToClasses)
}}
onEntered={(node) => {
removeClasses(node, [...enterToClasses, ...enterClasses])
}}
onExit={(node) => {
addClasses(node, [...leaveClasses, ...leaveFromClasses])
}}
onExiting={(node) => {
removeClasses(node, leaveFromClasses)
addClasses(node, leaveToClasses)
}}
onExited={(node) => {
removeClasses(node, [...leaveToClasses, ...leaveClasses])
}}
>
{children}
</ReactCSSTransition>
)
}
function Transition({ show, appear, ...rest }) {
const { parent } = useContext(TransitionContext)
const isInitialRender = useIsInitialRender()
const isChild = show === undefined
if (isChild) {
return (
<CSSTransition
appear={parent.appear || !parent.isInitialRender}
show={parent.show}
{...rest}
/>
)
}
return (
<TransitionContext.Provider
value={{
parent: {
show,
isInitialRender,
appear,
},
}}
>
<CSSTransition appear={appear} show={show} {...rest} />
</TransitionContext.Provider>
)
}
export default Transition

View File

@ -9,7 +9,10 @@
"@babel/core": "^7.14.3",
"@babel/preset-env": "^7.14.4",
"@babel/preset-react": "^7.13.13",
"@headlessui/react": "^1.3.0",
"@heroicons/react": "^1.0.1",
"@juggle/resize-observer": "^3.3.1",
"@kunukn/react-collapse": "^2.2.9",
"@tailwindcss/aspect-ratio": "^0.2.1",
"@tailwindcss/forms": "^0.3.2",
"@tailwindcss/typography": "^0.4.1",
@ -1587,11 +1590,44 @@
"purgecss": "^3.1.3"
}
},
"node_modules/@headlessui/react": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.3.0.tgz",
"integrity": "sha512-2gqTO6BQ3Jr8vDX1B67n1gl6MGKTt6DBmR+H0qxwj0gTMnR2+Qpktj8alRWxsZBODyOiBb77QSQpE/6gG3MX4Q==",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^16 || ^17 || ^18",
"react-dom": "^16 || ^17 || ^18"
}
},
"node_modules/@heroicons/react": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-1.0.1.tgz",
"integrity": "sha512-uikw2gKCmqnvjVxitecWfFLMOKyL9BTFcU4VM3hHj9OMwpkCr5Ke+MRMyY2/aQVmsYs4VTq7NCFX05MYwAHi3g==",
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@juggle/resize-observer": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.3.1.tgz",
"integrity": "sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw=="
},
"node_modules/@kunukn/react-collapse": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/@kunukn/react-collapse/-/react-collapse-2.2.9.tgz",
"integrity": "sha512-MKauq6R4HAHS/nGei+na8EpyO4qluTcoDeYetUnT1ME7K+CqxRCAIB4a4xo+gkufg2CuLhORA+s6sNjqm0oHEA==",
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"react": "^16.8 || ^17.x",
"react-dom": "^16.8 || ^17.x"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.3",
"license": "MIT",
@ -10691,11 +10727,29 @@
"purgecss": "^3.1.3"
}
},
"@headlessui/react": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.3.0.tgz",
"integrity": "sha512-2gqTO6BQ3Jr8vDX1B67n1gl6MGKTt6DBmR+H0qxwj0gTMnR2+Qpktj8alRWxsZBODyOiBb77QSQpE/6gG3MX4Q==",
"requires": {}
},
"@heroicons/react": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-1.0.1.tgz",
"integrity": "sha512-uikw2gKCmqnvjVxitecWfFLMOKyL9BTFcU4VM3hHj9OMwpkCr5Ke+MRMyY2/aQVmsYs4VTq7NCFX05MYwAHi3g==",
"requires": {}
},
"@juggle/resize-observer": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.3.1.tgz",
"integrity": "sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw=="
},
"@kunukn/react-collapse": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/@kunukn/react-collapse/-/react-collapse-2.2.9.tgz",
"integrity": "sha512-MKauq6R4HAHS/nGei+na8EpyO4qluTcoDeYetUnT1ME7K+CqxRCAIB4a4xo+gkufg2CuLhORA+s6sNjqm0oHEA==",
"requires": {}
},
"@nodelib/fs.scandir": {
"version": "2.1.3",
"requires": {

View File

@ -9,7 +9,10 @@
"@babel/core": "^7.14.3",
"@babel/preset-env": "^7.14.4",
"@babel/preset-react": "^7.13.13",
"@headlessui/react": "^1.3.0",
"@heroicons/react": "^1.0.1",
"@juggle/resize-observer": "^3.3.1",
"@kunukn/react-collapse": "^2.2.9",
"@tailwindcss/aspect-ratio": "^0.2.1",
"@tailwindcss/forms": "^0.3.2",
"@tailwindcss/typography": "^0.4.1",

View File

@ -33,7 +33,7 @@
<% end %>
<li class="w-full sm:w-auto">
<div class="relative font-bold rounded">
<div data-dropdown-trigger class="flex items-center p-1 m-1 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-900 dark:text-gray-100">
<div data-dropdown-trigger class="flex items-center justify-end p-1 m-1 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-900 dark:text-gray-100">
<span class="pl-2 mr-2 truncate"><%= @conn.assigns[:current_user].name || @conn.assigns[:current_user].email %></span>
<svg style="height: 18px; transform: translateY(2px); fill: #606f7b;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 512 640" enable-background="new 0 0 512 512" xml:space="preserve"><g><circle cx="256" cy="52.8" r="50.8"/><circle cx="256" cy="256" r="50.8"/><circle cx="256" cy="459.2" r="50.8"/></g></svg>
</div>

View File

@ -9,30 +9,31 @@ defmodule Plausible.Workers.NotifyAnnualRenewal do
Sends a notification at most 7 days and at least 1 day before the renewal of an annual subscription
"""
def perform(_job) do
current_subscriptions = from(
s in Plausible.Billing.Subscription,
group_by: s.user_id,
select: %{
user_id: s.user_id,
inserted_at: max(s.inserted_at)
}
)
current_subscriptions =
from(
s in Plausible.Billing.Subscription,
group_by: s.user_id,
select: %{
user_id: s.user_id,
inserted_at: max(s.inserted_at)
}
)
users =
Repo.all(
from u in Plausible.Auth.User,
join: cs in subquery(current_subscriptions),
on: cs.user_id == u.id,
join: s in Plausible.Billing.Subscription,
on: s.inserted_at == cs.inserted_at,
left_join: sent in "sent_renewal_notifications",
on: s.user_id == sent.user_id,
where: s.paddle_plan_id in @yearly_plans,
where:
s.next_bill_date > fragment("now()::date") and
s.next_bill_date <= fragment("now()::date + INTERVAL '7 days'"),
where: is_nil(sent.id) or sent.timestamp < fragment("now() - INTERVAL '1 month'"),
preload: [subscription: s]
join: cs in subquery(current_subscriptions),
on: cs.user_id == u.id,
join: s in Plausible.Billing.Subscription,
on: s.inserted_at == cs.inserted_at,
left_join: sent in "sent_renewal_notifications",
on: s.user_id == sent.user_id,
where: s.paddle_plan_id in @yearly_plans,
where:
s.next_bill_date > fragment("now()::date") and
s.next_bill_date <= fragment("now()::date + INTERVAL '7 days'"),
where: is_nil(sent.id) or sent.timestamp < fragment("now() - INTERVAL '1 month'"),
preload: [subscription: s]
)
for user <- users do

View File

@ -39,7 +39,9 @@ defmodule Plausible.Workers.NotifyAnnualRenewalTest do
assert_no_emails_delivered()
end
test "ignores user with old yearly subscription that's been superseded by a newer one", %{user: user} do
test "ignores user with old yearly subscription that's been superseded by a newer one", %{
user: user
} do
insert(:subscription,
inserted_at: Timex.shift(Timex.now(), days: -1),
user: user,