Skip to content

Commit

Permalink
Merge branch 'master' into kudlav-save_refused
Browse files Browse the repository at this point in the history
  • Loading branch information
zbycz authored Oct 17, 2024
2 parents 8f38058 + 7cf1b9c commit 20ae8f5
Show file tree
Hide file tree
Showing 22 changed files with 516 additions and 159 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>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import AddIcon from '@mui/icons-material/Add';
import { majorKeys } from '../MajorKeysEditor';
import { isString } from '../../../../helpers';
import { t, Translation } from '../../../../../services/intl';
import { useEditContext } from '../../EditContext';
import { TagsEntries, useEditContext } from '../../EditContext';
import { useEditDialogContext } from '../../../helpers/EditDialogContext';
import { KeyInput } from './KeyInput';
import { ValueInput } from './ValueInput';
Expand Down Expand Up @@ -68,10 +68,14 @@ const TagsEditorInfo = () => (
</tr>
);

const lastKeyAndValueSet = (tagsEntries: TagsEntries) => {
const [lastKey, lastValue] = tagsEntries[tagsEntries.length - 1];
return lastKey && lastValue;
};

const AddButton = () => {
const { tagsEntries, setTagsEntries } = useEditContext().tags;
const [lastKey, lastValue] = tagsEntries[tagsEntries.length - 1];
const active = tagsEntries.length === 0 || (lastKey && lastValue);
const active = tagsEntries.length === 0 || lastKeyAndValueSet(tagsEntries);

return (
<tr>
Expand Down
Loading

0 comments on commit 20ae8f5

Please sign in to comment.