mirror of https://github.com/nocodb/nocodb
Wing-Kam Wong
2 years ago
645 changed files with 47328 additions and 27866 deletions
@ -0,0 +1,155 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import type { GeoLocationType } from 'nocodb-sdk' |
||||||
|
import { Modal as AModal, latLongToJoinedString, useVModel } from '#imports' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
modelValue?: string | null |
||||||
|
} |
||||||
|
|
||||||
|
interface Emits { |
||||||
|
(event: 'update:modelValue', model: GeoLocationType): void |
||||||
|
} |
||||||
|
|
||||||
|
const props = defineProps<Props>() |
||||||
|
|
||||||
|
const emits = defineEmits<Emits>() |
||||||
|
|
||||||
|
const vModel = useVModel(props, 'modelValue', emits) |
||||||
|
|
||||||
|
let isExpanded = $ref(false) |
||||||
|
|
||||||
|
let isLoading = $ref(false) |
||||||
|
|
||||||
|
let isLocationSet = $ref(false) |
||||||
|
|
||||||
|
const [latitude, longitude] = (vModel.value || '').split(';') |
||||||
|
|
||||||
|
const latLongStr = computed(() => { |
||||||
|
const [latitude, longitude] = (vModel.value || '').split(';') |
||||||
|
if (latitude) isLocationSet = true |
||||||
|
return latitude && longitude ? `${latitude}; ${longitude}` : 'Set location' |
||||||
|
}) |
||||||
|
|
||||||
|
const formState = reactive({ |
||||||
|
latitude, |
||||||
|
longitude, |
||||||
|
}) |
||||||
|
|
||||||
|
const handleFinish = () => { |
||||||
|
vModel.value = latLongToJoinedString(parseFloat(formState.latitude), parseFloat(formState.longitude)) |
||||||
|
isExpanded = false |
||||||
|
} |
||||||
|
|
||||||
|
const clear = () => { |
||||||
|
isExpanded = false |
||||||
|
|
||||||
|
formState.latitude = latitude |
||||||
|
formState.longitude = longitude |
||||||
|
} |
||||||
|
|
||||||
|
const onClickSetCurrentLocation = () => { |
||||||
|
isLoading = true |
||||||
|
const onSuccess = (position) => { |
||||||
|
const crd = position.coords |
||||||
|
formState.latitude = crd.latitude |
||||||
|
formState.longitude = crd.longitude |
||||||
|
isLoading = false |
||||||
|
} |
||||||
|
|
||||||
|
const onError = (err) => { |
||||||
|
console.error(`ERROR(${err.code}): ${err.message}`) |
||||||
|
isLoading = false |
||||||
|
} |
||||||
|
|
||||||
|
const options = { |
||||||
|
enableHighAccuracy: true, |
||||||
|
timeout: 20000, |
||||||
|
maximumAge: 2000, |
||||||
|
} |
||||||
|
navigator.geolocation.getCurrentPosition(onSuccess, onError, options) |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<a-dropdown :is="isExpanded ? AModal : 'div'" v-model:visible="isExpanded" trigger="click"> |
||||||
|
<div |
||||||
|
v-if="!isLocationSet" |
||||||
|
class="group cursor-pointer flex gap-1 items-center mx-auto max-w-32 justify-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)" |
||||||
|
> |
||||||
|
<div class="flex items-center gap-2" data-testid="nc-geo-data-set-location-button"> |
||||||
|
<MdiMapMarker class="transform dark:(!text-white) group-hover:(!text-accent scale-120) text-gray-500 text-[0.75rem]" /> |
||||||
|
<div class="group-hover:text-primary text-gray-500 dark:text-gray-200 dark:group-hover:!text-white text-xs"> |
||||||
|
{{ latLongStr }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div v-else data-testid="nc-geo-data-lat-long-set">{{ latLongStr }}</div> |
||||||
|
<template #overlay> |
||||||
|
<a-form :model="formState" class="flex flex-col" @finish="handleFinish"> |
||||||
|
<a-form-item> |
||||||
|
<div class="flex mt-4 items-center mx-2"> |
||||||
|
<div class="mr-2">{{ $t('labels.lat') }}:</div> |
||||||
|
<a-input |
||||||
|
v-model:value="formState.latitude" |
||||||
|
data-testid="nc-geo-data-latitude" |
||||||
|
type="number" |
||||||
|
step="0.0000001" |
||||||
|
:min="-90" |
||||||
|
required |
||||||
|
:max="90" |
||||||
|
@keydown.stop |
||||||
|
@selectstart.capture.stop |
||||||
|
@mousedown.stop |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</a-form-item> |
||||||
|
|
||||||
|
<a-form-item> |
||||||
|
<div class="flex items-center mx-2"> |
||||||
|
<div class="mr-2">{{ $t('labels.lng') }}:</div> |
||||||
|
<a-input |
||||||
|
v-model:value="formState.longitude" |
||||||
|
data-testid="nc-geo-data-longitude" |
||||||
|
type="number" |
||||||
|
step="0.0000001" |
||||||
|
required |
||||||
|
:min="-180" |
||||||
|
:max="180" |
||||||
|
@keydown.stop |
||||||
|
@selectstart.capture.stop |
||||||
|
@mousedown.stop |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</a-form-item> |
||||||
|
<a-form-item> |
||||||
|
<div class="flex items-center mr-2"> |
||||||
|
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin text-gray-500': isLoading }" /> |
||||||
|
<a-button class="ml-2" @click="onClickSetCurrentLocation">{{ $t('labels.yourLocation') }}</a-button> |
||||||
|
</div> |
||||||
|
</a-form-item> |
||||||
|
<a-form-item> |
||||||
|
<div class="ml-auto mr-2"> |
||||||
|
<a-button type="text" @click="clear">{{ $t('general.cancel') }}</a-button> |
||||||
|
<a-button type="primary" html-type="submit" data-testid="nc-geo-data-save">{{ $t('general.submit') }}</a-button> |
||||||
|
</div> |
||||||
|
</a-form-item> |
||||||
|
</a-form> |
||||||
|
</template> |
||||||
|
</a-dropdown> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped lang="scss"> |
||||||
|
input[type='number']:focus { |
||||||
|
@apply ring-transparent; |
||||||
|
} |
||||||
|
|
||||||
|
input[type='number'] { |
||||||
|
width: 180px; |
||||||
|
} |
||||||
|
.ant-form-item { |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
.ant-dropdown-menu { |
||||||
|
align-items: flex-end; |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,57 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { isMac } from '#imports' |
||||||
|
|
||||||
|
const props = defineProps<{ |
||||||
|
keys: string[] |
||||||
|
}>() |
||||||
|
|
||||||
|
const isMacOs = isMac() |
||||||
|
|
||||||
|
const getLabel = (key: string) => { |
||||||
|
if (isMacOs) { |
||||||
|
switch (key.toLowerCase()) { |
||||||
|
case 'alt': |
||||||
|
return '⌥' |
||||||
|
case 'shift': |
||||||
|
return '⇧' |
||||||
|
case 'meta': |
||||||
|
return '⌘' |
||||||
|
case 'control': |
||||||
|
case 'ctrl': |
||||||
|
return '⌃' |
||||||
|
case 'enter': |
||||||
|
return '↩' |
||||||
|
} |
||||||
|
} |
||||||
|
switch (key.toLowerCase()) { |
||||||
|
case 'arrowup': |
||||||
|
return '↑' |
||||||
|
case 'arrowdown': |
||||||
|
return '↓' |
||||||
|
case 'arrowleft': |
||||||
|
return '←' |
||||||
|
case 'arrowright': |
||||||
|
return '→' |
||||||
|
} |
||||||
|
|
||||||
|
return key |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="nc-shortcut-label-wrapper"> |
||||||
|
<div v-for="(key, index) in props.keys" :key="index" class="nc-shortcut-label"> |
||||||
|
<span>{{ getLabel(key) }}</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped> |
||||||
|
.nc-shortcut-label-wrapper { |
||||||
|
@apply flex gap-1; |
||||||
|
} |
||||||
|
|
||||||
|
.nc-shortcut-label { |
||||||
|
@apply text-[0.7rem] leading-6 min-w-5 min-h-5 text-center relative z-0 after:(content-[''] left-0 top-0 -z-1 bg-current opacity-10 absolute w-full h-full rounded) px-1; |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,42 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { |
||||||
|
ActiveViewInj, |
||||||
|
FieldsInj, |
||||||
|
IsPublicInj, |
||||||
|
MetaInj, |
||||||
|
ReadonlyInj, |
||||||
|
ReloadViewDataHookInj, |
||||||
|
useProvideMapViewStore, |
||||||
|
} from '#imports' |
||||||
|
|
||||||
|
const { sharedView, meta, sorts, nestedFilters } = useSharedView() |
||||||
|
|
||||||
|
const reloadEventHook = createEventHook() |
||||||
|
|
||||||
|
provide(ReloadViewDataHookInj, reloadEventHook) |
||||||
|
|
||||||
|
provide(ReadonlyInj, ref(true)) |
||||||
|
|
||||||
|
provide(MetaInj, meta) |
||||||
|
|
||||||
|
provide(ActiveViewInj, sharedView) |
||||||
|
|
||||||
|
provide(FieldsInj, ref(meta.value?.columns || [])) |
||||||
|
|
||||||
|
provide(IsPublicInj, ref(true)) |
||||||
|
|
||||||
|
useProvideSmartsheetStore(sharedView, meta, true, sorts, nestedFilters) |
||||||
|
|
||||||
|
useProvideMapViewStore(meta, sharedView, true) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="nc-container h-full mt-1.5 px-12"> |
||||||
|
<div class="flex flex-col h-full flex-1 min-w-0"> |
||||||
|
<LazySmartsheetToolbar /> |
||||||
|
<div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50"> |
||||||
|
<LazySmartsheetMap /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
@ -0,0 +1,266 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import 'leaflet/dist/leaflet.css' |
||||||
|
import L, { LatLng } from 'leaflet' |
||||||
|
import 'leaflet.markercluster' |
||||||
|
import { ViewTypes } from 'nocodb-sdk' |
||||||
|
import { IsPublicInj, OpenNewRecordFormHookInj, latLongToJoinedString, onMounted, provide, ref } from '#imports' |
||||||
|
import type { Row as RowType } from '~/lib' |
||||||
|
|
||||||
|
const route = useRoute() |
||||||
|
|
||||||
|
const router = useRouter() |
||||||
|
|
||||||
|
const reloadViewDataHook = inject(ReloadViewDataHookInj) |
||||||
|
|
||||||
|
const reloadViewMetaHook = inject(ReloadViewMetaHookInj) |
||||||
|
|
||||||
|
const { formattedData, loadMapData, loadMapMeta, mapMetaData, geoDataFieldColumn, addEmptyRow, paginationData } = |
||||||
|
useMapViewStoreOrThrow() |
||||||
|
|
||||||
|
const markersClusterGroupRef = ref<L.MarkerClusterGroup>() |
||||||
|
|
||||||
|
const mapContainerRef = ref<HTMLElement>() |
||||||
|
|
||||||
|
const myMapRef = ref<L.Map>() |
||||||
|
|
||||||
|
const isPublic = inject(IsPublicInj, ref(false)) |
||||||
|
|
||||||
|
const meta = inject(MetaInj, ref()) |
||||||
|
|
||||||
|
const view = inject(ActiveViewInj, ref()) |
||||||
|
|
||||||
|
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj, createEventHook()) |
||||||
|
|
||||||
|
const expandedFormDlg = ref(false) |
||||||
|
|
||||||
|
const expandedFormRow = ref<RowType>() |
||||||
|
|
||||||
|
const expandedFormRowState = ref<Record<string, any>>() |
||||||
|
|
||||||
|
const fallBackCenterLocation = { |
||||||
|
lat: 51, |
||||||
|
lng: 0.0, |
||||||
|
} |
||||||
|
|
||||||
|
const getMapZoomLocalStorageKey = (viewId: string) => { |
||||||
|
return `mapView.${viewId}.zoom` |
||||||
|
} |
||||||
|
const getMapCenterLocalStorageKey = (viewId: string) => `mapView.${viewId}.center` |
||||||
|
|
||||||
|
const expandForm = (row: RowType, state?: Record<string, any>) => { |
||||||
|
const rowId = extractPkFromRow(row.row, meta.value!.columns!) |
||||||
|
if (rowId) { |
||||||
|
router.push({ |
||||||
|
query: { |
||||||
|
...route.query, |
||||||
|
rowId, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} else { |
||||||
|
expandedFormRow.value = row |
||||||
|
expandedFormRowState.value = state |
||||||
|
expandedFormDlg.value = true |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
openNewRecordFormHook?.on(async () => { |
||||||
|
const newRow = await addEmptyRow() |
||||||
|
expandForm(newRow) |
||||||
|
}) |
||||||
|
|
||||||
|
const expandedFormOnRowIdDlg = computed({ |
||||||
|
get() { |
||||||
|
return !!route.query.rowId |
||||||
|
}, |
||||||
|
set(val) { |
||||||
|
if (!val) |
||||||
|
router.push({ |
||||||
|
query: { |
||||||
|
...route.query, |
||||||
|
rowId: undefined, |
||||||
|
}, |
||||||
|
}) |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const addMarker = (lat: number, long: number, row: RowType) => { |
||||||
|
if (markersClusterGroupRef.value == null) { |
||||||
|
throw new Error('Marker cluster is null') |
||||||
|
} |
||||||
|
const newMarker = L.marker([lat, long], { |
||||||
|
alt: `${lat}, ${long}`, |
||||||
|
}).on('click', () => { |
||||||
|
expandForm(row) |
||||||
|
}) |
||||||
|
markersClusterGroupRef.value?.addLayer(newMarker) |
||||||
|
} |
||||||
|
|
||||||
|
const resetZoomAndCenterBasedOnLocalStorage = () => { |
||||||
|
if (mapMetaData?.value?.fk_view_id == null) { |
||||||
|
return |
||||||
|
} |
||||||
|
const initialZoomLevel = parseInt(localStorage.getItem(getMapZoomLocalStorageKey(mapMetaData.value.fk_view_id)) || '10') |
||||||
|
const initialCenterLocalStorageStr = localStorage.getItem(getMapCenterLocalStorageKey(mapMetaData.value.fk_view_id)) |
||||||
|
const initialCenter = initialCenterLocalStorageStr ? JSON.parse(initialCenterLocalStorageStr) : fallBackCenterLocation |
||||||
|
|
||||||
|
myMapRef?.value?.setView([initialCenter.lat, initialCenter.lng], initialZoomLevel) |
||||||
|
} |
||||||
|
|
||||||
|
onBeforeMount(async () => { |
||||||
|
await loadMapMeta() |
||||||
|
await loadMapData() |
||||||
|
}) |
||||||
|
|
||||||
|
onMounted(async () => { |
||||||
|
const myMap = L.map(mapContainerRef.value!, { |
||||||
|
center: new LatLng(10, 10), |
||||||
|
zoom: 2, |
||||||
|
}) |
||||||
|
|
||||||
|
myMapRef.value = myMap |
||||||
|
|
||||||
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { |
||||||
|
maxZoom: 19, |
||||||
|
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', |
||||||
|
}).addTo(myMap) |
||||||
|
|
||||||
|
markersClusterGroupRef.value = L.markerClusterGroup({ |
||||||
|
iconCreateFunction(cluster: { getChildCount: () => number }) { |
||||||
|
return L.divIcon({ |
||||||
|
html: `${cluster.getChildCount()}`, |
||||||
|
className: 'bg-pink rounded-full flex items-center justify-center geo-map-marker-cluster', |
||||||
|
iconSize: new L.Point(40, 40), |
||||||
|
}) |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
myMap.addLayer(markersClusterGroupRef.value) |
||||||
|
|
||||||
|
myMap.on('zoomend', function () { |
||||||
|
if (localStorage != null && mapMetaData?.value?.fk_view_id) { |
||||||
|
localStorage.setItem(getMapZoomLocalStorageKey(mapMetaData.value.fk_view_id), myMap.getZoom().toString()) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
myMap.on('moveend', function () { |
||||||
|
if (localStorage != null && mapMetaData?.value?.fk_view_id) { |
||||||
|
localStorage.setItem(getMapCenterLocalStorageKey(mapMetaData?.value?.fk_view_id), JSON.stringify(myMap.getCenter())) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
myMap.on('contextmenu', async function (e) { |
||||||
|
const { lat, lng } = e.latlng |
||||||
|
const newRow = await addEmptyRow() |
||||||
|
if (geoDataFieldColumn.value?.title) { |
||||||
|
newRow.row[geoDataFieldColumn.value.title] = latLongToJoinedString(lat, lng) |
||||||
|
} |
||||||
|
expandForm(newRow) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
reloadViewMetaHook?.on(async () => { |
||||||
|
await loadMapMeta() |
||||||
|
}) |
||||||
|
|
||||||
|
reloadViewDataHook?.on(async () => { |
||||||
|
await loadMapData() |
||||||
|
}) |
||||||
|
|
||||||
|
provide(ReloadRowDataHookInj, reloadViewDataHook) |
||||||
|
|
||||||
|
watch([formattedData, mapMetaData, markersClusterGroupRef], () => { |
||||||
|
if (formattedData.value == null || mapMetaData.value?.fk_view_id == null || markersClusterGroupRef.value == null) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
resetZoomAndCenterBasedOnLocalStorage() |
||||||
|
|
||||||
|
markersClusterGroupRef.value?.clearLayers() |
||||||
|
|
||||||
|
formattedData.value?.forEach((row) => { |
||||||
|
const primaryGeoDataColumnTitle = geoDataFieldColumn.value?.title |
||||||
|
|
||||||
|
if (primaryGeoDataColumnTitle == null) { |
||||||
|
throw new Error('Cannot find primary geo data column title') |
||||||
|
} |
||||||
|
|
||||||
|
const primaryGeoDataValue = row.row[primaryGeoDataColumnTitle] |
||||||
|
if (primaryGeoDataValue == null) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const [lat, long] = primaryGeoDataValue.split(';').map(parseFloat) |
||||||
|
|
||||||
|
addMarker(lat, long, row) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
watch(view, async (nextView) => { |
||||||
|
if (nextView?.type === ViewTypes.MAP) { |
||||||
|
await loadMapMeta() |
||||||
|
await loadMapData() |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const count = computed(() => paginationData.value.totalRows) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="flex flex-col h-full w-full no-underline" data-testid="nc-map-wrapper"> |
||||||
|
<div id="mapContainer" ref="mapContainerRef" class="w-full h-screen"> |
||||||
|
<a-tooltip placement="bottom" class="h-2 w-auto max-w-fit-content absolute top-3 right-3 p-2 z-500 cursor-default"> |
||||||
|
<template #title> |
||||||
|
<span v-if="count > 1000"> {{ $t('msg.info.map.overLimit') }} </span> |
||||||
|
<span v-else-if="count > 900"> {{ $t('msg.info.map.closeLimit') }} </span> |
||||||
|
<span> {{ $t('msg.info.map.limitNumber') }} </span> |
||||||
|
</template> |
||||||
|
|
||||||
|
<div v-if="count > 900" class="nc-warning-info flex min-w-32px h-32px items-center gap-1 px-2 bg-white"> |
||||||
|
<div>{{ count }} {{ $t('objects.records') }}</div> |
||||||
|
<mdi-map-marker-alert /> |
||||||
|
</div> |
||||||
|
</a-tooltip> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<Suspense v-if="!isPublic"> |
||||||
|
<LazySmartsheetExpandedForm |
||||||
|
v-if="expandedFormRow && expandedFormDlg" |
||||||
|
v-model="expandedFormDlg" |
||||||
|
:row="expandedFormRow" |
||||||
|
:state="expandedFormRowState" |
||||||
|
:meta="meta" |
||||||
|
:view="view" |
||||||
|
/> |
||||||
|
</Suspense> |
||||||
|
<Suspense v-if="!isPublic"> |
||||||
|
<LazySmartsheetExpandedForm |
||||||
|
v-if="expandedFormOnRowIdDlg" |
||||||
|
:key="route.query.rowId" |
||||||
|
v-model="expandedFormOnRowIdDlg" |
||||||
|
:row="{ row: {}, oldRow: {}, rowMeta: {} }" |
||||||
|
:meta="meta" |
||||||
|
:row-id="route.query.rowId" |
||||||
|
:view="view" |
||||||
|
/> |
||||||
|
</Suspense> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped lang="scss"> |
||||||
|
:global(.geo-map-marker-cluster) { |
||||||
|
background-color: pink; |
||||||
|
border-radius: 50%; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<style> |
||||||
|
.no-underline a { |
||||||
|
text-decoration: none !important; |
||||||
|
} |
||||||
|
.leaflet-popup-content-wrapper { |
||||||
|
max-height: 255px; |
||||||
|
overflow: scroll; |
||||||
|
} |
||||||
|
</style> |
@ -1,3 +1,3 @@ |
|||||||
<template> |
<template> |
||||||
<div class="mt-4 mb-2" /> |
<div /> |
||||||
</template> |
</template> |
||||||
|
@ -0,0 +1,15 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
function toggleGeodataFeature() { |
||||||
|
geodataToggleState.show = !geodataToggleState.show |
||||||
|
localStorage.setItem('geodataToggleState', JSON.stringify(geodataToggleState.show)) |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<a-tooltip placement="bottomRight"> |
||||||
|
<template #title> |
||||||
|
<span> Toggle GeoData </span> |
||||||
|
</template> |
||||||
|
<mdi-map-marker class="cursor-pointer" data-testid="toggle-geodata-feature-icon" @click="toggleGeodataFeature" /> |
||||||
|
</a-tooltip> |
||||||
|
</template> |
@ -0,0 +1,113 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import type { MapType } from 'nocodb-sdk' |
||||||
|
import { UITypes } from 'nocodb-sdk' |
||||||
|
import type { SelectProps } from 'ant-design-vue' |
||||||
|
import { |
||||||
|
ActiveViewInj, |
||||||
|
IsLockedInj, |
||||||
|
MetaInj, |
||||||
|
ReloadViewDataHookInj, |
||||||
|
computed, |
||||||
|
inject, |
||||||
|
ref, |
||||||
|
useViewColumns, |
||||||
|
watch, |
||||||
|
} from '#imports' |
||||||
|
|
||||||
|
const { eventBus } = useSmartsheetStoreOrThrow() |
||||||
|
|
||||||
|
const meta = inject(MetaInj, ref()) |
||||||
|
|
||||||
|
const activeView = inject(ActiveViewInj, ref()) |
||||||
|
|
||||||
|
const reloadDataHook = inject(ReloadViewDataHookInj)! |
||||||
|
|
||||||
|
const isLocked = inject(IsLockedInj, ref(false)) |
||||||
|
|
||||||
|
const { fields, loadViewColumns, metaColumnById } = useViewColumns(activeView, meta, () => reloadDataHook.trigger()) |
||||||
|
|
||||||
|
const { loadMapData, loadMapMeta, updateMapMeta, mapMetaData, geoDataFieldColumn } = useMapViewStoreOrThrow() |
||||||
|
|
||||||
|
const mappedByDropdown = ref(false) |
||||||
|
|
||||||
|
watch( |
||||||
|
() => activeView.value?.id, |
||||||
|
async (newVal, oldVal) => { |
||||||
|
if (newVal !== oldVal && meta.value) { |
||||||
|
await loadViewColumns() |
||||||
|
} |
||||||
|
}, |
||||||
|
{ immediate: true }, |
||||||
|
) |
||||||
|
|
||||||
|
const geoDataMappingFieldColumnId = computed({ |
||||||
|
get: () => mapMetaData.value.fk_geo_data_col_id, |
||||||
|
set: async (val) => { |
||||||
|
if (val) { |
||||||
|
await updateMapMeta({ |
||||||
|
fk_geo_data_col_id: val, |
||||||
|
}) |
||||||
|
await loadMapMeta() |
||||||
|
await loadMapData() |
||||||
|
;(activeView.value?.view as MapType).fk_geo_data_col_id = val |
||||||
|
eventBus.emit(SmartsheetStoreEvents.MAPPED_BY_COLUMN_CHANGE) |
||||||
|
} |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const geoDataFieldOptions = computed<SelectProps['options']>(() => { |
||||||
|
return fields.value |
||||||
|
?.filter((el) => el.fk_column_id && metaColumnById.value[el.fk_column_id].uidt === UITypes.GeoData) |
||||||
|
.map((field) => { |
||||||
|
return { |
||||||
|
value: field.fk_column_id, |
||||||
|
label: field.title, |
||||||
|
} |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
const handleChange = () => { |
||||||
|
mappedByDropdown.value = false |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<a-dropdown v-model:visible="mappedByDropdown" :trigger="['click']"> |
||||||
|
<div class="nc-map-btn"> |
||||||
|
<a-button v-e="['c:map:change-grouping-field']" class="nc-map-stacked-by-menu-btn nc-toolbar-btn" :disabled="isLocked"> |
||||||
|
<div class="flex items-center gap-1"> |
||||||
|
<mdi-arrow-down-drop-circle-outline /> |
||||||
|
<span class="text-capitalize !text-sm font-weight-normal"> |
||||||
|
{{ $t('activity.map.mappedBy') }} |
||||||
|
<span class="font-bold">{{ geoDataFieldColumn?.title }}</span> |
||||||
|
</span> |
||||||
|
<MdiMenuDown class="text-grey" /> |
||||||
|
</div> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
<template #overlay> |
||||||
|
<div |
||||||
|
v-if="mappedByDropdown" |
||||||
|
class="p-3 min-w-[280px] bg-gray-50 shadow-lg nc-table-toolbar-menu max-h-[max(80vh,500px)] overflow-auto !border" |
||||||
|
@click.stop |
||||||
|
> |
||||||
|
<div> |
||||||
|
<span class="font-bold"> {{ $t('activity.map.chooseMappingField') }}</span> |
||||||
|
<a-divider class="!my-2" /> |
||||||
|
</div> |
||||||
|
<div class="nc-fields-list py-1"> |
||||||
|
<div class="grouping-field"> |
||||||
|
<a-select |
||||||
|
v-model:value="geoDataMappingFieldColumnId" |
||||||
|
class="w-full nc-msp-grouping-field-select" |
||||||
|
:options="geoDataFieldOptions" |
||||||
|
placeholder="Select a Mapping Field" |
||||||
|
@change="handleChange" |
||||||
|
@click.stop |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</a-dropdown> |
||||||
|
</template> |
@ -0,0 +1,179 @@ |
|||||||
|
import { reactive } from 'vue' |
||||||
|
import type { ComputedRef, Ref } from 'vue' |
||||||
|
import type { ColumnType, MapType, PaginatedType, TableType, ViewType } from 'nocodb-sdk' |
||||||
|
import { IsPublicInj, ref, storeToRefs, useInjectionState, useMetas, useProject } from '#imports' |
||||||
|
import type { Row } from '~/lib' |
||||||
|
|
||||||
|
const storedValue = localStorage.getItem('geodataToggleState') |
||||||
|
|
||||||
|
const initialState = storedValue ? JSON.parse(storedValue) : false |
||||||
|
|
||||||
|
export const geodataToggleState = reactive({ show: initialState }) |
||||||
|
|
||||||
|
const formatData = (list: Record<string, any>[]) => |
||||||
|
list.map( |
||||||
|
(row) => |
||||||
|
({ |
||||||
|
row: { ...row }, |
||||||
|
oldRow: { ...row }, |
||||||
|
rowMeta: {}, |
||||||
|
} as Row), |
||||||
|
) |
||||||
|
|
||||||
|
const [useProvideMapViewStore, useMapViewStore] = useInjectionState( |
||||||
|
( |
||||||
|
meta: Ref<TableType | undefined>, |
||||||
|
viewMeta: Ref<(ViewType | MapType | undefined) & { id: string }> | ComputedRef<(ViewType & { id: string }) | undefined>, |
||||||
|
shared = false, |
||||||
|
where?: ComputedRef<string | undefined>, |
||||||
|
) => { |
||||||
|
if (!meta) { |
||||||
|
throw new Error('Table meta is not available') |
||||||
|
} |
||||||
|
|
||||||
|
const formattedData = ref<Row[]>([]) |
||||||
|
|
||||||
|
const { api } = useApi() |
||||||
|
|
||||||
|
const { project } = storeToRefs(useProject()) |
||||||
|
|
||||||
|
const { $api } = useNuxtApp() |
||||||
|
|
||||||
|
const { isUIAllowed } = useUIPermission() |
||||||
|
|
||||||
|
const isPublic = ref(shared) || inject(IsPublicInj, ref(false)) |
||||||
|
|
||||||
|
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow() |
||||||
|
|
||||||
|
const { fetchSharedViewData } = useSharedView() |
||||||
|
|
||||||
|
const mapMetaData = ref<MapType>({}) |
||||||
|
|
||||||
|
const geoDataFieldColumn = ref<ColumnType | undefined>() |
||||||
|
|
||||||
|
const defaultPageSize = 1000 |
||||||
|
|
||||||
|
const paginationData = ref<PaginatedType>({ page: 1, pageSize: defaultPageSize }) |
||||||
|
|
||||||
|
const queryParams = computed(() => ({ |
||||||
|
limit: paginationData.value.pageSize ?? defaultPageSize, |
||||||
|
where: where?.value ?? '', |
||||||
|
})) |
||||||
|
|
||||||
|
async function syncCount() { |
||||||
|
const { count } = await $api.dbViewRow.count( |
||||||
|
NOCO, |
||||||
|
project?.value?.title as string, |
||||||
|
meta?.value?.id as string, |
||||||
|
viewMeta?.value?.id as string, |
||||||
|
) |
||||||
|
paginationData.value.totalRows = count |
||||||
|
} |
||||||
|
|
||||||
|
async function loadMapMeta() { |
||||||
|
if (!viewMeta?.value?.id || !meta?.value?.columns) return |
||||||
|
mapMetaData.value = await $api.dbView.mapRead(viewMeta.value.id) |
||||||
|
geoDataFieldColumn.value = |
||||||
|
(meta.value.columns as ColumnType[]).filter((f) => f.id === mapMetaData.value.fk_geo_data_col_id)[0] || {} |
||||||
|
} |
||||||
|
|
||||||
|
async function loadMapData() { |
||||||
|
if ((!project?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic?.value) return |
||||||
|
|
||||||
|
const res = !isPublic.value |
||||||
|
? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, { |
||||||
|
...queryParams.value, |
||||||
|
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }), |
||||||
|
where: where?.value, |
||||||
|
}) |
||||||
|
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value }) |
||||||
|
|
||||||
|
formattedData.value = formatData(res!.list) |
||||||
|
} |
||||||
|
|
||||||
|
async function updateMapMeta(updateObj: Partial<MapType>) { |
||||||
|
if (!viewMeta?.value?.id || !isUIAllowed('xcDatatableEditable')) return |
||||||
|
await $api.dbView.mapUpdate(viewMeta.value.id, updateObj) |
||||||
|
} |
||||||
|
|
||||||
|
const { getMeta } = useMetas() |
||||||
|
|
||||||
|
async function insertRow( |
||||||
|
currentRow: Row, |
||||||
|
ltarState: Record<string, any> = {}, |
||||||
|
{ |
||||||
|
metaValue = meta.value, |
||||||
|
viewMetaValue = viewMeta.value, |
||||||
|
}: { metaValue?: MapType & { id: string }; viewMetaValue?: (ViewType | MapType) & { id: string } } = {}, |
||||||
|
) { |
||||||
|
const row = currentRow.row |
||||||
|
if (currentRow.rowMeta) currentRow.rowMeta.saving = true |
||||||
|
try { |
||||||
|
const { missingRequiredColumns, insertObj } = await populateInsertObject({ |
||||||
|
meta: metaValue!, |
||||||
|
ltarState, |
||||||
|
getMeta, |
||||||
|
row, |
||||||
|
}) |
||||||
|
|
||||||
|
if (missingRequiredColumns.size) return |
||||||
|
|
||||||
|
const insertedData = await $api.dbViewRow.create( |
||||||
|
NOCO, |
||||||
|
project?.value.id as string, |
||||||
|
metaValue?.id as string, |
||||||
|
viewMetaValue?.id as string, |
||||||
|
insertObj, |
||||||
|
) |
||||||
|
|
||||||
|
Object.assign(currentRow, { |
||||||
|
row: { ...insertedData, ...row }, |
||||||
|
rowMeta: { ...(currentRow.rowMeta || {}), new: undefined }, |
||||||
|
oldRow: { ...insertedData }, |
||||||
|
}) |
||||||
|
|
||||||
|
syncCount() |
||||||
|
|
||||||
|
return insertedData |
||||||
|
} catch (error: any) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(error)) |
||||||
|
} finally { |
||||||
|
if (currentRow.rowMeta) currentRow.rowMeta.saving = false |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function addEmptyRow(addAfter = formattedData.value.length) { |
||||||
|
formattedData.value.splice(addAfter, 0, { |
||||||
|
row: {}, |
||||||
|
oldRow: {}, |
||||||
|
rowMeta: { new: true }, |
||||||
|
}) |
||||||
|
|
||||||
|
return formattedData.value[addAfter] |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
formattedData, |
||||||
|
loadMapData, |
||||||
|
loadMapMeta, |
||||||
|
updateMapMeta, |
||||||
|
mapMetaData, |
||||||
|
geoDataFieldColumn, |
||||||
|
addEmptyRow, |
||||||
|
insertRow, |
||||||
|
geodataToggleState, |
||||||
|
syncCount, |
||||||
|
paginationData, |
||||||
|
} |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
export { useProvideMapViewStore } |
||||||
|
|
||||||
|
export function useMapViewStoreOrThrow() { |
||||||
|
const mapViewStore = useMapViewStore() |
||||||
|
|
||||||
|
if (mapViewStore == null) throw new Error('Please call `useProvideMapViewStore` on the appropriate parent component') |
||||||
|
|
||||||
|
return mapViewStore |
||||||
|
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue