diff --git a/CHANGELOG.md b/CHANGELOG.md index 355bde1c0e68..86775eeac22c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ All notable changes to this project will be documented in this file. - Fix overly-sticky from and to in query parameters plausible/analytics#495 - Adds support for single-day date selection plausible/analytics#495 - Goal conversion rate in realtime view is now accurate plausible/analytics#500 +- Various UI/UX issues plausible/analytics#503 ### Security - Do not run the plausible Docker container as root plausible/analytics#362 diff --git a/assets/css/app.css b/assets/css/app.css index 1879828815d7..ec9b17a2f72d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -5,7 +5,7 @@ @import "modal.css"; @import "loader.css"; @import "tooltip.css"; -@import "flatpickr.dark.css"; +@import "flatpickr.css"; .button { @apply bg-indigo-600 border border-transparent rounded-md py-2 px-4 inline-flex justify-center text-sm leading-5 font-medium text-white transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500; diff --git a/assets/css/flatpickr.dark.css b/assets/css/flatpickr.css similarity index 83% rename from assets/css/flatpickr.dark.css rename to assets/css/flatpickr.css index a4b6ad03e2ab..74637c8373ed 100644 --- a/assets/css/flatpickr.dark.css +++ b/assets/css/flatpickr.css @@ -1,3 +1,17 @@ +.flatpickr-calendar:before,.flatpickr-calendar:after { + right: 22px !important; +} + +.flatpickr-wrapper { + right: 35% !important; +} + +@media (max-width: 768px) { + .flatpickr-wrapper { + right: 50% !important; + } +} + /* Because Flatpickr offers zero support for dynamic theming on its own (outside of third-party plugins) */ .dark .flatpickr-calendar { background-color: #1f2937; @@ -23,6 +37,18 @@ color: #f3f4f6; } +.dark .numInput[disabled] { + color: #9ca3af !important; +} + +.dark .flatpickr-current-month .numInputWrapper span.arrowUp::after { + border-bottom-color: #f3f4f6 !important; +} + +.dark .flatpickr-current-month .numInputWrapper span.arrowDown::after { + border-top-color: #f3f4f6 !important; +} + .dark .flatpickr-day.prevMonthDay { color: #94a3af; } diff --git a/assets/js/dashboard/datepicker.js b/assets/js/dashboard/datepicker.js index d4045257b4cd..f330a975c6a2 100644 --- a/assets/js/dashboard/datepicker.js +++ b/assets/js/dashboard/datepicker.js @@ -101,7 +101,7 @@ class DatePicker extends React.Component { renderArrow(period, prevDate, nextDate) { return ( -
+
@@ -134,7 +134,7 @@ class DatePicker extends React.Component { renderDropDown() { return ( -
this.dropDownNode = node}> +
this.dropDownNode = node}>
{this.timeFrameText()} @@ -176,7 +176,7 @@ class DatePicker extends React.Component { if (opts.date) { opts.date = formatISO(opts.date) } return ( - + {text} ) @@ -185,7 +185,7 @@ class DatePicker extends React.Component { renderDropDownContent() { if (this.state.mode === 'menu') { return ( -
+
{ this.renderLink('day', 'Today') } @@ -208,7 +208,7 @@ class DatePicker extends React.Component {
- this.setState({mode: 'calendar'}, this.openCalendar.bind(this))} className="block 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">Custom range + this.setState({mode: 'calendar'}, this.openCalendar.bind(this))} className="block px-4 py-2 md:text-sm leading-tight hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer">Custom range
@@ -216,7 +216,7 @@ class DatePicker extends React.Component { } else if (this.state.mode === 'calendar') { const insertionDate = new Date(this.props.site.insertedAt); const dayBeforeCreation = insertionDate - 86400000; - return this.calendar = calendar} className="invisible" onChange={this.setCustomDate.bind(this)} /> + return this.calendar = calendar} className="invisible" onChange={this.setCustomDate.bind(this)} /> } } @@ -256,7 +256,7 @@ class DatePicker extends React.Component { render() { return ( -
+
{ this.renderArrows() } { this.renderDropDown() }
diff --git a/assets/js/dashboard/filters.js b/assets/js/dashboard/filters.js index 4adbe091dd53..f5f2c96a84b3 100644 --- a/assets/js/dashboard/filters.js +++ b/assets/js/dashboard/filters.js @@ -1,61 +1,145 @@ import React from 'react'; import { withRouter } from 'react-router-dom' -import {navigateToQuery, removeQueryParam} from './query' +import { countFilters, navigateToQuery, removeQueryParam } from './query' import Datamap from 'datamaps' +import Transition from "../transition.js"; -function filterText(key, value, query) { - if (key === "goal") { - return Completed goal {value} - } - if (key === "props") { - const [metaKey, metaValue] = Object.entries(value)[0] - const eventName = query.filters["goal"] ? query.filters["goal"] : 'event' - return {eventName}.{metaKey} is {metaValue} - } - if (key === "source") { - return Source: {value} - } - if (key === "utm_medium") { - return UTM medium: {value} - } - if (key === "utm_source") { - return UTM source: {value} - } - if (key === "utm_campaign") { - return UTM campaign: {value} - } - if (key === "referrer") { - return Referrer: {value} - } - if (key === "screen") { - return Screen size: {value} - } - if (key === "browser") { - return Browser: {value} +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 + }; + + 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); } - if (key === "browser_version") { - const browserName = query.filters["browser"] ? query.filters["browser"] : 'Browser' - return {browserName}.Version: {value} + + componentDidMount() { + document.addEventListener('mousedown', this.handleClick, false); + window.addEventListener('resize', this.handleResize, false); + + this.rewrapFilters(); + this.handleResize(); } - if (key === "os") { - return Operating System: {value} + + 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) { + this.setState({ wrapped: 1 }); + } + + if (wrapped === 1 && prevState.wrapped !== 1) { + this.rewrapFilters(); + } } - if (key === "os_version") { - const osName = query.filters["os"] ? query.filters["os"] : 'OS' - return {osName}.Version: {value} + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClick, false); + window.removeEventListener('resize', this.handleResize, false); } - if (key === "country") { - const allCountries = Datamap.prototype.worldTopo.objects.world.geometries; - const selectedCountry = allCountries.find((c) => c.id === value) - return Country: {selectedCountry.properties.name} + + handleResize() { + this.setState({ viewport: window.innerWidth || 639}); } - if (key === "page") { - return Page: {value} + + 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; + + 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) { + return; + }; + + // 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(); + if (prevItem && prevItem.top < currItem.top) { + this.setState({ wrapped: 2 }); + } + prevItem = currItem; + }); + }; + + filterText(key, value, query) { + if (key === "goal") { + return Completed goal {value} + } + if (key === "props") { + const [metaKey, metaValue] = Object.entries(value)[0] + const eventName = query.filters["goal"] ? query.filters["goal"] : 'event' + return {eventName}.{metaKey} is {metaValue} + } + if (key === "source") { + return Source: {value} + } + if (key === "utm_medium") { + return UTM medium: {value} + } + if (key === "utm_source") { + return UTM source: {value} + } + if (key === "utm_campaign") { + return UTM campaign: {value} + } + if (key === "referrer") { + return Referrer: {value} + } + if (key === "screen") { + return Screen size: {value} + } + if (key === "browser") { + return Browser: {value} + } + if (key === "browser_version") { + const browserName = query.filters["browser"] ? query.filters["browser"] : 'Browser' + return {browserName}.Version: {value} + } + if (key === "os") { + return Operating System: {value} + } + if (key === "os_version") { + const osName = query.filters["os"] ? query.filters["os"] : 'OS' + return {osName}.Version: {value} + } + if (key === "country") { + const allCountries = Datamap.prototype.worldTopo.objects.world.geometries; + const selectedCountry = allCountries.find((c) => c.id === value) + return Country: {selectedCountry.properties.name} + } + if (key === "page") { + return Page: {value} + } } -} -function renderFilter(history, [key, value], query) { - function removeFilter() { + removeFilter(key, history, query) { const newOpts = { [key]: false } @@ -67,27 +151,97 @@ function renderFilter(history, [key, value], query) { ) } - return ( - - {filterText(key, value, query)} - - ) -} + renderDropdownFilter(history, [key, value], query) { + return ( +
+ {this.filterText(key, value, query)} + this.removeFilter(key, history, query)}>✕ +
+ ) + } + + renderListFilter(history, [key, value], query) { + return ( + + {this.filterText(key, value, query)} this.removeFilter(key, history, query)}>✕ + + ) + } + + clearAllFilters(history, query) { + const newOpts = Object.keys(query.filters).reduce((acc, red) => ({ ...acc, [red]: false }), {}); + navigateToQuery( + history, + query, + newOpts + ); + } -function Filters({query, history, location}) { - const appliedFilters = Object.keys(query.filters) - .map((key) => [key, query.filters[key]]) - .filter(([key, value]) => !!value) + renderDropDownContent() { + const { viewport } = this.state; + const { history, query } = this.props; - if (appliedFilters.length > 0) { return ( -
- { appliedFilters.map((filter) => renderFilter(history, filter, query)) } +
this.dropDownNode = node}> +
+ {this.appliedFilters.map((filter) => this.renderDropdownFilter(history, filter, query))} +
this.clearAllFilters(history, query)}> + Clear All Filters +
+
) } - return null + renderDropDown() { + return ( +
+
+
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"> + Filters + + + +
+ + {this.renderDropDownContent()} + +
+
+ ); + } + + renderFilterList() { + const { history, query } = this.props; + + return ( +
+ {(this.appliedFilters.map((filter) => this.renderListFilter(history, filter, query)))} +
+ ); + } + + render() { + const { wrapped, viewport } = this.state; + + if (this.appliedFilters.length > 0) { + if (wrapped === 2 || viewport <= 768) { + return this.renderDropDown(); + } + + return this.renderFilterList(); + } + + return null; + } } -export default withRouter(Filters) +export default withRouter(Filters); diff --git a/assets/js/dashboard/historical.js b/assets/js/dashboard/historical.js index 21f9c044daba..5e1c79efe10b 100644 --- a/assets/js/dashboard/historical.js +++ b/assets/js/dashboard/historical.js @@ -27,15 +27,15 @@ class Historical extends React.Component { return (
-
-
-
+
+
+
- + +
-
diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js index fcd65dafc56b..adbf00292757 100644 --- a/assets/js/dashboard/query.js +++ b/assets/js/dashboard/query.js @@ -43,11 +43,17 @@ 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; +} + function generateQueryString(data) { const query = new URLSearchParams(window.location.search) - query.delete("date"); - query.delete("from"); - query.delete("to"); Object.keys(data).forEach(key => { if (!data[key]) { query.delete(key) diff --git a/assets/js/dashboard/realtime.js b/assets/js/dashboard/realtime.js index 33a3303b5389..e5ad08faa487 100644 --- a/assets/js/dashboard/realtime.js +++ b/assets/js/dashboard/realtime.js @@ -27,14 +27,14 @@ class Realtime extends React.Component { return (
-
+
-
+
+
-
diff --git a/assets/js/dashboard/site-switcher.js b/assets/js/dashboard/site-switcher.js index 0ac34d3e901d..13d8a9121f38 100644 --- a/assets/js/dashboard/site-switcher.js +++ b/assets/js/dashboard/site-switcher.js @@ -45,9 +45,9 @@ export default class SiteSwitcher extends React.Component { } renderSiteLink(domain) { - const extraClass = domain === this.props.site.domain ? 'font-medium text-gray-900 dark:text-gray-100' : 'hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100' + const extraClass = domain === this.props.site.domain ? 'font-medium text-gray-900 dark:text-gray-100 cursor-default font-bold' : 'hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100' return ( - + {domain} @@ -63,12 +63,12 @@ export default class SiteSwitcher extends React.Component { return ( -
+
{ this.state.sites.map(this.renderSiteLink.bind(this)) }
@@ -91,7 +91,7 @@ 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 ( -
+