Browse Source

Merge pull request #4749 from humannocode/geodata-prototyping-restart

Geodata column and Map View (WIP)
pull/5204/head
աɨռɢӄաօռɢ 2 years ago committed by GitHub
parent
commit
4796e09918
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      packages/nc-gui/assets/style.scss
  2. 2
      packages/nc-gui/components.d.ts
  3. 155
      packages/nc-gui/components/cell/GeoData.vue
  4. 66
      packages/nc-gui/components/dlg/ViewCreate.vue
  5. 0
      packages/nc-gui/components/general/EasterEgg.vue
  6. 42
      packages/nc-gui/components/shared-view/Map.vue
  7. 6
      packages/nc-gui/components/smartsheet/Cell.vue
  8. 266
      packages/nc-gui/components/smartsheet/Map.vue
  9. 12
      packages/nc-gui/components/smartsheet/Toolbar.vue
  10. 7
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  11. 5
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  12. 4
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  13. 216
      packages/nc-gui/components/smartsheet/sidebar/MenuBottom.vue
  14. 15
      packages/nc-gui/components/smartsheet/sidebar/toolbar/GeodataSwitcher.vue
  15. 4
      packages/nc-gui/components/smartsheet/sidebar/toolbar/index.vue
  16. 3
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  17. 113
      packages/nc-gui/components/smartsheet/toolbar/MappedBy.vue
  18. 9
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  19. 3
      packages/nc-gui/components/smartsheet/toolbar/SharedViewList.vue
  20. 5
      packages/nc-gui/components/tabs/Smartsheet.vue
  21. 182
      packages/nc-gui/composables/useMapViewDataStore.ts
  22. 3
      packages/nc-gui/composables/useSmartsheetStore.ts
  23. 14
      packages/nc-gui/composables/useViewColumns.ts
  24. 3
      packages/nc-gui/lang/de.json
  25. 20
      packages/nc-gui/lang/en.json
  26. 1
      packages/nc-gui/lib/enums.ts
  27. 1
      packages/nc-gui/lib/types.ts
  28. 114
      packages/nc-gui/package-lock.json
  29. 6
      packages/nc-gui/package.json
  30. 35
      packages/nc-gui/pages/[projectType]/map/[viewId]/index.vue
  31. 2
      packages/nc-gui/utils/cell.ts
  32. 6
      packages/nc-gui/utils/columnUtils.ts
  33. 3
      packages/nc-gui/utils/geoDataUtils.ts
  34. 1
      packages/nc-gui/utils/index.ts
  35. 3
      packages/nc-gui/utils/viewUtils.ts
  36. 81
      packages/nocodb-sdk/src/lib/Api.ts
  37. 1
      packages/nocodb-sdk/src/lib/UITypes.ts
  38. 1
      packages/nocodb-sdk/src/lib/globals.ts
  39. 6
      packages/nocodb-sdk/src/lib/sqlUi/MssqlUi.ts
  40. 8
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/helpers/getAst.ts
  41. 2
      packages/nocodb/src/lib/meta/api/index.ts
  42. 46
      packages/nocodb/src/lib/meta/api/mapViewApis.ts
  43. 3
      packages/nocodb/src/lib/meta/api/publicApis/publicDataApis.ts
  44. 6
      packages/nocodb/src/lib/meta/api/publicApis/publicDataExportApis.ts
  45. 1
      packages/nocodb/src/lib/meta/api/viewApis.ts
  46. 4
      packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts
  47. 53
      packages/nocodb/src/lib/migrations/v2/nc_026_map_view.ts
  48. 104
      packages/nocodb/src/lib/models/MapView.ts
  49. 106
      packages/nocodb/src/lib/models/MapViewColumn.ts
  50. 108
      packages/nocodb/src/lib/models/View.ts
  51. 7
      packages/nocodb/src/lib/utils/globals.ts
  52. 178
      scripts/sdk/swagger.json
  53. 8
      tests/playwright/pages/Dashboard/ExpandedForm/index.ts
  54. 51
      tests/playwright/pages/Dashboard/Map/index.ts
  55. 13
      tests/playwright/pages/Dashboard/ViewSidebar/index.ts
  56. 36
      tests/playwright/pages/Dashboard/common/Cell/GeoDataCell.ts
  57. 30
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  58. 5
      tests/playwright/pages/Dashboard/common/Toolbar/index.ts
  59. 3
      tests/playwright/pages/Dashboard/index.ts
  60. 73
      tests/playwright/tests/columnGeoData.spec.ts
  61. 92
      tests/playwright/tests/viewMap.spec.ts

4
packages/nc-gui/assets/style.scss

@ -287,6 +287,10 @@ a {
@apply !shadow-none rounded hover:(ring-1 ring-primary ring-opacity-100) focus:(ring-1 ring-accent ring-opacity-100);
}
.nc-warning-info {
@apply !shadow-none rounded ring-1 ring-red-600
}
.ant-modal {
@apply !top-[50px];
}

2
packages/nc-gui/components.d.ts vendored

