mirror of
https://github.com/plausible/analytics.git
synced 2024-12-22 17:11:36 +03:00
Add Typescript (#4427)
* Add typescript, rewrite Countries map * Add back DB IP geolocation notice * Silence all current eslint warnings: to be removed gradually * Reconfigure eslint import plugin for typescript * Insert formatting pragma by default, but ignore files without pragma in CI
This commit is contained in:
parent
ee3d1e770e
commit
039f3baf8e
@ -1,3 +1,3 @@
|
||||
erlang 27.0
|
||||
elixir 1.17.1-otp-27
|
||||
nodejs 21.0.0
|
||||
nodejs 21.7.3
|
||||
|
@ -1,22 +1,20 @@
|
||||
{
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"plugins": ["import"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
|
||||
"import/no-unresolved": "error",
|
||||
"react/destructuring-assignment": [0],
|
||||
"react/prop-types": [0],
|
||||
"max-classes-per-file": [0],
|
||||
@ -24,20 +22,39 @@
|
||||
"react/jsx-one-expression-per-line": [0],
|
||||
"react/self-closing-comp": [0],
|
||||
"no-unused-expressions": [1, { "allowShortCircuit": true }],
|
||||
"no-unused-vars": [2, { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
2,
|
||||
{
|
||||
"args": "all",
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrors": "all",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"no-prototype-builtins": [0],
|
||||
"react/jsx-props-no-spreading": [0],
|
||||
"jsx-a11y/click-events-have-key-events": [0],
|
||||
"jsx-a11y/no-static-element-interactions": [0],
|
||||
"react/no-did-update-set-state": [0],
|
||||
"react/no-unknown-property": [2, {"ignore": ["tooltip"]}]
|
||||
"react/no-unknown-property": [2, { "ignore": ["tooltip"] }]
|
||||
},
|
||||
"settings": {
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx"]
|
||||
},
|
||||
"import/resolver": {
|
||||
"typescript": {
|
||||
"alwaysTryTypes": true // always try to resolve types under `<root>@types` directory even it doesn't contain any source code, like `@types/unist`
|
||||
}
|
||||
},
|
||||
"react": {
|
||||
"createClass": "createReactClass", // Regex for Component Factory to use,
|
||||
// default to "createReactClass"
|
||||
"pragma": "React", // Pragma to use, default to "React"
|
||||
"fragment": "Fragment", // Fragment to use (may be a property of <pragma>), default to "Fragment"
|
||||
// default to "createReactClass"
|
||||
"pragma": "React", // Pragma to use, default to "React"
|
||||
"fragment": "Fragment", // Fragment to use (may be a property of <pragma>), default to "Fragment"
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"requirePragma": true,
|
||||
"insertPragma": true,
|
||||
"trailingComma": "none",
|
||||
"semi": false
|
||||
}
|
||||
|
@ -255,11 +255,6 @@ blockquote {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Only because the map handler doesn't expose an easier way to change the shadow color */
|
||||
.dark .hoverinfo {
|
||||
box-shadow: 1px 1px 5px rgb(26 32 44);
|
||||
}
|
||||
|
||||
.fullwidth-shadow::before {
|
||||
@apply absolute top-0 w-screen h-full bg-gray-50 dark:bg-gray-850;
|
||||
|
||||
|
@ -10,6 +10,7 @@ import * as timer from './dashboard/util/realtime-update-timer'
|
||||
import { filtersBackwardsCompatibilityRedirect } from './dashboard/query';
|
||||
import SiteContextProvider, { parseSiteFromDataset } from './dashboard/site-context';
|
||||
import UserContextProvider from './dashboard/user-context'
|
||||
import ThemeContextProvider from './dashboard/theme-context'
|
||||
|
||||
timer.start()
|
||||
|
||||
@ -32,11 +33,13 @@ if (container) {
|
||||
const router = createAppRouter(site);
|
||||
const app = (
|
||||
<ErrorBoundary>
|
||||
<SiteContextProvider site={site}>
|
||||
<UserContextProvider role={container.dataset.currentUserRole} loggedIn={container.dataset.loggedIn === 'true'}>
|
||||
<RouterProvider router={router} />
|
||||
</UserContextProvider>
|
||||
</SiteContextProvider>
|
||||
<ThemeContextProvider>
|
||||
<SiteContextProvider site={site}>
|
||||
<UserContextProvider role={container.dataset.currentUserRole} loggedIn={container.dataset.loggedIn === 'true'}>
|
||||
<RouterProvider router={router} />
|
||||
</UserContextProvider>
|
||||
</SiteContextProvider>
|
||||
</ThemeContextProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
|
@ -10,8 +10,9 @@ export default function LazyLoader(props) {
|
||||
useEffect(() => {
|
||||
if (inView && !hasBecomeVisibleYet) {
|
||||
setHasBecomeVisibleYet(true)
|
||||
props.onVisible && props.onVisible()
|
||||
if (props.onVisible) props.onVisible()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inView])
|
||||
|
||||
return (
|
||||
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { Fragment, useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAppNavigate } from "./navigation/use-app-navigate";
|
||||
import Flatpickr from "react-flatpickr";
|
||||
@ -291,7 +292,7 @@ function DatePicker() {
|
||||
}
|
||||
|
||||
function openCalendar() {
|
||||
calendar.current && calendar.current.flatpickr.open();
|
||||
calendar.current?.flatpickr.open();
|
||||
}
|
||||
|
||||
function renderLink(period, text, opts = {}) {
|
||||
|
@ -46,12 +46,14 @@ export default function Funnel({ funnelName, tabs }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, funnelName, visible, isSmallScreen])
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current && funnel && visible && !isSmallScreen) {
|
||||
initialiseChart()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [funnel, visible])
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -137,6 +137,7 @@ function Filters() {
|
||||
window.removeEventListener('resize', handleResize, false)
|
||||
document.removeEventListener("keyup", handleKeyup)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@ -145,6 +146,7 @@ function Filters() {
|
||||
|
||||
useEffect(() => {
|
||||
if (wrapped === WRAPSTATE.waiting) { updateDisplayMode() }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wrapped])
|
||||
|
||||
|
||||
|
@ -1,65 +0,0 @@
|
||||
import React, { forwardRef, useCallback } from 'react'
|
||||
import { Link, useLocation, useNavigate, generatePath } from 'react-router-dom'
|
||||
import { parseSearch, stringifySearch } from '../util/url'
|
||||
|
||||
const getNavigateToOptions = (
|
||||
currentSearchString,
|
||||
{ path, params, search }
|
||||
) => {
|
||||
const searchRecord = parseSearch(currentSearchString)
|
||||
const updatedSearchRecord = search && search(searchRecord)
|
||||
const updatedPath = path && generatePath(path, params)
|
||||
return {
|
||||
pathname: updatedPath,
|
||||
search: updatedSearchRecord && stringifySearch(updatedSearchRecord)
|
||||
}
|
||||
}
|
||||
|
||||
export const useGetNavigateOptions = () => {
|
||||
const location = useLocation()
|
||||
/**
|
||||
* @param path - path to target, for example `"/posts"` or `"/posts/:id"`
|
||||
* @param params - dictionary of param keys with their values, if needed, for example `{ id: "some-id" }`
|
||||
* @param search -
|
||||
* function in the form of `(currentSearchRecord) => newSearchRecord` to set link search value, for example
|
||||
* `(s) => s` preserves current search value,
|
||||
* `() => ({page: 5})` sets the search to `?page=5`,
|
||||
* `undefined` empties the search
|
||||
* @returns the appropriate value for `react-router-dom` `Link` and `navigate` `to` property
|
||||
*/
|
||||
const getToOptions = useCallback(
|
||||
({ path, params, search }) => {
|
||||
return getNavigateToOptions(location.search, { path, params, search })
|
||||
},
|
||||
[location.search]
|
||||
)
|
||||
return getToOptions
|
||||
}
|
||||
|
||||
export const useAppNavigate = () => {
|
||||
const _navigate = useNavigate()
|
||||
const getToOptions = useGetNavigateOptions()
|
||||
const navigate = useCallback(
|
||||
({ path, params, search, ...options }) => {
|
||||
return _navigate(getToOptions({ path, params, search }), options)
|
||||
},
|
||||
[getToOptions, _navigate]
|
||||
)
|
||||
return navigate
|
||||
}
|
||||
|
||||
export const AppNavigationLink = forwardRef(
|
||||
({ path, params, search, ...options }, ref) => {
|
||||
const getToOptions = useGetNavigateOptions()
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={getToOptions({ path, params, search })}
|
||||
{...options}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
AppNavigationLink.displayName = 'AppNavigationLink'
|
85
assets/js/dashboard/navigation/use-app-navigate.tsx
Normal file
85
assets/js/dashboard/navigation/use-app-navigate.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
/* @format */
|
||||
import React, { forwardRef, useCallback } from 'react'
|
||||
import {
|
||||
Link,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
generatePath,
|
||||
Params,
|
||||
NavigateOptions,
|
||||
LinkProps
|
||||
} from 'react-router-dom'
|
||||
import { parseSearch, stringifySearch } from '../util/url'
|
||||
|
||||
type AppNavigationTarget = {
|
||||
/**
|
||||
* path to target, for example `"/posts"` or `"/posts/:id"`
|
||||
*/
|
||||
path?: string
|
||||
/**
|
||||
* dictionary of param keys with their values, if needed, for example `{ id: "some-id" }`
|
||||
*/
|
||||
params?: Params
|
||||
/**
|
||||
* function in the form of `(currentSearchRecord) => newSearchRecord` to set link search value, for example
|
||||
* - `(s) => s` preserves current search value,
|
||||
* - `(s) => ({ ...s, calendar: !s.calendar })` toggles the value for calendar search parameter,
|
||||
* - `() => ({ page: 5 })` sets the search to `?page=5`,
|
||||
* - `undefined` empties the search
|
||||
*/
|
||||
search?: (search: Record<string, unknown>) => Record<string, unknown>
|
||||
}
|
||||
|
||||
const getNavigateToOptions = (
|
||||
currentSearchString: string,
|
||||
{ path, params, search }: AppNavigationTarget
|
||||
) => {
|
||||
const searchRecord = parseSearch(currentSearchString)
|
||||
const updatedSearchRecord = search && search(searchRecord)
|
||||
const updatedPath = path && generatePath(path, params)
|
||||
return {
|
||||
pathname: updatedPath,
|
||||
search: updatedSearchRecord && stringifySearch(updatedSearchRecord)
|
||||
}
|
||||
}
|
||||
|
||||
export const useGetNavigateOptions = () => {
|
||||
const location = useLocation()
|
||||
const getToOptions = useCallback(
|
||||
({ path, params, search }: AppNavigationTarget) => {
|
||||
return getNavigateToOptions(location.search, { path, params, search })
|
||||
},
|
||||
[location.search]
|
||||
)
|
||||
return getToOptions
|
||||
}
|
||||
|
||||
export const useAppNavigate = () => {
|
||||
const _navigate = useNavigate()
|
||||
const getToOptions = useGetNavigateOptions()
|
||||
const navigate = useCallback(
|
||||
({
|
||||
path,
|
||||
params,
|
||||
search,
|
||||
...options
|
||||
}: AppNavigationTarget & NavigateOptions) => {
|
||||
return _navigate(getToOptions({ path, params, search }), options)
|
||||
},
|
||||
[getToOptions, _navigate]
|
||||
)
|
||||
return navigate
|
||||
}
|
||||
|
||||
export const AppNavigationLink = forwardRef<
|
||||
HTMLAnchorElement | null,
|
||||
AppNavigationTarget & Omit<LinkProps, 'to'>
|
||||
>(({ path, params, search, ...options }, ref) => {
|
||||
const getToOptions = useGetNavigateOptions()
|
||||
|
||||
return (
|
||||
<Link to={getToOptions({ path, params, search })} {...options} ref={ref} />
|
||||
)
|
||||
})
|
||||
|
||||
AppNavigationLink.displayName = 'AppNavigationLink'
|
@ -30,7 +30,7 @@ export const withPinnedHeader = (WrappedComponent, selector) => {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.observer && this.observer.unobserve(this.el);
|
||||
this.observer?.unobserve(this.el);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -1,59 +0,0 @@
|
||||
import React, { createContext, useContext } from "react";
|
||||
|
||||
export function parseSiteFromDataset(dataset) {
|
||||
const site = {
|
||||
domain: dataset.domain,
|
||||
offset: dataset.offset,
|
||||
hasGoals: dataset.hasGoals === 'true',
|
||||
hasProps: dataset.hasProps === 'true',
|
||||
funnelsAvailable: dataset.funnelsAvailable === 'true',
|
||||
propsAvailable: dataset.propsAvailable === 'true',
|
||||
conversionsOptedOut: dataset.conversionsOptedOut === 'true',
|
||||
funnelsOptedOut: dataset.funnelsOptedOut === 'true',
|
||||
propsOptedOut: dataset.propsOptedOut === 'true',
|
||||
revenueGoals: JSON.parse(dataset.revenueGoals),
|
||||
funnels: JSON.parse(dataset.funnels),
|
||||
statsBegin: dataset.statsBegin,
|
||||
nativeStatsBegin: dataset.nativeStatsBegin,
|
||||
embedded: dataset.embedded,
|
||||
background: dataset.background,
|
||||
isDbip: dataset.isDbip === 'true',
|
||||
flags: JSON.parse(dataset.flags),
|
||||
validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod),
|
||||
shared: !!dataset.sharedLinkAuth,
|
||||
}
|
||||
return site;
|
||||
}
|
||||
|
||||
const siteContextDefaultValue = {
|
||||
domain: '',
|
||||
offset: '0',
|
||||
hasGoals: false,
|
||||
hasProps: false,
|
||||
funnelsAvailable: false,
|
||||
propsAvailable: false,
|
||||
conversionsOptedOut: false,
|
||||
funnelsOptedOut: false,
|
||||
propsOptedOut: false,
|
||||
revenueGoals: [],
|
||||
funnels: [],
|
||||
statsBegin: '',
|
||||
nativeStatsBegin: '',
|
||||
embedded: '',
|
||||
background: '',
|
||||
isDbip: false,
|
||||
flags: {},
|
||||
validIntervalsByPeriod: null,
|
||||
shared: false,
|
||||
}
|
||||
|
||||
|
||||
const SiteContext = createContext(siteContextDefaultValue)
|
||||
|
||||
export const useSiteContext = () => { return useContext(SiteContext) }
|
||||
|
||||
const SiteContextProvider = ({ site, children }) => {
|
||||
return <SiteContext.Provider value={site}>{children}</SiteContext.Provider>
|
||||
};
|
||||
|
||||
export default SiteContextProvider;
|
69
assets/js/dashboard/site-context.tsx
Normal file
69
assets/js/dashboard/site-context.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
/* @format */
|
||||
import React, { createContext, ReactNode, useContext } from 'react'
|
||||
|
||||
export function parseSiteFromDataset(dataset: Record<string, string>) {
|
||||
const site = {
|
||||
domain: dataset.domain,
|
||||
offset: dataset.offset,
|
||||
hasGoals: dataset.hasGoals === 'true',
|
||||
hasProps: dataset.hasProps === 'true',
|
||||
funnelsAvailable: dataset.funnelsAvailable === 'true',
|
||||
propsAvailable: dataset.propsAvailable === 'true',
|
||||
conversionsOptedOut: dataset.conversionsOptedOut === 'true',
|
||||
funnelsOptedOut: dataset.funnelsOptedOut === 'true',
|
||||
propsOptedOut: dataset.propsOptedOut === 'true',
|
||||
revenueGoals: JSON.parse(dataset.revenueGoals),
|
||||
funnels: JSON.parse(dataset.funnels),
|
||||
statsBegin: dataset.statsBegin,
|
||||
nativeStatsBegin: dataset.nativeStatsBegin,
|
||||
embedded: dataset.embedded,
|
||||
background: dataset.background,
|
||||
isDbip: dataset.isDbip === 'true',
|
||||
flags: JSON.parse(dataset.flags),
|
||||
validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod),
|
||||
shared: !!dataset.sharedLinkAuth
|
||||
}
|
||||
return site
|
||||
}
|
||||
|
||||
const siteContextDefaultValue = {
|
||||
domain: '',
|
||||
offset: '0',
|
||||
hasGoals: false,
|
||||
hasProps: false,
|
||||
funnelsAvailable: false,
|
||||
propsAvailable: false,
|
||||
conversionsOptedOut: false,
|
||||
funnelsOptedOut: false,
|
||||
propsOptedOut: false,
|
||||
revenueGoals: [] as { event_name: string; currency: 'USD' }[],
|
||||
funnels: [] as { id: number; name: string; steps_count: number }[],
|
||||
statsBegin: '',
|
||||
nativeStatsBegin: '',
|
||||
embedded: '',
|
||||
background: '',
|
||||
isDbip: false,
|
||||
flags: {},
|
||||
validIntervalsByPeriod: {} as Record<string, Array<string>>,
|
||||
shared: false
|
||||
}
|
||||
|
||||
export type PlausibleSite = typeof siteContextDefaultValue
|
||||
|
||||
const SiteContext = createContext(siteContextDefaultValue)
|
||||
|
||||
export const useSiteContext = () => {
|
||||
return useContext(SiteContext)
|
||||
}
|
||||
|
||||
const SiteContextProvider = ({
|
||||
site,
|
||||
children
|
||||
}: {
|
||||
site: PlausibleSite
|
||||
children: ReactNode
|
||||
}) => {
|
||||
return <SiteContext.Provider value={site}>{children}</SiteContext.Provider>
|
||||
}
|
||||
|
||||
export default SiteContextProvider
|
@ -16,6 +16,7 @@ import { useUserContext } from '../../user-context'
|
||||
/*global require*/
|
||||
function maybeRequire() {
|
||||
if (BUILD_EXTRA) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require('../../extra/funnel')
|
||||
} else {
|
||||
return { default: null }
|
||||
@ -65,6 +66,7 @@ export default function Behaviours({ importedDataInView }) {
|
||||
setShowingPropsForGoalFilter(true)
|
||||
setMode(PROPS)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@ -73,10 +75,12 @@ export default function Behaviours({ importedDataInView }) {
|
||||
setShowingPropsForGoalFilter(false)
|
||||
setMode(CONVERSIONS)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasGoalFilter(query)])
|
||||
|
||||
useEffect(() => {
|
||||
setMode(defaultMode())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabledModes])
|
||||
|
||||
useEffect(() => setLoading(true), [query, mode])
|
||||
|
@ -54,6 +54,7 @@ export default function Properties({ afterFetchData }) {
|
||||
|
||||
setPropKeyLoading(false)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query])
|
||||
|
||||
function getPropKeyFromStorage() {
|
||||
@ -73,6 +74,7 @@ export default function Properties({ afterFetchData }) {
|
||||
return (input) => {
|
||||
return api.get(url.apiPath(site, "/suggestions/prop_key"), query, { q: input.trim() })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query])
|
||||
|
||||
function onPropKeySelect() {
|
||||
|
@ -69,6 +69,7 @@ function subscribeKeybinding(element) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const handleKeyPress = useCallback((event) => {
|
||||
if (isKeyPressed(event, "i")) element.current?.click()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as api from '../../api';
|
||||
import * as storage from '../../util/storage';
|
||||
|
18
assets/js/dashboard/stats/locations/geolocation-notice.tsx
Normal file
18
assets/js/dashboard/stats/locations/geolocation-notice.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
/* @format */
|
||||
import React from 'react'
|
||||
|
||||
export const GeolocationNotice = () => {
|
||||
return (
|
||||
<div className="max-w-24 sm:max-w-none md:max-w-24 lg:max-w-none text-xs text-gray-500 absolute bottom-0 right-0">
|
||||
IP Geolocation by{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://db-ip.com"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-600"
|
||||
>
|
||||
DB-IP
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -200,7 +200,7 @@ export default class Locations extends React.Component {
|
||||
return <Countries onClick={this.onCountryFilter('countries')} site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} />
|
||||
case "map":
|
||||
default:
|
||||
return <CountriesMap onClick={this.onCountryFilter('map')} site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} />
|
||||
return <CountriesMap onCountrySelect={this.onCountryFilter('map')} afterFetchData={this.afterFetchData} />
|
||||
}
|
||||
}
|
||||
|
||||
|
39
assets/js/dashboard/stats/locations/map-tooltip.tsx
Normal file
39
assets/js/dashboard/stats/locations/map-tooltip.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
/* @format */
|
||||
import classNames from 'classnames'
|
||||
import React from 'react'
|
||||
|
||||
interface MapTooltipProps {
|
||||
name: string
|
||||
value: string
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export const MapTooltip = ({ name, value, label, x, y }: MapTooltipProps) => (
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute',
|
||||
'z-50',
|
||||
'p-2',
|
||||
'translate-x-2',
|
||||
'translate-y-2',
|
||||
'pointer-events-none',
|
||||
'rounded-sm',
|
||||
'bg-white',
|
||||
'dark:bg-gray-800',
|
||||
'shadow',
|
||||
'dark:border-gray-850',
|
||||
'dark:text-gray-200',
|
||||
'dark:shadow-gray-850',
|
||||
'shadow-gray-200'
|
||||
)}
|
||||
style={{
|
||||
left: x,
|
||||
top: y
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold">{name}</div>
|
||||
<strong className="dark:text-indigo-400">{value}</strong> {label}
|
||||
</div>
|
||||
)
|
@ -1,180 +0,0 @@
|
||||
import React from 'react';
|
||||
import Datamap from 'datamaps'
|
||||
import * as d3 from "d3"
|
||||
|
||||
import numberFormatter from '../../util/number-formatter'
|
||||
import FadeIn from '../../fade-in'
|
||||
import LazyLoader from '../../components/lazy-loader'
|
||||
import MoreLink from '../more-link'
|
||||
import * as api from '../../api'
|
||||
import { navigateToQuery } from '../../query'
|
||||
import { cleanLabels, replaceFilterByPrefix } from '../../util/filters';
|
||||
import { countriesRoute } from '../../router';
|
||||
import { useAppNavigate } from '../../navigation/use-app-navigate';
|
||||
import { useQueryContext } from '../../query-context';
|
||||
|
||||
class Countries extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.resizeMap = this.resizeMap.bind(this)
|
||||
this.drawMap = this.drawMap.bind(this)
|
||||
this.getDataset = this.getDataset.bind(this)
|
||||
this.state = {
|
||||
loading: true,
|
||||
darkTheme: document.querySelector('html').classList.contains('dark') || false
|
||||
}
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
this.updateCountries = this.updateCountries.bind(this)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.query !== prevProps.query) {
|
||||
// eslint-disable-next-line react/no-did-update-set-state
|
||||
this.setState({ loading: true, countries: null })
|
||||
this.fetchCountries(this.drawMap)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.resizeMap);
|
||||
document.removeEventListener('tick', this.updateCountries);
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.fetchCountries(this.drawMap)
|
||||
window.addEventListener('resize', this.resizeMap);
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.updateCountries)
|
||||
}
|
||||
}
|
||||
|
||||
getDataset() {
|
||||
const dataset = {};
|
||||
|
||||
var onlyValues = this.state.countries.map(function(obj) { return obj.visitors });
|
||||
var maxValue = Math.max.apply(null, onlyValues);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const paletteScale = d3.scale.linear()
|
||||
.domain([0, maxValue])
|
||||
.range([
|
||||
this.state.darkTheme ? "#2e3954" : "#f3ebff",
|
||||
this.state.darkTheme ? "#6366f1" : "#a779e9"
|
||||
])
|
||||
|
||||
this.state.countries.forEach(function(item) {
|
||||
dataset[item.alpha_3] = { numberOfThings: item.visitors, fillColor: paletteScale(item.visitors) };
|
||||
});
|
||||
|
||||
return dataset
|
||||
}
|
||||
|
||||
updateCountries() {
|
||||
this.fetchCountries(() => { this.map.updateChoropleth(this.getDataset(), { reset: true }) })
|
||||
}
|
||||
|
||||
fetchCountries(cb) {
|
||||
return api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.props.query, { limit: 300 })
|
||||
.then((response) => {
|
||||
if (this.props.afterFetchData) {
|
||||
this.props.afterFetchData(response)
|
||||
}
|
||||
|
||||
this.setState({ loading: false, countries: response.results }, cb)
|
||||
})
|
||||
}
|
||||
|
||||
resizeMap() {
|
||||
this.map && this.map.resize()
|
||||
}
|
||||
|
||||
drawMap() {
|
||||
const dataset = this.getDataset();
|
||||
const label = this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
|
||||
const defaultFill = this.state.darkTheme ? '#2d3747' : '#f8fafc'
|
||||
const highlightFill = this.state.darkTheme ? '#374151' : '#F5F5F5'
|
||||
const borderColor = this.state.darkTheme ? '#1f2937' : '#dae1e7'
|
||||
const highlightBorderColor = this.state.darkTheme ? '#4f46e5' : '#a779e9'
|
||||
|
||||
this.map = new Datamap({
|
||||
element: document.getElementById('map-container'),
|
||||
responsive: true,
|
||||
projection: 'mercator',
|
||||
fills: { defaultFill },
|
||||
data: dataset,
|
||||
geographyConfig: {
|
||||
borderColor,
|
||||
highlightBorderWidth: 2,
|
||||
highlightFillColor: (geo) => geo.fillColor || highlightFill,
|
||||
highlightBorderColor,
|
||||
popupTemplate: (geo, data) => {
|
||||
if (!data) { return null; }
|
||||
const pluralizedLabel = data.numberOfThings === 1 ? label.slice(0, -1) : label
|
||||
return ['<div class="hoverinfo dark:bg-gray-800 dark:shadow-gray-850 dark:border-gray-850 dark:text-gray-200">',
|
||||
'<strong>', geo.properties.name, ' </strong>',
|
||||
'<br><strong class="dark:text-indigo-400">', numberFormatter(data.numberOfThings), '</strong> ', pluralizedLabel,
|
||||
'</div>'].join('');
|
||||
}
|
||||
},
|
||||
done: (datamap) => {
|
||||
datamap.svg.selectAll('.datamaps-subunit').on('click', (geography) => {
|
||||
const country = this.state.countries.find(c => c.alpha_3 === geography.id)
|
||||
const filters = replaceFilterByPrefix(this.props.query, "country", ["is", "country", [country.code]])
|
||||
const labels = cleanLabels(filters, this.props.query.labels, "country", { [country.code]: country.name })
|
||||
|
||||
if (country) {
|
||||
this.props.onClick()
|
||||
|
||||
navigateToQuery(
|
||||
this.props.navigate,
|
||||
this.props.query,
|
||||
{
|
||||
filters,
|
||||
labels
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
geolocationDbNotice() {
|
||||
if (this.props.site.isDbip) {
|
||||
return (
|
||||
<span className="text-xs text-gray-500 absolute bottom-4 right-3">IP Geolocation by <a target="_blank" href="https://db-ip.com" rel="noreferrer" className="text-indigo-600">DB-IP</a></span>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
if (this.state.countries) {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto mt-4" style={{ width: '100%', maxWidth: '475px', height: '335px' }} id="map-container"></div>
|
||||
<MoreLink list={this.state.countries} linkProps={{ path: countriesRoute.path, search: (search) => search }} />
|
||||
{this.geolocationDbNotice()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<LazyLoader onVisible={this.onVisible}>
|
||||
{this.state.loading && <div className="mx-auto my-32 loading"><div></div></div>}
|
||||
<FadeIn show={!this.state.loading}>
|
||||
{this.renderBody()}
|
||||
</FadeIn>
|
||||
</LazyLoader>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function CountriesWithRouter(props) {const {query} = useQueryContext(); const navigate = useAppNavigate();
|
||||
return (<Countries {...props} query={query} navigate={navigate}/>)}
|
329
assets/js/dashboard/stats/locations/map.tsx
Normal file
329
assets/js/dashboard/stats/locations/map.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
/* @format */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import classNames from 'classnames'
|
||||
// @ts-expect-error untyped
|
||||
import * as api from '../../api'
|
||||
// @ts-expect-error untyped
|
||||
import { navigateToQuery } from '../../query'
|
||||
// @ts-expect-error untyped
|
||||
import { replaceFilterByPrefix, cleanLabels } from '../../util/filters'
|
||||
import { useAppNavigate } from '../../navigation/use-app-navigate'
|
||||
// @ts-expect-error untyped
|
||||
import numberFormatter from '../../util/number-formatter'
|
||||
import * as topojson from 'topojson-client'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
// @ts-expect-error untyped
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import worldJson from 'visionscarto-world-atlas/world/110m.json'
|
||||
import { UIMode, useTheme } from '../../theme-context'
|
||||
import { apiPath } from '../../util/url'
|
||||
// @ts-expect-error untyped
|
||||
import MoreLink from '../more-link'
|
||||
// @ts-expect-error untyped
|
||||
import { countriesRoute } from '../../router'
|
||||
// @ts-expect-error untyped
|
||||
import { MIN_HEIGHT } from '../reports/list'
|
||||
import { MapTooltip } from './map-tooltip'
|
||||
import { GeolocationNotice } from './geolocation-notice'
|
||||
|
||||
const width = 475
|
||||
const height = 335
|
||||
|
||||
type CountryData = {
|
||||
alpha_3: string
|
||||
name: string
|
||||
visitors: number
|
||||
code: string
|
||||
}
|
||||
type WorldJsonCountryData = { properties: { name: string; a3: string } }
|
||||
|
||||
const WorldMap = ({
|
||||
onCountrySelect,
|
||||
afterFetchData
|
||||
}: {
|
||||
onCountrySelect: () => void
|
||||
afterFetchData: (response: unknown) => void
|
||||
}) => {
|
||||
const navigate = useAppNavigate()
|
||||
const { mode } = useTheme()
|
||||
const site = useSiteContext()
|
||||
const { query } = useQueryContext()
|
||||
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
hoveredCountryAlpha3Code: string | null
|
||||
}>({ x: 0, y: 0, hoveredCountryAlpha3Code: null })
|
||||
|
||||
const labels =
|
||||
query.period === 'realtime'
|
||||
? { singular: 'Current visitor', plural: 'Current visitors' }
|
||||
: { singular: 'Visitor', plural: 'Visitors' }
|
||||
|
||||
const { data, refetch, isFetching, isError } = useQuery({
|
||||
queryKey: ['countries', 'map', query],
|
||||
placeholderData: (previousData) => previousData,
|
||||
queryFn: async (): Promise<{
|
||||
results: CountryData[]
|
||||
}> => {
|
||||
return await api.get(apiPath(site, '/countries'), query, {
|
||||
limit: 300
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const onTickRefetchData = () => {
|
||||
if (query.period === 'realtime') {
|
||||
refetch()
|
||||
}
|
||||
}
|
||||
document.addEventListener('tick', onTickRefetchData)
|
||||
return () => document.removeEventListener('tick', onTickRefetchData)
|
||||
}, [query.period, refetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
afterFetchData(data)
|
||||
}
|
||||
}, [afterFetchData, data])
|
||||
|
||||
const { maxValue, dataByCountryCode } = useMemo(() => {
|
||||
const dataByCountryCode: Map<string, CountryData> = new Map()
|
||||
let maxValue = 0
|
||||
for (const { alpha_3, visitors, name, code } of data?.results || []) {
|
||||
if (visitors > maxValue) {
|
||||
maxValue = visitors
|
||||
}
|
||||
dataByCountryCode.set(alpha_3, { alpha_3, visitors, name, code })
|
||||
}
|
||||
return { maxValue, dataByCountryCode }
|
||||
}, [data])
|
||||
|
||||
const onCountryClick = useCallback(
|
||||
(d: WorldJsonCountryData) => {
|
||||
const country = dataByCountryCode.get(d.properties.a3)
|
||||
const clickable = country && country.visitors
|
||||
if (clickable) {
|
||||
const filters = replaceFilterByPrefix(query, 'country', [
|
||||
'is',
|
||||
'country',
|
||||
[country.code]
|
||||
])
|
||||
const labels = cleanLabels(filters, query.labels, 'country', {
|
||||
[country.code]: country.name
|
||||
})
|
||||
onCountrySelect()
|
||||
navigateToQuery(navigate, query, { filters, labels })
|
||||
}
|
||||
},
|
||||
[navigate, query, dataByCountryCode, onCountrySelect]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const svg = drawInteractiveCountries(svgRef.current, setTooltip)
|
||||
|
||||
return () => {
|
||||
svg.selectAll('*').remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (svgRef.current) {
|
||||
const palette = colorScales[mode]
|
||||
|
||||
const getColorForValue = d3
|
||||
.scaleLinear<string>()
|
||||
.domain([0, maxValue])
|
||||
.range(palette)
|
||||
|
||||
colorInCountriesWithValues(
|
||||
svgRef.current,
|
||||
getColorForValue,
|
||||
dataByCountryCode
|
||||
).on('click', (_event, countryPath) => {
|
||||
onCountryClick(countryPath as unknown as WorldJsonCountryData)
|
||||
})
|
||||
}
|
||||
}, [mode, maxValue, dataByCountryCode, onCountryClick])
|
||||
|
||||
const hoveredCountryData = tooltip.hoveredCountryAlpha3Code
|
||||
? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="flex flex-col relative" style={{ minHeight: MIN_HEIGHT }}>
|
||||
<div className="mt-4" />
|
||||
<div
|
||||
className="relative mx-auto w-full"
|
||||
style={{ height: height, maxWidth: width }}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full"
|
||||
/>
|
||||
{!!hoveredCountryData && (
|
||||
<MapTooltip
|
||||
x={tooltip.x}
|
||||
y={tooltip.y}
|
||||
name={hoveredCountryData.name}
|
||||
value={numberFormatter(hoveredCountryData.visitors)}
|
||||
label={
|
||||
labels[hoveredCountryData.visitors === 1 ? 'singular' : 'plural']
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isFetching ||
|
||||
(isError && (
|
||||
<div className="absolute inset-0 flex justify-center items-center">
|
||||
<div className="loading">
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<MoreLink
|
||||
list={data?.results ?? []}
|
||||
linkProps={{
|
||||
path: countriesRoute.path,
|
||||
// @ts-expect-error MoreLink not typed yet
|
||||
search: (search) => search
|
||||
}}
|
||||
/>
|
||||
{site.isDbip && <GeolocationNotice />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const colorScales = {
|
||||
[UIMode.dark]: ['#2e3954', '#6366f1'],
|
||||
[UIMode.light]: ['#f3ebff', '#a779e9']
|
||||
}
|
||||
|
||||
const sharedCountryClass = classNames('transition-colors')
|
||||
|
||||
const countryClass = classNames(
|
||||
sharedCountryClass,
|
||||
'stroke-1',
|
||||
'fill-[#f8fafc]',
|
||||
'stroke-[#dae1e7]',
|
||||
'dark:fill-[#2d3747]',
|
||||
'dark:stroke-[#1f2937]'
|
||||
)
|
||||
|
||||
const highlightedCountryClass = classNames(
|
||||
sharedCountryClass,
|
||||
'stroke-2',
|
||||
'fill-[#f5f5f5]',
|
||||
'stroke-[#a779e9]',
|
||||
'dark:fill-[#374151]',
|
||||
'dark:stroke-[#4f46e5]'
|
||||
)
|
||||
|
||||
/**
|
||||
* Used to color the countries
|
||||
* @returns the svg elements represeting countries
|
||||
*/
|
||||
function colorInCountriesWithValues(
|
||||
element: SVGSVGElement,
|
||||
getColorForValue: d3.ScaleLinear<string, string, never>,
|
||||
dataByCountryCode: Map<string, CountryData>
|
||||
) {
|
||||
function getCountryByCountryPath(countryPath: unknown) {
|
||||
return dataByCountryCode.get(
|
||||
(countryPath as unknown as WorldJsonCountryData).properties.a3
|
||||
)
|
||||
}
|
||||
|
||||
const svg = d3.select(element)
|
||||
|
||||
return svg
|
||||
.selectAll('path')
|
||||
.style('fill', (countryPath) => {
|
||||
const country = getCountryByCountryPath(countryPath)
|
||||
if (!country?.visitors) {
|
||||
return null
|
||||
}
|
||||
return getColorForValue(country.visitors)
|
||||
})
|
||||
.style('cursor', (countryPath) => {
|
||||
const country = getCountryByCountryPath(countryPath)
|
||||
if (!country?.visitors) {
|
||||
return null
|
||||
}
|
||||
return 'pointer'
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns the d3 selected svg element */
|
||||
function drawInteractiveCountries(
|
||||
element: SVGSVGElement,
|
||||
setTooltip: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
x: number
|
||||
y: number
|
||||
hoveredCountryAlpha3Code: string | null
|
||||
}>
|
||||
>
|
||||
) {
|
||||
const path = setupProjetionPath()
|
||||
const data = parseWorldTopoJsonToGeoJsonFeatures()
|
||||
const svg = d3.select(element)
|
||||
|
||||
svg
|
||||
.selectAll('path')
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('class', countryClass)
|
||||
.attr('d', path as never)
|
||||
|
||||
.on('mouseover', function (event, country) {
|
||||
const [x, y] = d3.pointer(event, svg.node()?.parentNode)
|
||||
setTooltip({ x, y, hoveredCountryAlpha3Code: country.properties.a3 })
|
||||
// brings country to front
|
||||
this.parentNode?.appendChild(this)
|
||||
d3.select(this).attr('class', highlightedCountryClass)
|
||||
})
|
||||
|
||||
.on('mousemove', function (event) {
|
||||
const [x, y] = d3.pointer(event, svg.node()?.parentNode)
|
||||
setTooltip((currentState) => ({ ...currentState, x, y }))
|
||||
})
|
||||
|
||||
.on('mouseout', function () {
|
||||
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null })
|
||||
d3.select(this).attr('class', countryClass)
|
||||
})
|
||||
|
||||
return svg
|
||||
}
|
||||
|
||||
function setupProjetionPath() {
|
||||
const projection = d3
|
||||
.geoMercator()
|
||||
.scale(75)
|
||||
.translate([width / 2, height / 1.5])
|
||||
|
||||
const path = d3.geoPath().projection(projection)
|
||||
return path
|
||||
}
|
||||
|
||||
function parseWorldTopoJsonToGeoJsonFeatures(): Array<WorldJsonCountryData> {
|
||||
const collection = topojson.feature(
|
||||
// @ts-expect-error strings in worldJson not recongizable as the enum values declared in library
|
||||
worldJson,
|
||||
worldJson.objects.countries
|
||||
)
|
||||
// @ts-expect-error topojson.feature return type incorrectly inferred as not a collection
|
||||
return collection.features
|
||||
}
|
||||
|
||||
export default WorldMap
|
@ -19,6 +19,7 @@ export default function FilterModalRow({
|
||||
|
||||
const selectedClauses = useMemo(
|
||||
() => clauses.map((value) => ({ value, label: getLabel(labels, filterKey, value) })),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[filter, labels]
|
||||
)
|
||||
|
||||
|
@ -14,7 +14,6 @@ function detailsIcon() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
</svg>
|
||||
)
|
||||
@ -26,7 +25,6 @@ export default function MoreLink({ linkProps, list, className, onClick }) {
|
||||
<div className={`w-full text-center ${className ? className : ''}`}>
|
||||
<AppNavigationLink
|
||||
{...linkProps}
|
||||
// eslint-disable-next-line max-len
|
||||
className="leading-snug font-bold text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition tracking-wide"
|
||||
onClick={onClick}
|
||||
>
|
||||
|
@ -125,6 +125,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
|
||||
|
||||
setState({ loading: false, list: response.results })
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keyLabel, query])
|
||||
|
||||
const onVisible = () => { setVisible(true) }
|
||||
@ -136,6 +137,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
|
||||
// only read the new metrics once the new list is loaded.
|
||||
setState({ loading: true, list: null })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [goalFilterApplied]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -145,6 +147,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
|
||||
}
|
||||
|
||||
return () => { document.removeEventListener('tick', getData) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keyLabel, query, visible]);
|
||||
|
||||
// returns a filtered `metrics` list. Since currently, the backend can return different
|
||||
|
@ -6,6 +6,7 @@ import React from "react"
|
||||
/*global require*/
|
||||
function maybeRequire() {
|
||||
if (BUILD_EXTRA) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require('../../extra/money')
|
||||
} else {
|
||||
return { default: null }
|
||||
|
62
assets/js/dashboard/theme-context.tsx
Normal file
62
assets/js/dashboard/theme-context.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
/* @format */
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
export enum UIMode {
|
||||
light = 'light',
|
||||
dark = 'dark'
|
||||
}
|
||||
|
||||
const defaultValue = { mode: UIMode.light }
|
||||
|
||||
const ThemeContext = createContext(defaultValue)
|
||||
|
||||
function parseUIMode(element: Element | null): UIMode {
|
||||
return element?.classList.contains('dark') ? UIMode.dark : UIMode.light
|
||||
}
|
||||
|
||||
export default function ThemeContextProvider({
|
||||
children
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const observerRef = useRef<MutationObserver | null>(null)
|
||||
const [mode, setMode] = useState<UIMode>(
|
||||
parseUIMode(document.querySelector('html'))
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
const htmlElement = document.querySelector('html')
|
||||
const currentObserver = observerRef.current
|
||||
if (htmlElement && !currentObserver) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (
|
||||
mutation.type === 'attributes' &&
|
||||
mutation.attributeName === 'class'
|
||||
) {
|
||||
return setMode(parseUIMode(mutation.target as Element))
|
||||
}
|
||||
})
|
||||
})
|
||||
observerRef.current = observer
|
||||
observer.observe(htmlElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
})
|
||||
}
|
||||
return () => currentObserver?.disconnect()
|
||||
}, [])
|
||||
return (
|
||||
<ThemeContext.Provider value={{ mode }}>{children}</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext)
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
import React, { createContext, useContext } from "react";
|
||||
|
||||
const userContextDefaultValue = {
|
||||
role: '',
|
||||
loggedIn: false,
|
||||
}
|
||||
|
||||
const UserContext = createContext(userContextDefaultValue)
|
||||
|
||||
export const useUserContext = () => { return useContext(UserContext) }
|
||||
|
||||
export default function UserContextProvider({ role, loggedIn, children }) {
|
||||
return <UserContext.Provider value={{role, loggedIn}}>{children}</UserContext.Provider>
|
||||
};
|
35
assets/js/dashboard/user-context.tsx
Normal file
35
assets/js/dashboard/user-context.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
/* @format */
|
||||
import React, { createContext, ReactNode, useContext } from 'react'
|
||||
|
||||
export enum Role {
|
||||
owner = 'owner',
|
||||
admin = 'admin',
|
||||
viewer = 'viewer'
|
||||
}
|
||||
|
||||
const userContextDefaultValue = {
|
||||
role: Role.viewer,
|
||||
loggedIn: false
|
||||
}
|
||||
|
||||
const UserContext = createContext(userContextDefaultValue)
|
||||
|
||||
export const useUserContext = () => {
|
||||
return useContext(UserContext)
|
||||
}
|
||||
|
||||
export default function UserContextProvider({
|
||||
role,
|
||||
loggedIn,
|
||||
children
|
||||
}: {
|
||||
role: Role
|
||||
loggedIn: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<UserContext.Provider value={{ role, loggedIn }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
)
|
||||
}
|
@ -11,7 +11,7 @@ function testLocalStorageAvailability(){
|
||||
localStorage.setItem(testItem, testItem);
|
||||
localStorage.removeItem(testItem);
|
||||
return true;
|
||||
} catch(e) {
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -1,129 +0,0 @@
|
||||
import JsonURL from '@jsonurl/jsonurl'
|
||||
|
||||
export function apiPath(site, path = '') {
|
||||
return `/api/stats/${encodeURIComponent(site.domain)}${path}/`
|
||||
}
|
||||
|
||||
export function externalLinkForPage(domain, page) {
|
||||
const domainURL = new URL(`https://${domain}`)
|
||||
return `https://${domainURL.host}${page}`
|
||||
}
|
||||
|
||||
export function isValidHttpUrl(string) {
|
||||
let url;
|
||||
|
||||
try {
|
||||
url = new URL(string);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
}
|
||||
|
||||
|
||||
export function trimURL(url, maxLength) {
|
||||
if (url.length <= maxLength) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const ellipsis = "...";
|
||||
|
||||
if (isValidHttpUrl(url)) {
|
||||
const [protocol, restURL] = url.split('://');
|
||||
const parts = restURL.split('/');
|
||||
|
||||
const host = parts.shift();
|
||||
if (host.length > maxLength - 5) {
|
||||
return `${protocol}://${host.substr(0, maxLength - 5)}${ellipsis}${restURL.slice(-maxLength + 5)}`;
|
||||
}
|
||||
|
||||
let remainingLength = maxLength - host.length - 5;
|
||||
let trimmedURL = `${protocol}://${host}`;
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.length <= remainingLength) {
|
||||
trimmedURL += '/' + part;
|
||||
remainingLength -= part.length + 1;
|
||||
} else {
|
||||
const startTrim = Math.floor((remainingLength - 3) / 2);
|
||||
const endTrim = Math.ceil((remainingLength - 3) / 2);
|
||||
trimmedURL += `/${part.substr(0, startTrim)}...${part.slice(-endTrim)}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmedURL;
|
||||
} else {
|
||||
const leftSideLength = Math.floor(maxLength / 2);
|
||||
const rightSideLength = maxLength - leftSideLength;
|
||||
|
||||
const leftSide = url.slice(0, leftSideLength);
|
||||
const rightSide = url.slice(-rightSideLength);
|
||||
|
||||
return leftSide + ellipsis + rightSide;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} input - value to encode for URI
|
||||
* @returns {String} value encoded for URI
|
||||
*/
|
||||
export function encodeURIComponentPermissive(input) {
|
||||
return encodeURIComponent(input)
|
||||
.replaceAll("%2C", ",")
|
||||
.replaceAll("%3A", ":")
|
||||
.replaceAll("%2F", "/")
|
||||
}
|
||||
|
||||
export function encodeSearchParamEntries([k, v]) {
|
||||
return `${encodeURIComponentPermissive(k)}=${encodeURIComponentPermissive(v)}`
|
||||
}
|
||||
|
||||
export function isSearchEntryDefined([_key, value]) {
|
||||
return value !== undefined
|
||||
}
|
||||
|
||||
export function stringifySearch(searchRecord) {
|
||||
const definedSearchEntries = Object.entries(searchRecord || {}).map(stringifySearchEntry).filter(isSearchEntryDefined)
|
||||
|
||||
const encodedSearchEntries = definedSearchEntries.map(encodeSearchParamEntries)
|
||||
|
||||
return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : ''
|
||||
}
|
||||
|
||||
export function stringifySearchEntry([key, value]) {
|
||||
const isEmptyObjectOrArray = typeof value === 'object' && value !== null && Object.entries(value).length === 0;
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
isEmptyObjectOrArray
|
||||
) {
|
||||
return [key, undefined]
|
||||
}
|
||||
|
||||
return [key, JsonURL.stringify(value)]
|
||||
}
|
||||
|
||||
export function parseSearchFragment(searchStringFragment) {
|
||||
if (searchStringFragment === '') {
|
||||
return null
|
||||
}
|
||||
const fragmentWithReEncodedSymbols =
|
||||
searchStringFragment
|
||||
.replaceAll('=',encodeURIComponent('='))
|
||||
.replaceAll('#',encodeURIComponent('#'))
|
||||
try {
|
||||
return JsonURL.parse(fragmentWithReEncodedSymbols)
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse URL fragment ${fragmentWithReEncodedSymbols}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSearch(searchString) {
|
||||
const urlSearchParams = new URLSearchParams(searchString);
|
||||
const searchRecord = {};
|
||||
urlSearchParams.forEach((v, k) => searchRecord[k] = parseSearchFragment(v))
|
||||
return searchRecord;
|
||||
}
|
149
assets/js/dashboard/util/url.ts
Normal file
149
assets/js/dashboard/util/url.ts
Normal file
@ -0,0 +1,149 @@
|
||||
/* @format */
|
||||
import JsonURL from '@jsonurl/jsonurl'
|
||||
import { PlausibleSite } from '../site-context'
|
||||
|
||||
export function apiPath(site: PlausibleSite, path = ''): string {
|
||||
return `/api/stats/${encodeURIComponent(site.domain)}${path}/`
|
||||
}
|
||||
|
||||
export function externalLinkForPage(
|
||||
domain: PlausibleSite['domain'],
|
||||
page: string
|
||||
): string {
|
||||
const domainURL = new URL(`https://${domain}`)
|
||||
return `https://${domainURL.host}${page}`
|
||||
}
|
||||
|
||||
export function isValidHttpUrl(input: string): boolean {
|
||||
let url
|
||||
|
||||
try {
|
||||
url = new URL(input)
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
}
|
||||
|
||||
export function trimURL(url: string, maxLength: number): string {
|
||||
if (url.length <= maxLength) {
|
||||
return url
|
||||
}
|
||||
|
||||
const ellipsis = '...'
|
||||
|
||||
if (isValidHttpUrl(url)) {
|
||||
const [protocol, restURL] = url.split('://')
|
||||
const parts = restURL.split('/')
|
||||
|
||||
const host = parts.shift() || ''
|
||||
if (host.length > maxLength - 5) {
|
||||
return `${protocol}://${host.substr(0, maxLength - 5)}${ellipsis}${restURL.slice(-maxLength + 5)}`
|
||||
}
|
||||
|
||||
let remainingLength = maxLength - host.length - 5
|
||||
let trimmedURL = `${protocol}://${host}`
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.length <= remainingLength) {
|
||||
trimmedURL += '/' + part
|
||||
remainingLength -= part.length + 1
|
||||
} else {
|
||||
const startTrim = Math.floor((remainingLength - 3) / 2)
|
||||
const endTrim = Math.ceil((remainingLength - 3) / 2)
|
||||
trimmedURL += `/${part.substr(0, startTrim)}...${part.slice(-endTrim)}`
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return trimmedURL
|
||||
} else {
|
||||
const leftSideLength = Math.floor(maxLength / 2)
|
||||
const rightSideLength = maxLength - leftSideLength
|
||||
|
||||
const leftSide = url.slice(0, leftSideLength)
|
||||
const rightSide = url.slice(-rightSideLength)
|
||||
|
||||
return leftSide + ellipsis + rightSide
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeURIComponentPermissive(input: string): string {
|
||||
return (
|
||||
encodeURIComponent(input)
|
||||
/* @ts-expect-error API supposedly not present in compilation target */
|
||||
.replaceAll('%2C', ',')
|
||||
.replaceAll('%3A', ':')
|
||||
.replaceAll('%2F', '/')
|
||||
)
|
||||
}
|
||||
|
||||
export function encodeSearchParamEntries([k, v]: [string, string]): string {
|
||||
return `${encodeURIComponentPermissive(k)}=${encodeURIComponentPermissive(v)}`
|
||||
}
|
||||
|
||||
export function isSearchEntryDefined(
|
||||
entry: [string, undefined | string]
|
||||
): entry is [string, string] {
|
||||
return entry[1] !== undefined
|
||||
}
|
||||
|
||||
export function stringifySearch(
|
||||
searchRecord: Record<string, unknown>
|
||||
): '' | string {
|
||||
const definedSearchEntries = Object.entries(
|
||||
searchRecord || ({} as Record<string, unknown>)
|
||||
)
|
||||
.map(stringifySearchEntry)
|
||||
.filter(isSearchEntryDefined)
|
||||
|
||||
const encodedSearchEntries = definedSearchEntries.map(
|
||||
encodeSearchParamEntries
|
||||
)
|
||||
|
||||
return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : ''
|
||||
}
|
||||
|
||||
export function stringifySearchEntry([key, value]: [string, unknown]): [
|
||||
string,
|
||||
undefined | string
|
||||
] {
|
||||
const isEmptyObjectOrArray =
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
Object.entries(value).length === 0
|
||||
if (value === undefined || value === null || isEmptyObjectOrArray) {
|
||||
return [key, undefined]
|
||||
}
|
||||
|
||||
return [key, JsonURL.stringify(value)]
|
||||
}
|
||||
|
||||
export function parseSearchFragment(
|
||||
searchStringFragment: string
|
||||
): null | unknown {
|
||||
if (searchStringFragment === '') {
|
||||
return null
|
||||
}
|
||||
const fragmentWithReEncodedSymbols = searchStringFragment
|
||||
/* @ts-expect-error API supposedly not present in compilation target */
|
||||
.replaceAll('=', encodeURIComponent('='))
|
||||
.replaceAll('#', encodeURIComponent('#'))
|
||||
try {
|
||||
return JsonURL.parse(fragmentWithReEncodedSymbols)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to parse URL fragment ${fragmentWithReEncodedSymbols}`,
|
||||
error
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSearch(searchString: string): Record<string, unknown> {
|
||||
const urlSearchParams = new URLSearchParams(searchString)
|
||||
const searchRecord: Record<string, unknown> = {}
|
||||
urlSearchParams.forEach((v, k) => (searchRecord[k] = parseSearchFragment(v)))
|
||||
return searchRecord
|
||||
}
|
@ -15,6 +15,6 @@ export default () => ({
|
||||
if (! this.open) return
|
||||
|
||||
this.open = false
|
||||
focusAfter && focusAfter.focus()
|
||||
focusAfter?.focus()
|
||||
},
|
||||
})
|
||||
|
@ -3,6 +3,7 @@ if (window.Element && !Element.prototype.closest) {
|
||||
function(s) {
|
||||
var matches = (this.document || this.ownerDocument).querySelectorAll(s),
|
||||
i,
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
el = this;
|
||||
do {
|
||||
i = matches.length;
|
||||
|
1577
assets/package-lock.json
generated
1577
assets/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,11 +3,12 @@
|
||||
"version": "1.4.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"format": "prettier --write {css,js}/**",
|
||||
"check-format": "prettier --check {css,js}/**",
|
||||
"format": "prettier --write",
|
||||
"check-format": "prettier --check **/*.{js,css,ts,tsx} --require-pragma",
|
||||
"eslint": "eslint js/**",
|
||||
"stylelint": "stylelint css/**",
|
||||
"lint": "npm run eslint && npm run stylelint"
|
||||
"lint": "npm run eslint && npm run stylelint",
|
||||
"typecheck": "tsc --noEmit --pretty"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.10",
|
||||
@ -24,7 +25,7 @@
|
||||
"chart.js": "^3.3.2",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"classnames": "^2.3.1",
|
||||
"datamaps": "^0.5.9",
|
||||
"d3": "^7.9.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"iframe-resizer": "^4.3.2",
|
||||
"phoenix": "^1.7.2",
|
||||
@ -38,18 +39,29 @@
|
||||
"react-popper": "^2.3.0",
|
||||
"react-router-dom": "^6.25.1",
|
||||
"react-transition-group": "^4.4.2",
|
||||
"url-search-params-polyfill": "^8.2.5"
|
||||
"topojson-client": "^3.1.0",
|
||||
"url-search-params-polyfill": "^8.2.5",
|
||||
"visionscarto-world-atlas": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/classnames": "^2.3.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/topojson-client": "^3.1.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.1",
|
||||
"@typescript-eslint/parser": "^8.0.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"prettier": "^3.3.3",
|
||||
"stylelint": "^16.8.1",
|
||||
"stylelint-config-standard": "^36.0.1"
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"name": "assets"
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ const plugin = require('tailwindcss/plugin')
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
"./js/**/*.js",
|
||||
"./js/**/*.{js,ts,tsx}",
|
||||
"../lib/*_web.ex",
|
||||
"../lib/*_web/**/*.*ex",
|
||||
"../extra/**/*.*ex",
|
||||
|
12
assets/tsconfig.json
Normal file
12
assets/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"target": "es2017",
|
||||
"module": "commonjs",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
@ -26,7 +26,7 @@ config :esbuild,
|
||||
]
|
||||
|
||||
config :tailwind,
|
||||
version: "3.3.3",
|
||||
version: "3.4.7",
|
||||
default: [
|
||||
args: ~w(
|
||||
--config=tailwind.config.js
|
||||
|
@ -8,6 +8,7 @@ config :plausible, PlausibleWeb.Endpoint,
|
||||
watchers: [
|
||||
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
|
||||
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
|
||||
npm: ["--prefix", "assets", "run", "typecheck", "--", "--watch"],
|
||||
npm: [
|
||||
"run",
|
||||
"deploy",
|
||||
|
1
mix.exs
1
mix.exs
@ -155,6 +155,7 @@ defmodule Plausible.MixProject do
|
||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||
test: ["ecto.create --quiet", "ecto.migrate", "test", "clean_clickhouse"],
|
||||
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
||||
"assets.typecheck": ["cmd npm --prefix assets run typecheck"],
|
||||
"assets.build": [
|
||||
"tailwind default",
|
||||
"esbuild default"
|
||||
|
Loading…
Reference in New Issue
Block a user