mirror of https://github.com/nocodb/nocodb
Wing-Kam Wong
2 years ago
68 changed files with 2466 additions and 1116 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 data-testid="nc-geo-data-lat-long-set" v-else>{{ 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,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 { OpenNewRecordFormHookInj, latLongToJoinedString, onMounted, provide, ref, IsPublicInj } 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, syncCount, 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> |
@ -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,182 @@ |
|||||||
|
import { reactive } from 'vue' |
||||||
|
import type { ComputedRef, Ref } from 'vue' |
||||||
|
import type { ColumnType, MapType, PaginatedType, TableType, ViewType } from 'nocodb-sdk' |
||||||
|
import { IsPublicInj, ref, 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> | 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 } = 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, { |
||||||
|
...mapMetaData.value, |
||||||
|
...updateObj, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const { getMeta } = useMetas() |
||||||
|
|
||||||
|
async function insertRow( |
||||||
|
currentRow: Row, |
||||||
|
ltarState: Record<string, any> = {}, |
||||||
|
{ |
||||||
|
metaValue = meta.value, |
||||||
|
viewMetaValue = viewMeta.value, |
||||||
|
}: { metaValue?: MapType; viewMetaValue?: ViewType | MapType } = {}, |
||||||
|
) { |
||||||
|
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 |
||||||
|
} |
@ -0,0 +1,35 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { message } from 'ant-design-vue' |
||||||
|
import { definePageMeta } from '#imports' |
||||||
|
|
||||||
|
definePageMeta({ |
||||||
|
public: true, |
||||||
|
requiresAuth: false, |
||||||
|
layout: 'shared-view', |
||||||
|
}) |
||||||
|
|
||||||
|
const route = useRoute() |
||||||
|
|
||||||
|
const { loadSharedView } = useSharedView() |
||||||
|
|
||||||
|
const showPassword = ref(false) |
||||||
|
|
||||||
|
try { |
||||||
|
await loadSharedView(route.params.viewId as string) |
||||||
|
} catch (e: any) { |
||||||
|
if (e?.response?.status === 403) { |
||||||
|
showPassword.value = true |
||||||
|
} else { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<NuxtLayout class="flex" name="shared-view"> |
||||||
|
<div v-if="showPassword"> |
||||||
|
<LazySharedViewAskPassword v-model="showPassword" /> |
||||||
|
</div> |
||||||
|
<LazySharedViewMap v-else /> |
||||||
|
</NuxtLayout> |
||||||
|
</template> |
@ -0,0 +1,3 @@ |
|||||||
|
const latLongToJoinedString = (lat: number, long: number) => `${lat.toFixed(7)};${long.toFixed(7)}` |
||||||
|
|
||||||
|
export { latLongToJoinedString } |
@ -0,0 +1,46 @@ |
|||||||
|
import { Request, Response, Router } from 'express'; |
||||||
|
import { MapType, ViewTypes } from 'nocodb-sdk'; |
||||||
|
import View from '../../models/View'; |
||||||
|
import ncMetaAclMw from '../helpers/ncMetaAclMw'; |
||||||
|
import { Tele } from 'nc-help'; |
||||||
|
import { metaApiMetrics } from '../helpers/apiMetrics'; |
||||||
|
import MapView from '../../models/MapView'; |
||||||
|
|
||||||
|
export async function mapViewGet(req: Request, res: Response<MapType>) { |
||||||
|
res.json(await MapView.get(req.params.mapViewId)); |
||||||
|
} |
||||||
|
|
||||||
|
export async function mapViewCreate(req: Request<any, any>, res) { |
||||||
|
Tele.emit('evt', { evt_type: 'vtable:created', show_as: 'map' }); |
||||||
|
const view = await View.insert({ |
||||||
|
...req.body, |
||||||
|
// todo: sanitize
|
||||||
|
fk_model_id: req.params.tableId, |
||||||
|
type: ViewTypes.MAP, |
||||||
|
}); |
||||||
|
res.json(view); |
||||||
|
} |
||||||
|
|
||||||
|
export async function mapViewUpdate(req, res) { |
||||||
|
Tele.emit('evt', { evt_type: 'view:updated', type: 'map' }); |
||||||
|
res.json(await MapView.update(req.params.mapViewId, req.body)); |
||||||
|
} |
||||||
|
|
||||||
|
const router = Router({ mergeParams: true }); |
||||||
|
|
||||||
|
router.post( |
||||||
|
'/api/v1/db/meta/tables/:tableId/maps', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(mapViewCreate, 'mapViewCreate') |
||||||
|
); |
||||||
|
router.patch( |
||||||
|
'/api/v1/db/meta/maps/:mapViewId', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(mapViewUpdate, 'mapViewUpdate') |
||||||
|
); |
||||||
|
router.get( |
||||||
|
'/api/v1/db/meta/maps/:mapViewId', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(mapViewGet, 'mapViewGet') |
||||||
|
); |
||||||
|
export default router; |
@ -0,0 +1,55 @@ |
|||||||
|
import { Knex } from 'knex'; |
||||||
|
import { MetaTable } from '../../utils/globals'; |
||||||
|
|
||||||
|
const up = async (knex: Knex) => { |
||||||
|
await knex.schema.createTable(MetaTable.MAP_VIEW, (table) => { |
||||||
|
table.string('fk_view_id', 20).primary(); |
||||||
|
table.foreign('fk_view_id').references(`${MetaTable.VIEWS}.id`); |
||||||
|
|
||||||
|
table.string('base_id', 20); |
||||||
|
table.foreign('base_id').references(`${MetaTable.BASES}.id`); |
||||||
|
|
||||||
|
table.string('project_id', 128); |
||||||
|
table.foreign('project_id').references(`${MetaTable.PROJECT}.id`); |
||||||
|
|
||||||
|
table.string('uuid'); |
||||||
|
table.string('title'); |
||||||
|
|
||||||
|
table.string('fk_geo_data_col_id', 20); |
||||||
|
table.foreign('fk_geo_data_col_id').references(`${MetaTable.COLUMNS}.id`); |
||||||
|
|
||||||
|
table.text('meta'); |
||||||
|
|
||||||
|
table.dateTime('created_at'); |
||||||
|
table.dateTime('updated_at'); |
||||||
|
}); |
||||||
|
|
||||||
|
await knex.schema.createTable(MetaTable.MAP_VIEW_COLUMNS, (table) => { |
||||||
|
table.string('id', 20).primary().notNullable(); |
||||||
|
|
||||||
|
table.string('base_id', 20); |
||||||
|
table.string('project_id', 128); |
||||||
|
|
||||||
|
table.string('fk_view_id', 20); |
||||||
|
table.foreign('fk_view_id').references(`${MetaTable.MAP_VIEW}.fk_view_id`); |
||||||
|
table.string('fk_column_id', 20); |
||||||
|
table.foreign('fk_column_id').references(`${MetaTable.COLUMNS}.id`); |
||||||
|
|
||||||
|
table.string('uuid'); |
||||||
|
|
||||||
|
table.string('label'); |
||||||
|
table.string('help'); |
||||||
|
|
||||||
|
table.boolean('show'); |
||||||
|
table.float('order'); |
||||||
|
|
||||||
|
table.timestamps(true, true); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const down = async (knex: Knex) => { |
||||||
|
await knex.schema.dropTable(MetaTable.MAP_VIEW); |
||||||
|
await knex.schema.dropTable(MetaTable.MAP_VIEW_COLUMNS); |
||||||
|
}; |
||||||
|
|
||||||
|
export { up, down }; |
@ -0,0 +1,104 @@ |
|||||||
|
import Noco from '../Noco'; |
||||||
|
import { MapType } from 'nocodb-sdk'; |
||||||
|
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals'; |
||||||
|
import View from './View'; |
||||||
|
import NocoCache from '../cache/NocoCache'; |
||||||
|
import MapViewColumn from './MapViewColumn'; |
||||||
|
|
||||||
|
export default class MapView implements MapType { |
||||||
|
fk_view_id: string; |
||||||
|
title: string; |
||||||
|
project_id?: string; |
||||||
|
base_id?: string; |
||||||
|
fk_geo_data_col_id?: string; |
||||||
|
meta?: string | Record<string, unknown>; |
||||||
|
|
||||||
|
// below fields are not in use at this moment
|
||||||
|
// keep them for time being
|
||||||
|
show?: boolean; |
||||||
|
uuid?: string; |
||||||
|
public?: boolean; |
||||||
|
password?: string; |
||||||
|
show_all_fields?: boolean; |
||||||
|
|
||||||
|
constructor(data: MapView) { |
||||||
|
Object.assign(this, data); |
||||||
|
} |
||||||
|
|
||||||
|
public static async get(viewId: string, ncMeta = Noco.ncMeta) { |
||||||
|
let view = |
||||||
|
viewId && |
||||||
|
(await NocoCache.get( |
||||||
|
`${CacheScope.MAP_VIEW}:${viewId}`, |
||||||
|
CacheGetType.TYPE_OBJECT |
||||||
|
)); |
||||||
|
if (!view) { |
||||||
|
view = await ncMeta.metaGet2(null, null, MetaTable.MAP_VIEW, { |
||||||
|
fk_view_id: viewId, |
||||||
|
}); |
||||||
|
await NocoCache.set(`${CacheScope.MAP_VIEW}:${viewId}`, view); |
||||||
|
} |
||||||
|
|
||||||
|
return view && new MapView(view); |
||||||
|
} |
||||||
|
|
||||||
|
static async insert(view: Partial<MapView>, ncMeta = Noco.ncMeta) { |
||||||
|
const insertObj = { |
||||||
|
project_id: view.project_id, |
||||||
|
base_id: view.base_id, |
||||||
|
fk_view_id: view.fk_view_id, |
||||||
|
fk_geo_data_col_id: view.fk_geo_data_col_id, |
||||||
|
meta: view.meta, |
||||||
|
}; |
||||||
|
|
||||||
|
const viewRef = await View.get(view.fk_view_id); |
||||||
|
|
||||||
|
if (!(view.project_id && view.base_id)) { |
||||||
|
insertObj.project_id = viewRef.project_id; |
||||||
|
insertObj.base_id = viewRef.base_id; |
||||||
|
} |
||||||
|
|
||||||
|
await ncMeta.metaInsert2(null, null, MetaTable.MAP_VIEW, insertObj, true); |
||||||
|
|
||||||
|
return this.get(view.fk_view_id, ncMeta); |
||||||
|
} |
||||||
|
|
||||||
|
static async update( |
||||||
|
mapId: string, |
||||||
|
body: Partial<MapView>, |
||||||
|
ncMeta = Noco.ncMeta |
||||||
|
) { |
||||||
|
// get existing cache
|
||||||
|
const key = `${CacheScope.MAP_VIEW}:${mapId}`; |
||||||
|
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT); |
||||||
|
const updateObj = { |
||||||
|
...body, |
||||||
|
meta: |
||||||
|
typeof body.meta === 'string' |
||||||
|
? body.meta |
||||||
|
: JSON.stringify(body.meta ?? {}), |
||||||
|
}; |
||||||
|
if (o) { |
||||||
|
o = { ...o, ...updateObj }; |
||||||
|
// set cache
|
||||||
|
await NocoCache.set(key, o); |
||||||
|
} |
||||||
|
|
||||||
|
if (body.fk_geo_data_col_id != null) { |
||||||
|
const mapViewColumns = await MapViewColumn.list(mapId); |
||||||
|
const mapViewMappedByColumn = mapViewColumns.find( |
||||||
|
(mapViewColumn) => |
||||||
|
mapViewColumn.fk_column_id === body.fk_geo_data_col_id |
||||||
|
); |
||||||
|
await View.updateColumn(body.fk_view_id, mapViewMappedByColumn.id, { |
||||||
|
show: true, |
||||||
|
}); |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
// update meta
|
||||||
|
return await ncMeta.metaUpdate(null, null, MetaTable.MAP_VIEW, updateObj, { |
||||||
|
fk_view_id: mapId, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,106 @@ |
|||||||
|
import Noco from '../Noco'; |
||||||
|
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals'; |
||||||
|
import View from './View'; |
||||||
|
import NocoCache from '../cache/NocoCache'; |
||||||
|
|
||||||
|
export default class MapViewColumn { |
||||||
|
id: string; |
||||||
|
title?: string; |
||||||
|
show?: boolean; |
||||||
|
order?: number; |
||||||
|
|
||||||
|
fk_view_id: string; |
||||||
|
fk_column_id: string; |
||||||
|
project_id?: string; |
||||||
|
base_id?: string; |
||||||
|
|
||||||
|
constructor(data: MapViewColumn) { |
||||||
|
Object.assign(this, data); |
||||||
|
} |
||||||
|
|
||||||
|
public static async get(mapViewColumnId: string, ncMeta = Noco.ncMeta) { |
||||||
|
let view = |
||||||
|
mapViewColumnId && |
||||||
|
(await NocoCache.get( |
||||||
|
`${CacheScope.MAP_VIEW_COLUMN}:${mapViewColumnId}`, |
||||||
|
CacheGetType.TYPE_OBJECT |
||||||
|
)); |
||||||
|
if (!view) { |
||||||
|
view = await ncMeta.metaGet2( |
||||||
|
null, |
||||||
|
null, |
||||||
|
MetaTable.MAP_VIEW_COLUMNS, |
||||||
|
mapViewColumnId |
||||||
|
); |
||||||
|
await NocoCache.set( |
||||||
|
`${CacheScope.MAP_VIEW_COLUMN}:${mapViewColumnId}`, |
||||||
|
view |
||||||
|
); |
||||||
|
} |
||||||
|
return view && new MapViewColumn(view); |
||||||
|
} |
||||||
|
static async insert(column: Partial<MapViewColumn>, ncMeta = Noco.ncMeta) { |
||||||
|
const insertObj = { |
||||||
|
fk_view_id: column.fk_view_id, |
||||||
|
fk_column_id: column.fk_column_id, |
||||||
|
order: await ncMeta.metaGetNextOrder(MetaTable.MAP_VIEW_COLUMNS, { |
||||||
|
fk_view_id: column.fk_view_id, |
||||||
|
}), |
||||||
|
show: column.show, |
||||||
|
project_id: column.project_id, |
||||||
|
base_id: column.base_id, |
||||||
|
}; |
||||||
|
|
||||||
|
if (!(column.project_id && column.base_id)) { |
||||||
|
const viewRef = await View.get(column.fk_view_id, ncMeta); |
||||||
|
insertObj.project_id = viewRef.project_id; |
||||||
|
insertObj.base_id = viewRef.base_id; |
||||||
|
} |
||||||
|
|
||||||
|
const { id, fk_column_id } = await ncMeta.metaInsert2( |
||||||
|
null, |
||||||
|
null, |
||||||
|
MetaTable.MAP_VIEW_COLUMNS, |
||||||
|
insertObj |
||||||
|
); |
||||||
|
|
||||||
|
await NocoCache.set(`${CacheScope.MAP_VIEW_COLUMN}:${fk_column_id}`, id); |
||||||
|
|
||||||
|
// if cache is not present skip pushing it into the list to avoid unexpected behaviour
|
||||||
|
if ( |
||||||
|
(await NocoCache.getList(CacheScope.MAP_VIEW_COLUMN, [column.fk_view_id])) |
||||||
|
?.length |
||||||
|
) |
||||||
|
await NocoCache.appendToList( |
||||||
|
CacheScope.MAP_VIEW_COLUMN, |
||||||
|
[column.fk_view_id], |
||||||
|
`${CacheScope.MAP_VIEW_COLUMN}:${id}` |
||||||
|
); |
||||||
|
|
||||||
|
return this.get(id, ncMeta); |
||||||
|
} |
||||||
|
|
||||||
|
public static async list( |
||||||
|
viewId: string, |
||||||
|
ncMeta = Noco.ncMeta |
||||||
|
): Promise<MapViewColumn[]> { |
||||||
|
let views = await NocoCache.getList(CacheScope.MAP_VIEW_COLUMN, [viewId]); |
||||||
|
if (!views.length) { |
||||||
|
views = await ncMeta.metaList2(null, null, MetaTable.MAP_VIEW_COLUMNS, { |
||||||
|
condition: { |
||||||
|
fk_view_id: viewId, |
||||||
|
}, |
||||||
|
orderBy: { |
||||||
|
order: 'asc', |
||||||
|
}, |
||||||
|
}); |
||||||
|
await NocoCache.setList(CacheScope.MAP_VIEW_COLUMN, [viewId], views); |
||||||
|
} |
||||||
|
views.sort( |
||||||
|
(a, b) => |
||||||
|
(a.order != null ? a.order : Infinity) - |
||||||
|
(b.order != null ? b.order : Infinity) |
||||||
|
); |
||||||
|
return views?.map((v) => new MapViewColumn(v)); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,51 @@ |
|||||||
|
import { expect } from '@playwright/test'; |
||||||
|
import { DashboardPage } from '..'; |
||||||
|
import BasePage from '../../Base'; |
||||||
|
import { ToolbarPage } from '../common/Toolbar'; |
||||||
|
|
||||||
|
export class MapPage extends BasePage { |
||||||
|
readonly dashboard: DashboardPage; |
||||||
|
readonly toolbar: ToolbarPage; |
||||||
|
|
||||||
|
constructor(dashboard: DashboardPage) { |
||||||
|
super(dashboard.rootPage); |
||||||
|
this.dashboard = dashboard; |
||||||
|
this.toolbar = new ToolbarPage(this); |
||||||
|
} |
||||||
|
|
||||||
|
get() { |
||||||
|
return this.dashboard.get().locator('[data-testid="nc-map-wrapper"]'); |
||||||
|
} |
||||||
|
|
||||||
|
async marker(lat: string, long: string) { |
||||||
|
const latLongStr = `${lat}, ${long}`; |
||||||
|
const marker = await this.get().locator(`.leaflet-marker-pane img[alt="${latLongStr}"]`); |
||||||
|
return marker; |
||||||
|
} |
||||||
|
|
||||||
|
async clickAddRowButton() { |
||||||
|
await this.rootPage.locator('.nc-add-new-row-btn').click(); |
||||||
|
} |
||||||
|
|
||||||
|
async clickMarker(lat: string, long: string) { |
||||||
|
return (await this.marker(lat, long)).click(); |
||||||
|
} |
||||||
|
|
||||||
|
async verifyMarkerCount(count: number) { |
||||||
|
const markers = await this.get().locator('.leaflet-marker-pane img'); |
||||||
|
await expect(markers).toHaveCount(count); |
||||||
|
} |
||||||
|
|
||||||
|
async zoomOut(times = 10) { |
||||||
|
const zoomOutButton = await this.get().locator('.leaflet-control-zoom-out'); |
||||||
|
for (let i = 0; i < times; i++) { |
||||||
|
await zoomOutButton.click(); |
||||||
|
await this.rootPage.waitForTimeout(400); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// todo: Wait for render to complete
|
||||||
|
async waitLoading() { |
||||||
|
await this.rootPage.waitForTimeout(1000); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,36 @@ |
|||||||
|
import { CellPageObject } from '.'; |
||||||
|
import BasePage from '../../../Base'; |
||||||
|
|
||||||
|
export class GeoDataCellPageObject extends BasePage { |
||||||
|
readonly cell: CellPageObject; |
||||||
|
|
||||||
|
constructor(cell: CellPageObject) { |
||||||
|
super(cell.rootPage); |
||||||
|
this.cell = cell; |
||||||
|
} |
||||||
|
|
||||||
|
get({ index, columnHeader }: { index?: number; columnHeader: string }) { |
||||||
|
return this.cell.get({ index, columnHeader }); |
||||||
|
} |
||||||
|
|
||||||
|
async openSetLocation({ index, columnHeader }: { index: number; columnHeader: string }) { |
||||||
|
await this.cell.get({ index, columnHeader }).locator(`[data-testid="nc-geo-data-set-location-button"]`).click(); |
||||||
|
} |
||||||
|
|
||||||
|
async openLatLngSet({ index, columnHeader }: { index: number; columnHeader: string }) { |
||||||
|
await this.cell.get({ index, columnHeader }).locator(`[data-testid="nc-geo-data-lat-long-set"]`).click(); |
||||||
|
} |
||||||
|
|
||||||
|
async enterLatLong({ lat, long }: { lat: string; long: string }) { |
||||||
|
await this.rootPage.locator(`[data-testid="nc-geo-data-latitude"]`).fill(lat); |
||||||
|
await this.rootPage.locator(`[data-testid="nc-geo-data-longitude"]`).fill(long); |
||||||
|
} |
||||||
|
|
||||||
|
async clickSave() { |
||||||
|
await this.rootPage.locator(`[data-testid="nc-geo-data-save"]`).click(); |
||||||
|
} |
||||||
|
|
||||||
|
async close() { |
||||||
|
await this.rootPage.keyboard.press('Escape'); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,73 @@ |
|||||||
|
import { test } from '@playwright/test'; |
||||||
|
import { DashboardPage } from '../pages/Dashboard'; |
||||||
|
import setup from '../setup'; |
||||||
|
import { GridPage } from '../pages/Dashboard/Grid'; |
||||||
|
|
||||||
|
test.describe('Geo Data column', () => { |
||||||
|
let dashboard: DashboardPage; |
||||||
|
let grid: GridPage; |
||||||
|
let context: any; |
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => { |
||||||
|
context = await setup({ page }); |
||||||
|
dashboard = new DashboardPage(page, context.project); |
||||||
|
grid = dashboard.grid; |
||||||
|
}); |
||||||
|
|
||||||
|
test('creation, validation and deleting geo data column', async () => { |
||||||
|
// close 'Team & Auth' tab
|
||||||
|
await dashboard.closeTab({ title: 'Team & Auth' }); |
||||||
|
|
||||||
|
await dashboard.treeView.openTable({ title: 'City' }); |
||||||
|
|
||||||
|
await dashboard.viewSidebar.activateGeoDataEasterEgg(); |
||||||
|
|
||||||
|
await grid.column.create({ |
||||||
|
title: 'GeoData1', |
||||||
|
type: 'GeoData', |
||||||
|
}); |
||||||
|
|
||||||
|
await grid.column.verify({ title: 'GeoData1', isVisible: true }); |
||||||
|
|
||||||
|
await grid.cell.geoData.openSetLocation({ |
||||||
|
index: 0, |
||||||
|
columnHeader: 'GeoData1', |
||||||
|
}); |
||||||
|
await grid.cell.geoData.enterLatLong({ |
||||||
|
lat: '50.4501', |
||||||
|
long: '30.5234', |
||||||
|
}); |
||||||
|
await grid.cell.geoData.clickSave(); |
||||||
|
|
||||||
|
await grid.cell.verifyGeoDataCell({ |
||||||
|
index: 0, |
||||||
|
columnHeader: 'GeoData1', |
||||||
|
lat: '50.4501000', |
||||||
|
long: '30.5234000', |
||||||
|
}); |
||||||
|
|
||||||
|
// Trying to change to value that is not valid
|
||||||
|
await grid.cell.geoData.openLatLngSet({ |
||||||
|
index: 0, |
||||||
|
columnHeader: 'GeoData1', |
||||||
|
}); |
||||||
|
await grid.cell.geoData.enterLatLong({ |
||||||
|
lat: '543210.4501', |
||||||
|
long: '30.5234', |
||||||
|
}); |
||||||
|
await grid.cell.geoData.clickSave(); |
||||||
|
|
||||||
|
// value should not be changed
|
||||||
|
await grid.cell.verifyGeoDataCell({ |
||||||
|
index: 0, |
||||||
|
columnHeader: 'GeoData1', |
||||||
|
lat: '50.4501000', |
||||||
|
long: '30.5234000', |
||||||
|
}); |
||||||
|
|
||||||
|
await grid.column.delete({ title: 'GeoData1' }); |
||||||
|
await grid.column.verify({ title: 'GeoData1', isVisible: false }); |
||||||
|
|
||||||
|
await dashboard.closeTab({ title: 'City' }); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,92 @@ |
|||||||
|
import { test } from '@playwright/test'; |
||||||
|
import { DashboardPage } from '../pages/Dashboard'; |
||||||
|
import { ToolbarPage } from '../pages/Dashboard/common/Toolbar'; |
||||||
|
|
||||||
|
import setup from '../setup'; |
||||||
|
|
||||||
|
test.describe('Map View', () => { |
||||||
|
let dashboard: DashboardPage, toolbar: ToolbarPage; |
||||||
|
let context: any; |
||||||
|
|
||||||
|
const latitudeInFullDecimalLength = '50.4501000'; |
||||||
|
const longitudeInFullDecimalLength = '30.5234000'; |
||||||
|
|
||||||
|
const latitudeInShortDecimalLength = '50.4501'; |
||||||
|
const longitudeInShortDecimalLength = '30.5234'; |
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => { |
||||||
|
context = await setup({ page }); |
||||||
|
dashboard = new DashboardPage(page, context.project); |
||||||
|
toolbar = dashboard.map.toolbar; |
||||||
|
|
||||||
|
await dashboard.viewSidebar.activateGeoDataEasterEgg(); |
||||||
|
|
||||||
|
// close 'Team & Auth' tab
|
||||||
|
await dashboard.closeTab({ title: 'Team & Auth' }); |
||||||
|
await dashboard.treeView.openTable({ title: 'Actor' }); |
||||||
|
|
||||||
|
const grid = dashboard.grid; |
||||||
|
|
||||||
|
await grid.column.create({ |
||||||
|
title: 'Actors Birthplace', |
||||||
|
type: 'GeoData', |
||||||
|
}); |
||||||
|
|
||||||
|
await grid.column.verify({ title: 'Actors Birthplace', isVisible: true }); |
||||||
|
|
||||||
|
await grid.cell.geoData.openSetLocation({ |
||||||
|
index: 0, |
||||||
|
columnHeader: 'Actors Birthplace', |
||||||
|
}); |
||||||
|
await grid.cell.geoData.enterLatLong({ |
||||||
|
lat: latitudeInShortDecimalLength, |
||||||
|
long: longitudeInShortDecimalLength, |
||||||
|
}); |
||||||
|
await grid.cell.geoData.clickSave(); |
||||||
|
|
||||||
|
await grid.cell.verifyGeoDataCell({ |
||||||
|
index: 0, |
||||||
|
columnHeader: 'Actors Birthplace', |
||||||
|
lat: latitudeInFullDecimalLength, |
||||||
|
long: longitudeInFullDecimalLength, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
test('shows the marker and opens the expanded form view when clicking on it', async () => { |
||||||
|
await dashboard.viewSidebar.createMapView({ |
||||||
|
title: 'Map 1', |
||||||
|
}); |
||||||
|
// Zoom out
|
||||||
|
await dashboard.map.zoomOut(8); |
||||||
|
await dashboard.map.verifyMarkerCount(1); |
||||||
|
|
||||||
|
await dashboard.map.clickAddRowButton(); |
||||||
|
|
||||||
|
await dashboard.expandedForm.fillField({ |
||||||
|
columnTitle: 'FirstName', |
||||||
|
value: 'Mario', |
||||||
|
type: 'text', |
||||||
|
}); |
||||||
|
|
||||||
|
await dashboard.expandedForm.fillField({ |
||||||
|
columnTitle: 'LastName', |
||||||
|
value: 'Ali', |
||||||
|
type: 'text', |
||||||
|
}); |
||||||
|
|
||||||
|
await dashboard.expandedForm.fillField({ |
||||||
|
columnTitle: 'Actors Birthplace', |
||||||
|
value: '12, 34', |
||||||
|
type: 'geodata', |
||||||
|
}); |
||||||
|
|
||||||
|
await dashboard.expandedForm.save(); |
||||||
|
|
||||||
|
await dashboard.map.verifyMarkerCount(2); |
||||||
|
|
||||||
|
await dashboard.map.clickMarker('12', '34'); |
||||||
|
|
||||||
|
await dashboard.expandedForm.clickDeleteRow(); |
||||||
|
await dashboard.map.verifyMarkerCount(1); |
||||||
|
}); |
||||||
|
}); |
Loading…
Reference in new issue