@ -203,6 +203,8 @@ declare module '@vue/runtime-core' {
MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['default']
MdiMapMarker: typeof import('~icons/mdi/map-marker')['default']
MdiMapMarkerAlert: typeof import('~icons/mdi/map-marker-alert')['default']
MdiMenu: typeof import('~icons/mdi/menu')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default']

155
packages/nc-gui/components/cell/GeoData.vue

@ -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>

66
packages/nc-gui/components/dlg/ViewCreate.vue

@ -2,7 +2,7 @@
import type { ComponentPublicInstance } from '@vue/runtime-core'
import type { Form as AntForm, SelectProps } from 'ant-design-vue'
import { capitalize } from '@vue/runtime-core'
import type { FormType, GalleryType, GridType, KanbanType, TableType, ViewType } from 'nocodb-sdk'
import type { FormType, GalleryType, GridType, KanbanType, MapType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, ViewTypes } from 'nocodb-sdk'
import {
computed,
@ -25,13 +25,14 @@ interface Props {
title?: string
selectedViewId?: string
groupingFieldColumnId?: string
geoDataFieldColumnId?: string
views: ViewType[]
meta: TableType
}
interface Emits {
(event: 'update:modelValue', value: boolean): void
(event: 'created', value: GridType | KanbanType | GalleryType | FormType): void
(event: 'created', value: GridType | KanbanType | GalleryType | FormType | MapType): void
}
interface Form {
@ -40,9 +41,10 @@ interface Form {
copy_from_id: string | null
// for kanban view only
fk_grp_col_id: string | null
fk_geo_data_col_id: string | null
}
const { views = [], meta, selectedViewId, groupingFieldColumnId, ...props } = defineProps<Props>()
const { views = [], meta, selectedViewId, groupingFieldColumnId, geoDataFieldColumnId, ...props } = defineProps<Props>()
const emits = defineEmits<Emits>()
@ -61,9 +63,10 @@ const form = reactive<Form>({
type: props.type,
copy_from_id: null,
fk_grp_col_id: null,
fk_geo_data_col_id: null,
})
const singleSelectFieldOptions = ref<SelectProps['options']>([])
const viewSelectFieldOptions = ref<SelectProps['options']>([])
const viewNameRules = [
// name is required
@ -72,7 +75,7 @@ const viewNameRules = [
{
validator: (_: unknown, v: string) =>
new Promise((resolve, reject) => {
views.every((v1) => ((v1 as GridType | KanbanType | GalleryType).alias || v1.title) !== v)
views.every((v1) => ((v1 as GridType | KanbanType | GalleryType | MapType).alias || v1.title) !== v)
? resolve(true)
: reject(new Error(`View name should be unique`))
}),
@ -80,10 +83,9 @@ const viewNameRules = [
},
]
const groupingFieldColumnRules = [
// name is required
{ required: true, message: `${t('general.groupingField')} ${t('general.required')}` },
]
const groupingFieldColumnRules = [{ required: true, message: `${t('general.groupingField')} ${t('general.required')}` }]
const geoDataFieldColumnRules = [{ required: true, message: `${t('general.geoDataField')} ${t('general.required')}` }]
const typeAlias = computed(
() =>
@ -92,6 +94,7 @@ const typeAlias = computed(
[ViewTypes.GALLERY]: 'gallery',
[ViewTypes.FORM]: 'form',
[ViewTypes.KANBAN]: 'kanban',
[ViewTypes.MAP]: 'map',
}[props.type]),
)
@ -113,7 +116,7 @@ function init() {
// preset the grouping field column
if (props.type === ViewTypes.KANBAN) {
singleSelectFieldOptions.value = meta
viewSelectFieldOptions.value = meta
.columns!.filter((el) => el.uidt === UITypes.SingleSelect)
.map((field) => {
return {
@ -127,7 +130,26 @@ function init() {
form.fk_grp_col_id = groupingFieldColumnId
} else {
// take the first option
form.fk_grp_col_id = singleSelectFieldOptions.value?.[0]?.value as string
form.fk_grp_col_id = viewSelectFieldOptions.value?.[0]?.value as string
}
}
if (props.type === ViewTypes.MAP) {
viewSelectFieldOptions.value = meta
.columns!.filter((el) => el.uidt === UITypes.GeoData)
.map((field) => {
return {
value: field.id,
label: field.title,
}
})
if (geoDataFieldColumnId) {
// take from the one from copy view
form.fk_geo_data_col_id = geoDataFieldColumnId
} else {
// take the first option
form.fk_geo_data_col_id = viewSelectFieldOptions.value?.[0]?.value as string
}
}
@ -150,7 +172,7 @@ async function onSubmit() {
if (!_meta || !_meta.id) return
try {
let data: GridType | KanbanType | GalleryType | FormType | null = null
let data: GridType | KanbanType | GalleryType | FormType | MapType | null = null
switch (form.type) {
case ViewTypes.GRID:
@ -164,6 +186,9 @@ async function onSubmit() {
break
case ViewTypes.KANBAN:
data = await api.dbView.kanbanCreate(_meta.id, form)
break
case ViewTypes.MAP:
data = await api.dbView.mapCreate(_meta.id, form)
}
if (data) {
@ -207,12 +232,27 @@ async function onSubmit() {
<a-select
v-model:value="form.fk_grp_col_id"
class="w-full nc-kanban-grouping-field-select"
:options="singleSelectFieldOptions"
:options="viewSelectFieldOptions"
:disabled="groupingFieldColumnId"
placeholder="Select a Grouping Field"
not-found-content="No Single Select Field can be found. Please create one first."
/>
</a-form-item>
<a-form-item
v-if="form.type === ViewTypes.MAP"
:label="$t('general.geoDataField')"
name="fk_geo_data_col_id"
:rules="geoDataFieldColumnRules"
>
<a-select
v-model:value="form.fk_geo_data_col_id"
class="w-full"
:options="viewSelectFieldOptions"
:disabled="geoDataFieldColumnId"
placeholder="Select a GeoData Field"
not-found-content="No GeoData Field can be found. Please create one first."
/>
</a-form-item>
</a-form>
<template #footer>

0
packages/nc-gui/components/general/EasterEgg.vue

42
packages/nc-gui/components/shared-view/Map.vue

@ -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>

6
packages/nc-gui/components/smartsheet/Cell.vue

@ -21,6 +21,7 @@ import {
isDuration,
isEmail,
isFloat,
isGeoData,
isInt,
isJSON,
isManualSaved,
@ -100,7 +101,9 @@ const syncValue = useDebounceFn(
)
const vModel = computed({
get: () => props.modelValue,
get: () => {
return props.modelValue
},
set: (val) => {
if (val !== props.modelValue) {
currentRow.value.rowMeta.changed = true
@ -151,6 +154,7 @@ const isNumericField = computed(() => {
>
<template v-if="column">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellSingleSelect v-else-if="isSingleSelect(column)" v-model="vModel" :row-index="props.rowIndex" />

266
packages/nc-gui/components/smartsheet/Map.vue

@ -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: '&copy; <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>

12
packages/nc-gui/components/smartsheet/Toolbar.vue

@ -1,7 +1,7 @@
<script setup lang="ts">
import { IsPublicInj, inject, ref, useSharedView, useSidebar, useSmartsheetStoreOrThrow, useUIPermission } from '#imports'
const { isGrid, isForm, isGallery, isKanban, isSqlView } = useSmartsheetStoreOrThrow()
const { isGrid, isForm, isGallery, isKanban, isMap, isSqlView } = useSmartsheetStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
@ -18,7 +18,7 @@ const { allowCSVDownload } = useSharedView()
style="z-index: 7"
>
<LazySmartsheetToolbarViewActions
v-if="(isGrid || isGallery || isKanban) && !isPublic && isUIAllowed('dataInsert')"
v-if="(isGrid || isGallery || isKanban || isMap) && !isPublic && isUIAllowed('dataInsert')"
:show-system-fields="false"
class="ml-1"
/>
@ -29,15 +29,17 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarKanbanStackEditOrAdd v-if="isKanban" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban" :show-system-fields="false" />
<LazySmartsheetToolbarMappedBy v-if="isMap" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban || isMap" :show-system-fields="false" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap" />
<LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarRowHeight v-if="isGrid" />
<LazySmartsheetToolbarShareView v-if="(isForm || isGrid || isKanban || isGallery) && !isPublic" />
<LazySmartsheetToolbarShareView v-if="(isForm || isGrid || isKanban || isGallery || isMap) && !isPublic" />
<LazySmartsheetToolbarExport v-if="(!isPublic && !isUIAllowed('dataInsert')) || (isPublic && allowCSVDownload)" />
<div class="flex-1" />

7
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -57,9 +57,13 @@ const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup]
const geoDataToggleCondition = (t) => {
return geodataToggleState.show ? geodataToggleState.show : !t.name.includes(UITypes.GeoData)
}
const uiTypesOptions = computed<typeof uiTypes>(() => {
return [
...uiTypes.filter((t) => !isEdit.value || !t.virtual),
...uiTypes.filter((t) => geoDataToggleCondition(t) && (!isEdit.value || !t.virtual)),
...(!isEdit.value && meta?.value?.columns?.every((c) => !c.pk)
? [
{
@ -182,6 +186,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<LazySmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" />
<LazySmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" />
<LazySmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" v-model:value="formState" />
<LazySmartsheetColumnGeoDataOptions v-if="formState.uidt === UITypes.GeoData" v-model:value="formState" />
<LazySmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" v-model:value="formState" />
<LazySmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" v-model:value="formState" />
<LazySmartsheetColumnCheckboxOptions v-if="formState.uidt === UITypes.Checkbox" v-model:value="formState" />

5
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -136,7 +136,6 @@ reloadHook.on(() => {
if (isNew.value) return
loadRow()
})
provide(ReloadRowDataHookInj, reloadHook)
if (isKanban.value) {
@ -151,9 +150,7 @@ if (isKanban.value) {
const cellWrapperEl = ref<HTMLElement>()
onMounted(() => {
setTimeout(() => {
;(cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus()
})
setTimeout(() => (cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus())
})
</script>

4
packages/nc-gui/components/smartsheet/header/CellIcon.ts

@ -15,6 +15,7 @@ import {
isDuration,
isEmail,
isFloat,
isGeoData,
isInt,
isJSON,
isPercent,
@ -44,6 +45,7 @@ import CalendarIcon from '~icons/mdi/calendar'
import SingleSelectIcon from '~icons/mdi/arrow-down-drop-circle'
import MultiSelectIcon from '~icons/mdi/format-list-bulleted-square'
import DatetimeIcon from '~icons/mdi/calendar-clock'
import GeoDataIcon from '~icons/mdi/map-marker'
import RatingIcon from '~icons/mdi/star'
import GenericIcon from '~icons/mdi/square-rounded'
import NumericIcon from '~icons/mdi/numeric'
@ -64,6 +66,8 @@ const renderIcon = (column: ColumnType, abstractType: any) => {
return CalendarIcon
} else if (isDateTime(column, abstractType)) {
return DatetimeIcon
} else if (isGeoData(column)) {
return GeoDataIcon
} else if (isSet(column)) {
return MultiSelectIcon
} else if (isSingleSelect(column)) {

216
packages/nc-gui/components/smartsheet/sidebar/MenuBottom.vue

@ -2,12 +2,12 @@
import { ViewTypes } from 'nocodb-sdk'
import { useNuxtApp, useSmartsheetStoreOrThrow, viewIcons } from '#imports'
const emits = defineEmits<Emits>()
interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string }): void
}
const emits = defineEmits<Emits>()
const { $e } = useNuxtApp()
const { isSqlView } = useSmartsheetStoreOrThrow()
@ -20,101 +20,121 @@ function onOpenModal(type: ViewTypes, title = '') {
<template>
<a-menu :selected-keys="[]" class="flex flex-col">
<div>
<h3 class="px-3 text-xs font-semibold flex items-center gap-4 text-gray-500">
{{ $t('activity.createView') }}
</h3>
<a-menu-item
key="grid"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-grid-view"
@click="onOpenModal(ViewTypes.GRID)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.grid') }}
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.GRID].icon" :style="{ color: viewIcons[ViewTypes.GRID].color }" />
<div>{{ $t('objects.viewType.grid') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
key="gallery"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-gallery-view"
@click="onOpenModal(ViewTypes.GALLERY)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.gallery') }}
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.GALLERY].icon" :style="{ color: viewIcons[ViewTypes.GALLERY].color }" />
<div>{{ $t('objects.viewType.gallery') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
v-if="!isSqlView"
key="form"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-form-view"
@click="onOpenModal(ViewTypes.FORM)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.form') }}
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.FORM].icon" :style="{ color: viewIcons[ViewTypes.FORM].color }" />
<div>{{ $t('objects.viewType.form') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
key="kanban"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-kanban-view"
@click="onOpenModal(ViewTypes.KANBAN)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.kanban') }}
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.KANBAN].icon" :style="{ color: viewIcons[ViewTypes.KANBAN].color }" />
<div>{{ $t('objects.viewType.kanban') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<div class="w-full h-4" />
</div>
<h3 class="px-3 text-xs font-semibold flex items-center gap-4 text-gray-500">
{{ $t('activity.createView') }}
</h3>
<a-menu-item
key="grid"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-grid-view"
@click="onOpenModal(ViewTypes.GRID)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.grid') }}
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.GRID].icon" :style="{ color: viewIcons[ViewTypes.GRID].color }" />
<div>{{ $t('objects.viewType.grid') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
key="gallery"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-gallery-view"
@click="onOpenModal(ViewTypes.GALLERY)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.gallery') }}
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.GALLERY].icon" :style="{ color: viewIcons[ViewTypes.GALLERY].color }" />
<div>{{ $t('objects.viewType.gallery') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
v-if="!isSqlView"
key="form"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-form-view"
@click="onOpenModal(ViewTypes.FORM)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.form') }}
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.FORM].icon" :style="{ color: viewIcons[ViewTypes.FORM].color }" />
<div>{{ $t('objects.viewType.form') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
key="kanban"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-kanban-view"
@click="onOpenModal(ViewTypes.KANBAN)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.kanban') }}
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.KANBAN].icon" :style="{ color: viewIcons[ViewTypes.KANBAN].color }" />
<div>{{ $t('objects.viewType.kanban') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
v-if="geodataToggleState.show"
key="map"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-map-view"
@click="onOpenModal(ViewTypes.MAP)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.map') }}
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.MAP].icon" :style="{ color: viewIcons[ViewTypes.MAP].color }" />
<div>{{ $t('objects.viewType.map') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<div class="w-full h-4" />
</a-menu>
</template>

15
packages/nc-gui/components/smartsheet/sidebar/toolbar/GeodataSwitcher.vue

@ -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>

4
packages/nc-gui/components/smartsheet/sidebar/toolbar/index.vue

@ -29,6 +29,10 @@ const onClick = () => {
<LazySmartsheetSidebarToolbarDebugMeta />
<div class="dot" />
<LazySmartsheetSidebarToolbarGeodataSwitcher />
<div class="dot" />
</template>
<slot name="end" />

3
packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue

@ -55,6 +55,8 @@ const { eventBus } = useSmartsheetStoreOrThrow()
eventBus.on((event) => {
if (event === SmartsheetStoreEvents.FIELD_RELOAD) {
loadViewColumns()
} else if (event === SmartsheetStoreEvents.MAPPED_BY_COLUMN_CHANGE) {
loadViewColumns()
}
})
@ -204,6 +206,7 @@ useMenuCloseOnEsc(open)
v-model:checked="field.show"
v-e="['a:fields:show-hide']"
class="shrink"
:disabled="field.isViewEssentialField"
@change="saveOrUpdate(field, index)"
>
<div class="flex items-center">

113
packages/nc-gui/components/smartsheet/toolbar/MappedBy.vue

@ -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>

9
packages/nc-gui/components/smartsheet/toolbar/ShareView.vue

@ -116,6 +116,9 @@ const sharedViewUrl = computed(() => {
case ViewTypes.GALLERY:
viewType = 'gallery'
break
case ViewTypes.MAP:
viewType = 'map'
break
default:
viewType = 'view'
}
@ -350,7 +353,11 @@ const copyIframeCode = async () => {
<div
v-if="
shared && (shared.type === ViewTypes.GRID || shared.type === ViewTypes.KANBAN || shared.type === ViewTypes.GALLERY)
shared &&
(shared.type === ViewTypes.GRID ||
shared.type === ViewTypes.KANBAN ||
shared.type === ViewTypes.GALLERY ||
shared.type === ViewTypes.MAP)
"
>
<!-- Allow Download -->

3
packages/nc-gui/components/smartsheet/toolbar/SharedViewList.vue

@ -56,6 +56,9 @@ const sharedViewUrl = (view: SharedViewType) => {
case ViewTypes.FORM:
viewType = 'form'
break
case ViewTypes.MAP:
viewType = 'map'
break
case ViewTypes.KANBAN:
viewType = 'kanban'
break

5
packages/nc-gui/components/tabs/Smartsheet.vue

@ -39,7 +39,7 @@ const fields = ref<ColumnType[]>([])
const meta = computed<TableType | undefined>(() => activeTab.value && metas.value[activeTab.value.id!])
const { isGallery, isGrid, isForm, isKanban, isLocked } = useProvideSmartsheetStore(activeView, meta)
const { isGallery, isGrid, isForm, isKanban, isLocked, isMap } = useProvideSmartsheetStore(activeView, meta)
const reloadEventHook = createEventHook<void | boolean>()
@ -48,6 +48,7 @@ const reloadViewMetaEventHook = createEventHook<void | boolean>()
const openNewRecordFormHook = createEventHook<void>()
useProvideKanbanViewStore(meta, activeView)
useProvideMapViewStore(meta, activeView)
// todo: move to store
provide(MetaInj, meta)
@ -81,6 +82,8 @@ provide(
<LazySmartsheetForm v-else-if="isForm && !$route.query.reload" />
<LazySmartsheetKanban v-else-if="isKanban" />
<LazySmartsheetMap v-else-if="isMap" />
</div>
</div>
</template>

182
packages/nc-gui/composables/useMapViewDataStore.ts

@ -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
}

3
packages/nc-gui/composables/useSmartsheetStore.ts

@ -26,13 +26,13 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore'))
// getters
const isLocked = computed(() => view.value?.lock_type === 'locked')
const isPkAvail = computed(() => (meta.value as TableType)?.columns?.some((c) => c.pk))
const isGrid = computed(() => view.value?.type === ViewTypes.GRID)
const isForm = computed(() => view.value?.type === ViewTypes.FORM)
const isGallery = computed(() => view.value?.type === ViewTypes.GALLERY)
const isKanban = computed(() => view.value?.type === ViewTypes.KANBAN)
const isMap = computed(() => view.value?.type === ViewTypes.MAP)
const isSharedForm = computed(() => isForm.value && shared)
const xWhere = computed(() => {
let where
@ -65,6 +65,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
isGrid,
isGallery,
isKanban,
isMap,
cellRefs,
isSharedForm,
sorts,

14
packages/nc-gui/composables/useViewColumns.ts

@ -1,4 +1,4 @@
import { isSystemColumn } from 'nocodb-sdk'
import { isSystemColumn, MapType, ViewTypes } from 'nocodb-sdk'
import type { ColumnType, TableType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import { IsPublicInj, computed, inject, ref, useNuxtApp, useProject, useUIPermission, watch } from '#imports'
@ -25,6 +25,13 @@ export function useViewColumns(
() => isPublic.value || !isUIAllowed('hideAllColumns') || !isUIAllowed('showAllColumns') || isSharedBase.value,
)
const isColumnViewEssential = (column: ColumnType) => {
// TODO: consider at some point ti delegate this via a cleaner design pattern to view specific check logic
// which could be inside of a view specific helper class (and generalized via an interface)
// (on the other hand, the logic complexity is still very low atm - might be overkill)
return view.value?.type === ViewTypes.MAP && (view.value?.view as MapType)?.fk_geo_data_col_id === column.id
}
const metaColumnById = computed<Record<string, ColumnType>>(() => {
if (!meta.value?.columns) return {}
@ -38,6 +45,7 @@ export function useViewColumns(
})
const loadViewColumns = async () => {
if (!meta || !view) return
let order = 1
@ -62,8 +70,10 @@ export function useViewColumns(
title: column.title,
fk_column_id: column.id,
...currentColumnField,
show: currentColumnField.show || isColumnViewEssential(currentColumnField),
order: currentColumnField.order || order++,
system: isSystemColumn(metaColumnById?.value?.[currentColumnField.fk_column_id!]),
isViewEssentialField: isColumnViewEssential(column),
}
})
.sort((a: Field, b: Field) => a.order - b.order)
@ -98,7 +108,7 @@ export function useViewColumns(
if (isLocalMode.value) {
fields.value = fields.value?.map((field: Field) => ({
...field,
show: false,
show: !!field.isViewEssentialField,
}))
reloadData?.()
return

3
packages/nc-gui/lang/de.json

@ -98,7 +98,8 @@
"gallery": "Galerie",
"form": "Formular",
"kanban": "Kanban",
"calendar": "Kalender"
"calendar": "Kalender",
"map": "Karte"
},
"user": "Nutzer",
"users": "Benutzer",

20
packages/nc-gui/lang/en.json

@ -74,7 +74,8 @@
"insertBefore": "Insert Before",
"hideField": "Hide Field",
"sortAsc": "Sort Ascending",
"sortDesc": "Sort Descending"
"sortDesc": "Sort Descending",
"geoDataField": "GeoData Field"
},
"objects": {
"project": "Project",
@ -98,7 +99,8 @@
"gallery": "Gallery",
"form": "Form",
"kanban": "Kanban",
"calendar": "Calendar"
"calendar": "Calendar",
"map": "Map"
},
"user": "User",
"users": "Users",
@ -136,6 +138,7 @@
"Currency": "Currency",
"Percent": "Percent",
"Duration": "Duration",
"GeoData": "GeoData",
"Rating": "Rating",
"Formula": "Formula",
"Rollup": "Rollup",
@ -253,6 +256,9 @@
"barcodeFormat": "Barcode format",
"qrCodeValueTooLong": "Too many characters for a QR code",
"barcodeValueTooLong": "Too many characters for a barcode",
"yourLocation": "Your Location",
"lng": "Lng",
"lat": "Lat",
"aggregateFunction": "Aggregate function",
"dbCreateIfNotExists": "Database : create if not exists",
"clientKey": "Client Key",
@ -452,6 +458,10 @@
"stackedBy": "Stacked By",
"chooseGroupingField": "Choose a Grouping Field",
"addOrEditStack": "Add / Edit Stack"
},
"map": {
"mappedBy": "Mapped By",
"chooseMappingField": "Choose a Mapping Field"
}
},
"tooltip": {
@ -518,6 +528,11 @@
"orgCreator": "Creator can create new projects and access any invited project.",
"orgViewer": "Viewer is not allowed to create new projects but they can access any invited project."
},
"map": {
"overLimit": "You're over the limit.",
"closeLimit": "You're getting close to the limit.",
"limitNumber": "The limit of markers shown in a Map View is 1000 records."
},
"footerInfo": "Rows per page",
"upload": "Select file to Upload",
"upload_sub": "or drag and drop file",
@ -600,6 +615,7 @@
"gallery": "Add Gallery View",
"form": "Add Form View",
"kanban": "Add Kanban View",
"map": "Add Map View",
"calendar": "Add Calendar View"
},
"tablesMetadataInSync": "Tables metadata is in Sync",

1
packages/nc-gui/lib/enums.ts

@ -88,6 +88,7 @@ export enum SmartsheetStoreEvents {
DATA_RELOAD = 'data-reload',
FIELD_RELOAD = 'field-reload',
FIELD_ADD = 'field-add',
MAPPED_BY_COLUMN_CHANGE = 'mapped-by-column-change',
}
export enum DataSourcesSubTab {

1
packages/nc-gui/lib/types.ts

@ -32,6 +32,7 @@ export interface Field {
title: string
fk_column_id?: string
system?: boolean
isViewEssentialField?: boolean
}
export type Roles<T extends Role | ProjectRole = Role | ProjectRole> = Record<T | string, boolean>

114
packages/nc-gui/package-lock.json generated

@ -27,6 +27,8 @@
"jsep": "^1.3.6",
"just-clone": "^6.1.1",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.2",
"leaflet.markercluster": "^1.5.3",
"locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0",
"nocodb-sdk": "file:../nocodb-sdk",
@ -41,6 +43,7 @@
"vue-dompurify-html": "^3.0.0",
"vue-github-button": "^3.0.3",
"vue-i18n": "^9.2.2",
"vue3-contextmenu": "^0.2.12",
"vue3-text-clamp": "^0.1.1",
"vuedraggable": "^4.1.0",
"xlsx": "^0.18.5"
@ -69,6 +72,9 @@
"@nuxt/image-edge": "^1.0.0-27657146.da85542",
"@types/axios": "^0.14.0",
"@types/dagre": "^0.7.48",
"@types/file-saver": "^2.0.5",
"@types/leaflet": "^1.9.0",
"@types/leaflet.markercluster": "^1.5.1",
"@types/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0",
"@types/tinycolor2": "^1.4.3",
@ -3131,7 +3137,8 @@
"node_modules/@types/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ=="
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==",
"dev": true
},
"node_modules/@types/form-data": {
"version": "0.0.33",
@ -3142,6 +3149,12 @@
"@types/node": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
@ -3154,6 +3167,24 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
"node_modules/@types/leaflet": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.0.tgz",
"integrity": "sha512-7LeOSj7EloC5UcyOMo+1kc3S1UT3MjJxwqsMT1d2PTyvQz53w0Y0oSSk9nwZnOZubCmBvpSNGceucxiq+ZPEUw==",
"dev": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/leaflet.markercluster": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.1.tgz",
"integrity": "sha512-gzJzP10qO6Zkts5QNVmSAEDLYicQHTEBLT9HZpFrJiSww9eDAs5OWHvIskldf41MvDv1gbMukuEBQEawHn+wtA==",
"dev": true,
"dependencies": {
"@types/leaflet": "*"
}
},
"node_modules/@types/mdast": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@ -10661,6 +10692,19 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/leaflet": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ=="
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
"peerDependencies": {
"leaflet": "^1.3.1"
}
},
"node_modules/less": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/less/-/less-4.1.3.tgz",
@ -11274,6 +11318,11 @@
"minipass": "^2.9.0"
}
},
"node_modules/mitt": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
"integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg=="
},
"node_modules/mkdir": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/mkdir/-/mkdir-0.0.2.tgz",
@ -17172,6 +17221,16 @@
"vue": "^3.0.0"
}
},
"node_modules/vue3-contextmenu": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/vue3-contextmenu/-/vue3-contextmenu-0.2.12.tgz",
"integrity": "sha512-lSA+Pq4wozbf9nDmMBvz2Z2DXwGwCEeXEDsmQi+cIxc5FzPa1Ia013hv5/DQZwr4dPilNxrOXPht1u0CMXcpVw==",
"dependencies": {
"core-js": "^3.6.5",
"mitt": "^2.1.0",
"vue": "^3.0.0"
}
},
"node_modules/vue3-text-clamp": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/vue3-text-clamp/-/vue3-text-clamp-0.1.1.tgz",
@ -20061,7 +20120,8 @@
"@types/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ=="
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==",
"dev": true
},
"@types/form-data": {
"version": "0.0.33",
@ -20072,6 +20132,12 @@
"@types/node": "*"
}
},
"@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
},
"@types/json-schema": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
@ -20084,6 +20150,24 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
"@types/leaflet": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.0.tgz",
"integrity": "sha512-7LeOSj7EloC5UcyOMo+1kc3S1UT3MjJxwqsMT1d2PTyvQz53w0Y0oSSk9nwZnOZubCmBvpSNGceucxiq+ZPEUw==",
"dev": true,
"requires": {
"@types/geojson": "*"
}
},
"@types/leaflet.markercluster": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.1.tgz",
"integrity": "sha512-gzJzP10qO6Zkts5QNVmSAEDLYicQHTEBLT9HZpFrJiSww9eDAs5OWHvIskldf41MvDv1gbMukuEBQEawHn+wtA==",
"dev": true,
"requires": {
"@types/leaflet": "*"
}
},
"@types/mdast": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@ -25481,6 +25565,17 @@
}
}
},
"leaflet": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ=="
},
"leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
"requires": {}
},
"less": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/less/-/less-4.1.3.tgz",
@ -25974,6 +26069,11 @@
"minipass": "^2.9.0"
}
},
"mitt": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
"integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg=="
},
"mkdir": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/mkdir/-/mkdir-0.0.2.tgz",
@ -30167,6 +30267,16 @@
"is-plain-object": "3.0.1"
}
},
"vue3-contextmenu": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/vue3-contextmenu/-/vue3-contextmenu-0.2.12.tgz",
"integrity": "sha512-lSA+Pq4wozbf9nDmMBvz2Z2DXwGwCEeXEDsmQi+cIxc5FzPa1Ia013hv5/DQZwr4dPilNxrOXPht1u0CMXcpVw==",
"requires": {
"core-js": "^3.6.5",
"mitt": "^2.1.0",
"vue": "^3.0.0"
}
},
"vue3-text-clamp": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/vue3-text-clamp/-/vue3-text-clamp-0.1.1.tgz",

6
packages/nc-gui/package.json

@ -50,6 +50,8 @@
"jsep": "^1.3.6",
"just-clone": "^6.1.1",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.2",
"leaflet.markercluster": "^1.5.3",
"locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0",
"nocodb-sdk": "file:../nocodb-sdk",
@ -64,6 +66,7 @@
"vue-dompurify-html": "^3.0.0",
"vue-github-button": "^3.0.3",
"vue-i18n": "^9.2.2",
"vue3-contextmenu": "^0.2.12",
"vue3-text-clamp": "^0.1.1",
"vuedraggable": "^4.1.0",
"xlsx": "^0.18.5"
@ -92,6 +95,9 @@
"@nuxt/image-edge": "^1.0.0-27657146.da85542",
"@types/axios": "^0.14.0",
"@types/dagre": "^0.7.48",
"@types/file-saver": "^2.0.5",
"@types/leaflet": "^1.9.0",
"@types/leaflet.markercluster": "^1.5.1",
"@types/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0",
"@types/tinycolor2": "^1.4.3",

35
packages/nc-gui/pages/[projectType]/map/[viewId]/index.vue

@ -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>

2
packages/nc-gui/utils/cell.ts

@ -27,6 +27,7 @@ export const isCurrency = (column: ColumnType) => column.uidt === UITypes.Curren
export const isPhoneNumber = (column: ColumnType) => column.uidt === UITypes.PhoneNumber
export const isDecimal = (column: ColumnType) => column.uidt === UITypes.Decimal
export const isDuration = (column: ColumnType) => column.uidt === UITypes.Duration
export const isGeoData = (column: ColumnType) => column.uidt === UITypes.GeoData
export const isPercent = (column: ColumnType) => column.uidt === UITypes.Percent
export const isSpecificDBType = (column: ColumnType) => column.uidt === UITypes.SpecificDBType
export const isAutoSaved = (column: ColumnType) =>
@ -43,6 +44,7 @@ export const isAutoSaved = (column: ColumnType) =>
UITypes.AutoNumber,
UITypes.SpecificDBType,
UITypes.Geometry,
UITypes.GeoData,
UITypes.Duration,
].includes(column.uidt as UITypes)

6
packages/nc-gui/utils/columnUtils.ts

@ -8,6 +8,7 @@ import TextSubject from '~icons/mdi/text-subject'
import JSONIcon from '~icons/mdi/code-json'
import SpecificDBTypeIcon from '~icons/mdi/database-settings'
import Attachment from '~icons/mdi/attachment'
import Marker from '~icons/mdi/map-marker'
import CheckboxMarkedOutline from '~icons/mdi/checkbox-marked-outline'
import FormatListBulletedSquare from '~icons/mdi/format-list-bulleted-square'
import ArrowDownDropCircle from '~icons/mdi/arrow-down-drop-circle'
@ -141,6 +142,11 @@ const uiTypes = [
name: UITypes.Geometry,
icon: RulerSquareCompass,
},
{
name: UITypes.GeoData,
icon: Marker,
},
{
name: UITypes.JSON,
icon: JSONIcon,

3
packages/nc-gui/utils/geoDataUtils.ts

@ -0,0 +1,3 @@
const latLongToJoinedString = (lat: number, long: number) => `${lat.toFixed(7)};${long.toFixed(7)}`
export { latLongToJoinedString }

1
packages/nc-gui/utils/index.ts

@ -20,4 +20,5 @@ export * from './userUtils'
export * from './stringUtils'
export * from './memStorage'
export * from './browserUtils'
export * from './geoDataUtils'
export * from './mimeTypeUtils'

3
packages/nc-gui/utils/viewUtils.ts

@ -6,6 +6,7 @@ import MdiFormIcon from '~icons/mdi/form-select'
import MdiCalendarIcon from '~icons/mdi/calendar'
import MdiGalleryIcon from '~icons/mdi/camera-image'
import MdiKanbanIcon from '~icons/mdi/tablet-dashboard'
import MdiMapIcon from '~icons/mdi/map-outline'
import MdiEyeIcon from '~icons/mdi/eye-circle-outline'
import type { Language } from '~/lib'
@ -14,6 +15,7 @@ export const viewIcons: Record<number | string, { icon: any; color: string }> =
[ViewTypes.FORM]: { icon: MdiFormIcon, color: themeV2Colors.pink['500'] },
calendar: { icon: MdiCalendarIcon, color: 'purple' },
[ViewTypes.GALLERY]: { icon: MdiGalleryIcon, color: 'orange' },
[ViewTypes.MAP]: { icon: MdiMapIcon, color: 'blue' },
[ViewTypes.KANBAN]: { icon: MdiKanbanIcon, color: 'green' },
view: { icon: MdiEyeIcon, color: 'blue' },
}
@ -23,6 +25,7 @@ export const viewTypeAlias: Record<number, string> = {
[ViewTypes.FORM]: 'form',
[ViewTypes.GALLERY]: 'gallery',
[ViewTypes.KANBAN]: 'kanban',
[ViewTypes.MAP]: 'map',
}
export const isRtlLang = (lang: keyof typeof Language) => ['fa', 'ar'].includes(lang)

81
packages/nocodb-sdk/src/lib/Api.ts

@ -139,7 +139,7 @@ export interface ViewType {
show_system_fields?: boolean;
lock_type?: 'collaborative' | 'locked' | 'personal';
type?: number;
view?: FormType | GridType | GalleryType | KanbanType;
view?: FormType | GridType | GalleryType | KanbanType | MapType;
}
export interface TableInfoType {
@ -398,6 +398,33 @@ export interface KanbanType {
meta?: string | object;
}
export interface GeoLocationType {
/** @format double */
latitude?: number;
/** @format double */
longitude?: number;
}
export interface MapType {
id?: string;
title?: string;
alias?: string;
initial_geo_position?: GeoLocationType;
fk_model_id?: string;
fk_view_id?: string;
fk_geo_data_col_id?: string | null;
columns?: MapColumnType[];
meta?: string | object;
}
export interface MapColumnType {
id?: string;
label?: string;
help?: string;
fk_col_id?: string;
fk_gallery_id?: string;
}
export interface FormType {
id?: string;
title?: string;
@ -590,6 +617,7 @@ export interface NormalColumnRequestType {
| 'Collaborator'
| 'Date'
| 'Year'
| 'GeoData'
| 'Time'
| 'PhoneNumber'
| 'Email'
@ -2691,6 +2719,57 @@ export class Api<
format: 'json',
...params,
}),
/**
* No description
*
* @tags DB view
* @name MapCreate
* @request POST:/api/v1/db/meta/tables/{tableId}/maps
* @response `200` `object` OK
*/
mapCreate: (tableId: string, data: MapType, params: RequestParams = {}) =>
this.request<object, any>({
path: `/api/v1/db/meta/tables/${tableId}/maps`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* No description
*
* @tags DB view
* @name MapUpdate
* @request PATCH:/api/v1/db/meta/maps/{mapId}
* @response `200` `void` OK
*/
mapUpdate: (mapId: string, data: MapType, params: RequestParams = {}) =>
this.request<void, any>({
path: `/api/v1/db/meta/maps/${mapId}`,
method: 'PATCH',
body: data,
type: ContentType.Json,
...params,
}),
/**
* No description
*
* @tags DB view
* @name MapRead
* @request GET:/api/v1/db/meta/maps/{mapId}
* @response `200` `MapType` OK
*/
mapRead: (mapId: string, params: RequestParams = {}) =>
this.request<MapType, any>({
path: `/api/v1/db/meta/maps/${mapId}`,
method: 'GET',
format: 'json',
...params,
}),
};
dbViewShare = {
/**

1
packages/nocodb-sdk/src/lib/UITypes.ts

@ -16,6 +16,7 @@ enum UITypes {
Year = 'Year',
Time = 'Time',
PhoneNumber = 'PhoneNumber',
GeoData = 'GeoData',
Email = 'Email',
URL = 'URL',
Number = 'Number',

1
packages/nocodb-sdk/src/lib/globals.ts

@ -3,6 +3,7 @@ export enum ViewTypes {
GALLERY = 2,
GRID = 3,
KANBAN = 4,
MAP = 5,
}
export enum RelationTypes {

6
packages/nocodb-sdk/src/lib/sqlUi/MssqlUi.ts

@ -978,6 +978,9 @@ export class MssqlUi {
case 'Collaborator':
colProp.dt = 'varchar';
break;
case 'GeoData':
colProp.dt = 'varchar';
break;
case 'Date':
colProp.dt = 'date';
@ -1133,6 +1136,9 @@ export class MssqlUi {
case 'Decimal':
return ['decimal', 'float'];
case 'GeoData':
return ['decimal', 'float'];
case 'Currency':
return [
'int',

8
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/helpers/getAst.ts

@ -81,13 +81,13 @@ const getAst = async ({
...(await obj),
[col.title]:
allowedCols && (!includePkByDefault || !col.pk)
? allowedCols[col.id] &&
? (allowedCols[col.id] &&
(!isSystemColumn(col) || view.show_system_fields) &&
(!fields?.length || fields.includes(col.title)) &&
value
: fields?.length
value)
: (fields?.length
? fields.includes(col.title) && value
: value,
: value),
};
}, Promise.resolve({}));
};

2
packages/nocodb/src/lib/meta/api/index.ts

@ -54,6 +54,7 @@ import crypto from 'crypto';
import swaggerApis from './swagger/swaggerApis';
import importApis from './sync/importApis';
import syncSourceApis from './sync/syncSourceApis';
import mapViewApis from './mapViewApis';
const clients: { [id: string]: Socket } = {};
const jobs: { [id: string]: { last_message: any } } = {};
@ -105,6 +106,7 @@ export default function (router: Router, server) {
router.use(swaggerApis);
router.use(syncSourceApis);
router.use(kanbanViewApis);
router.use(mapViewApis);
userApis(router);

46
packages/nocodb/src/lib/meta/api/mapViewApis.ts

@ -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;

3
packages/nocodb/src/lib/meta/api/publicApis/publicDataApis.ts

@ -28,7 +28,8 @@ export async function dataList(req: Request, res: Response) {
if (
view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN &&
view.type !== ViewTypes.GALLERY
view.type !== ViewTypes.GALLERY &&
view.type !== ViewTypes.MAP
) {
NcError.notFound('Not found');
}

6
packages/nocodb/src/lib/meta/api/publicApis/publicDataExportApis.ts

@ -19,7 +19,8 @@ async function exportExcel(req: Request, res: Response) {
if (
view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN &&
view.type !== ViewTypes.GALLERY
view.type !== ViewTypes.GALLERY &&
view.type !== ViewTypes.MAP
)
NcError.notFound('Not found');
@ -67,7 +68,8 @@ async function exportCsv(req: Request, res: Response) {
if (
view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN &&
view.type !== ViewTypes.GALLERY
view.type !== ViewTypes.GALLERY &&
view.type !== ViewTypes.MAP
)
NcError.notFound('Not found');

1
packages/nocodb/src/lib/meta/api/viewApis.ts

@ -89,6 +89,7 @@ async function showAllColumns(req: Request<any, any>, res) {
}
async function hideAllColumns(req: Request<any, any>, res) {
res.json(
await View.hideAllColumns(
req.params.viewId,

4
packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts

@ -13,6 +13,7 @@ import * as nc_022_qr_code_column_type from './v2/nc_022_qr_code_column_type';
import * as nc_023_multiple_source from './v2/nc_023_multiple_source';
import * as nc_024_barcode_column_type from './v2/nc_024_barcode_column_type';
import * as nc_025_add_row_height from './v2/nc_025_add_row_height';
import * as nc_026_map_view from './v2/nc_026_map_view';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -37,6 +38,7 @@ export default class XcMigrationSourcev2 {
'nc_023_multiple_source',
'nc_024_barcode_column_type',
'nc_025_add_row_height',
'nc_026_map_view',
]);
}
@ -76,6 +78,8 @@ export default class XcMigrationSourcev2 {
return nc_024_barcode_column_type;
case 'nc_025_add_row_height':
return nc_025_add_row_height;
case 'nc_026_map_view':
return nc_026_map_view;
}
}
}

53
packages/nocodb/src/lib/migrations/v2/nc_026_map_view.ts

@ -0,0 +1,53 @@
import { MetaTable } from '../../utils/globals';
const up = async (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) => {
await knex.schema.dropTable(MetaTable.MAP_VIEW);
await knex.schema.dropTable(MetaTable.MAP_VIEW_COLUMNS);
};
export { up, down };

104
packages/nocodb/src/lib/models/MapView.ts

@ -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,
});
}
}

106
packages/nocodb/src/lib/models/MapViewColumn.ts

@ -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));
}
}

108
packages/nocodb/src/lib/models/View.ts

@ -27,6 +27,8 @@ import KanbanViewColumn from './KanbanViewColumn';
import Column from './Column';
import NocoCache from '../cache/NocoCache';
import { extractProps } from '../meta/helpers/extractProps';
import MapView from './MapView';
import MapViewColumn from './MapViewColumn';
const { v4: uuidv4 } = require('uuid');
export default class View implements ViewType {
@ -42,9 +44,13 @@ export default class View implements ViewType {
fk_model_id: string;
model?: Model;
view?: FormView | GridView | KanbanView | GalleryView;
view?: FormView | GridView | KanbanView | GalleryView | MapView;
columns?: Array<
FormViewColumn | GridViewColumn | GalleryViewColumn | KanbanViewColumn
| FormViewColumn
| GridViewColumn
| GalleryViewColumn
| KanbanViewColumn
| MapViewColumn
>;
sorts: Sort[];
@ -83,6 +89,9 @@ export default class View implements ViewType {
case ViewTypes.GALLERY:
this.view = await GalleryView.get(this.id);
break;
case ViewTypes.MAP:
this.view = await MapView.get(this.id);
break;
case ViewTypes.FORM:
this.view = await FormView.get(this.id);
break;
@ -103,6 +112,9 @@ export default class View implements ViewType {
case ViewTypes.GALLERY:
this.view = await GalleryView.get(this.id, ncMeta);
break;
case ViewTypes.MAP:
this.view = await MapView.get(this.id, ncMeta);
break;
case ViewTypes.FORM:
this.view = await FormView.get(this.id, ncMeta);
break;
@ -236,7 +248,7 @@ export default class View implements ViewType {
static async insert(
view: Partial<View> &
Partial<FormView | GridView | GalleryView | KanbanView> & {
Partial<FormView | GridView | GalleryView | KanbanView | MapView> & {
copy_from_id?: string;
fk_grp_col_id?: string;
created_at?;
@ -310,6 +322,15 @@ export default class View implements ViewType {
ncMeta
);
break;
case ViewTypes.MAP:
await MapView.insert(
{
...(view as MapView),
fk_view_id: view_id,
},
ncMeta
);
break;
case ViewTypes.GALLERY:
await GalleryView.insert(
{
@ -433,6 +454,11 @@ export default class View implements ViewType {
// other columns will be hidden
show = false;
}
} else if (view.type === ViewTypes.MAP && !copyFromView) {
const mapView = await MapView.get(view_id, ncMeta);
if (vCol.id === mapView?.fk_geo_data_col_id) {
show = true;
}
}
// if columns is list of virtual columns then get the parent column
@ -490,6 +516,16 @@ export default class View implements ViewType {
case ViewTypes.GALLERY:
await GalleryViewColumn.insert(modifiedInsertObj, ncMeta);
break;
case ViewTypes.MAP:
await MapViewColumn.insert(
{
...insertObj,
fk_view_id: view.id,
},
ncMeta
);
break;
case ViewTypes.KANBAN:
await KanbanViewColumn.insert(modifiedInsertObj, ncMeta);
break;
@ -533,6 +569,17 @@ export default class View implements ViewType {
);
}
break;
case ViewTypes.MAP:
{
col = await MapViewColumn.insert(
{
...param,
fk_view_id: view.id,
},
ncMeta
);
}
break;
case ViewTypes.FORM:
{
col = await FormViewColumn.insert(
@ -573,7 +620,11 @@ export default class View implements ViewType {
ncMeta = Noco.ncMeta
): Promise<
Array<
GridViewColumn | FormViewColumn | GalleryViewColumn | KanbanViewColumn
| GridViewColumn
| FormViewColumn
| GalleryViewColumn
| KanbanViewColumn
| MapViewColumn
>
> {
let columns: Array<GridViewColumn | any> = [];
@ -587,6 +638,9 @@ export default class View implements ViewType {
case ViewTypes.GALLERY:
columns = await GalleryViewColumn.list(viewId, ncMeta);
break;
case ViewTypes.MAP:
columns = await MapViewColumn.list(viewId, ncMeta);
break;
case ViewTypes.FORM:
columns = await FormViewColumn.list(viewId, ncMeta);
break;
@ -620,6 +674,10 @@ export default class View implements ViewType {
table = MetaTable.GRID_VIEW_COLUMNS;
cacheScope = CacheScope.GRID_VIEW_COLUMN;
break;
case ViewTypes.MAP:
table = MetaTable.MAP_VIEW_COLUMNS;
cacheScope = CacheScope.MAP_VIEW_COLUMN;
break;
case ViewTypes.GALLERY:
table = MetaTable.GALLERY_VIEW_COLUMNS;
cacheScope = CacheScope.GALLERY_VIEW_COLUMN;
@ -687,7 +745,12 @@ export default class View implements ViewType {
},
ncMeta = Noco.ncMeta
): Promise<
GridViewColumn | FormViewColumn | GalleryViewColumn | KanbanViewColumn | any
| GridViewColumn
| FormViewColumn
| GalleryViewColumn
| KanbanViewColumn
| MapViewColumn
| any
> {
const view = await this.get(viewId);
const table = this.extractViewColumnsTableName(view);
@ -734,6 +797,14 @@ export default class View implements ViewType {
show: colData.show,
});
break;
case ViewTypes.MAP:
return await MapViewColumn.insert({
fk_view_id: viewId,
fk_column_id: fkColId,
order: colData.order,
show: colData.show,
});
break;
case ViewTypes.FORM:
return await FormViewColumn.insert({
fk_view_id: viewId,
@ -974,6 +1045,9 @@ export default class View implements ViewType {
case ViewTypes.FORM:
table = MetaTable.FORM_VIEW_COLUMNS;
break;
case ViewTypes.MAP:
table = MetaTable.MAP_VIEW_COLUMNS;
break;
}
return table;
}
@ -993,6 +1067,9 @@ export default class View implements ViewType {
case ViewTypes.FORM:
table = MetaTable.FORM_VIEW;
break;
case ViewTypes.MAP:
table = MetaTable.MAP_VIEW;
break;
}
return table;
}
@ -1006,6 +1083,9 @@ export default class View implements ViewType {
case ViewTypes.GALLERY:
scope = CacheScope.GALLERY_VIEW_COLUMN;
break;
case ViewTypes.MAP:
scope = CacheScope.MAP_VIEW_COLUMN;
break;
case ViewTypes.KANBAN:
scope = CacheScope.KANBAN_VIEW_COLUMN;
break;
@ -1025,6 +1105,9 @@ export default class View implements ViewType {
case ViewTypes.GALLERY:
scope = CacheScope.GALLERY_VIEW;
break;
case ViewTypes.MAP:
scope = CacheScope.MAP_VIEW;
break;
case ViewTypes.KANBAN:
scope = CacheScope.KANBAN_VIEW;
break;
@ -1140,9 +1223,20 @@ export default class View implements ViewType {
// get existing cache
const dataList = await NocoCache.getList(scope, [viewId]);
const colsEssentialForView =
view.type === ViewTypes.MAP
? [(await MapView.get(viewId)).fk_geo_data_col_id]
: [];
const mergedIgnoreColdIds = [...ignoreColdIds, ...colsEssentialForView];
if (dataList?.length) {
for (const o of dataList) {
if (!ignoreColdIds?.length || !ignoreColdIds.includes(o.fk_column_id)) {
if (
!mergedIgnoreColdIds?.length ||
!mergedIgnoreColdIds.includes(o.fk_column_id)
) {
// set data
o.show = false;
// set cache
@ -1159,7 +1253,7 @@ export default class View implements ViewType {
{
fk_view_id: viewId,
},
ignoreColdIds?.length
mergedIgnoreColdIds?.length
? {
_not: {
fk_column_id: {

7
packages/nocodb/src/lib/utils/globals.ts

@ -39,7 +39,10 @@ export enum MetaTable {
API_TOKENS = 'nc_api_tokens',
SYNC_SOURCE = 'nc_sync_source_v2',
SYNC_LOGS = 'nc_sync_logs_v2',
MAP_VIEW = 'nc_map_view_v2',
MAP_VIEW_COLUMNS = 'nc_map_view_columns_v2',
STORE = 'nc_store',
}
export const orderedMetaTables = [
@ -51,6 +54,8 @@ export const orderedMetaTables = [
MetaTable.ORGS,
MetaTable.PROJECT_USERS,
MetaTable.USERS,
MetaTable.MAP_VIEW,
MetaTable.MAP_VIEW_COLUMNS,
MetaTable.KANBAN_VIEW_COLUMNS,
MetaTable.KANBAN_VIEW,
MetaTable.GRID_VIEW_COLUMNS,
@ -127,6 +132,8 @@ export enum CacheScope {
GRID_VIEW = 'gridView',
GRID_VIEW_COLUMN = 'gridViewColumn',
KANBAN_VIEW = 'kanbanView',
MAP_VIEW = 'mapView',
MAP_VIEW_COLUMN = 'mapViewColumn',
KANBAN_VIEW_COLUMN = 'kanbanViewColumn',
USER = 'user',
ORGS = 'orgs',

178
scripts/sdk/swagger.json

@ -3320,6 +3320,99 @@
]
}
},
"/api/v1/db/meta/tables/{tableId}/maps": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true
}
],
"post": {
"summary": "",
"operationId": "db-view-map-create",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {}
}
}
}
}
},
"tags": [
"DB view"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Map"
}
}
}
}
}
},
"/api/v1/db/meta/maps/{mapId}": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "mapId",
"in": "path",
"required": true
}
],
"patch": {
"summary": "",
"operationId": "db-view-map-update",
"responses": {
"200": {
"description": "OK"
}
},
"tags": [
"DB view"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Map"
}
}
}
}
},
"get": {
"summary": "",
"operationId": "db-view-map-read",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Map"
}
}
}
}
},
"tags": [
"DB view"
]
}
},
"/api/v1/db/meta/projects/{projectId}/meta-diff": {
"parameters": [
{
@ -7703,6 +7796,9 @@
},
{
"$ref": "#/components/schemas/Kanban"
},
{
"$ref": "#/components/schemas/Map"
}
]
}
@ -8755,6 +8851,87 @@
}
}
},
"GeoLocation": {
"title": "GeoLocation",
"type": "object",
"description": "",
"properties": {
"latitude": {
"type": "number",
"format": "double"
},
"longitude": {
"type": "number",
"format": "double"
}
}
},
"Map": {
"title": "Map",
"type": "object",
"description": "",
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"alias": {
"type": "string"
},
"initial_geo_position": {
"$ref": "#/components/schemas/GeoLocation"
},
"fk_model_id": {
"type": "string"
},
"fk_view_id": {
"type": "string",
"minLength": 1
},
"fk_geo_data_col_id": {
"type": [
"string",
"null"
]
},
"columns": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MapColumn"
}
},
"meta": {
"type": [
"string",
"object"
]
}
}
},
"MapColumn": {
"title": "MapColumn",
"type": "object",
"description": "",
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"help": {
"type": "string"
},
"fk_col_id": {
"type": "string"
},
"fk_gallery_id": {
"type": "string"
}
}
},
"Form": {
"title": "Form",
"type": "object",
@ -9407,6 +9584,7 @@
"Collaborator",
"Date",
"Year",
"GeoData",
"Time",
"PhoneNumber",
"Email",

8
tests/playwright/pages/Dashboard/ExpandedForm/index.ts

@ -67,6 +67,14 @@ export class ExpandedFormPage extends BasePage {
case 'text':
await field.locator('input').fill(value);
break;
case 'geodata': {
const [lat, long] = value.split(',');
await this.rootPage.locator(`[data-testid="nc-geo-data-set-location-button"]`).click();
await this.rootPage.locator(`[data-testid="nc-geo-data-latitude"]`).fill(lat);
await this.rootPage.locator(`[data-testid="nc-geo-data-longitude"]`).fill(long);
await this.rootPage.locator(`[data-testid="nc-geo-data-save"]`).click();
break;
}
case 'belongsTo':
await field.locator('.nc-action-icon').click();
await this.dashboard.linkRecord.select(value);

51
tests/playwright/pages/Dashboard/Map/index.ts

@ -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);
}
}

13
tests/playwright/pages/Dashboard/ViewSidebar/index.ts

@ -9,6 +9,7 @@ export class ViewSidebarPage extends BasePage {
readonly createGridButton: Locator;
readonly createFormButton: Locator;
readonly createKanbanButton: Locator;
readonly createMapButton: Locator;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
@ -17,6 +18,7 @@ export class ViewSidebarPage extends BasePage {
this.createGridButton = this.get().locator('.nc-create-grid-view:visible');
this.createFormButton = this.get().locator('.nc-create-form-view:visible');
this.createKanbanButton = this.get().locator('.nc-create-kanban-view:visible');
this.createMapButton = this.get().locator('.nc-create-map-view:visible');
}
get() {
@ -35,6 +37,13 @@ export class ViewSidebarPage extends BasePage {
}
}
async activateGeoDataEasterEgg() {
await this.dashboard.rootPage.evaluate(_ => {
window.localStorage.setItem('geodataToggleState', 'true');
});
await this.rootPage.goto(this.rootPage.url());
}
private async createView({ title, locator }: { title: string; locator: Locator }) {
await locator.click();
await this.rootPage.locator('input[id="form_item_title"]:visible').fill(title);
@ -71,6 +80,10 @@ export class ViewSidebarPage extends BasePage {
await this.createView({ title, locator: this.createKanbanButton });
}
async createMapView({ title }: { title: string }) {
await this.createView({ title, locator: this.createMapButton });
}
// Todo: Make selection better
async verifyView({ title, index }: { title: string; index: number }) {
await expect(

36
tests/playwright/pages/Dashboard/common/Cell/GeoDataCell.ts

@ -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');
}
}

30
tests/playwright/pages/Dashboard/common/Cell/index.ts

@ -8,6 +8,7 @@ import { CheckboxCellPageObject } from './CheckboxCell';
import { RatingCellPageObject } from './RatingCell';
import { DateCellPageObject } from './DateCell';
import { DateTimeCellPageObject } from './DateTimeCell';
import { GeoDataCellPageObject } from './GeoDataCell';
export interface CellProps {
index?: number;
@ -20,6 +21,7 @@ export class CellPageObject extends BasePage {
readonly attachment: AttachmentCellPageObject;
readonly checkbox: CheckboxCellPageObject;
readonly rating: RatingCellPageObject;
readonly geoData: GeoDataCellPageObject;
readonly date: DateCellPageObject;
readonly dateTime: DateTimeCellPageObject;
@ -30,6 +32,7 @@ export class CellPageObject extends BasePage {
this.attachment = new AttachmentCellPageObject(this);
this.checkbox = new CheckboxCellPageObject(this);
this.rating = new RatingCellPageObject(this);
this.geoData = new GeoDataCellPageObject(this);
this.date = new DateCellPageObject(this);
this.dateTime = new DateTimeCellPageObject(this);
}
@ -138,6 +141,33 @@ export class CellPageObject extends BasePage {
}
}
async verifyGeoDataCell({
index,
columnHeader,
lat,
long,
}: {
index: number;
columnHeader: string;
lat: string;
long: string;
}) {
const _verify = async expectedValue => {
await expect
.poll(async () => {
const cell = await this.get({
index,
columnHeader,
}).locator(`[data-testid="nc-geo-data-lat-long-set"]`);
return await cell.textContent(); //.getAttribute('title');
})
.toEqual(expectedValue);
};
const value = `${lat}; ${long}`;
await _verify(value);
}
async verifyDateCell({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) {
const _verify = async expectedValue => {
await expect

5
tests/playwright/pages/Dashboard/common/Toolbar/index.ts

@ -15,9 +15,10 @@ import { ToolbarStackbyPage } from './StackBy';
import { ToolbarAddEditStackPage } from './AddEditKanbanStack';
import { ToolbarSearchDataPage } from './SearchData';
import { RowHeight } from './RowHeight';
import { MapPage } from '../../Map';
export class ToolbarPage extends BasePage {
readonly parent: GridPage | GalleryPage | FormPage | KanbanPage;
readonly parent: GridPage | GalleryPage | FormPage | KanbanPage | MapPage;
readonly fields: ToolbarFieldsPage;
readonly sort: ToolbarSortPage;
readonly filter: ToolbarFilterPage;
@ -29,7 +30,7 @@ export class ToolbarPage extends BasePage {
readonly searchData: ToolbarSearchDataPage;
readonly rowHeight: RowHeight;
constructor(parent: GridPage | GalleryPage | FormPage | KanbanPage) {
constructor(parent: GridPage | GalleryPage | FormPage | KanbanPage | MapPage) {
super(parent.rootPage);
this.parent = parent;
this.fields = new ToolbarFieldsPage(this);

3
tests/playwright/pages/Dashboard/index.ts

@ -10,6 +10,7 @@ import { SettingsPage } from './Settings';
import { ViewSidebarPage } from './ViewSidebar';
import { GalleryPage } from './Gallery';
import { KanbanPage } from './Kanban';
import { MapPage } from './Map';
import { ImportAirtablePage } from './Import/Airtable';
import { ImportTemplatePage } from './Import/ImportTemplate';
import { WebhookFormPage } from './WebhookForm';
@ -24,6 +25,7 @@ export class DashboardPage extends BasePage {
readonly gallery: GalleryPage;
readonly form: FormPage;
readonly kanban: KanbanPage;
readonly map: MapPage;
readonly expandedForm: ExpandedFormPage;
readonly webhookForm: WebhookFormPage;
readonly childList: ChildList;
@ -43,6 +45,7 @@ export class DashboardPage extends BasePage {
this.gallery = new GalleryPage(this);
this.form = new FormPage(this);
this.kanban = new KanbanPage(this);
this.map = new MapPage(this);
this.expandedForm = new ExpandedFormPage(this);
this.webhookForm = new WebhookFormPage(this);
this.childList = new ChildList(this);

73
tests/playwright/tests/columnGeoData.spec.ts

@ -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' });
});
});

92
tests/playwright/tests/viewMap.spec.ts

@ -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…
Cancel
Save