mirror of
https://github.com/plausible/analytics.git
synced 2024-12-26 19:15:03 +03:00
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:
parent
2d413129a7
commit
0de89bad82
@ -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
|
||||
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
91
assets/js/dashboard/datepicker-arrows.js
Normal file
91
assets/js/dashboard/datepicker-arrows.js
Normal 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
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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() }
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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} />
|
||||
|
@ -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) {
|
||||
|
@ -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');
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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'}
|
||||
|
@ -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() {
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
|
7
assets/js/dashboard/url.js
Normal file
7
assets/js/dashboard/url.js
Normal 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}`
|
||||
}
|
@ -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
|
54
assets/package-lock.json
generated
54
assets/package-lock.json
generated
@ -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": {
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user