Files
static-cms/packages/core/src/widgets/relation/RelationControl.tsx
2023-02-24 09:01:59 -05:00

299 lines
8.8 KiB
TypeScript

import Autocomplete from '@mui/material/Autocomplete';
import CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField';
import * as fuzzy from 'fuzzy';
import find from 'lodash/find';
import get from 'lodash/get';
import uniqBy from 'lodash/uniqBy';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
currentBackend,
expandSearchEntries,
getEntryField,
mergeExpandedEntries,
sortByScore,
} from '@staticcms/core/backend';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import {
addFileTemplateFields,
compileStringTemplate,
expandPath,
extractTemplateVars,
} from '@staticcms/core/lib/widgets/stringTemplate';
import { selectCollection } from '@staticcms/core/reducers/selectors/collections';
import { useAppSelector } from '@staticcms/core/store/hooks';
import type { FilterOptionsState } from '@mui/material/useAutocomplete';
import type {
Entry,
EntryData,
RelationField,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, ReactNode } from 'react';
import type { ListChildComponentProps } from 'react-window';
function Option({ index, style, data }: ListChildComponentProps<{ options: ReactNode[] }>) {
return <div style={style}>{data.options[index]}</div>;
}
export interface HitOption {
data: EntryData;
value: string;
label: string;
}
export interface Option {
value: string;
label: string;
}
function optionToString(option: Option | HitOption | null): string {
return option && option.value ? option.value : '';
}
function convertToOption(raw: string | HitOption): HitOption;
function convertToOption(raw: string | Option | HitOption): Option;
function convertToOption(raw: string | HitOption | undefined): HitOption | undefined;
function convertToOption(raw: string | Option | HitOption | undefined): Option | undefined;
function convertToOption(raw: string | Option | HitOption | undefined): Option | undefined {
if (typeof raw === 'string') {
return { label: raw, value: raw };
}
return raw;
}
function getSelectedOptions(value: HitOption[] | undefined | null): HitOption[] | null;
function getSelectedOptions(value: string[] | undefined | null): string[] | null;
function getSelectedOptions(value: string[] | HitOption[] | undefined | null) {
if (!value || !Array.isArray(value)) {
return null;
}
return value;
}
function uniqOptions(initial: HitOption[], current: HitOption[]): HitOption[] {
return uniqBy(initial.concat(current), o => o.value);
}
function getSelectedValue(
value: string,
options: HitOption[],
isMultiple: boolean,
): HitOption | null;
function getSelectedValue(
value: string[],
options: HitOption[],
isMultiple: boolean,
): HitOption[] | null;
function getSelectedValue(
value: string | string[] | null | undefined,
options: HitOption[],
isMultiple: boolean,
): HitOption | HitOption[] | null;
function getSelectedValue(
value: string | string[] | null | undefined,
options: HitOption[],
isMultiple: boolean,
): HitOption | HitOption[] | null {
if (isMultiple && Array.isArray(value)) {
const selectedOptions = getSelectedOptions(value);
if (selectedOptions === null) {
return null;
}
const selected = selectedOptions
.map(i => options.find(o => o.value === i))
.filter(Boolean)
.map(convertToOption) as HitOption[];
return selected;
} else {
return find(options, ['value', value]) ?? null;
}
}
const RelationControl: FC<WidgetControlProps<string | string[], RelationField>> = ({
value,
field,
isDuplicate,
onChange,
config,
locale,
label,
hasErrors,
}) => {
const [internalRawValue, setInternalValue] = useState(value);
const internalValue = useMemo(
() => (isDuplicate ? value : internalRawValue),
[internalRawValue, isDuplicate, value],
);
const [initialOptions, setInitialOptions] = useState<HitOption[]>([]);
const searchCollectionSelector = useMemo(
() => selectCollection(field.collection),
[field.collection],
);
const searchCollection = useAppSelector(searchCollectionSelector);
const isMultiple = useMemo(() => {
return field.multiple ?? false;
}, [field.multiple]);
const parseNestedFields = useCallback(
(hit: Entry, field: string): string => {
const hitData =
locale != null && hit.i18n != null && hit.i18n[locale] != null
? hit.i18n[locale].data
: hit.data;
const templateVars = extractTemplateVars(field);
// return non template fields as is
if (templateVars.length <= 0) {
return get(hitData, field) as string;
}
const data = addFileTemplateFields(hit.path, hitData);
return compileStringTemplate(field, null, hit.slug, data);
},
[locale],
);
const parseHitOptions = useCallback(
(hits: Entry[]) => {
const valueField = field.value_field;
const displayField = field.display_fields || [field.value_field];
const options = hits.reduce((acc, hit) => {
const valuesPaths = expandPath({ data: hit.data, path: valueField });
for (let i = 0; i < valuesPaths.length; i++) {
const label = displayField
.map(key => {
const displayPaths = expandPath({ data: hit.data, path: key });
return parseNestedFields(hit, displayPaths[i] || displayPaths[0]);
})
.join(' ');
const value = parseNestedFields(hit, valuesPaths[i]) as string;
acc.push({ data: hit.data, value, label });
}
return acc;
}, [] as HitOption[]);
return options;
},
[field.display_fields, field.value_field, parseNestedFields],
);
const handleChange = useCallback(
(selectedOption: HitOption | HitOption[] | null) => {
if (Array.isArray(selectedOption)) {
const options = selectedOption;
setInitialOptions(options.filter(Boolean));
const newValue = options.map(optionToString);
setInternalValue(newValue);
onChange(newValue);
} else {
setInitialOptions([selectedOption].filter(Boolean) as HitOption[]);
const newValue = optionToString(selectedOption);
setInternalValue(newValue);
onChange(newValue);
}
},
[onChange],
);
const [options, setOptions] = useState<HitOption[]>([]);
const [entries, setEntries] = useState<Entry[]>([]);
const [open, setOpen] = useState(false);
const valueNotEmpty = useMemo(
() => (Array.isArray(internalValue) ? internalValue.length > 0 : isNotEmpty(internalValue)),
[internalValue],
);
const loading = useMemo(
() => (open || valueNotEmpty) && options.length === 0,
[open, valueNotEmpty, options.length],
);
const filterOptions = useCallback(
(_options: HitOption[], { inputValue }: FilterOptionsState<HitOption>) => {
const searchFields = field.search_fields;
const limit = field.options_length || 20;
const expandedEntries = expandSearchEntries(entries, searchFields);
let hits = fuzzy
.filter(inputValue, expandedEntries, {
extract: entry => {
return getEntryField(entry.field, entry);
},
})
.sort(sortByScore)
.map(f => f.original);
if (limit !== undefined && limit > 0) {
hits = hits.slice(0, limit);
}
return parseHitOptions(mergeExpandedEntries(hits));
},
[entries, field.options_length, field.search_fields, parseHitOptions],
);
useEffect(() => {
if (!loading || !searchCollection) {
return;
}
const getOptions = async () => {
const backend = currentBackend(config);
const options = await backend.listAllEntries(searchCollection);
setEntries(options);
setOptions(parseHitOptions(options));
};
getOptions();
}, [searchCollection, config, loading, parseHitOptions]);
const uniqueOptions = uniqOptions(initialOptions, options);
const selectedValue = getSelectedValue(internalValue, uniqueOptions, isMultiple);
return (
<Autocomplete
key="relation-control-autocomplete"
disablePortal
options={uniqueOptions}
fullWidth
filterOptions={filterOptions}
renderInput={params => (
<TextField
key="relation-control-input"
{...params}
label={label}
error={hasErrors}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
value={selectedValue ? selectedValue : isMultiple ? [] : null}
onChange={(_event, newValue) => handleChange(newValue)}
multiple={isMultiple}
open={open}
onOpen={() => {
setOpen(true);
}}
onClose={() => {
setOpen(false);
}}
/>
);
};
export default RelationControl;