Skip to content

Commit

Permalink
Filter modal fixes (plausible#4553)
Browse files Browse the repository at this point in the history
* fix deciding whether a filter operation is freeChoice

* fix displaying loading spinner when loading combobox options

* extract lastLoadTimestamp into a separate context

* prettier
  • Loading branch information
RobertJoonas authored Sep 9, 2024
1 parent c536af0 commit e6993b1
Show file tree
Hide file tree
Showing 10 changed files with 568 additions and 263 deletions.
191 changes: 135 additions & 56 deletions assets/js/dashboard/components/combobox.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import React, { Fragment, useState, useCallback, useEffect, useRef } from 'react'
/** @format */

import React, {
Fragment,
useState,
useCallback,
useEffect,
useRef
} from 'react'
import { Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import { useMountedEffect, useDebounce } from '../custom-hooks'

function Option({isHighlighted, onClick, onMouseEnter, text, id}) {
const className = classNames('relative cursor-pointer select-none py-2 px-3', {
'text-gray-900 dark:text-gray-300': !isHighlighted,
'bg-indigo-600 text-white': isHighlighted,
})
function Option({ isHighlighted, onClick, onMouseEnter, text, id }) {
const className = classNames(
'relative cursor-pointer select-none py-2 px-3',
{
'text-gray-900 dark:text-gray-300': !isHighlighted,
'bg-indigo-600 text-white': isHighlighted
}
)

return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
Expand All @@ -27,7 +38,7 @@ function scrollTo(wrapper, id) {
const el = wrapper.querySelector('#' + id)

if (el) {
el.scrollIntoView({block: 'center'})
el.scrollIntoView({ block: 'center' })
}
}
}
Expand All @@ -48,7 +59,7 @@ export default function PlausibleCombobox({
placeholder,
forceLoading,
className,
boxClass,
boxClass
}) {
const isEmpty = values.length === 0
const [options, setOptions] = useState([])
Expand All @@ -63,8 +74,12 @@ export default function PlausibleCombobox({
const loading = isLoading || !!forceLoading

const visibleOptions = [...options]
if (freeChoice && search.length > 0 && options.every(option => option.value !== search)) {
visibleOptions.push({value: search, label: search, freeChoice: true})
if (
freeChoice &&
search.length > 0 &&
options.every((option) => option.value !== search)
) {
visibleOptions.push({ value: search, label: search, freeChoice: true })
}

const afterFetchOptions = useCallback((loadedOptions) => {
Expand All @@ -74,6 +89,7 @@ export default function PlausibleCombobox({
}, [])

const initialFetchOptions = useCallback(() => {
setLoading(true)
fetchOptions('').then(afterFetchOptions)
}, [fetchOptions, afterFetchOptions])

Expand All @@ -87,7 +103,9 @@ export default function PlausibleCombobox({
const debouncedSearchOptions = useDebounce(searchOptions)

useEffect(() => {
if (isOpen) { initialFetchOptions() }
if (isOpen) {
initialFetchOptions()
}
}, [isOpen, initialFetchOptions])

useMountedEffect(() => {
Expand Down Expand Up @@ -136,13 +154,19 @@ export default function PlausibleCombobox({
}

function isOptionDisabled(option) {
const optionAlreadySelected = values.some((val) => val.value === option.value)
const optionDisabled = (disabledOptions || []).some((val) => val?.value === option.value)
const optionAlreadySelected = values.some(
(val) => val.value === option.value
)
const optionDisabled = (disabledOptions || []).some(
(val) => val?.value === option.value
)
return optionAlreadySelected || optionDisabled
}

function onInput(e) {
if (!isOpen) { setOpen(true) }
if (!isOpen) {
setOpen(true)
}
setSearch(e.target.value)
}

Expand Down Expand Up @@ -177,15 +201,19 @@ export default function PlausibleCombobox({
}

const handleClick = useCallback((e) => {
if (containerRef.current && containerRef.current.contains(e.target)) { return }
if (containerRef.current && containerRef.current.contains(e.target)) {
return
}

setSearch('')
setOpen(false)
}, [])

useEffect(() => {
document.addEventListener("mousedown", handleClick, false)
return () => { document.removeEventListener("mousedown", handleClick, false) }
document.addEventListener('mousedown', handleClick, false)
return () => {
document.removeEventListener('mousedown', handleClick, false)
}
}, [handleClick])

useEffect(() => {
Expand All @@ -194,7 +222,8 @@ export default function PlausibleCombobox({
}
}, [isEmpty, singleOption, autoFocus])

const searchBoxClass = 'border-none py-1 px-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm'
const searchBoxClass =
'border-none py-1 px-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm'

const containerClass = classNames('relative w-full', {
[className]: !!className,
Expand All @@ -205,17 +234,17 @@ export default function PlausibleCombobox({
const itemSelected = values.length === 1

return (
<div className='flex items-center truncate'>
{ itemSelected && renderSingleSelectedItem() }
<div className="flex items-center truncate">
{itemSelected && renderSingleSelectedItem()}
<input
className={searchBoxClass}
ref={searchRef}
value={search}
style={{backgroundColor: "inherit"}}
style={{ backgroundColor: 'inherit' }}
placeholder={itemSelected ? '' : placeholder}
type="text"
onChange={onInput}>
</input>
onChange={onInput}
></input>
</div>
)
}
Expand All @@ -233,32 +262,55 @@ export default function PlausibleCombobox({
function renderMultiOptionContent() {
return (
<>
{ values.map((value) => {
return (
<div key={value.value} className="bg-indigo-100 dark:bg-indigo-600 flex justify-between w-full rounded-sm px-2 py-0.5 m-0.5 text-sm">
<span className='break-all'>{value.label}</span>
<span onClick={(e) => removeOption(value, e)} className="cursor-pointer font-bold ml-1">&times;</span>
</div>
)
})
}
<input className={searchBoxClass} ref={searchRef} value={search} style={{backgroundColor: "inherit"}} placeholder={placeholder} type="text" onChange={onInput}></input>
{values.map((value) => {
return (
<div
key={value.value}
className="bg-indigo-100 dark:bg-indigo-600 flex justify-between w-full rounded-sm px-2 py-0.5 m-0.5 text-sm"
>
<span className="break-all">{value.label}</span>
<span
onClick={(e) => removeOption(value, e)}
className="cursor-pointer font-bold ml-1"
>
&times;
</span>
</div>
)
})}
<input
className={searchBoxClass}
ref={searchRef}
value={search}
style={{ backgroundColor: 'inherit' }}
placeholder={placeholder}
type="text"
onChange={onInput}
></input>
</>
)
}

function renderDropDownContent() {
const matchesFound = visibleOptions.length > 0 && visibleOptions.some(option => !isOptionDisabled(option))
const matchesFound =
visibleOptions.length > 0 &&
visibleOptions.some((option) => !isOptionDisabled(option))

if (loading) {
return <div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">Loading options...</div>
return (
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
Loading options...
</div>
)
}

if (matchesFound) {
return visibleOptions
.filter(option => !isOptionDisabled(option))
.filter((option) => !isOptionDisabled(option))
.map((option, i) => {
const text = option.freeChoice ? `Filter by '${option.label}'` : option.label
const text = option.freeChoice
? `Filter by '${option.label}'`
: option.label

return (
<Option
Expand All @@ -274,51 +326,78 @@ export default function PlausibleCombobox({
}

if (freeChoice) {
return <div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">Start typing to apply filter</div>
return (
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
Start typing to apply filter
</div>
)
}

return (
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
No matches found in the current dashboard. Try selecting a different time range or searching for something different
No matches found in the current dashboard. Try selecting a different
time range or searching for something different
</div>
)
}

const defaultBoxClass = 'pl-2 pr-8 py-1 w-full dark:bg-gray-900 dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-700 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500'
const defaultBoxClass =
'pl-2 pr-8 py-1 w-full dark:bg-gray-900 dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-700 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500'
const finalBoxClass = classNames(boxClass || defaultBoxClass, {
'border-indigo-500 ring-1 ring-indigo-500': isOpen,
'border-indigo-500 ring-1 ring-indigo-500': isOpen
})

return (
<div onKeyDown={onKeyDown} ref={containerRef} className={containerClass}>
<div onClick={toggleOpen} className={finalBoxClass }>
<div onClick={toggleOpen} className={finalBoxClass}>
{singleOption && renderSingleOptionContent()}
{!singleOption && renderMultiOptionContent()}
<div className="cursor-pointer absolute inset-y-0 right-0 flex items-center pr-2">
{!loading && <ChevronDownIcon className="h-4 w-4 text-gray-500" />}
{loading && <Spinner />}
</div>
</div>
{isOpen && <Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={isOpen}
>
<ul ref={listRef} className="z-50 absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm dark:bg-gray-900">
{ renderDropDownContent() }
</ul>
</Transition>}
{isOpen && (
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={isOpen}
>
<ul
ref={listRef}
className="z-50 absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm dark:bg-gray-900"
>
{renderDropDownContent()}
</ul>
</Transition>
)}
</div>
)
}

function Spinner() {
return (
<svg className="animate-spin h-4 w-4 text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="animate-spin h-4 w-4 text-indigo-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)
}
Loading

0 comments on commit e6993b1

Please sign in to comment.