feat(map-plugin): map block support select map field of association tables (#2214)

* feat(map-plugin): map block support select map field of association tables

* fix: update incorrect default value

* fix: should support o2m and fix bugs

* fix: height Close T1185 Close 1183

* fix: o2m, m2m cannot display data

* fix: switch map field will break
This commit is contained in:
Dunqing 2023-08-10 17:36:02 +08:00 committed by GitHub
parent fa43d9c870
commit b7d23c408a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 202 additions and 142 deletions

View File

@ -728,6 +728,7 @@ SchemaSettings.CascaderItem = (props: CascaderProps<any> & { title: any }) => {
onChange={onChange as any}
options={options}
style={{ textAlign: 'right', minWidth: 100 }}
{...props}
/>
</div>
</SchemaSettings.Item>

View File

@ -1,11 +1,15 @@
import React, { useState } from 'react';
import { useCollection, useCollectionManager, useProps } from '@nocobase/client';
import React, { useMemo } from 'react';
import { MapBlockComponent } from '../components';
import { useCollection, useProps } from '@nocobase/client';
export const MapBlock = (props) => {
const { fieldNames } = useProps(props);
const { getField } = useCollection();
const field = getField(fieldNames?.field);
const fieldComponentProps = field?.uiSchema?.['x-component-props'];
return <MapBlockComponent {...fieldComponentProps} {...props} />;
const { getCollectionJoinField } = useCollectionManager();
const { name } = useCollection();
const collectionField = useMemo(() => {
return getCollectionJoinField([name, fieldNames?.field].flat().join('.'));
}, [name, fieldNames?.field]);
const fieldComponentProps = collectionField?.uiSchema?.['x-component-props'];
return <MapBlockComponent {...fieldComponentProps} {...props} collectionField={collectionField} />;
};

View File

@ -13,9 +13,9 @@ import {
} from '@nocobase/client';
import lodash from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useMapTranslation } from '../locale';
import { useMapBlockContext } from './MapBlockProvider';
import { findNestedOption } from './utils';
export const MapBlockDesigner = () => {
const { name, title } = useCollection();
@ -23,8 +23,7 @@ export const MapBlockDesigner = () => {
const fieldSchema = useFieldSchema();
const dataSource = useCollectionFilterOptions(name);
const { service } = useMapBlockContext();
const { t: mapT } = useMapTranslation();
const { t } = useTranslation();
const { t } = useMapTranslation();
const { dn } = useDesignable();
const { getCollectionFieldsOptions } = useCollectionManager();
const collection = useCollection();
@ -35,17 +34,21 @@ export const MapBlockDesigner = () => {
const template = useSchemaTemplate();
const mapFieldOptions = getCollectionFieldsOptions(collection?.name, ['point', 'lineString', 'polygon']);
const mapFieldOptions = getCollectionFieldsOptions(collection?.name, ['point', 'lineString', 'polygon'], {
association: ['o2o', 'obo', 'oho', 'o2m', 'm2o', 'm2m'],
});
const markerFieldOptions = getCollectionFieldsOptions(collection?.name, 'string');
const isPointField = findNestedOption(fieldNames.field, mapFieldOptions)?.type === 'point';
return (
<GeneralSchemaDesigner template={template} title={title || name}>
<SchemaSettings.BlockTitleItem />
<FixedBlockDesignerItem />
<SchemaSettings.SelectItem
title={mapT('Map field')}
<SchemaSettings.CascaderItem
title={t('Map field')}
value={fieldNames.field}
options={mapFieldOptions}
allowClear={false}
onChange={(v) => {
const fieldNames = field.decoratorProps.fieldNames || {};
fieldNames['field'] = v;
@ -61,8 +64,9 @@ export const MapBlockDesigner = () => {
dn.refresh();
}}
/>
{isPointField ? (
<SchemaSettings.SelectItem
title={mapT('Marker field')}
title={t('Marker field')}
value={fieldNames.marker}
options={markerFieldOptions}
onChange={(v) => {
@ -80,15 +84,16 @@ export const MapBlockDesigner = () => {
dn.refresh();
}}
/>
) : null}
<SchemaSettings.ModalItem
title={mapT('The default zoom level of the map')}
title={t('The default zoom level of the map')}
schema={
{
type: 'object',
title: mapT('Set default zoom level'),
title: t('Set default zoom level'),
properties: {
zoom: {
title: mapT('Zoom'),
title: t('Zoom'),
default: defaultZoom,
'x-component': 'InputNumber',
'x-decorator': 'FormItem',

View File

@ -11,7 +11,7 @@ import {
} from '@nocobase/client';
import React, { useContext } from 'react';
import { useMapTranslation } from '../locale';
import { createMapBlockSchema } from './utils';
import { createMapBlockSchema, findNestedOption } from './utils';
export const MapBlockInitializer = (props) => {
const { insert } = props;
@ -26,7 +26,9 @@ export const MapBlockInitializer = (props) => {
componentType={'Map'}
icon={<TableOutlined />}
onCreateBlockSchema={async ({ item }) => {
const mapFieldOptions = getCollectionFieldsOptions(item.name, ['point', 'lineString', 'polygon']);
const mapFieldOptions = getCollectionFieldsOptions(item.name, ['point', 'lineString', 'polygon'], {
association: ['o2o', 'obo', 'oho', 'o2m', 'm2o', 'm2m'],
});
const markerFieldOptions = getCollectionFieldsOptions(item.name, 'string');
const values = await FormDialog(
t('Create map block'),
@ -41,9 +43,13 @@ export const MapBlockInitializer = (props) => {
title: t('Map field'),
enum: mapFieldOptions,
required: true,
'x-component': 'Select',
'x-component': 'Cascader',
'x-decorator': 'FormItem',
default: mapFieldOptions[0]?.value,
default: mapFieldOptions.length
? [mapFieldOptions[0].value, mapFieldOptions[0].children?.[0].value].filter(
(v) => v !== undefined && v !== null,
)
: [],
},
marker: {
title: t('Marker field'),
@ -52,17 +58,14 @@ export const MapBlockInitializer = (props) => {
'x-decorator': 'FormItem',
'x-reactions': (field) => {
const value = field.form.values.field;
console.log('🚀 ~ file: MapBlockInitializer.tsx:45 ~ values ~ value:', value);
console.log(
'🚀 ~ file: MapBlockInitializer.tsx:50 ~ values ~ mapFieldOptions:',
mapFieldOptions,
);
if (!value) {
if (!value?.length) {
return;
}
const item = mapFieldOptions.find((item) => item.value === value).type;
field.hidden = item !== 'point';
const item = findNestedOption(value, mapFieldOptions);
if (item) {
field.hidden = item.type !== 'point';
}
},
},
},

View File

@ -33,8 +33,14 @@ const InternalMapBlockProvider = (props) => {
};
export const MapBlockProvider = (props) => {
const { params, fieldNames } = props;
const appends = params.appends || [];
const { field } = fieldNames || {};
if (Array.isArray(field) && field.length > 1) {
appends.push(field[0]);
}
return (
<BlockProvider {...props} params={{ ...props.params, paginate: false }}>
<BlockProvider {...props} params={{ ...params, appends, paginate: false }}>
<InternalMapBlockProvider {...props} />
</BlockProvider>
);

View File

@ -79,3 +79,13 @@ export const createMapBlockSchema = (options) => {
};
return schema;
};
export const findNestedOption = (value: string[] | string, options = []) => {
if (typeof value === 'string') {
value = [value];
}
return value?.reduce((cur, v, index) => {
const matched = cur?.find((item) => item.value === v);
return index === value.length - 1 ? matched : matched?.children;
}, options);
};

View File

@ -2,9 +2,10 @@ import { CheckOutlined, EnvironmentOutlined, ExpandOutlined } from '@ant-design/
import { RecursionField, Schema, useFieldSchema } from '@formily/react';
import {
ActionContextProvider,
css,
RecordProvider,
css,
useCollection,
useCollectionManager,
useCompile,
useFilterAPI,
useProps,
@ -15,11 +16,13 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { defaultImage, selectedImage } from '../../constants';
import { useMapTranslation } from '../../locale';
import { AMapComponent, AMapForwardedRefProps } from './Map';
import { getSource } from '../../utils';
export const AMapBlock = (props) => {
const { fieldNames, dataSource = [], fixedBlock, zoom, setSelectedRecordKeys } = useProps(props);
const { getField, getPrimaryKey } = useCollection();
const field = getField(fieldNames?.field);
const { collectionField, fieldNames, dataSource, fixedBlock, zoom, setSelectedRecordKeys } = useProps(props);
const { name, getPrimaryKey } = useCollection();
const { getCollectionJoinField } = useCollectionManager();
const primaryKey = getPrimaryKey();
const [isMapInitialization, setIsMapInitialization] = useState(false);
const mapRef = useRef<AMapForwardedRefProps>();
const geometryUtils: AMap.IGeometryUtil = mapRef.current?.aMap?.GeometryUtil;
@ -28,7 +31,7 @@ export const AMapBlock = (props) => {
const { t } = useMapTranslation();
const compile = useCompile();
const { isConnected, doFilter } = useFilterAPI();
const [, setPrevSelected] = useState(null);
const [, setPrevSelected] = useState<any>(null);
const selectingModeRef = useRef(selectingMode);
selectingModeRef.current = selectingMode;
@ -38,7 +41,7 @@ export const AMapBlock = (props) => {
extData.selected = !selected;
if ('setIcon' in overlay) {
overlay.setIcon(
new mapRef.current.aMap.Icon({
new mapRef.current!.aMap.Icon({
imageSize: [19, 32],
image: selected ? defaultImage : selectedImage,
} as AMap.IconOpts),
@ -54,9 +57,9 @@ export const AMapBlock = (props) => {
const removeSelection = () => {
if (!mapRef.current) return;
mapRef.current.mouseTool().close(true);
mapRef.current.editor().setTarget(null);
mapRef.current.editor().close();
mapRef.current?.mouseTool().close(true);
mapRef.current?.editor().setTarget(null);
mapRef.current?.editor().close();
};
// selection
@ -64,11 +67,11 @@ export const AMapBlock = (props) => {
if (selectingMode !== 'selection') {
return;
}
if (!mapRef.current.editor()) {
mapRef.current.createEditor('polygon');
mapRef.current.createMouseTool('polygon');
if (!mapRef.current?.editor()) {
mapRef.current?.createEditor('polygon');
mapRef.current?.createMouseTool('polygon');
} else {
mapRef.current.executeMouseTool('polygon');
mapRef.current?.executeMouseTool('polygon');
}
return () => {
removeSelection();
@ -79,7 +82,7 @@ export const AMapBlock = (props) => {
if (selectingMode) {
return () => {
if (!selectingModeRef.current) {
mapRef.current.map.getAllOverlays().forEach((o) => {
mapRef.current?.map.getAllOverlays().forEach((o) => {
setOverlayOptions(o, false);
});
}
@ -88,31 +91,37 @@ export const AMapBlock = (props) => {
}, [selectingMode]);
const onSelectingComplete = useMemoizedFn(() => {
const selectingOverlay = mapRef.current.editor().getTarget();
const overlays = mapRef.current.map.getAllOverlays();
const selectedOverlays = overlays.filter((o) => {
const selectingOverlay = mapRef.current?.editor().getTarget();
const overlays = mapRef.current?.map.getAllOverlays();
const selectedOverlays = overlays?.filter((o) => {
if (o === selectingOverlay || o.getExtData().id === undefined) return;
if ('getPosition' in o) {
return geometryUtils.isPointInRing(o.getPosition(), selectingOverlay.getPath() as any);
return geometryUtils.isPointInRing(o.getPosition(), selectingOverlay?.getPath() as any);
}
return geometryUtils.doesRingRingIntersect(o.getPath(), selectingOverlay.getPath() as any);
return geometryUtils.doesRingRingIntersect(o.getPath(), selectingOverlay?.getPath() as any);
});
const ids = selectedOverlays.map((o) => {
const ids = selectedOverlays?.map((o) => {
setOverlayOptions(o, true);
return o.getExtData().id;
});
setSelectedRecordKeys((lastIds) => ids.concat(lastIds));
selectingOverlay.remove();
mapRef.current.editor().close();
setSelectedRecordKeys((lastIds) => ids?.concat(lastIds));
selectingOverlay?.remove();
mapRef.current?.editor().close();
});
useEffect(() => {
if (!field || !mapRef.current) return;
if (!collectionField || !mapRef.current || !dataSource) return;
const fieldPaths =
Array.isArray(fieldNames?.field) && fieldNames?.field.length > 1
? fieldNames?.field.slice(0, -1)
: fieldNames?.field;
const cf = getCollectionJoinField([name, ...fieldPaths].flat().join('.'));
const overlays = dataSource
.map((item) => {
const data = item[fieldNames?.field];
if (!data) return;
const overlay = mapRef.current.setOverlay(field.type, data, {
const data = getSource(item, fieldNames?.field, cf?.interface);
if (!data?.length) return [];
return data?.filter(Boolean).map((mapItem) => {
const overlay = mapRef.current?.setOverlay(collectionField.type, mapItem, {
strokeColor: '#4e9bff',
fillColor: '#4e9bff',
cursor: 'pointer',
@ -122,13 +131,14 @@ export const AMapBlock = (props) => {
content: fieldNames?.marker ? compile(item[fieldNames.marker]) : undefined,
},
extData: {
id: item[getPrimaryKey()],
id: item[primaryKey],
},
});
return overlay;
});
})
.filter(Boolean);
mapRef.current.map?.setFitView(overlays);
.flat();
mapRef.current?.map?.setFitView(overlays);
const events = overlays.map((o: AMap.Marker) => {
const onClick = (e) => {
@ -144,8 +154,8 @@ export const AMapBlock = (props) => {
}
return;
}
const data = dataSource?.find((item) => {
return extData.id === item[getPrimaryKey()];
const data = dataSource.find((item) => {
return extData.id === item[primaryKey];
});
// 筛选区块模式
@ -160,7 +170,7 @@ export const AMapBlock = (props) => {
return null;
} else {
selectMarker(o);
doFilter(data[getPrimaryKey()], (target) => target.field || getPrimaryKey(), '$eq');
doFilter(data[primaryKey], (target) => target.field || primaryKey, '$eq');
}
return o;
});
@ -182,7 +192,7 @@ export const AMapBlock = (props) => {
});
events.forEach((e) => e());
};
}, [dataSource, isMapInitialization, fieldNames, field.type, isConnected]);
}, [dataSource, isMapInitialization, fieldNames, name, primaryKey, collectionField.type, isConnected]);
useEffect(() => {
setTimeout(() => {
@ -210,7 +220,7 @@ export const AMapBlock = (props) => {
z-index: 999;
`}
>
{isMapInitialization && !mapRef.current.errMessage ? (
{isMapInitialization && !mapRef.current?.errMessage ? (
<Space direction="vertical">
<Button
style={{
@ -247,7 +257,7 @@ export const AMapBlock = (props) => {
</div>
<MapBlockDrawer record={record} setVisible={setRecord} />
<AMapComponent
{...field?.uiSchema?.['x-component-props']}
{...collectionField?.uiSchema?.['x-component-props']}
ref={mapRefCallback}
style={{ height: fixedBlock ? '100%' : null }}
zoom={zoom}
@ -265,7 +275,7 @@ export const AMapBlock = (props) => {
const MapBlockDrawer = (props) => {
const { setVisible, record } = props;
const fieldSchema = useFieldSchema();
const schema: Schema = useMemo(
const schema = useMemo(
() =>
fieldSchema.reduceProperties((buf, current) => {
if (current.name === 'drawer') {

View File

@ -2,9 +2,10 @@ import { CheckOutlined, EnvironmentOutlined, ExpandOutlined } from '@ant-design/
import { RecursionField, Schema, useFieldSchema } from '@formily/react';
import {
ActionContextProvider,
css,
RecordProvider,
css,
useCollection,
useCollectionManager,
useCompile,
useFilterAPI,
useProps,
@ -16,6 +17,7 @@ import { defaultImage, selectedImage } from '../../constants';
import { useMapTranslation } from '../../locale';
import { GoogleMapForwardedRefProps, GoogleMapsComponent, OverlayOptions } from './Map';
import { getIcon } from './utils';
import { getSource } from '../../utils';
const OVERLAY_KEY = 'google-maps-overlay-id';
const OVERLAY_SELECtED = 'google-maps-overlay-selected';
@ -28,13 +30,10 @@ const labelClass = css`
`;
export const GoogleMapsBlock = (props) => {
const { fieldNames, dataSource = [], fixedBlock, zoom, setSelectedRecordKeys } = useProps(props);
const { getField, getPrimaryKey } = useCollection();
const { marker: markerName, field: fieldName } = fieldNames || {
marker: 'id',
field: 'id',
};
const field = getField(fieldName);
const { collectionField, fieldNames, dataSource, fixedBlock, zoom, setSelectedRecordKeys } = useProps(props);
const { getPrimaryKey } = useCollection();
const primaryKey = getPrimaryKey();
const { marker: markerName = 'id' } = fieldNames;
const [isMapInitialization, setIsMapInitialization] = useState(false);
const mapRef = useRef<GoogleMapForwardedRefProps>();
const [record, setRecord] = useState();
@ -42,12 +41,14 @@ export const GoogleMapsBlock = (props) => {
const { t } = useMapTranslation();
const compile = useCompile();
const { isConnected, doFilter } = useFilterAPI();
const [, setPrevSelected] = useState(null);
const [, setPrevSelected] = useState<any>(null);
const selectingModeRef = useRef(selectingMode);
const selectionOverlayRef = useRef<google.maps.Polygon>();
const selectionOverlayRef = useRef<google.maps.Polygon | null>(null);
const overlaysRef = useRef<google.maps.MVCObject[]>([]);
selectingModeRef.current = selectingMode;
const { getCollectionJoinField } = useCollectionManager();
const setOverlayOptions = (overlay: google.maps.MVCObject, state?: boolean) => {
const selected = typeof state !== 'undefined' ? !state : overlay.get(OVERLAY_SELECtED);
overlay.set(OVERLAY_SELECtED, !selected);
@ -71,17 +72,17 @@ export const GoogleMapsBlock = (props) => {
if (selectingMode !== 'selection') {
return;
}
if (!mapRef.current.drawingManager) {
mapRef.current.drawingManager = mapRef.current.createDraw(true, {
if (mapRef.current && !mapRef.current?.drawingManager) {
mapRef.current.drawingManager = mapRef.current?.createDraw(true, {
editable: true,
draggable: true,
});
}
const listenerSet = new Set<() => void>();
mapRef.current.drawingManager.setDrawingMode(google.maps.drawing.OverlayType.POLYGON);
mapRef.current.drawingManager.addListener('overlaycomplete', (event) => {
mapRef.current?.drawingManager.setDrawingMode(google.maps.drawing.OverlayType.POLYGON);
mapRef.current?.drawingManager.addListener('overlaycomplete', (event) => {
const polygon = event.overlay as google.maps.Polygon;
mapRef.current.drawingManager.setDrawingMode(null);
mapRef.current?.drawingManager.setDrawingMode(null);
selectionOverlayRef.current = polygon;
const path = polygon.getPath();
['insert_at', 'remove_at', 'set_at'].forEach((key) => {
@ -96,8 +97,8 @@ export const GoogleMapsBlock = (props) => {
selectionOverlayRef.current?.unbindAll();
selectionOverlayRef.current?.setMap(null);
selectionOverlayRef.current = null;
mapRef.current.drawingManager.setDrawingMode(null);
mapRef.current.drawingManager.unbindAll();
mapRef.current?.drawingManager.setDrawingMode(null);
mapRef.current?.drawingManager.unbindAll();
};
}, [selectingMode]);
@ -120,15 +121,15 @@ export const GoogleMapsBlock = (props) => {
const selectedOverlays = overlays.filter((o) => {
if (o === overlay || o.get(OVERLAY_KEY) === undefined) return;
if (o instanceof google.maps.Marker) {
return poly.containsLocation(o.getPosition(), overlay);
return poly.containsLocation(o.getPosition()!, overlay!);
} else if (o instanceof google.maps.Circle) {
return poly.containsLocation(o.getCenter(), overlay);
return poly.containsLocation(o.getCenter()!, overlay!);
} else {
return (o as google.maps.Polygon)
.getPath()
.getArray()
.some((position) => {
return poly.containsLocation(position, overlay);
return poly.containsLocation(position, overlay!);
});
}
});
@ -137,18 +138,26 @@ export const GoogleMapsBlock = (props) => {
return o.get(OVERLAY_KEY);
});
setSelectedRecordKeys((lastIds) => ids.concat(lastIds));
overlay.unbindAll();
overlay.setMap(null);
mapRef.current.drawingManager.setDrawingMode(google.maps.drawing.OverlayType.POLYGON);
overlay?.unbindAll();
overlay?.setMap(null);
mapRef.current?.drawingManager.setDrawingMode(google.maps.drawing.OverlayType.POLYGON);
});
useEffect(() => {
if (!field || !mapRef.current?.map) return;
if (!collectionField || !dataSource?.length || !mapRef.current?.map) return;
const fieldPaths =
Array.isArray(fieldNames?.field) && fieldNames?.field.length > 1
? fieldNames?.field.slice(0, -1)
: fieldNames?.field;
const cf = getCollectionJoinField([name, ...fieldPaths].flat().join('.'));
const overlays: google.maps.Polygon[] = dataSource
.map((item) => {
const data = item[fieldNames?.field];
const data = getSource(item, fieldNames?.field, cf?.interface);
if (!data?.length) return [];
return data?.filter(Boolean).map((mapItem) => {
if (!data) return;
const overlay = mapRef.current.setOverlay(field.type, data, {
const overlay = mapRef.current?.setOverlay(collectionField.type, mapItem, {
strokeColor: '#4e9bff',
fillColor: '#4e9bff',
cursor: 'pointer',
@ -160,13 +169,14 @@ export const GoogleMapsBlock = (props) => {
text: fieldNames?.marker ? compile(item[markerName]) : undefined,
} as google.maps.MarkerLabel,
});
overlay.set(OVERLAY_KEY, item[getPrimaryKey()]);
overlay?.set(OVERLAY_KEY, item[primaryKey]);
return overlay;
});
})
.filter(Boolean);
.flat();
overlaysRef.current = overlays;
mapRef.current.setFitView(overlays);
mapRef.current?.setFitView(overlays);
const events = overlays.map((o: google.maps.MVCObject) => {
const onClick = (event) => {
@ -175,7 +185,7 @@ export const GoogleMapsBlock = (props) => {
if (!id) return;
const data = dataSource?.find((item) => {
return id === item[getPrimaryKey()];
return id === item[primaryKey];
});
// 筛选区块模式
@ -190,7 +200,7 @@ export const GoogleMapsBlock = (props) => {
return null;
} else {
selectMarker(overlay);
doFilter(data[getPrimaryKey()], (target) => target.field || getPrimaryKey(), '$eq');
doFilter(data[primaryKey], (target) => target.field || primaryKey, '$eq');
}
return overlay;
});
@ -213,7 +223,7 @@ export const GoogleMapsBlock = (props) => {
});
events.forEach((e) => e());
};
}, [dataSource, isMapInitialization, markerName, field.type, isConnected]);
}, [dataSource, isMapInitialization, markerName, collectionField.type, isConnected]);
useEffect(() => {
setTimeout(() => {

View File

@ -11,9 +11,11 @@ export function lang(key: string) {
}
export function generateNTemplate(key: string) {
return `{{t('${key}', { ns: '${NAMESPACE}' })}}`;
return `{{t('${key}', { ns: '${NAMESPACE}', nsMode: 'fallback' })}}`;
}
export function useMapTranslation() {
return useTranslation(NAMESPACE);
return useTranslation(NAMESPACE, {
nsMode: 'fallback',
});
}

View File

@ -0,0 +1,9 @@
export const getSource = (data: Record<string, any>, fields?: string[], type?: string) => {
const res = fields?.reduce((obj, field, index) => {
if (index === fields.length - 1 && (type === 'o2m' || type === 'm2m')) {
return obj?.map((item) => item[field]).filter((v) => v !== null && v !== undefined);
}
return obj?.[field];
}, data);
return type === 'o2m' || type === 'm2m' ? res : [res];
};