Skip to content

Commit

Permalink
EditDialog: add PresetSelect (#506)
Browse files Browse the repository at this point in the history
  • Loading branch information
zbycz authored Oct 16, 2024
1 parent 4c53f82 commit 402a375
Show file tree
Hide file tree
Showing 16 changed files with 469 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import { CommentField } from './CommentField';
import { OsmUserLogged } from './OsmUserLogged';
import { ContributionInfoBox } from './ContributionInfoBox';
import { OsmUserLoggedOut } from './OsmUserLoggedOut';
import { PresetSelect } from './PresetSelect';

export const EditContent = () => (
<>
<DialogContent dividers>
<form autoComplete="off" onSubmit={(e) => e.preventDefault()}>
<OsmUserLoggedOut />
<PresetSelect />
<MajorKeysEditor />
<OptionsEditor />
<ContributionInfoBox />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useOsmAuthContext } from '../../../utils/OsmAuthContext';
import { useGetHandleSave } from '../useGetHandleSave';

const SaveButton = () => {
const { loggedIn, loading } = useOsmAuthContext();
const { loggedIn } = useOsmAuthContext();
const { tags } = useEditContext();
const handleSave = useGetHandleSave();

Expand Down
224 changes: 224 additions & 0 deletions src/components/FeaturePanel/EditDialog/EditContent/PresetSearchBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import React, { useMemo, useState } from 'react';
import {
Box,
Button,
InputBase,
ListSubheader,
MenuItem,
Select,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import styled from '@emotion/styled';
import Maki from '../../../utils/Maki';
import { TranslatedPreset } from './PresetSelect';
import { Setter } from '../../../../types';
import { t } from '../../../../services/intl';
import { SelectChangeEvent } from '@mui/material/Select/SelectInput';
import { useEditContext } from '../EditContext';
import { useBoolState } from '../../../helpers';
import { useFeatureContext } from '../../../utils/FeatureContext';
import { PROJECT_ID } from '../../../../services/project';
import { useOsmAuthContext } from '../../../utils/OsmAuthContext';
import { OsmType } from '../../../../services/types';
import { geometryMatchesOsmType } from '../../../../services/tagging/presets';

// https://stackoverflow.com/a/70918883/671880

const containsText = (text: string, searchText: string) =>
text.toLowerCase().indexOf(searchText.toLowerCase()) > -1;

const StyledListSubheader = styled(ListSubheader)`
display: flex;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
padding: 6px 13px;
& > svg:first-of-type {
margin-right: 8px;
}
`;

const emptyOptions = [
'amenity/cafe',
'amenity/restaurant',
'amenity/fast_food',
'amenity/bar',
'shop',
'leisure/park',
'amenity/place_of_worship',
...(PROJECT_ID === 'openclimbing'
? [
'climbing/route_bottom',
'climbing/route',
'climbing/crag',
// 'climbing/area',
]
: []),
];

const Placeholder = styled.span`
color: ${({ theme }) => theme.palette.text.secondary};
`;

const renderOption = (option: TranslatedPreset) =>
!option ? (
<Placeholder>{t('editdialog.preset_select.placeholder')}</Placeholder>
) : (
<>
<Maki ico={option.icon} size={16} middle themed />
<span style={{ paddingLeft: 5 }} />
{option.name}
</>
);

const getFilteredOptions = (
options: TranslatedPreset[],
searchText: string,
osmType: OsmType | undefined,
) => {
const filteredOptions = options
.filter(({ geometry }) => geometryMatchesOsmType(geometry, osmType))
.filter(({ searchable }) => searchable === undefined || searchable)
.filter((option) => containsText(option.name, searchText))
.map((option) => option.presetKey);

if (searchText.length <= 2) {
return filteredOptions.splice(0, 50); // too many rows in select are slow
}

return filteredOptions;
};

const useDisplayedOptions = (
searchText: string,
options: TranslatedPreset[],
): string[] => {
const { feature } = useFeatureContext();
return useMemo<string[]>(
() =>
searchText.length
? getFilteredOptions(options, searchText, feature.osmMeta?.type)
: emptyOptions,
[feature.osmMeta?.type, options, searchText],
);
};

const SearchRow = ({
onChange,
}: {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) => (
<StyledListSubheader>
<SearchIcon fontSize="small" />
<InputBase
size="small"
autoFocus
placeholder={t('editdialog.preset_select.search_placeholder')}
fullWidth
onChange={onChange}
onKeyDown={(e) => {
if (e.key !== 'Escape') {
e.stopPropagation();
}
}}
/>
</StyledListSubheader>
);

const useGetOnChange = (
options: TranslatedPreset[],
value: string,
setValue: Setter<string>,
) => {
const { setTagsEntries } = useEditContext().tags;

return (e: SelectChangeEvent<string>) => {
const oldPreset = options.find((o) => o.presetKey === value);
if (oldPreset) {
Object.entries(oldPreset.addTags ?? oldPreset.tags ?? {}).forEach(
(tag) => {
setTagsEntries((state) =>
state.filter(([key, value]) => key !== tag[0] && value !== tag[1]),
);
},
);
}

const newPreset = options.find((o) => o.presetKey === e.target.value);
if (newPreset) {
const newTags = Object.entries(newPreset.addTags ?? newPreset.tags ?? {});
setTagsEntries((state) => [...newTags, ...state]);
}
setValue(newPreset.presetKey);
};
};

const getPaperMaxHeight = (
selectRef: React.MutableRefObject<HTMLDivElement>,
) => {
if (!selectRef.current) {
return undefined;
}
const BOTTOM_PADDING = 50;
const rect = selectRef.current.getBoundingClientRect();
const height = window.innerHeight - (rect.top + rect.height) - BOTTOM_PADDING;
return {
style: {
maxHeight: height,
},
};
};

type Props = {
value: string;
setValue: Setter<string>;
options: TranslatedPreset[];
};
export const PresetSearchBox = ({ value, setValue, options }: Props) => {
const selectRef = React.useRef<HTMLDivElement>(null);
const { feature } = useFeatureContext();
const { loggedIn } = useOsmAuthContext();
const [enabled, enable] = useBoolState(feature.point || !loggedIn);

const [searchText, setSearchText] = useState('');
const displayedOptions = useDisplayedOptions(searchText, options);

const onChange = useGetOnChange(options, value, setValue);

return (
<>
<Select
disabled={!enabled}
MenuProps={{
autoFocus: false,
slotProps: { paper: getPaperMaxHeight(selectRef) },
}}
value={value}
onChange={onChange}
onClose={() => setSearchText('')}
renderValue={() =>
renderOption(options.find((o) => o.presetKey === value))
}
size="small"
variant="outlined"
fullWidth
displayEmpty
ref={selectRef}
>
<SearchRow onChange={(e) => setSearchText(e.target.value)} />
{displayedOptions.map((option) => (
<MenuItem key={option} component="li" value={option}>
{renderOption(options.find((o) => o.presetKey === option))}
</MenuItem>
))}
</Select>
{!enabled && (
// TODO we may warn users that this is not usual operation, if this becomes an issue
<Box ml={1}>
<Button color="secondary" onClick={enable}>
{t('editdialog.preset_select.edit_button')}
</Button>
</Box>
)}
</>
);
};
114 changes: 114 additions & 0 deletions src/components/FeaturePanel/EditDialog/EditContent/PresetSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography } from '@mui/material';
import styled from '@emotion/styled';
import { getPoiClass } from '../../../../services/getPoiClass';
import { allPresets } from '../../../../services/tagging/data';
import {
fetchSchemaTranslations,
getPresetTermsTranslation,
getPresetTranslation,
} from '../../../../services/tagging/translations';
import { useFeatureContext } from '../../../utils/FeatureContext';
import { PresetSearchBox } from './PresetSearchBox';
import { useEditContext } from '../EditContext';
import { Preset } from '../../../../services/tagging/types/Presets';
import { getPresetForFeature } from '../../../../services/tagging/presets';
import { Feature, FeatureTags } from '../../../../services/types';
import { t } from '../../../../services/intl';
import { Setter } from '../../../../types';

export type TranslatedPreset = Preset & {
name: string;
icon: string;
};

type PresetCacheItem = Preset & { name: string; icon: string; terms: string[] };
type PresetsCache = PresetCacheItem[];

let presetsCache: PresetsCache | null = null;
const getTranslatedPresets = async (): Promise<PresetsCache> => {
if (presetsCache) {
return presetsCache;
}

await fetchSchemaTranslations();

// resolve symlinks to {landuse...} etc
presetsCache = Object.values(allPresets)
.filter(({ locationSet }) => !locationSet?.include)
.filter(({ tags }) => Object.keys(tags).length > 0)
.map((preset) => {
return {
...preset,
name: getPresetTranslation(preset.presetKey) ?? preset.presetKey,
icon: getPoiClass(preset.tags).class,
terms: getPresetTermsTranslation(preset.presetKey) ?? preset.terms,
};
})
.sort((a, b) => a.name.localeCompare(b.name));

return presetsCache;
};

const Row = styled(Box)`
display: flex;
align-items: center;
`;

const LabelWrapper = styled.div`
min-width: 44px;
margin-right: 1em;
`;

const useMatchTags = (
feature: Feature,
tags: FeatureTags,
setPreset: Setter<string>,
) => {
useEffect(() => {
(async () => {
const updatedFeature: Feature = {
...feature,
...(feature.point ? { osmMeta: { type: 'node', id: -1 } } : {}),
tags,
};
const foundPreset = getPresetForFeature(updatedFeature); // takes ~ 1 ms
const translatedPreset = (await getTranslatedPresets()).find(
(option) => option.presetKey === foundPreset.presetKey,
);
setPreset(translatedPreset?.presetKey ?? '');
})();
}, [tags, feature, setPreset]);
};

const useOptions = () => {
const [options, setOptions] = useState<PresetsCache>([]);
useEffect(() => {
getTranslatedPresets().then((presets) => setOptions(presets));
}, []);
return options;
};

export const PresetSelect = () => {
const { tags } = useEditContext().tags;
const [preset, setPreset] = useState('');
const { feature } = useFeatureContext();
const options = useOptions();
useMatchTags(feature, tags, setPreset);

if (options.length === 0) {
return null;
}

return (
<Row mb={3}>
<LabelWrapper>
<Typography variant="body1" component="span" color="textSecondary">
{t('editdialog.preset_select.label')}
</Typography>
</LabelWrapper>

<PresetSearchBox value={preset} setValue={setPreset} options={options} />
</Row>
);
};
4 changes: 2 additions & 2 deletions src/components/SearchBox/options/preset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
getPresetTermsTranslation,
getPresetTranslation,
} from '../../../services/tagging/translations';
import { presets } from '../../../services/tagging/data';
import { allPresets } from '../../../services/tagging/data';
import { PresetOption } from '../types';
import { t } from '../../../services/intl';
import { highlightText, IconPart } from '../utils';
Expand All @@ -29,7 +29,7 @@ const getPresetsForSearch = async () => {
await fetchSchemaTranslations();

// resolve symlinks to {landuse...} etc
presetsForSearch = Object.values(presets)
presetsForSearch = Object.values(allPresets)
.filter(({ searchable }) => searchable === undefined || searchable)
.filter(({ locationSet }) => !locationSet?.include)
.filter(({ tags }) => Object.keys(tags).length > 0)
Expand Down
Loading

0 comments on commit 402a375

Please sign in to comment.