diff --git a/.github/ISSUE_TEMPLATE/2.feature_request.yml b/.github/ISSUE_TEMPLATE/2.feature_request.yml index 3034767b..529a6c73 100644 --- a/.github/ISSUE_TEMPLATE/2.feature_request.yml +++ b/.github/ISSUE_TEMPLATE/2.feature_request.yml @@ -1,10 +1,9 @@ -name: "✨ Feature Request" +name: '✨ Feature Request' description: Create a feature or enhancement request for Umami. -labels: ['enhancement'] body: - type: textarea attributes: label: Describe the feature or enhancement description: A clear and concise description of what the feature or enhancement is. validations: - required: true \ No newline at end of file + required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c24c2e6d..c140f626 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,9 +26,9 @@ jobs: db-type: mysql steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: 'npm' diff --git a/assets/add-user.svg b/assets/add-user.svg index 9d0544c6..c6b4f484 100644 --- a/assets/add-user.svg +++ b/assets/add-user.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/clock.svg b/assets/clock.svg index 9c2a9a41..ab4c1dec 100644 --- a/assets/clock.svg +++ b/assets/clock.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/dashboard.svg b/assets/dashboard.svg index 11859d28..2090e5dc 100644 --- a/assets/dashboard.svg +++ b/assets/dashboard.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/funnel.svg b/assets/funnel.svg new file mode 100644 index 00000000..63fb7158 --- /dev/null +++ b/assets/funnel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/lightbulb.svg b/assets/lightbulb.svg new file mode 100644 index 00000000..c7895a7d --- /dev/null +++ b/assets/lightbulb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/lock.svg b/assets/lock.svg index c13fb7c7..27fcc5e1 100644 --- a/assets/lock.svg +++ b/assets/lock.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/logo.svg b/assets/logo.svg index d2c71326..b1395313 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/nodes.svg b/assets/nodes.svg new file mode 100644 index 00000000..b3e22a75 --- /dev/null +++ b/assets/nodes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/profile.svg b/assets/profile.svg index 133b1bc1..6a1af5a0 100644 --- a/assets/profile.svg +++ b/assets/profile.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/user.svg b/assets/user.svg index a75cbb8d..245a67f6 100644 --- a/assets/user.svg +++ b/assets/user.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/users.svg b/assets/users.svg index f775ea91..7036a22c 100644 --- a/assets/users.svg +++ b/assets/users.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/website.svg b/assets/website.svg index cfa9e565..6096a650 100644 --- a/assets/website.svg +++ b/assets/website.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/components/common/NoData.js b/components/common/Empty.js similarity index 57% rename from components/common/NoData.js rename to components/common/Empty.js index e9c95754..95681b16 100644 --- a/components/common/NoData.js +++ b/components/common/Empty.js @@ -1,15 +1,15 @@ import classNames from 'classnames'; -import styles from './NoData.module.css'; +import styles from './Empty.module.css'; import useMessages from 'hooks/useMessages'; -export function NoData({ className }) { +export function Empty({ message, className }) { const { formatMessage, messages } = useMessages(); return (
- {formatMessage(messages.noDataAvailable)} + {message || formatMessage(messages.noDataAvailable)}
); } -export default NoData; +export default Empty; diff --git a/components/common/NoData.module.css b/components/common/Empty.module.css similarity index 100% rename from components/common/NoData.module.css rename to components/common/Empty.module.css diff --git a/components/common/HoverTooltip.js b/components/common/HoverTooltip.js index 2a98ab84..614841df 100644 --- a/components/common/HoverTooltip.js +++ b/components/common/HoverTooltip.js @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { Tooltip } from 'react-basics'; import styles from './HoverTooltip.module.css'; -export function HoverTooltip({ tooltip }) { +export function HoverTooltip({ children }) { const [position, setPosition] = useState({ x: -1000, y: -1000 }); useEffect(() => { @@ -18,9 +18,9 @@ export function HoverTooltip({ tooltip }) { }, []); return ( -
- -
+ + {children} + ); } diff --git a/components/common/HoverTooltip.module.css b/components/common/HoverTooltip.module.css index ec1abc1c..c4bb76ea 100644 --- a/components/common/HoverTooltip.module.css +++ b/components/common/HoverTooltip.module.css @@ -1,43 +1,6 @@ -.chart { - position: relative; -} - .tooltip { position: fixed; pointer-events: none; z-index: var(--z-index-popup); -} - -.content { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - text-align: center; -} - -.title { - font-size: var(--font-size-xs); - font-weight: 600; -} - -.metric { - display: flex; - justify-content: center; - align-items: center; - font-size: var(--font-size-sm); - font-weight: 600; -} - -.dot { - position: relative; - overflow: hidden; - border-radius: 100%; - margin-right: 8px; - background: var(--base50); -} - -.color { - width: 10px; - height: 10px; + transform: translate(-50%, calc(-100% - 5px)); } diff --git a/components/common/WorldMap.js b/components/common/WorldMap.js index 55a13f0b..9c91e4a4 100644 --- a/components/common/WorldMap.js +++ b/components/common/WorldMap.js @@ -4,7 +4,7 @@ import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simp import classNames from 'classnames'; import { colord } from 'colord'; import HoverTooltip from 'components/common/HoverTooltip'; -import { ISO_COUNTRIES, THEME_COLORS, MAP_FILE } from 'lib/constants'; +import { ISO_COUNTRIES, MAP_FILE } from 'lib/constants'; import useTheme from 'hooks/useTheme'; import useCountryNames from 'hooks/useCountryNames'; import useLocale from 'hooks/useLocale'; @@ -14,17 +14,8 @@ import styles from './WorldMap.module.css'; export function WorldMap({ data, className }) { const { basePath } = useRouter(); - const [tooltip, setTooltip] = useState(); - const [theme] = useTheme(); - const colors = useMemo( - () => ({ - baseColor: THEME_COLORS[theme].primary, - fillColor: THEME_COLORS[theme].gray100, - strokeColor: THEME_COLORS[theme].primary, - hoverColor: THEME_COLORS[theme].primary, - }), - [theme], - ); + const [tooltip, setTooltipPopup] = useState(); + const { theme, colors } = useTheme(); const { locale } = useLocale(); const countryNames = useCountryNames(locale); const metrics = useMemo(() => (data ? percentFilter(data) : []), [data]); @@ -34,10 +25,10 @@ export function WorldMap({ data, className }) { const country = metrics?.find(({ x }) => x === code); if (!country) { - return colors.fillColor; + return colors.map.fillColor; } - return colord(colors.baseColor) + return colord(colors.map.baseColor) [theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100)) .toHex(); } @@ -49,7 +40,7 @@ export function WorldMap({ data, className }) { function handleHover(code) { if (code === 'AQ') return; const country = metrics?.find(({ x }) => x === code); - setTooltip(`${countryNames[code]}: ${formatLongNumber(country?.y || 0)} visitors`); + setTooltipPopup(`${countryNames[code]}: ${formatLongNumber(country?.y || 0)} visitors`); } return ( @@ -70,15 +61,15 @@ export function WorldMap({ data, className }) { key={geo.rsmKey} geography={geo} fill={getFillColor(code)} - stroke={colors.strokeColor} + stroke={colors.map.strokeColor} opacity={getOpacity(code)} style={{ default: { outline: 'none' }, - hover: { outline: 'none', fill: colors.hoverColor }, + hover: { outline: 'none', fill: colors.map.hoverColor }, pressed: { outline: 'none' }, }} onMouseOver={() => handleHover(code)} - onMouseOut={() => setTooltip(null)} + onMouseOut={() => setTooltipPopup(null)} /> ); }); @@ -86,7 +77,7 @@ export function WorldMap({ data, className }) { - {tooltip && } + {tooltip && {tooltip}} ); } diff --git a/components/input/DateFilter.js b/components/input/DateFilter.js index b6c1ee72..7fc4319d 100644 --- a/components/input/DateFilter.js +++ b/components/input/DateFilter.js @@ -3,31 +3,22 @@ import { Icon, Modal, Dropdown, Item, Text, Flexbox } from 'react-basics'; import { endOfYear, isSameDay } from 'date-fns'; import DatePickerForm from 'components/metrics/DatePickerForm'; import useLocale from 'hooks/useLocale'; -import { dateFormat, getDateRangeValues } from 'lib/date'; +import { dateFormat } from 'lib/date'; import Icons from 'components/icons'; -import useApi from 'hooks/useApi'; -import useDateRange from 'hooks/useDateRange'; import useMessages from 'hooks/useMessages'; -export function DateFilter({ websiteId, value, className }) { +export function DateFilter({ + value, + startDate, + endDate, + className, + onChange, + showAllTime = false, + alignment = 'end', +}) { const { formatMessage, labels } = useMessages(); - const { get } = useApi(); - const [dateRange, setDateRange] = useDateRange(websiteId); - const { startDate, endDate } = dateRange; const [showPicker, setShowPicker] = useState(false); - async function handleDateChange(value) { - if (value === 'all' && websiteId) { - const data = await get(`/websites/${websiteId}`); - - if (data) { - setDateRange({ value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) }); - } - } else if (value !== 'all') { - setDateRange(value); - } - } - const options = [ { label: formatMessage(labels.today), value: '1day' }, { @@ -61,7 +52,7 @@ export function DateFilter({ websiteId, value, className }) { value: '90day', }, { label: formatMessage(labels.thisYear), value: '1year' }, - websiteId && { + showAllTime && { label: formatMessage(labels.allTime), value: 'all', divider: true, @@ -74,7 +65,7 @@ export function DateFilter({ websiteId, value, className }) { ].filter(n => n); const renderValue = value => { - return value === 'custom' ? ( + return value.startsWith('range') ? ( handleChange('custom')} /> ) : ( options.find(e => e.value === value).label @@ -86,12 +77,12 @@ export function DateFilter({ websiteId, value, className }) { setShowPicker(true); return; } - handleDateChange(value); + onChange(value); }; const handlePickerChange = value => { setShowPicker(false); - handleDateChange(value); + onChange(value); }; const handleClose = () => setShowPicker(false); @@ -103,7 +94,8 @@ export function DateFilter({ websiteId, value, className }) { items={options} renderValue={renderValue} value={value} - alignment="end" + alignment={alignment} + placeholder={formatMessage(labels.selectDate)} onChange={handleChange} > {({ label, value, divider }) => ( diff --git a/components/input/LanguageButton.js b/components/input/LanguageButton.js index 1297d6c2..d4c1cbc3 100644 --- a/components/input/LanguageButton.js +++ b/components/input/LanguageButton.js @@ -9,8 +9,10 @@ export function LanguageButton() { const { locale, saveLocale, dir } = useLocale(); const items = Object.keys(languages).map(key => ({ ...languages[key], value: key })); - function handleSelect(value) { + function handleSelect(value, close, e) { + e.stopPropagation(); saveLocale(value); + close(); } return ( @@ -21,24 +23,28 @@ export function LanguageButton() { -
- {items.map(({ value, label }) => { - return ( -
- {label} - {value === locale && ( - - - - )} -
- ); - })} -
+ {close => { + return ( +
+ {items.map(({ value, label }) => { + return ( +
+ {label} + {value === locale && ( + + + + )} +
+ ); + })} +
+ ); + }}
); diff --git a/components/input/LanguageButton.module.css b/components/input/LanguageButton.module.css index 16d61978..3d4c0c56 100644 --- a/components/input/LanguageButton.module.css +++ b/components/input/LanguageButton.module.css @@ -1,8 +1,7 @@ .menu { display: flex; flex-flow: row wrap; - min-width: 600px; - max-width: 100vw; + min-width: 640px; padding: 10px; background: var(--base50); z-index: var(--z-index-popup); diff --git a/components/input/LogoutButton.js b/components/input/LogoutButton.js index 3314956e..4a15cd68 100644 --- a/components/input/LogoutButton.js +++ b/components/input/LogoutButton.js @@ -1,4 +1,4 @@ -import { Button, Icon, Icons, Tooltip } from 'react-basics'; +import { Button, Icon, Icons, TooltipPopup } from 'react-basics'; import Link from 'next/link'; import useMessages from 'hooks/useMessages'; @@ -6,13 +6,13 @@ export function LogoutButton({ tooltipPosition = 'top' }) { const { formatMessage, labels } = useMessages(); return ( - + - + ); } diff --git a/components/input/RefreshButton.js b/components/input/RefreshButton.js index b3e2b815..444f3247 100644 --- a/components/input/RefreshButton.js +++ b/components/input/RefreshButton.js @@ -1,4 +1,4 @@ -import { LoadingButton, Icon, Tooltip } from 'react-basics'; +import { LoadingButton, Icon, TooltipPopup } from 'react-basics'; import { setWebsiteDateRange } from 'store/websites'; import useDateRange from 'hooks/useDateRange'; import Icons from 'components/icons'; @@ -19,13 +19,13 @@ export function RefreshButton({ websiteId, isLoading }) { } return ( - + - + ); } diff --git a/components/input/ThemeButton.js b/components/input/ThemeButton.js index b945ab7d..8ab0cdcd 100644 --- a/components/input/ThemeButton.js +++ b/components/input/ThemeButton.js @@ -5,7 +5,7 @@ import Icons from 'components/icons'; import styles from './ThemeButton.module.css'; export function ThemeButton() { - const [theme, setTheme] = useTheme(); + const { theme, saveTheme } = useTheme(); const transitions = useTransition(theme, { initial: { opacity: 1 }, @@ -21,7 +21,7 @@ export function ThemeButton() { }); function handleClick() { - setTheme(theme === 'light' ? 'dark' : 'light'); + saveTheme(theme === 'light' ? 'dark' : 'light'); } return ( diff --git a/components/input/WebsiteDateFilter.js b/components/input/WebsiteDateFilter.js new file mode 100644 index 00000000..71075dd7 --- /dev/null +++ b/components/input/WebsiteDateFilter.js @@ -0,0 +1,25 @@ +import useApi from 'hooks/useApi'; +import useDateRange from 'hooks/useDateRange'; +import DateFilter from './DateFilter'; + +export default function WebsiteDateFilter({ websiteId, value }) { + const { get } = useApi(); + const [dateRange, setDateRange] = useDateRange(websiteId); + const { startDate, endDate } = dateRange; + + const handleChange = async value => { + if (value === 'all' && websiteId) { + const data = await get(`/websites/${websiteId}`); + + if (data) { + setDateRange(`range:${new Date(data.createdAt)}:${Date.now()}`); + } + } else if (value !== 'all') { + setDateRange(value); + } + }; + + return ( + + ); +} diff --git a/components/input/WebsiteSelect.js b/components/input/WebsiteSelect.js index a0ac38e4..b77ae57c 100644 --- a/components/input/WebsiteSelect.js +++ b/components/input/WebsiteSelect.js @@ -19,7 +19,6 @@ export function WebsiteSelect({ websiteId, onSelect }) { onChange={onSelect} alignment="end" placeholder={formatMessage(labels.selectWebsite)} - style={{ width: 200 }} > {({ id, name }) => {name}} diff --git a/components/layout/AppLayout.module.css b/components/layout/AppLayout.module.css index 6cc9e414..a83039ce 100644 --- a/components/layout/AppLayout.module.css +++ b/components/layout/AppLayout.module.css @@ -2,6 +2,7 @@ display: grid; grid-template-rows: max-content 1fr; grid-template-columns: 1fr; + overflow: hidden; } .nav { diff --git a/components/layout/NavBar.js b/components/layout/NavBar.js index 5a6c877e..a5ac35ef 100644 --- a/components/layout/NavBar.js +++ b/components/layout/NavBar.js @@ -19,6 +19,7 @@ export function NavBar() { const links = [ { label: formatMessage(labels.dashboard), url: '/dashboard' }, { label: formatMessage(labels.realtime), url: '/realtime' }, + { label: formatMessage(labels.reports), url: '/reports' }, !cloudMode && { label: formatMessage(labels.settings), url: '/settings' }, ].filter(n => n); diff --git a/components/layout/NavBar.module.css b/components/layout/NavBar.module.css index 05dce2af..dd5085a0 100644 --- a/components/layout/NavBar.module.css +++ b/components/layout/NavBar.module.css @@ -27,7 +27,6 @@ gap: 10px; font-size: 16px; font-weight: 700; - cursor: pointer; min-width: 0; } diff --git a/components/layout/NavGroup.js b/components/layout/NavGroup.js index b9e7155d..94f9d8e6 100644 --- a/components/layout/NavGroup.js +++ b/components/layout/NavGroup.js @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Icon, Text, Tooltip } from 'react-basics'; +import { Icon, Text, TooltipPopup } from 'react-basics'; import classNames from 'classnames'; import { useRouter } from 'next/router'; import Link from 'next/link'; @@ -36,7 +36,7 @@ export function NavGroup({
{items.map(({ label, url, icon, divider }) => { return ( - + {icon} {label} - + ); })}
diff --git a/components/layout/PageHeader.js b/components/layout/PageHeader.js index bf243c21..f1363140 100644 --- a/components/layout/PageHeader.js +++ b/components/layout/PageHeader.js @@ -1,10 +1,11 @@ +import classNames from 'classnames'; import React from 'react'; import styles from './PageHeader.module.css'; -export function PageHeader({ title, children }) { +export function PageHeader({ title, children, className }) { return ( -
-
{title}
+
+ {title &&
{title}
}
{children}
); diff --git a/components/layout/PageHeader.module.css b/components/layout/PageHeader.module.css index 5ea85b70..03a1c7c8 100644 --- a/components/layout/PageHeader.module.css +++ b/components/layout/PageHeader.module.css @@ -4,7 +4,6 @@ align-items: center; align-content: center; align-self: stretch; - margin-bottom: 40px; flex-wrap: wrap; } @@ -23,6 +22,7 @@ font-weight: 700; gap: 20px; height: 60px; + flex: 1; } .actions { diff --git a/components/layout/ReportsLayout.js b/components/layout/ReportsLayout.js new file mode 100644 index 00000000..fd63a67e --- /dev/null +++ b/components/layout/ReportsLayout.js @@ -0,0 +1,23 @@ +import { Column, Row } from 'react-basics'; +import styles from './ReportsLayout.module.css'; + +export function SettingsLayout({ children, filter, header }) { + return ( + <> + {header} + + {filter && ( + +

Filters

+ {filter} +
+ )} + + {children} + +
+ + ); +} + +export default SettingsLayout; diff --git a/components/layout/ReportsLayout.module.css b/components/layout/ReportsLayout.module.css new file mode 100644 index 00000000..6922665f --- /dev/null +++ b/components/layout/ReportsLayout.module.css @@ -0,0 +1,23 @@ +.filter { + margin-top: 30px; + min-width: 200px; + max-width: 100vw; + padding: 10px; + background: var(--base50); + border-radius: 5px; + border: 1px solid var(--border-color); +} + +.filter h2 { + padding-bottom: 20px; +} + +.content { + min-height: 50vh; +} + +@media only screen and (max-width: 768px) { + .menu { + display: none; + } +} diff --git a/components/messages.js b/components/messages.js index aa268225..016d3c4c 100644 --- a/components/messages.js +++ b/components/messages.js @@ -18,6 +18,7 @@ export const labels = defineMessages({ admin: { id: 'label.admin', defaultMessage: 'Administrator' }, confirm: { id: 'label.confirm', defaultMessage: 'Confirm' }, details: { id: 'label.details', defaultMessage: 'Details' }, + website: { id: 'label.website', defaultMessage: 'Website' }, websites: { id: 'label.websites', defaultMessage: 'Websites' }, created: { id: 'label.created', defaultMessage: 'Created' }, edit: { id: 'label.edit', defaultMessage: 'Edit' }, @@ -47,6 +48,8 @@ export const labels = defineMessages({ deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' }, reset: { id: 'label.reset', defaultMessage: 'Reset' }, addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' }, + addField: { id: 'label.add-field', defaultMessage: 'Add field' }, + addDescription: { id: 'label.add-description', defaultMessage: 'Add description' }, changePassword: { id: 'label.change-password', defaultMessage: 'Change password' }, currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' }, newPassword: { id: 'label.new-password', defaultMessage: 'New password' }, @@ -79,7 +82,8 @@ export const labels = defineMessages({ countries: { id: 'label.countries', defaultMessage: 'Countries' }, languages: { id: 'label.languages', defaultMessage: 'Languages' }, events: { id: 'label.events', defaultMessage: 'Events' }, - query: { id: 'label.query-parameters', defaultMessage: 'Query parameters' }, + query: { id: 'label.query', defaultMessage: 'Query' }, + queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' }, back: { id: 'label.back', defaultMessage: 'Back' }, visitors: { id: 'label.visitors', defaultMessage: 'Visitors' }, filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' }, @@ -97,6 +101,7 @@ export const labels = defineMessages({ allTime: { id: 'label.all-time', defaultMessage: 'All time' }, customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' }, selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' }, + selectDate: { id: 'label.select-date', defaultMessage: 'Select date' }, all: { id: 'label.all', defaultMessage: 'All' }, sessions: { id: 'label.sessions', defaultMessage: 'Sessions' }, pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' }, @@ -117,6 +122,34 @@ export const labels = defineMessages({ view: { id: 'label.view', defaultMessage: 'View' }, cities: { id: 'label.cities', defaultMessage: 'Cities' }, regions: { id: 'label.regions', defaultMessage: 'Regions' }, + reports: { id: 'label.reports', defaultMessage: 'Reports' }, + eventData: { id: 'label.event-data', defaultMessage: 'Event data' }, + funnel: { id: 'label.funnel', defaultMessage: 'Funnel' }, + url: { id: 'label.url', defaultMessage: 'URL' }, + urls: { id: 'label.urls', defaultMessage: 'URLs' }, + add: { id: 'label.add', defaultMessage: 'Add' }, + window: { id: 'label.window', defaultMessage: 'Window' }, + runQuery: { id: 'label.run-query', defaultMessage: 'Run query' }, + field: { id: 'label.field', defaultMessage: 'Field' }, + fields: { id: 'label.fields', defaultMessage: 'Fields' }, + createReport: { id: 'labels.create-report', defaultMessage: 'Create report' }, + description: { id: 'labels.description', defaultMessage: 'Description' }, + untitled: { id: 'labels.untitled', defaultMessage: 'Untitled' }, + type: { id: 'labels.type', defaultMessage: 'Type' }, + filters: { id: 'labels.filters', defaultMessage: 'Filters' }, + groupBy: { id: 'labels.group-by', defaultMessage: 'Group by' }, + true: { id: 'labels.true', defaultMessage: 'True' }, + false: { id: 'labels.false', defaultMessage: 'False' }, + equals: { id: 'labels.equals', defaultMessage: 'Equals' }, + doesNotEqual: { id: 'labels.does-not-equal', defaultMessage: 'Does not equal' }, + greaterThan: { id: 'labels.greater-than', defaultMessage: 'Greater than' }, + lessThan: { id: 'labels.less-than', defaultMessage: 'Less than' }, + greaterThanEquals: { id: 'labels.greater-than-equals', defaultMessage: 'Greater than or equals' }, + lessThanEquals: { id: 'labels.less-than-equals', defaultMessage: 'Less than or equals' }, + contains: { id: 'labels.contains', defaultMessage: 'Contains' }, + doesNotContain: { id: 'labels.does-not-contain', defaultMessage: 'Does not contain' }, + before: { id: 'labels.before', defaultMessage: 'Before' }, + after: { id: 'labels.after', defaultMessage: 'After' }, }); export const messages = defineMessages({ @@ -158,6 +191,10 @@ export const messages = defineMessages({ id: 'message.team-already-member', defaultMessage: 'You are already a member of the team.', }, + deleteAccount: { + id: 'message.delete-account', + defaultMessage: 'To delete this account, type {confirmation} in the box below to confirm.', + }, deleteWebsite: { id: 'message.delete-website', defaultMessage: 'To delete this website, type {confirmation} in the box below to confirm.', @@ -179,6 +216,10 @@ export const messages = defineMessages({ id: 'message.delete-website-warning', defaultMessage: 'All website data will be deleted.', }, + noResultsFound: { + id: 'messages.no-results-found', + defaultMessage: 'No results were found.', + }, noWebsitesConfigured: { id: 'messages.no-websites-configured', defaultMessage: 'You do not have any websites configured.', @@ -216,4 +257,8 @@ export const messages = defineMessages({ id: 'message.incorrect-username-password', defaultMessage: 'Incorrect username and/or password.', }, + noEventData: { + id: 'message.no-event-data', + defaultMessage: 'No event data is available.', + }, }); diff --git a/components/metrics/BarChart.js b/components/metrics/BarChart.js index cd7070e8..c086017e 100644 --- a/components/metrics/BarChart.js +++ b/components/metrics/BarChart.js @@ -1,14 +1,13 @@ -import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { StatusLight, Loading } from 'react-basics'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Loading } from 'react-basics'; import classNames from 'classnames'; import Chart from 'chart.js/auto'; import HoverTooltip from 'components/common/HoverTooltip'; import Legend from 'components/metrics/Legend'; -import { formatLongNumber } from 'lib/format'; -import { dateFormat } from 'lib/date'; import useLocale from 'hooks/useLocale'; import useTheme from 'hooks/useTheme'; -import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants'; +import { DEFAULT_ANIMATION_DURATION } from 'lib/constants'; +import { renderNumberLabels } from 'lib/charts'; import styles from './BarChart.module.css'; export function BarChart({ @@ -17,84 +16,20 @@ export function BarChart({ animationDuration = DEFAULT_ANIMATION_DURATION, stacked = false, loading = false, - onCreate = () => {}, - onUpdate = () => {}, + renderXLabel, + renderYLabel, + XAxisType = 'time', + YAxisType = 'linear', + renderTooltipPopup, + onCreate, + onUpdate, className, }) { const canvas = useRef(); const chart = useRef(null); - const [tooltip, setTooltip] = useState(null); + const [tooltip, setTooltipPopup] = useState(null); const { locale } = useLocale(); - const [theme] = useTheme(); - - const colors = useMemo( - () => ({ - text: THEME_COLORS[theme].gray700, - line: THEME_COLORS[theme].gray200, - }), - [theme], - ); - - const renderYLabel = label => { - return +label > 1000 ? formatLongNumber(label) : label; - }; - - const renderXLabel = useCallback( - (label, index, values) => { - const d = new Date(values[index].value); - - switch (unit) { - case 'minute': - return dateFormat(d, 'h:mm', locale); - case 'hour': - return dateFormat(d, 'p', locale); - case 'day': - return dateFormat(d, 'MMM d', locale); - case 'month': - return dateFormat(d, 'MMM', locale); - default: - return label; - } - }, - [locale, unit], - ); - - const renderTooltip = useCallback( - model => { - const { opacity, labelColors, dataPoints } = model.tooltip; - - if (!dataPoints?.length || !opacity) { - setTooltip(null); - return; - } - - const formats = { - millisecond: 'T', - second: 'pp', - minute: 'p', - hour: 'h:mm aaa - PP', - day: 'PPPP', - week: 'PPPP', - month: 'LLLL yyyy', - quarter: 'qqq', - year: 'yyyy', - }; - - setTooltip( -
-
{dateFormat(new Date(dataPoints[0].raw.x), formats[unit], locale)}
-
- -
- {formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label} -
-
-
-
, - ); - }, - [unit], - ); + const { theme, colors } = useTheme(); const getOptions = useCallback(() => { return { @@ -115,12 +50,12 @@ export function BarChart({ }, tooltip: { enabled: false, - external: renderTooltip, + external: renderTooltipPopup ? renderTooltipPopup.bind(null, setTooltipPopup) : undefined, }, }, scales: { x: { - type: 'time', + type: XAxisType, stacked: true, time: { unit, @@ -129,34 +64,44 @@ export function BarChart({ display: false, }, border: { - color: colors.line, + color: colors.chart.line, }, ticks: { - color: colors.text, + color: colors.chart.text, autoSkip: false, maxRotation: 0, callback: renderXLabel, }, }, y: { - type: 'linear', + type: YAxisType, min: 0, beginAtZero: true, stacked, grid: { - color: colors.line, + color: colors.chart.line, }, border: { - color: colors.line, + color: colors.chart.line, }, ticks: { color: colors.text, - callback: renderYLabel, + callback: renderYLabel || renderNumberLabels, }, }, }, }; - }, [animationDuration, renderTooltip, renderXLabel, stacked, colors, unit, locale]); + }, [ + animationDuration, + renderTooltipPopup, + renderXLabel, + XAxisType, + YAxisType, + stacked, + colors, + unit, + locale, + ]); const createChart = () => { Chart.defaults.font.family = 'Inter'; @@ -171,11 +116,11 @@ export function BarChart({ options, }); - onCreate(chart.current); + onCreate?.(chart.current); }; const updateChart = () => { - setTooltip(null); + setTooltipPopup(null); datasets.forEach((dataset, index) => { chart.current.data.datasets[index].data = dataset.data; @@ -184,7 +129,7 @@ export function BarChart({ chart.current.options = getOptions(); - onUpdate(chart.current); + onUpdate?.(chart.current); chart.current.update(); }; @@ -206,7 +151,11 @@ export function BarChart({
- {tooltip && } + {tooltip && ( + +
{tooltip}
+
+ )} ); } diff --git a/components/metrics/BarChart.module.css b/components/metrics/BarChart.module.css index 850d1ea7..f2e26db1 100644 --- a/components/metrics/BarChart.module.css +++ b/components/metrics/BarChart.module.css @@ -13,9 +13,3 @@ .tooltip .value { text-transform: lowercase; } - -@media only screen and (max-width: 992px) { - .chart { - /*height: 200px;*/ - } -} diff --git a/components/metrics/DataTable.js b/components/metrics/DataTable.js index 086f98ae..e2e9462d 100644 --- a/components/metrics/DataTable.js +++ b/components/metrics/DataTable.js @@ -3,10 +3,10 @@ import useMeasure from 'react-use-measure'; import { FixedSizeList } from 'react-window'; import { useSpring, animated, config } from 'react-spring'; import classNames from 'classnames'; -import NoData from 'components/common/NoData'; +import Empty from 'components/common/Empty'; import { formatNumber, formatLongNumber } from 'lib/format'; +import useMessages from 'hooks/useMessages'; import styles from './DataTable.module.css'; -import useMessages from '../../hooks/useMessages'; export function DataTable({ data = [], @@ -55,7 +55,7 @@ export function DataTable({
- {data?.length === 0 && } + {data?.length === 0 && } {virtualize && data.length > 0 ? ( {Row} diff --git a/components/metrics/DataTable.module.css b/components/metrics/DataTable.module.css index c5b2bd7c..04e12e9b 100644 --- a/components/metrics/DataTable.module.css +++ b/components/metrics/DataTable.module.css @@ -1,9 +1,9 @@ .table { position: relative; - height: 100%; display: grid; grid-template-rows: fit-content(100%) auto; overflow: hidden; + flex: 1; } .body { diff --git a/components/metrics/DatePickerForm.js b/components/metrics/DatePickerForm.js index 96730591..53f027bb 100644 --- a/components/metrics/DatePickerForm.js +++ b/components/metrics/DatePickerForm.js @@ -2,7 +2,6 @@ import { useState } from 'react'; import { Button, ButtonGroup, Calendar } from 'react-basics'; import { isAfter, isBefore, isSameDay } from 'date-fns'; import useLocale from 'hooks/useLocale'; -import { getDateRangeValues } from 'lib/date'; import { getDateLocale } from 'lib/lang'; import { FILTER_DAY, FILTER_RANGE } from 'lib/constants'; import useMessages from 'hooks/useMessages'; @@ -19,7 +18,7 @@ export function DatePickerForm({ const [selected, setSelected] = useState( isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE, ); - const [date, setDate] = useState(defaultStartDate); + const [singleDate, setSingleDate] = useState(defaultStartDate); const [startDate, setStartDate] = useState(defaultStartDate); const [endDate, setEndDate] = useState(defaultEndDate); const { locale } = useLocale(); @@ -27,14 +26,14 @@ export function DatePickerForm({ const disabled = selected === FILTER_DAY - ? isAfter(minDate, date) && isBefore(maxDate, date) + ? isAfter(minDate, singleDate) && isBefore(maxDate, singleDate) : isAfter(startDate, endDate); const handleSave = () => { if (selected === FILTER_DAY) { - onChange({ ...getDateRangeValues(date, date), value: 'custom' }); + onChange(`range:${singleDate.getTime()}:${singleDate.getTime()}`); } else { - onChange({ ...getDateRangeValues(startDate, endDate), value: 'custom' }); + onChange(`range:${startDate.getTime()}:${endDate.getTime()}`); } }; @@ -48,7 +47,12 @@ export function DatePickerForm({
{selected === FILTER_DAY && ( - + )} {selected === FILTER_RANGE && ( <> diff --git a/components/metrics/EventsChart.js b/components/metrics/EventsChart.js index eb397cc9..82b8c8f7 100644 --- a/components/metrics/EventsChart.js +++ b/components/metrics/EventsChart.js @@ -2,16 +2,15 @@ import { useMemo } from 'react'; import { Loading } from 'react-basics'; import { colord } from 'colord'; import BarChart from './BarChart'; -import { getDateArray, getDateLength } from 'lib/date'; -import useApi from 'hooks/useApi'; -import useDateRange from 'hooks/useDateRange'; -import useTimezone from 'hooks/useTimezone'; -import usePageQuery from 'hooks/usePageQuery'; +import { getDateArray } from 'lib/date'; +import { useApi, useLocale, useDateRange, useTimezone, usePageQuery } from 'hooks'; import { EVENT_COLORS } from 'lib/constants'; +import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts'; export function EventsChart({ websiteId, className, token }) { const { get, useQuery } = useApi(); const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId); + const { locale } = useLocale(); const [timezone] = useTimezone(); const { query: { url, eventName }, @@ -70,9 +69,10 @@ export function EventsChart({ websiteId, className, token }) { datasets={datasets} unit={unit} height={300} - records={getDateLength(startDate, endDate, unit)} loading={isLoading} stacked + renderXLabel={renderDateLabels(unit, locale)} + renderTooltipPopup={renderStatusTooltipPopup(unit, locale)} /> ); } diff --git a/components/metrics/MetricsBar.js b/components/metrics/MetricsBar.js index 25b93115..ccaf627c 100644 --- a/components/metrics/MetricsBar.js +++ b/components/metrics/MetricsBar.js @@ -16,13 +16,13 @@ export function MetricsBar({ websiteId }) { const { startDate, endDate, modified } = dateRange; const [format, setFormat] = useState(true); const { - query: { url, referrer, os, browser, device, country, region, city }, + query: { url, referrer, title, os, browser, device, country, region, city }, } = usePageQuery(); const { data, error, isLoading, isFetched } = useQuery( [ 'websites:stats', - { websiteId, modified, url, referrer, os, browser, device, country, region, city }, + { websiteId, modified, url, referrer, title, os, browser, device, country, region, city }, ], () => get(`/websites/${websiteId}/stats`, { @@ -30,6 +30,7 @@ export function MetricsBar({ websiteId }) { endAt: +endDate, url, referrer, + title, os, browser, device, diff --git a/components/metrics/MetricsBar.module.css b/components/metrics/MetricsBar.module.css index 0e305c70..eaf81c48 100644 --- a/components/metrics/MetricsBar.module.css +++ b/components/metrics/MetricsBar.module.css @@ -1,7 +1,7 @@ .bar { display: flex; cursor: pointer; - min-height: 80px; + min-height: 110px; gap: 20px; flex-wrap: wrap; } diff --git a/components/metrics/MetricsTable.js b/components/metrics/MetricsTable.js index 97deb39d..50262798 100644 --- a/components/metrics/MetricsTable.js +++ b/components/metrics/MetricsTable.js @@ -30,7 +30,7 @@ export function MetricsTable({ const { resolveUrl, router, - query: { url, referrer, os, browser, device, country, region, city }, + query: { url, referrer, title, os, browser, device, country, region, city }, } = usePageQuery(); const { formatMessage, labels } = useMessages(); const { get, useQuery } = useApi(); @@ -38,7 +38,20 @@ export function MetricsTable({ const { data, isLoading, isFetched, error } = useQuery( [ 'websites:metrics', - { websiteId, type, modified, url, referrer, os, browser, device, country, region, city }, + { + websiteId, + type, + modified, + url, + referrer, + os, + title, + browser, + device, + country, + region, + city, + }, ], () => get(`/websites/${websiteId}/metrics`, { @@ -46,6 +59,7 @@ export function MetricsTable({ startAt: +startDate, endAt: +endDate, url, + title, referrer, os, browser, @@ -59,13 +73,27 @@ export function MetricsTable({ const filteredData = useMemo(() => { if (data) { - let items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data); + let items = data; + + if (dataFilter) { + if (Array.isArray(dataFilter)) { + items = dataFilter.reduce((arr, filter) => { + return filter(arr); + }, items); + } else { + items = dataFilter(data); + } + } + + items = percentFilter(items); + if (limit) { items = items.filter((e, i) => i < limit); } if (filterOptions?.sort === false) { return items; } + return items.sort(firstBy('y', -1).thenBy('x')); } return []; diff --git a/components/metrics/PageviewsChart.js b/components/metrics/PageviewsChart.js index 6ea16226..362c616e 100644 --- a/components/metrics/PageviewsChart.js +++ b/components/metrics/PageviewsChart.js @@ -1,34 +1,13 @@ import { useMemo } from 'react'; -import { colord } from 'colord'; import BarChart from './BarChart'; -import { THEME_COLORS } from 'lib/constants'; -import useTheme from 'hooks/useTheme'; -import useMessages from 'hooks/useMessages'; -import useLocale from 'hooks/useLocale'; +import { useLocale, useTheme, useMessages } from 'hooks'; +import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts'; -export function PageviewsChart({ websiteId, data, unit, records, className, loading, ...props }) { +export function PageviewsChart({ websiteId, data, unit, className, loading, ...props }) { const { formatMessage, labels } = useMessages(); - const [theme] = useTheme(); + const { colors } = useTheme(); const { locale } = useLocale(); - const colors = useMemo(() => { - const primaryColor = colord(THEME_COLORS[theme].primary); - return { - views: { - hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(), - backgroundColor: primaryColor.alpha(0.4).toRgbString(), - borderColor: primaryColor.alpha(0.7).toRgbString(), - hoverBorderColor: primaryColor.toRgbString(), - }, - visitors: { - hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(), - backgroundColor: primaryColor.alpha(0.6).toRgbString(), - borderColor: primaryColor.alpha(0.9).toRgbString(), - hoverBorderColor: primaryColor.toRgbString(), - }, - }; - }, [theme]); - const datasets = useMemo(() => { if (!data) return []; @@ -37,13 +16,13 @@ export function PageviewsChart({ websiteId, data, unit, records, className, load label: formatMessage(labels.uniqueVisitors), data: data.sessions, borderWidth: 1, - ...colors.visitors, + ...colors.chart.visitors, }, { label: formatMessage(labels.pageViews), data: data.pageviews, borderWidth: 1, - ...colors.views, + ...colors.chart.views, }, ]; }, [data, locale, colors]); @@ -55,8 +34,9 @@ export function PageviewsChart({ websiteId, data, unit, records, className, load className={className} datasets={datasets} unit={unit} - records={records} loading={loading} + renderXLabel={renderDateLabels(unit, locale)} + renderTooltipPopup={renderStatusTooltipPopup(unit, locale)} /> ); } diff --git a/components/metrics/QueryParametersTable.js b/components/metrics/QueryParametersTable.js index c5f573e3..23193c2e 100644 --- a/components/metrics/QueryParametersTable.js +++ b/components/metrics/QueryParametersTable.js @@ -9,7 +9,7 @@ import styles from './QueryParametersTable.module.css'; const filters = { [FILTER_RAW]: emptyFilter, - [FILTER_COMBINED]: paramFilter, + [FILTER_COMBINED]: [emptyFilter, paramFilter], }; export function QueryParametersTable({ websiteId, showFilters, ...props }) { diff --git a/components/metrics/WebsiteChart.js b/components/metrics/WebsiteChart.js index 6614d40f..7b902df1 100644 --- a/components/metrics/WebsiteChart.js +++ b/components/metrics/WebsiteChart.js @@ -5,7 +5,7 @@ import classNames from 'classnames'; import PageviewsChart from './PageviewsChart'; import MetricsBar from './MetricsBar'; import WebsiteHeader from './WebsiteHeader'; -import DateFilter from 'components/input/DateFilter'; +import WebsiteDateFilter from 'components/input/WebsiteDateFilter'; import ErrorMessage from 'components/common/ErrorMessage'; import FilterTags from 'components/metrics/FilterTags'; import RefreshButton from 'components/input/RefreshButton'; @@ -107,7 +107,7 @@ export function WebsiteChart({
- +
diff --git a/components/metrics/WebsiteHeader.module.css b/components/metrics/WebsiteHeader.module.css index e5ebcca7..68fd22f8 100644 --- a/components/metrics/WebsiteHeader.module.css +++ b/components/metrics/WebsiteHeader.module.css @@ -1,3 +1,9 @@ +.header { + display: flex; + flex-direction: row; + align-items: center; +} + .title { display: flex; flex-direction: row; diff --git a/components/pages/console/TestConsole.js b/components/pages/console/TestConsole.js index 745bf94c..eda93f0b 100644 --- a/components/pages/console/TestConsole.js +++ b/components/pages/console/TestConsole.js @@ -28,20 +28,41 @@ export function TestConsole() { window.umami.track({ url: '/page-view', referrer: 'https://www.google.com' }); window.umami.track('track-event-no-data'); window.umami.track('track-event-with-data', { - data: { + test: 'test-data', + boolean: true, + booleanError: 'true', + time: new Date(), + number: 1, + time2: new Date().toISOString(), + nested: { test: 'test-data', - time: new Date(), number: 1, - time2: new Date().toISOString(), - nested: { + object: { test: 'test-data', - number: 1, - object: { - test: 'test-data', - }, }, - array: [1, 2, 3], }, + array: [1, 2, 3], + }); + } + + function handleIdentifyClick() { + window.umami.identify({ + userId: 123, + name: 'brian', + number: Math.random() * 100, + test: 'test-data', + boolean: true, + booleanError: 'true', + time: new Date(), + time2: new Date().toISOString(), + nested: { + test: 'test-data', + number: 1, + object: { + test: 'test-data', + }, + }, + array: [1, 2, 3], }); } @@ -114,6 +135,10 @@ export function TestConsole() { +

+ diff --git a/components/pages/realtime/RealtimeLog.js b/components/pages/realtime/RealtimeLog.js index ddd35751..fe12963c 100644 --- a/components/pages/realtime/RealtimeLog.js +++ b/components/pages/realtime/RealtimeLog.js @@ -3,7 +3,7 @@ import { StatusLight, Icon, Text } from 'react-basics'; import { FixedSizeList } from 'react-window'; import firstBy from 'thenby'; import FilterButtons from 'components/common/FilterButtons'; -import NoData from 'components/common/NoData'; +import Empty from 'components/common/Empty'; import useLocale from 'hooks/useLocale'; import useCountryNames from 'hooks/useCountryNames'; import { BROWSERS } from 'lib/constants'; @@ -144,7 +144,7 @@ export function RealtimeLog({ data, websiteDomain }) {

{formatMessage(labels.activityLog)}
- {logs?.length === 0 && } + {logs?.length === 0 && } {logs?.length > 0 && ( {Row} diff --git a/components/pages/realtime/RealtimeUrls.js b/components/pages/realtime/RealtimeUrls.js index dfbf1fda..18d8f2f6 100644 --- a/components/pages/realtime/RealtimeUrls.js +++ b/components/pages/realtime/RealtimeUrls.js @@ -10,6 +10,7 @@ export function RealtimeUrls({ websiteDomain, data = {} }) { const { formatMessage, labels } = useMessages(); const { pageviews } = data; const [filter, setFilter] = useState(FILTER_REFERRERS); + const limit = 15; const buttons = [ { @@ -47,7 +48,8 @@ export function RealtimeUrls({ websiteDomain, data = {} }) { } return arr; }, []) - .sort(firstBy('y', -1)), + .sort(firstBy('y', -1)) + .slice(0, limit), ); const pages = percentFilter( @@ -62,7 +64,8 @@ export function RealtimeUrls({ websiteDomain, data = {} }) { } return arr; }, []) - .sort(firstBy('y', -1)), + .sort(firstBy('y', -1)) + .slice(0, limit), ); return [referrers, pages]; diff --git a/components/pages/reports/BaseParameters.js b/components/pages/reports/BaseParameters.js new file mode 100644 index 00000000..5dbe0f60 --- /dev/null +++ b/components/pages/reports/BaseParameters.js @@ -0,0 +1,43 @@ +import { FormRow } from 'react-basics'; +import DateFilter from 'components/input/DateFilter'; +import WebsiteSelect from 'components/input/WebsiteSelect'; +import { parseDateRange } from 'lib/date'; +import { useContext } from 'react'; +import { ReportContext } from './Report'; +import { useMessages } from 'hooks'; + +export function BaseParameters() { + const { report, updateReport } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + + const { parameters } = report || {}; + const { websiteId, dateRange } = parameters || {}; + const { value, startDate, endDate } = dateRange || {}; + + const handleWebsiteSelect = websiteId => { + updateReport({ websiteId, parameters: { websiteId } }); + }; + + const handleDateChange = value => { + updateReport({ parameters: { dateRange: { ...parseDateRange(value) } } }); + }; + + return ( + <> + + + + + + + + ); +} + +export default BaseParameters; diff --git a/components/pages/reports/FieldAggregateForm.js b/components/pages/reports/FieldAggregateForm.js new file mode 100644 index 00000000..f4298c16 --- /dev/null +++ b/components/pages/reports/FieldAggregateForm.js @@ -0,0 +1,38 @@ +import { Form, FormRow, Menu, Item } from 'react-basics'; + +const options = { + number: [ + { label: 'SUM', value: 'sum' }, + { label: 'AVERAGE', value: 'average' }, + { label: 'MIN', value: 'min' }, + { label: 'MAX', value: 'max' }, + ], + date: [ + { label: 'MIN', value: 'min' }, + { label: 'MAX', value: 'max' }, + ], + string: [ + { label: 'COUNT', value: 'count' }, + { label: 'DISTINCT', value: 'distinct' }, + ], +}; + +export default function FieldAggregateForm({ name, type, onSelect }) { + const items = options[type]; + + const handleSelect = value => { + onSelect({ name, value }); + }; + + return ( +
+ + + {items.map(({ label, value }) => { + return {label}; + })} + + +
+ ); +} diff --git a/components/pages/reports/FieldFilterForm.js b/components/pages/reports/FieldFilterForm.js new file mode 100644 index 00000000..e4272c69 --- /dev/null +++ b/components/pages/reports/FieldFilterForm.js @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { Form, FormRow, Menu, Item, Flexbox, Dropdown, TextField, Button } from 'react-basics'; +import { useFilters } from 'hooks'; +import styles from './FieldFilterForm.module.css'; + +export default function FieldFilterForm({ name, type, onSelect }) { + const [filter, setFilter] = useState(''); + const [value, setValue] = useState(''); + const { filters, types } = useFilters(); + const items = types[type]; + + const renderValue = value => { + return filters[value]; + }; + + if (type === 'boolean') { + return ( +
+ + onSelect({ name, value: ['eq', value] })}> + {items.map(value => { + return {filters[value]}; + })} + + +
+ ); + } + + return ( +
+ + + + {value => { + return {filters[value]}; + }} + + setValue(e.target.value)} autoFocus={true} /> + + + +
+ ); +} diff --git a/components/pages/reports/FieldFilterForm.module.css b/components/pages/reports/FieldFilterForm.module.css new file mode 100644 index 00000000..f0cc46f3 --- /dev/null +++ b/components/pages/reports/FieldFilterForm.module.css @@ -0,0 +1,17 @@ +.selected { + font-weight: bold; +} + +.popup { + display: flex; +} + +.filter { + display: flex; + flex-direction: column; + gap: 20px; +} + +.dropdown { + min-width: 60px; +} diff --git a/components/pages/reports/FieldSelectForm.js b/components/pages/reports/FieldSelectForm.js new file mode 100644 index 00000000..1ff6412a --- /dev/null +++ b/components/pages/reports/FieldSelectForm.js @@ -0,0 +1,24 @@ +import { Menu, Item, Form, FormRow } from 'react-basics'; +import { useMessages } from 'hooks'; +import styles from './FieldSelectForm.module.css'; + +export default function FieldSelectForm({ fields, onSelect }) { + const { formatMessage, labels } = useMessages(); + + return ( +
+ + onSelect(fields[key])}> + {fields.map(({ name, type }, index) => { + return ( + +
{name}
+
{type}
+
+ ); + })} +
+
+
+ ); +} diff --git a/components/pages/reports/FieldSelectForm.module.css b/components/pages/reports/FieldSelectForm.module.css new file mode 100644 index 00000000..3a5ed9b8 --- /dev/null +++ b/components/pages/reports/FieldSelectForm.module.css @@ -0,0 +1,20 @@ +.menu { + width: 360px; + max-height: 300px; + overflow: auto; +} + +.item { + display: flex; + flex-direction: row; + justify-content: space-between; + border-radius: var(--border-radius); +} + +.item:hover { + background: var(--base75); +} + +.type { + color: var(--font-color300); +} diff --git a/components/pages/reports/ParameterList.js b/components/pages/reports/ParameterList.js new file mode 100644 index 00000000..604f6223 --- /dev/null +++ b/components/pages/reports/ParameterList.js @@ -0,0 +1,33 @@ +import { Icon, Text, TooltipPopup } from 'react-basics'; +import Icons from 'components/icons'; +import Empty from 'components/common/Empty'; +import { useMessages } from 'hooks'; +import styles from './ParameterList.module.css'; + +export function ParameterList({ items = [], children, onRemove }) { + const { formatMessage, labels } = useMessages(); + + return ( +
+ {!items.length && } + {items.map((item, index) => { + return ( +
+ {typeof children === 'function' ? children(item) : item} + + + + + +
+ ); + })} +
+ ); +} + +export default ParameterList; diff --git a/components/pages/reports/ParameterList.module.css b/components/pages/reports/ParameterList.module.css new file mode 100644 index 00000000..601b37e5 --- /dev/null +++ b/components/pages/reports/ParameterList.module.css @@ -0,0 +1,20 @@ +.list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.item { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 12px; + border: 1px solid var(--base400); + border-radius: var(--border-radius); + box-shadow: 1px 1px 1px var(--base400); +} + +.icon { + align-self: center; +} diff --git a/components/pages/reports/PopupForm.js b/components/pages/reports/PopupForm.js new file mode 100644 index 00000000..0f0ead36 --- /dev/null +++ b/components/pages/reports/PopupForm.js @@ -0,0 +1,30 @@ +import { createPortal } from 'react-dom'; +import { useDocumentClick, useKeyDown } from 'react-basics'; +import classNames from 'classnames'; +import styles from './PopupForm.module.css'; + +export function PopupForm({ element, className, children, onClose }) { + const { right, top } = element.getBoundingClientRect(); + const style = { position: 'absolute', left: right, top }; + + useKeyDown('Escape', onClose); + + useDocumentClick(e => { + if (e.target !== element && !element?.parentElement?.contains(e.target)) { + onClose(); + } + }); + + const handleClick = e => { + e.stopPropagation(); + }; + + return createPortal( +
+ {children} +
, + document.body, + ); +} + +export default PopupForm; diff --git a/components/pages/reports/PopupForm.module.css b/components/pages/reports/PopupForm.module.css new file mode 100644 index 00000000..4daf199a --- /dev/null +++ b/components/pages/reports/PopupForm.module.css @@ -0,0 +1,10 @@ +.form { + position: absolute; + background: var(--base50); + min-width: 300px; + padding: 20px; + margin-left: 30px; + border: 1px solid var(--base400); + border-radius: var(--border-radius); + box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1); +} diff --git a/components/pages/reports/Report.js b/components/pages/reports/Report.js new file mode 100644 index 00000000..685ebb9f --- /dev/null +++ b/components/pages/reports/Report.js @@ -0,0 +1,22 @@ +import { createContext } from 'react'; +import Page from 'components/layout/Page'; +import styles from './reports.module.css'; +import { useReport } from 'hooks'; + +export const ReportContext = createContext(null); + +export function Report({ reportId, defaultParameters, children, ...props }) { + const report = useReport(reportId, defaultParameters); + + //console.log({ report }); + + return ( + + + {children} + + + ); +} + +export default Report; diff --git a/components/pages/reports/ReportBody.js b/components/pages/reports/ReportBody.js new file mode 100644 index 00000000..2310c8af --- /dev/null +++ b/components/pages/reports/ReportBody.js @@ -0,0 +1,7 @@ +import styles from './reports.module.css'; + +export function ReportBody({ children }) { + return
{children}
; +} + +export default ReportBody; diff --git a/components/pages/reports/ReportDetails.js b/components/pages/reports/ReportDetails.js new file mode 100644 index 00000000..c41d12f6 --- /dev/null +++ b/components/pages/reports/ReportDetails.js @@ -0,0 +1,13 @@ +import FunnelReport from './funnel/FunnelReport'; +import EventDataReport from './event-data/EventDataReport'; + +const reports = { + funnel: FunnelReport, + 'event-data': EventDataReport, +}; + +export default function ReportDetails({ reportId, reportType }) { + const Report = reports[reportType]; + + return ; +} diff --git a/components/pages/reports/ReportHeader.js b/components/pages/reports/ReportHeader.js new file mode 100644 index 00000000..394b1951 --- /dev/null +++ b/components/pages/reports/ReportHeader.js @@ -0,0 +1,89 @@ +import { useContext } from 'react'; +import { useRouter } from 'next/router'; +import { Icon, LoadingButton, InlineEditField, useToasts } from 'react-basics'; +import PageHeader from 'components/layout/PageHeader'; +import { useMessages, useApi } from 'hooks'; +import { ReportContext } from './Report'; +import styles from './ReportHeader.module.css'; +import reportStyles from './reports.module.css'; + +export function ReportHeader({ icon }) { + const { report, updateReport } = useContext(ReportContext); + const { formatMessage, labels, messages } = useMessages(); + const { showToast } = useToasts(); + const { post, useMutation } = useApi(); + const router = useRouter(); + const { mutate: create, isLoading: isCreating } = useMutation(data => post(`/reports`, data)); + const { mutate: update, isLoading: isUpdating } = useMutation(data => + post(`/reports/${data.id}`, data), + ); + + const { name, description, parameters } = report || {}; + const { websiteId, dateRange } = parameters || {}; + + const handleSave = async () => { + if (!report.id) { + create(report, { + onSuccess: async ({ id }) => { + showToast({ message: formatMessage(messages.saved), variant: 'success' }); + router.push(`/reports/${id}`, null, { shallow: true }); + }, + }); + } else { + update(report, { + onSuccess: async () => { + showToast({ message: formatMessage(messages.saved), variant: 'success' }); + }, + }); + } + }; + + const handleNameChange = name => { + updateReport({ name: name || 'Untitled' }); + }; + + const handleDescriptionChange = description => { + updateReport({ description }); + }; + + const Title = () => { + return ( + <> + {icon} + + + ); + }; + + return ( +
+ }> + + {formatMessage(labels.save)} + + +
+ +
+
+ ); +} + +export default ReportHeader; diff --git a/components/pages/reports/ReportHeader.module.css b/components/pages/reports/ReportHeader.module.css new file mode 100644 index 00000000..01e483a0 --- /dev/null +++ b/components/pages/reports/ReportHeader.module.css @@ -0,0 +1,3 @@ +.description { + color: var(--font-color300); +} diff --git a/components/pages/reports/ReportMenu.js b/components/pages/reports/ReportMenu.js new file mode 100644 index 00000000..abfea6fe --- /dev/null +++ b/components/pages/reports/ReportMenu.js @@ -0,0 +1,7 @@ +import styles from './reports.module.css'; + +export function ReportMenu({ children }) { + return
{children}
; +} + +export default ReportMenu; diff --git a/components/pages/reports/ReportTemplates.js b/components/pages/reports/ReportTemplates.js new file mode 100644 index 00000000..7ed40af7 --- /dev/null +++ b/components/pages/reports/ReportTemplates.js @@ -0,0 +1,71 @@ +import Link from 'next/link'; +import { Button, Icons, Text, Icon } from 'react-basics'; +import Page from 'components/layout/Page'; +import PageHeader from 'components/layout/PageHeader'; +import Funnel from 'assets/funnel.svg'; +import Nodes from 'assets/nodes.svg'; +import Lightbulb from 'assets/lightbulb.svg'; +import styles from './ReportTemplates.module.css'; +import { useMessages } from 'hooks'; + +const reports = [ + { + title: 'Event data', + description: 'Query your custom event data.', + url: '/reports/event-data', + icon: , + }, + { + title: 'Funnel', + description: 'Understand the conversion and drop-off rate of users.', + url: '/reports/funnel', + icon: , + }, + { + title: 'Insights', + description: 'Explore your data by applying segments and filters.', + url: '/reports/insights', + icon: , + }, +]; + +function ReportItem({ title, description, url, icon }) { + return ( +
+
+ {icon} + {title} +
+
{description}
+
+ + + +
+
+ ); +} + +export function ReportTemplates() { + const { formatMessage, labels } = useMessages(); + + return ( + + +
+ {reports.map(({ title, description, url, icon }) => { + return ( + + ); + })} +
+
+ ); +} + +export default ReportTemplates; diff --git a/components/pages/reports/ReportTemplates.module.css b/components/pages/reports/ReportTemplates.module.css new file mode 100644 index 00000000..0cdcb835 --- /dev/null +++ b/components/pages/reports/ReportTemplates.module.css @@ -0,0 +1,32 @@ +.reports { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: 20px; +} + +.report { + display: flex; + flex-direction: column; + gap: 20px; + padding: 20px; + border: 1px solid var(--base500); + border-radius: var(--border-radius); +} + +.title { + display: flex; + gap: 10px; + align-items: center; + font-size: var(--font-size-lg); + font-weight: 700; +} + +.description { + flex: 1; +} + +.buttons { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/components/pages/reports/ReportsList.js b/components/pages/reports/ReportsList.js new file mode 100644 index 00000000..255cb546 --- /dev/null +++ b/components/pages/reports/ReportsList.js @@ -0,0 +1,29 @@ +import Page from 'components/layout/Page'; +import PageHeader from 'components/layout/PageHeader'; +import Link from 'next/link'; +import { Button, Icon, Icons, Text } from 'react-basics'; +import { useMessages, useReports } from 'hooks'; +import ReportsTable from './ReportsTable'; + +export function ReportsList() { + const { formatMessage, labels } = useMessages(); + const { reports, error, isLoading } = useReports(); + + return ( + + + + + + + + + ); +} + +export default ReportsList; diff --git a/components/pages/reports/ReportsTable.js b/components/pages/reports/ReportsTable.js new file mode 100644 index 00000000..bcc97204 --- /dev/null +++ b/components/pages/reports/ReportsTable.js @@ -0,0 +1,36 @@ +import Link from 'next/link'; +import { Button, Text, Icon, Icons } from 'react-basics'; +import SettingsTable from 'components/common/SettingsTable'; +import useMessages from 'hooks/useMessages'; + +export function ReportsTable({ data = [] }) { + const { formatMessage, labels } = useMessages(); + + const columns = [ + { name: 'name', label: formatMessage(labels.name) }, + { name: 'description', label: formatMessage(labels.description) }, + { name: 'type', label: formatMessage(labels.type) }, + { name: 'action', label: ' ' }, + ]; + + return ( + + {row => { + const { id } = row; + + return ( + + + + ); + }} + + ); +} + +export default ReportsTable; diff --git a/components/pages/reports/event-data/EventDataParameters.js b/components/pages/reports/event-data/EventDataParameters.js new file mode 100644 index 00000000..c703f9bf --- /dev/null +++ b/components/pages/reports/event-data/EventDataParameters.js @@ -0,0 +1,144 @@ +import { useContext, useRef } from 'react'; +import { useApi, useMessages } from 'hooks'; +import { Form, FormRow, FormButtons, SubmitButton, PopupTrigger, Icon, Popup } from 'react-basics'; +import { ReportContext } from 'components/pages/reports/Report'; +import Empty from 'components/common/Empty'; +import { DATA_TYPES } from 'lib/constants'; +import BaseParameters from '../BaseParameters'; +import FieldAddForm from './FieldAddForm'; +import ParameterList from '../ParameterList'; +import Icons from 'components/icons'; +import styles from './EventDataParameters.module.css'; + +function useFields(websiteId, startDate, endDate) { + const { get, useQuery } = useApi(); + const { data, error, isLoading } = useQuery( + ['fields', websiteId, startDate, endDate], + () => + get('/reports/event-data', { + websiteId, + startAt: +startDate, + endAt: +endDate, + }), + { enabled: !!(websiteId && startDate && endDate) }, + ); + + return { data, error, isLoading }; +} + +export function EventDataParameters() { + const { report, runReport, updateReport, isRunning } = useContext(ReportContext); + const { formatMessage, labels, messages } = useMessages(); + const ref = useRef(null); + const { parameters } = report || {}; + const { websiteId, dateRange, fields, filters, groups } = parameters || {}; + const { startDate, endDate } = dateRange || {}; + const queryDisabled = !websiteId || !dateRange; + const { data, error } = useFields(websiteId, startDate, endDate); + const parametersSelected = websiteId && startDate && endDate; + const hasData = data?.length !== 0; + + const parameterGroups = [ + { label: formatMessage(labels.fields), type: 'fields' }, + { label: formatMessage(labels.filters), type: 'filters' }, + { label: formatMessage(labels.groupBy), type: 'groups' }, + ]; + + const parameterData = { + fields, + filters, + groups, + }; + + const handleSubmit = values => { + runReport(values); + }; + + const handleAdd = (type, value) => { + const data = parameterData[type]; + updateReport({ parameters: { [type]: data.concat(value) } }); + }; + + const handleRemove = (type, index) => { + const data = [...parameterData[type]]; + data.splice(index, 1); + updateReport({ parameters: { [type]: data } }); + }; + + const AddButton = ({ type }) => { + return ( + + + + + + {(close, element) => { + return ( + ({ + name: eventKey, + type: DATA_TYPES[eventDataType], + }))} + element={element} + onAdd={handleAdd} + onClose={close} + /> + ); + }} + + + ); + }; + + return ( +
+ + {!hasData && } + {parametersSelected && + hasData && + parameterGroups.map(({ label, type }) => { + return ( + }> + handleRemove(type, index)} + > + {({ name, value }) => { + return ( +
+ {type === 'fields' && ( + <> +
{value}
+
{name}
+ + )} + {type === 'filters' && ( + <> +
{name}
+
{value[0]}
+
{value[1]}
+ + )} + {type === 'groups' && ( + <> +
{name}
+ + )} +
+ ); + }} +
+
+ ); + })} + + + {formatMessage(labels.runQuery)} + + + + ); +} + +export default EventDataParameters; diff --git a/components/pages/reports/event-data/EventDataParameters.module.css b/components/pages/reports/event-data/EventDataParameters.module.css new file mode 100644 index 00000000..435cb1f6 --- /dev/null +++ b/components/pages/reports/event-data/EventDataParameters.module.css @@ -0,0 +1,8 @@ +.parameter { + display: flex; + gap: 10px; +} + +.op { + font-weight: bold; +} diff --git a/components/pages/reports/event-data/EventDataReport.js b/components/pages/reports/event-data/EventDataReport.js new file mode 100644 index 00000000..b3358ecf --- /dev/null +++ b/components/pages/reports/event-data/EventDataReport.js @@ -0,0 +1,26 @@ +import Report from '../Report'; +import ReportHeader from '../ReportHeader'; +import ReportMenu from '../ReportMenu'; +import ReportBody from '../ReportBody'; +import EventDataParameters from './EventDataParameters'; +import Nodes from 'assets/nodes.svg'; +import EventDataTable from './EventDataTable'; + +const defaultParameters = { + type: 'event-data', + parameters: { fields: [], filters: [], groups: [] }, +}; + +export default function EventDataReport({ reportId }) { + return ( + + } /> + + + + + + + + ); +} diff --git a/components/pages/reports/event-data/EventDataTable.js b/components/pages/reports/event-data/EventDataTable.js new file mode 100644 index 00000000..ffe9fb3a --- /dev/null +++ b/components/pages/reports/event-data/EventDataTable.js @@ -0,0 +1,20 @@ +import { useContext } from 'react'; +import DataTable from 'components/metrics/DataTable'; +import { useMessages } from 'hooks'; +import { ReportContext } from '../Report'; + +export function EventDataTable() { + const { report } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + + return ( + + ); +} + +export default EventDataTable; diff --git a/components/pages/reports/event-data/FieldAddForm.js b/components/pages/reports/event-data/FieldAddForm.js new file mode 100644 index 00000000..df71b370 --- /dev/null +++ b/components/pages/reports/event-data/FieldAddForm.js @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import PopupForm from '../PopupForm'; +import FieldSelectForm from '../FieldSelectForm'; +import FieldAggregateForm from '../FieldAggregateForm'; +import FieldFilterForm from '../FieldFilterForm'; +import styles from './FieldAddForm.module.css'; + +export function FieldAddForm({ fields = [], type, element, onAdd, onClose }) { + const [selected, setSelected] = useState(); + + const handleSelect = value => { + if (type === 'groups') { + handleSave(value); + return; + } + + setSelected(value); + }; + + const handleSave = value => { + onAdd(type, value); + onClose(); + }; + + return createPortal( + + {!selected && } + {selected && type === 'fields' && } + {selected && type === 'filters' && } + , + document.body, + ); +} + +export default FieldAddForm; diff --git a/components/pages/reports/event-data/FieldAddForm.module.css b/components/pages/reports/event-data/FieldAddForm.module.css new file mode 100644 index 00000000..5c5aaa4f --- /dev/null +++ b/components/pages/reports/event-data/FieldAddForm.module.css @@ -0,0 +1,38 @@ +.menu { + width: 360px; + max-height: 300px; + overflow: auto; +} + +.item { + display: flex; + flex-direction: row; + justify-content: space-between; + border-radius: var(--border-radius); +} + +.item:hover { + background: var(--base75); +} + +.type { + color: var(--font-color300); +} + +.selected { + font-weight: bold; +} + +.popup { + display: flex; +} + +.filter { + display: flex; + flex-direction: column; + gap: 20px; +} + +.dropdown { + min-width: 60px; +} diff --git a/components/pages/reports/funnel/FunnelChart.js b/components/pages/reports/funnel/FunnelChart.js new file mode 100644 index 00000000..7253c3fa --- /dev/null +++ b/components/pages/reports/funnel/FunnelChart.js @@ -0,0 +1,63 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { Loading } from 'react-basics'; +import useMessages from 'hooks/useMessages'; +import useTheme from 'hooks/useTheme'; +import BarChart from 'components/metrics/BarChart'; +import { formatLongNumber } from 'lib/format'; +import styles from './FunnelChart.module.css'; +import { ReportContext } from '../Report'; + +export function FunnelChart({ className, loading }) { + const { report } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + const { colors } = useTheme(); + + const { parameters, data } = report || {}; + + const renderXLabel = useCallback( + (label, index) => { + return parameters.urls[index]; + }, + [parameters], + ); + + const renderTooltipPopup = useCallback((setTooltipPopup, model) => { + const { opacity, dataPoints } = model.tooltip; + + if (!dataPoints?.length || !opacity) { + setTooltipPopup(null); + return; + } + + setTooltipPopup(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`); + }, []); + + const datasets = useMemo(() => { + return [ + { + label: formatMessage(labels.uniqueVisitors), + data: data, + borderWidth: 1, + ...colors.chart.visitors, + }, + ]; + }, [data]); + + if (loading) { + return ; + } + + return ( + + ); +} + +export default FunnelChart; diff --git a/components/pages/reports/funnel/FunnelChart.module.css b/components/pages/reports/funnel/FunnelChart.module.css new file mode 100644 index 00000000..9e1690b3 --- /dev/null +++ b/components/pages/reports/funnel/FunnelChart.module.css @@ -0,0 +1,3 @@ +.loading { + height: 300px; +} diff --git a/components/pages/reports/funnel/FunnelParameters.js b/components/pages/reports/funnel/FunnelParameters.js new file mode 100644 index 00000000..ae498176 --- /dev/null +++ b/components/pages/reports/funnel/FunnelParameters.js @@ -0,0 +1,86 @@ +import { useContext, useRef } from 'react'; +import { useMessages } from 'hooks'; +import { + Icon, + Form, + FormButtons, + FormInput, + FormRow, + PopupTrigger, + Popup, + SubmitButton, + TextField, +} from 'react-basics'; +import Icons from 'components/icons'; +import UrlAddForm from './UrlAddForm'; +import { ReportContext } from 'components/pages/reports/Report'; +import BaseParameters from '../BaseParameters'; +import ParameterList from '../ParameterList'; + +export function FunnelParameters() { + const { report, runReport, updateReport, isRunning } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + const ref = useRef(null); + + const { parameters } = report || {}; + const { websiteId, dateRange, urls } = parameters || {}; + const queryDisabled = !websiteId || !dateRange || urls?.length < 2; + + const handleSubmit = (data, e) => { + e.stopPropagation(); + e.preventDefault(); + if (!queryDisabled) { + runReport(data); + } + }; + + const handleAddUrl = url => { + updateReport({ parameters: { urls: parameters.urls.concat(url) } }); + }; + + const handleRemoveUrl = (index, e) => { + e.stopPropagation(); + const urls = [...parameters.urls]; + urls.splice(index, 1); + updateReport({ parameters: { urls } }); + }; + + const AddUrlButton = () => { + return ( + + + + + + {(close, element) => { + return ; + }} + + + ); + }; + + return ( +
+ + + + + + + }> + + + + + {formatMessage(labels.runQuery)} + + + + ); +} + +export default FunnelParameters; diff --git a/components/pages/reports/funnel/FunnelReport.js b/components/pages/reports/funnel/FunnelReport.js new file mode 100644 index 00000000..7b4d8ece --- /dev/null +++ b/components/pages/reports/funnel/FunnelReport.js @@ -0,0 +1,28 @@ +import FunnelChart from './FunnelChart'; +import FunnelTable from './FunnelTable'; +import FunnelParameters from './FunnelParameters'; +import Report from '../Report'; +import ReportHeader from '../ReportHeader'; +import ReportMenu from '../ReportMenu'; +import ReportBody from '../ReportBody'; +import Funnel from 'assets/funnel.svg'; + +const defaultParameters = { + type: 'funnel', + parameters: { window: 60, urls: [] }, +}; + +export default function FunnelReport({ reportId }) { + return ( + + } /> + + + + + + + + + ); +} diff --git a/components/pages/reports/funnel/FunnelReport.module.css b/components/pages/reports/funnel/FunnelReport.module.css new file mode 100644 index 00000000..aed66b74 --- /dev/null +++ b/components/pages/reports/funnel/FunnelReport.module.css @@ -0,0 +1,10 @@ +.filters { + display: flex; + flex-direction: column; + justify-content: space-between; + border: 1px solid var(--base400); + border-radius: var(--border-radius); + line-height: 32px; + padding: 10px; + overflow: hidden; +} diff --git a/components/pages/reports/funnel/FunnelTable.js b/components/pages/reports/funnel/FunnelTable.js new file mode 100644 index 00000000..ff6bdfb5 --- /dev/null +++ b/components/pages/reports/funnel/FunnelTable.js @@ -0,0 +1,20 @@ +import { useContext } from 'react'; +import DataTable from 'components/metrics/DataTable'; +import { useMessages } from 'hooks'; +import { ReportContext } from '../Report'; + +export function FunnelTable() { + const { report } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + + return ( + + ); +} + +export default FunnelTable; diff --git a/components/pages/reports/funnel/UrlAddForm.js b/components/pages/reports/funnel/UrlAddForm.js new file mode 100644 index 00000000..0fb78b3d --- /dev/null +++ b/components/pages/reports/funnel/UrlAddForm.js @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { useMessages } from 'hooks'; +import { Button, Form, FormRow, TextField, Flexbox } from 'react-basics'; +import styles from './UrlAddForm.module.css'; +import PopupForm from '../PopupForm'; + +export function UrlAddForm({ defaultValue = '', element, onAdd, onClose }) { + const [url, setUrl] = useState(defaultValue); + const { formatMessage, labels } = useMessages(); + + const handleSave = () => { + onAdd(url); + setUrl(''); + onClose(); + }; + + const handleChange = e => { + setUrl(e.target.value); + }; + + const handleKeyDown = e => { + if (e.key === 'Enter') { + e.stopPropagation(); + handleSave(); + } + }; + + return ( + +
+ + + + + + +
+
+ ); +} + +export default UrlAddForm; diff --git a/components/pages/reports/funnel/UrlAddForm.module.css b/components/pages/reports/funnel/UrlAddForm.module.css new file mode 100644 index 00000000..6a3e03b5 --- /dev/null +++ b/components/pages/reports/funnel/UrlAddForm.module.css @@ -0,0 +1,14 @@ +.form { + position: absolute; + background: var(--base50); + width: 300px; + padding: 30px; + margin-top: 10px; + border: 1px solid var(--base400); + border-radius: var(--border-radius); + box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1); +} + +.input { + width: 100%; +} diff --git a/components/pages/reports/reports.module.css b/components/pages/reports/reports.module.css new file mode 100644 index 00000000..6fa54281 --- /dev/null +++ b/components/pages/reports/reports.module.css @@ -0,0 +1,25 @@ +.container { + display: grid; + grid-template-rows: max-content 1fr; + grid-template-columns: max-content 1fr; +} + +.header { + grid-row: 1 / 2; + grid-column: 1 / 3; + margin-bottom: 40px; +} + +.menu { + width: 300px; + padding-right: 20px; + border-right: 1px solid var(--base300); + grid-row: 2/3; + grid-column: 1 / 2; +} + +.body { + padding-left: 20px; + grid-row: 2/3; + grid-column: 2 / 3; +} diff --git a/components/pages/settings/profile/DateRangeSetting.js b/components/pages/settings/profile/DateRangeSetting.js index 152aba1d..16db3c07 100644 --- a/components/pages/settings/profile/DateRangeSetting.js +++ b/components/pages/settings/profile/DateRangeSetting.js @@ -7,13 +7,19 @@ import useMessages from 'hooks/useMessages'; export function DateRangeSetting() { const { formatMessage, labels } = useMessages(); const [dateRange, setDateRange] = useDateRange(); - const { startDate, endDate, value } = dateRange; + const { value } = dateRange; + const handleChange = value => setDateRange(value); const handleReset = () => setDateRange(DEFAULT_DATE_RANGE); return ( - + ); diff --git a/components/pages/settings/profile/PasswordChangeButton.js b/components/pages/settings/profile/PasswordChangeButton.js index 9aa6fdca..03bf74bc 100644 --- a/components/pages/settings/profile/PasswordChangeButton.js +++ b/components/pages/settings/profile/PasswordChangeButton.js @@ -1,11 +1,11 @@ -import { Button, Icon, Text, useToast, ModalTrigger, Modal } from 'react-basics'; +import { Button, Icon, Text, useToasts, ModalTrigger, Modal } from 'react-basics'; import PasswordEditForm from 'components/pages/settings/profile/PasswordEditForm'; import Icons from 'components/icons'; import useMessages from 'hooks/useMessages'; export function PasswordChangeButton() { const { formatMessage, labels, messages } = useMessages(); - const { toast, showToast } = useToast(); + const { showToast } = useToasts(); const handleSave = () => { showToast({ message: formatMessage(messages.saved), variant: 'success' }); @@ -13,7 +13,6 @@ export function PasswordChangeButton() { return ( <> - {toast}