first commit
This commit is contained in:
29
admin/static/sections-autocomplete/Autocomplete.tsx
Normal file
29
admin/static/sections-autocomplete/Autocomplete.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from './utils';
|
||||
import { useMemo } from 'react';
|
||||
import { type Section, SectionRow } from './SectionRow';
|
||||
|
||||
function getEndpoint(searchTerm: string) {
|
||||
return `/admin/autocomplete/sections?term=${searchTerm}`;
|
||||
}
|
||||
|
||||
export function AutocompleteSections({ searchTerm }: { searchTerm: string }) {
|
||||
const { data } = useQuery<Section[]>(useMemo(() => getEndpoint(searchTerm), [searchTerm]), { cache: false });
|
||||
|
||||
return (
|
||||
<>
|
||||
{data?.map((section, i) => (
|
||||
<SectionRow
|
||||
section={section}
|
||||
hasSubsections={!!section.has_subsections}
|
||||
key={`as${section.id}-${i}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!!data && data.length === 0 && (
|
||||
<div className="AutocompleteWindow__not-found">
|
||||
<strong>Sekce nenalezena</strong>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
admin/static/sections-autocomplete/SectionRow.tsx
Normal file
146
admin/static/sections-autocomplete/SectionRow.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useQuery, useWidgetProps } from './utils';
|
||||
import { useEffect, useState, type KeyboardEvent } from 'react';
|
||||
|
||||
export type Section = {
|
||||
name: string;
|
||||
id: number;
|
||||
has_subsections: 0 | 1;
|
||||
}
|
||||
|
||||
type SectionRowProps = {
|
||||
section: { name: string; id: number; full_path?: string };
|
||||
depth?: number;
|
||||
hasSubsections?: boolean;
|
||||
};
|
||||
|
||||
function getEndpoint(sectionId: number) {
|
||||
return `/admin/autocomplete/sections/${sectionId}`;
|
||||
}
|
||||
|
||||
export function SectionRow({ section, hasSubsections, depth = 0 }: SectionRowProps) {
|
||||
const [showChildren, setShowChildren] = useState(false);
|
||||
const [shouldFetchChildren, setShouldFetchChildren] = useState(false);
|
||||
const { data } = useQuery<Section[]>(shouldFetchChildren ? getEndpoint(section.id): undefined);
|
||||
const { onAddSection, onAddWithSubsections, onSelect, hide, shouldHideAfterSelect } = useWidgetProps();
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetchChildren) {
|
||||
setShouldFetchChildren(showChildren);
|
||||
}
|
||||
}, [showChildren]);
|
||||
|
||||
function handleKeyboardNav(ev: KeyboardEvent<HTMLDivElement>) {
|
||||
switch (ev.key) {
|
||||
case 'ArrowLeft':
|
||||
setShowChildren(false);
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
setShowChildren(true);
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
if (ev.ctrlKey) {
|
||||
if (onAddWithSubsections) {
|
||||
onAddWithSubsections(section.id);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (onAddSection) {
|
||||
onAddSection(section.id);
|
||||
}
|
||||
|
||||
if (onSelect) {
|
||||
onSelect(section.id);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className={`section-row${depth > 0 ? ' subsection' : ''}`}
|
||||
style={{ paddingLeft: `${depth * 28}px` }}
|
||||
onKeyUp={handleKeyboardNav}
|
||||
>
|
||||
<div
|
||||
className="SectionRow__content"
|
||||
onClick={ev => {
|
||||
if (onSelect) {
|
||||
ev.stopPropagation();
|
||||
if (shouldHideAfterSelect) {
|
||||
hide();
|
||||
}
|
||||
|
||||
onSelect(section.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="SectionRow__left">
|
||||
{!!hasSubsections && (
|
||||
<div className="SectionRow__unwrap-button">
|
||||
<span
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
setShowChildren(prev => !prev);
|
||||
}}
|
||||
className={`bi bi-dash-circle opener ${showChildren ? 'minus' : 'plus'}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="SectionRow__name">
|
||||
<strong>
|
||||
{section.name}
|
||||
</strong>
|
||||
{!!section.full_path && (
|
||||
<span className="SectionRow__full-path">{section.full_path}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="SectionRow__buttons">
|
||||
{!!onAddSection && (
|
||||
<button
|
||||
key="btn-add"
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
onAddSection(section.id);
|
||||
}}
|
||||
>
|
||||
<span className="bi bi-plus-lg m-r-1"></span> Přidat
|
||||
</button>
|
||||
)}
|
||||
{!!onAddWithSubsections && (
|
||||
<button
|
||||
type="button"
|
||||
key="btn-add-all"
|
||||
className="btn btn-sm btn-secondary"
|
||||
disabled={!hasSubsections}
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
onAddWithSubsections(section.id);
|
||||
}}
|
||||
>
|
||||
<span className="bi bi-plus-lg m-r-1"></span> Včetně podsekcí
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showChildren && data?.map((child, i) => (
|
||||
<SectionRow
|
||||
section={child}
|
||||
key={`c${section.id}-${child.id}-${i}`}
|
||||
depth={depth + 1}
|
||||
hasSubsections={child.has_subsections === 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
122
admin/static/sections-autocomplete/SectionsWidget.tsx
Normal file
122
admin/static/sections-autocomplete/SectionsWidget.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import './styles.scss';
|
||||
import { memo, useEffect, useState, type KeyboardEvent } from 'react';
|
||||
import { useCache } from './cache';
|
||||
import { SectionRow } from './SectionRow';
|
||||
import { AutocompleteSections } from './Autocomplete';
|
||||
import type { WidgetProps } from './index';
|
||||
import { WidgetPropsProvider } from './utils';
|
||||
|
||||
function handleKeyboardNav(ev: KeyboardEvent<HTMLDivElement>) {
|
||||
const { currentTarget } = ev;
|
||||
|
||||
switch (ev.key) {
|
||||
case 'Escape':
|
||||
(currentTarget.querySelector('.form-control') as HTMLInputElement).focus();
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
if (!document.activeElement?.classList.contains('section-row')) {
|
||||
const firstFocusTarget = currentTarget.querySelector('.section-row') as HTMLDivElement | undefined;
|
||||
if (!firstFocusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
firstFocusTarget.focus();
|
||||
firstFocusTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSibling = document.activeElement.nextElementSibling as HTMLDivElement | undefined;
|
||||
if (nextSibling && nextSibling.classList.contains('section-row')) {
|
||||
nextSibling.focus();
|
||||
nextSibling.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
const prevSibling = document.activeElement?.previousElementSibling as HTMLDivElement | undefined;
|
||||
if (prevSibling && prevSibling.classList.contains('section-row')) {
|
||||
prevSibling.focus();
|
||||
prevSibling.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function SectionsWidget(props: WidgetProps) {
|
||||
const [shouldShow, setShouldShow] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
|
||||
const [cache] = useCache();
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick() {
|
||||
setShouldShow(false);
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebouncedSearchTerm(value), 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onKeyUp={handleKeyboardNav}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setShouldShow(true);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
onClick={() => {
|
||||
cache._fetchTopLevelSections();
|
||||
setShouldShow(true);
|
||||
}}
|
||||
type="text"
|
||||
className="form-control"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
value={value}
|
||||
placeholder="Vyhledejte sekci"
|
||||
onFocus={() => {
|
||||
setShouldShow(true);
|
||||
setValue('');
|
||||
}}
|
||||
/>
|
||||
<WidgetPropsProvider value={{...props, hide: () => setShouldShow(false), show: () => setShouldShow(true) }}>
|
||||
{shouldShow && <WidgetPopover searchTerm={debouncedSearchTerm} />}
|
||||
</WidgetPropsProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WidgetPopover = memo(({ searchTerm }: { searchTerm: string }) => {
|
||||
const topLevel = useCache('topLevelSections');
|
||||
|
||||
return (
|
||||
<div className="autocomplete-window">
|
||||
{!searchTerm && topLevel.map((section, i) => (
|
||||
<SectionRow
|
||||
section={section}
|
||||
key={`t${section.id}-${i}`}
|
||||
hasSubsections={!!section.has_subsections}
|
||||
/>
|
||||
))}
|
||||
{!!searchTerm && <AutocompleteSections searchTerm={searchTerm} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
35
admin/static/sections-autocomplete/cache.ts
Normal file
35
admin/static/sections-autocomplete/cache.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Section } from './SectionRow';
|
||||
|
||||
export type Cache = {
|
||||
topLevelSections: Section[];
|
||||
_query: Record<string, unknown>;
|
||||
|
||||
_fetchTopLevelSections: () => void;
|
||||
};
|
||||
|
||||
type CacheContext = [Cache, (p: Cache | ((prev: Cache) => Cache)) => void];
|
||||
|
||||
const CacheContext = createContext<CacheContext>([
|
||||
{
|
||||
topLevelSections: [],
|
||||
_query: {},
|
||||
|
||||
_fetchTopLevelSections: () => {},
|
||||
},
|
||||
() => {},
|
||||
]);
|
||||
|
||||
export const Provider = CacheContext.Provider;
|
||||
|
||||
export function useCache<Key extends undefined>(): CacheContext;
|
||||
export function useCache<Key extends keyof Cache>(key: Key): Cache[Key];
|
||||
export function useCache<Key extends keyof Cache>(key?: Key) {
|
||||
const cacheContext = useContext(CacheContext);
|
||||
|
||||
if (key === undefined) {
|
||||
return cacheContext;
|
||||
}
|
||||
|
||||
return cacheContext[0][key];
|
||||
}
|
||||
127
admin/static/sections-autocomplete/index.tsx
Normal file
127
admin/static/sections-autocomplete/index.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { SectionsWidget } from './SectionsWidget';
|
||||
import { render, createPortal } from 'react-dom';
|
||||
import { Fragment, memo, useEffect, useState } from 'react';
|
||||
import { Provider as CacheContextProvider, type Cache } from './cache';
|
||||
|
||||
const EVENT_MOUNT = 'wpj:sections-autocomplete:mount';
|
||||
|
||||
export type RuntimeProps = {
|
||||
runtimeTarget: HTMLElement;
|
||||
onInit?: () => void;
|
||||
};
|
||||
|
||||
export type WidgetProps = {
|
||||
// Buttons
|
||||
onAddSection?: (sectionId: number) => void;
|
||||
onAddWithSubsections?: (topsectionId: number) => void;
|
||||
|
||||
// Row click
|
||||
onSelect?: (idSection: number) => void;
|
||||
shouldHideAfterSelect?: boolean;
|
||||
};
|
||||
|
||||
export type MountEvent = CustomEvent<{
|
||||
mountTarget: HTMLElement;
|
||||
} & WidgetProps>;
|
||||
|
||||
let CacheInitialized = false;
|
||||
|
||||
export function RuntimeComponent({ options }: { options: RuntimeProps }) {
|
||||
const { runtimeTarget, onInit } = options;
|
||||
const [portals, setPortals] = useState<MountEvent['detail'][]>([]);
|
||||
const [cache, setCache] = useState<Cache>({
|
||||
topLevelSections: [],
|
||||
_query: {},
|
||||
_fetchTopLevelSections: fetchTopLevelSections,
|
||||
});
|
||||
|
||||
function fetchTopLevelSections() {
|
||||
if (CacheInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL('/admin/autocomplete/sections', `${window.location.protocol}//${window.location.host}`);
|
||||
fetch(url.href)
|
||||
.then(r => r.json())
|
||||
.then(sections => {
|
||||
setCache(p => ({ ...p, topLevelSections: sections }));
|
||||
CacheInitialized = true;
|
||||
})
|
||||
.catch(alert);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function mountEventHandler(ev: Event | MountEvent) {
|
||||
if (ev.type !== EVENT_MOUNT) {
|
||||
return;
|
||||
}
|
||||
const mountEvent = ev as MountEvent;
|
||||
|
||||
setPortals(previous => [...previous, mountEvent.detail]);
|
||||
}
|
||||
|
||||
runtimeTarget.addEventListener(EVENT_MOUNT, mountEventHandler);
|
||||
if (onInit) {
|
||||
onInit();
|
||||
}
|
||||
|
||||
return () => {
|
||||
runtimeTarget.removeEventListener(EVENT_MOUNT, mountEventHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CacheContextProvider value={[cache, setCache]}>
|
||||
<PortalMounter portals={portals} />
|
||||
</CacheContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const PortalMounter = memo(({ portals }: { portals: MountEvent['detail'][] }) => {
|
||||
return (
|
||||
<>
|
||||
{portals.map((props, index) => (
|
||||
<Fragment key={index}>
|
||||
{createPortal(<SectionsWidget {...props} />, props.mountTarget, index.toString())}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function prepareRuntime(runtimeTarget?: HTMLElement) {
|
||||
if (!runtimeTarget) {
|
||||
const elem = document.createElement('meta');
|
||||
document.body.insertAdjacentElement('beforeend', elem);
|
||||
|
||||
runtimeTarget = elem;
|
||||
}
|
||||
|
||||
runtimeTarget.dataset.sectionsAutocompleteRuntime = '';
|
||||
|
||||
return new Promise<HTMLElement>((resolve) => {
|
||||
const options = { runtimeTarget, onInit: () => { resolve(runtimeTarget!) } } as RuntimeProps;
|
||||
|
||||
render(
|
||||
<RuntimeComponent options={options} />,
|
||||
options.runtimeTarget,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function mountAutocomplete(target: HTMLElement, options: WidgetProps = { shouldHideAfterSelect: true }) {
|
||||
const event: MountEvent = new CustomEvent(EVENT_MOUNT, {
|
||||
detail: {
|
||||
mountTarget: target,
|
||||
...options,
|
||||
},
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
const runtimeElement = document.querySelector('[data-sections-autocomplete-runtime]');
|
||||
if (!runtimeElement) {
|
||||
throw new Error('SectionsAutocomplete runtime not found');
|
||||
}
|
||||
|
||||
runtimeElement.dispatchEvent(event);
|
||||
}
|
||||
83
admin/static/sections-autocomplete/styles.scss
Normal file
83
admin/static/sections-autocomplete/styles.scss
Normal file
@@ -0,0 +1,83 @@
|
||||
@import "../scss/style.scss";
|
||||
|
||||
.autocomplete-window {
|
||||
@include custom-scrollbar;
|
||||
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
height: 100%;
|
||||
border-radius: $border-radius-base;
|
||||
overflow-y: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $body-bg;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.AutocompleteWindow__not-found {
|
||||
width: 100%;
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-row {
|
||||
border-bottom: 1px solid $border-color-light;
|
||||
background-color: $gray-background;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
|
||||
.SectionRow__content {
|
||||
background-color: $table-bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&.subsection .SectionRow__content {
|
||||
border-left: 1px solid $border-color-light;
|
||||
}
|
||||
}
|
||||
|
||||
.SectionRow__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: $padding-small-vertical $padding-small-horizontal;
|
||||
min-height: 46px;
|
||||
background-color: $body-bg;
|
||||
|
||||
&:hover {
|
||||
background-color: $table-bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.SectionRow__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.SectionRow__unwrap-button {
|
||||
color: $primary;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.SectionRow__name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
.SectionRow__full-path {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.SectionRow__buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
96
admin/static/sections-autocomplete/utils.ts
Normal file
96
admin/static/sections-autocomplete/utils.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useCache } from './cache';
|
||||
import type { WidgetProps } from './index';
|
||||
|
||||
type UseQueryOptions = {
|
||||
cache: boolean;
|
||||
cacheKey?: string;
|
||||
};
|
||||
|
||||
export function useQuery<Result>(endpoint: string | undefined, options: UseQueryOptions = { cache: true }) {
|
||||
const [cache, setCache] = useCache();
|
||||
|
||||
const [result, setResult] = useState<Result | undefined>(undefined);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let abortController: AbortController | undefined = undefined;
|
||||
|
||||
async function handleQuery() {
|
||||
if (typeof endpoint === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = options.cacheKey ?? endpoint;
|
||||
if (options.cache && cacheKey in cache._query) {
|
||||
setResult(cache._query[cacheKey] as Result);
|
||||
return;
|
||||
}
|
||||
|
||||
abortController = new AbortController();
|
||||
setFetching(true);
|
||||
try {
|
||||
const url = new URL(endpoint, `${window.location.protocol}//${window.location.host}`);
|
||||
|
||||
const response = await fetch(url.href, { signal: abortController.signal });
|
||||
if (!response.ok || response.status !== 200) {
|
||||
throw new Error('Failed to fetch sections');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
setError(undefined);
|
||||
if (cache) {
|
||||
setCache(prev => ({
|
||||
...prev,
|
||||
_query: {
|
||||
...prev._query,
|
||||
[cacheKey]: data,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (!(e instanceof Error)) {
|
||||
e = new Error(String(e));
|
||||
}
|
||||
|
||||
if ((e as Error).name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(e as Error);
|
||||
console.error(e);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
|
||||
void handleQuery();
|
||||
|
||||
return () => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
};
|
||||
}, [endpoint]);
|
||||
|
||||
function clear() {
|
||||
setResult(undefined);
|
||||
setFetching(false);
|
||||
setError(undefined);
|
||||
}
|
||||
|
||||
return { data: result, isFetching: fetching, error, clear };
|
||||
}
|
||||
|
||||
type WidgetPropsContextValue = WidgetProps & {
|
||||
hide: () => void;
|
||||
show: () => void;
|
||||
};
|
||||
|
||||
const WidgetPropsContext = createContext<WidgetPropsContextValue | undefined>(undefined);
|
||||
export const WidgetPropsProvider = WidgetPropsContext.Provider;
|
||||
export function useWidgetProps() {
|
||||
return useContext(WidgetPropsContext) as WidgetPropsContextValue;
|
||||
}
|
||||
Reference in New Issue
Block a user