Browse Source

Merge branch 'develop' into enhancement/date-filters

pull/5185/head
Wing-Kam Wong 2 years ago
parent
commit
2c8b61dccb
  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. 9
      packages/nc-gui/components/dlg/QuickImport.vue
  5. 11
      packages/nc-gui/components/dlg/TableCreate.vue
  6. 66
      packages/nc-gui/components/dlg/ViewCreate.vue
  7. 42
      packages/nc-gui/components/shared-view/Map.vue
  8. 6
      packages/nc-gui/components/smartsheet/Cell.vue
  9. 266
      packages/nc-gui/components/smartsheet/Map.vue
  10. 12
      packages/nc-gui/components/smartsheet/Toolbar.vue
  11. 13
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  12. 9
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  13. 4
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  14. 28
      packages/nc-gui/components/smartsheet/sidebar/MenuBottom.vue
  15. 15
      packages/nc-gui/components/smartsheet/sidebar/toolbar/GeodataSwitcher.vue
  16. 4
      packages/nc-gui/components/smartsheet/sidebar/toolbar/index.vue
  17. 3
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  18. 113
      packages/nc-gui/components/smartsheet/toolbar/MappedBy.vue
  19. 9
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  20. 3
      packages/nc-gui/components/smartsheet/toolbar/SharedViewList.vue
  21. 39
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  22. 5
      packages/nc-gui/components/tabs/Smartsheet.vue
  23. 16
      packages/nc-gui/components/template/Editor.vue
  24. 182
      packages/nc-gui/composables/useMapViewDataStore.ts
  25. 2
      packages/nc-gui/composables/useMultiSelect/index.ts
  26. 3
      packages/nc-gui/composables/useSmartsheetStore.ts
  27. 15
      packages/nc-gui/composables/useViewColumns.ts
  28. 3
      packages/nc-gui/lang/de.json
  29. 20
      packages/nc-gui/lang/en.json
  30. 1
      packages/nc-gui/lib/enums.ts
  31. 1
      packages/nc-gui/lib/types.ts
  32. 114
      packages/nc-gui/package-lock.json
  33. 6
      packages/nc-gui/package.json
  34. 4
      packages/nc-gui/pages/[projectType]/[projectId]/index/index/index.vue
  35. 35
      packages/nc-gui/pages/[projectType]/map/[viewId]/index.vue
  36. 9
      packages/nc-gui/pages/index/index/create.vue
  37. 2
      packages/nc-gui/utils/cell.ts
  38. 6
      packages/nc-gui/utils/columnUtils.ts
  39. 3
      packages/nc-gui/utils/geoDataUtils.ts
  40. 1
      packages/nc-gui/utils/index.ts
  41. 3
      packages/nc-gui/utils/viewUtils.ts
  42. 75
      packages/noco-docs/content/en/setup-and-usages/column-types.md
  43. 81
      packages/nocodb-sdk/src/lib/Api.ts
  44. 1
      packages/nocodb-sdk/src/lib/UITypes.ts
  45. 1
      packages/nocodb-sdk/src/lib/globals.ts
  46. 6
      packages/nocodb-sdk/src/lib/sqlUi/MssqlUi.ts
  47. 8
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/helpers/getAst.ts
  48. 79
      packages/nocodb/src/lib/meta/api/dataApis/bulkDataAliasApis.ts
  49. 2
      packages/nocodb/src/lib/meta/api/index.ts
  50. 46
      packages/nocodb/src/lib/meta/api/mapViewApis.ts
  51. 3
      packages/nocodb/src/lib/meta/api/publicApis/publicDataApis.ts
  52. 6
      packages/nocodb/src/lib/meta/api/publicApis/publicDataExportApis.ts
  53. 1
      packages/nocodb/src/lib/meta/api/viewApis.ts
  54. 12
      packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts
  55. 55
      packages/nocodb/src/lib/migrations/v2/nc_026_map_view.ts
  56. 0
      packages/nocodb/src/lib/migrations/v2/nc_027_add_comparison_sub_op.ts
  57. 104
      packages/nocodb/src/lib/models/MapView.ts
  58. 106
      packages/nocodb/src/lib/models/MapViewColumn.ts
  59. 108
      packages/nocodb/src/lib/models/View.ts
  60. 7
      packages/nocodb/src/lib/utils/globals.ts
  61. 178
      scripts/sdk/swagger.json
  62. 8
      tests/playwright/pages/Dashboard/ExpandedForm/index.ts
  63. 51
      tests/playwright/pages/Dashboard/Map/index.ts
  64. 13
      tests/playwright/pages/Dashboard/ViewSidebar/index.ts
  65. 36
      tests/playwright/pages/Dashboard/common/Cell/GeoDataCell.ts
  66. 30
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  67. 5
      tests/playwright/pages/Dashboard/common/Toolbar/index.ts
  68. 3
      tests/playwright/pages/Dashboard/index.ts
  69. 73
      tests/playwright/tests/columnGeoData.spec.ts
  70. 351
      tests/playwright/tests/keyboardShortcuts.spec.ts
  71. 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); @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 { .ant-modal {
@apply !top-[50px]; @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'] MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default'] MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['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'] MdiMenu: typeof import('~icons/mdi/menu')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default'] MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['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 v-else data-testid="nc-geo-data-lat-long-set">{{ latLongStr }}</div>
<template #overlay>
<a-form :model="formState" class="flex flex-col" @finish="handleFinish">
<a-form-item>
<div class="flex mt-4 items-center mx-2">
<div class="mr-2">{{ $t('labels.lat') }}:</div>
<a-input
v-model:value="formState.latitude"
data-testid="nc-geo-data-latitude"
type="number"
step="0.0000001"
:min="-90"
required
:max="90"
@keydown.stop
@selectstart.capture.stop
@mousedown.stop
/>
</div>
</a-form-item>
<a-form-item>
<div class="flex items-center mx-2">
<div class="mr-2">{{ $t('labels.lng') }}:</div>
<a-input
v-model:value="formState.longitude"
data-testid="nc-geo-data-longitude"
type="number"
step="0.0000001"
required
:min="-180"
:max="180"
@keydown.stop
@selectstart.capture.stop
@mousedown.stop
/>
</div>
</a-form-item>
<a-form-item>
<div class="flex items-center mr-2">
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin text-gray-500': isLoading }" />
<a-button class="ml-2" @click="onClickSetCurrentLocation">{{ $t('labels.yourLocation') }}</a-button>
</div>
</a-form-item>
<a-form-item>
<div class="ml-auto mr-2">
<a-button type="text" @click="clear">{{ $t('general.cancel') }}</a-button>
<a-button type="primary" html-type="submit" data-testid="nc-geo-data-save">{{ $t('general.submit') }}</a-button>
</div>
</a-form-item>
</a-form>
</template>
</a-dropdown>
</template>
<style scoped lang="scss">
input[type='number']:focus {
@apply ring-transparent;
}
input[type='number'] {
width: 180px;
}
.ant-form-item {
margin-bottom: 1rem;
}
.ant-dropdown-menu {
align-items: flex-end;
}
</style>

9
packages/nc-gui/components/dlg/QuickImport.vue

@ -31,7 +31,7 @@ interface Props {
importDataOnly?: boolean importDataOnly?: boolean
} }
const { importType, importDataOnly = false, ...rest } = defineProps<Props>() const { importType, importDataOnly = false, baseId, ...rest } = defineProps<Props>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@ -61,7 +61,7 @@ const isParsingData = ref(false)
const useForm = Form.useForm const useForm = Form.useForm
const importState = reactive({ const defaultImportState = {
fileList: [] as importFileList | streamImportFileList, fileList: [] as importFileList | streamImportFileList,
url: '', url: '',
jsonEditor: {}, jsonEditor: {},
@ -72,7 +72,8 @@ const importState = reactive({
firstRowAsHeaders: true, firstRowAsHeaders: true,
shouldImportData: true, shouldImportData: true,
}, },
}) }
const importState = reactive(defaultImportState)
const isImportTypeJson = computed(() => importType === 'json') const isImportTypeJson = computed(() => importType === 'json')
@ -176,6 +177,8 @@ async function handleImport() {
return message.error(await extractSdkResponseErrorMsg(e)) return message.error(await extractSdkResponseErrorMsg(e))
} finally { } finally {
importLoading.value = false importLoading.value = false
templateEditorModal.value = false
Object.assign(importState, defaultImportState)
} }
dialogShow.value = false dialogShow.value = false
} }

11
packages/nc-gui/components/dlg/TableCreate.vue

@ -78,14 +78,19 @@ const systemColumnsCheckboxInfo = SYSTEM_COLUMNS.map((c, index) => ({
disabled: index === 0, disabled: index === 0,
})) }))
const creating = ref(false)
const _createTable = async () => { const _createTable = async () => {
try { try {
creating.value = true
await validate() await validate()
await createTable()
} catch (e: any) { } catch (e: any) {
e.errorFields.map((f: Record<string, any>) => message.error(f.errors.join(','))) e.errorFields.map((f: Record<string, any>) => message.error(f.errors.join(',')))
if (e.errorFields.length) return if (e.errorFields.length) return
} finally {
creating.value = false
} }
await createTable()
} }
onMounted(() => { onMounted(() => {
@ -109,7 +114,9 @@ onMounted(() => {
<template #footer> <template #footer>
<a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button> <a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" size="large" type="primary" @click="_createTable">{{ $t('general.submit') }}</a-button> <a-button key="submit" size="large" type="primary" :loading="creating" @click="_createTable"
>{{ $t('general.submit') }}
</a-button>
</template> </template>
<div class="pl-10 pr-10 pt-5"> <div class="pl-10 pr-10 pt-5">

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

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

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, isDuration,
isEmail, isEmail,
isFloat, isFloat,
isGeoData,
isInt, isInt,
isJSON, isJSON,
isManualSaved, isManualSaved,
@ -100,7 +101,9 @@ const syncValue = useDebounceFn(
) )
const vModel = computed({ const vModel = computed({
get: () => props.modelValue, get: () => {
return props.modelValue
},
set: (val) => { set: (val) => {
if (val !== props.modelValue) { if (val !== props.modelValue) {
currentRow.value.rowMeta.changed = true currentRow.value.rowMeta.changed = true
@ -151,6 +154,7 @@ const isNumericField = computed(() => {
> >
<template v-if="column"> <template v-if="column">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" /> <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" /> <LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" /> <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" /> <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 { IsPublicInj, OpenNewRecordFormHookInj, latLongToJoinedString, onMounted, provide, ref } from '#imports'
import type { Row as RowType } from '~/lib'
const route = useRoute()
const router = useRouter()
const reloadViewDataHook = inject(ReloadViewDataHookInj)
const reloadViewMetaHook = inject(ReloadViewMetaHookInj)
const { formattedData, loadMapData, loadMapMeta, mapMetaData, geoDataFieldColumn, addEmptyRow, paginationData } =
useMapViewStoreOrThrow()
const markersClusterGroupRef = ref<L.MarkerClusterGroup>()
const mapContainerRef = ref<HTMLElement>()
const myMapRef = ref<L.Map>()
const isPublic = inject(IsPublicInj, ref(false))
const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref())
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj, createEventHook())
const expandedFormDlg = ref(false)
const expandedFormRow = ref<RowType>()
const expandedFormRowState = ref<Record<string, any>>()
const fallBackCenterLocation = {
lat: 51,
lng: 0.0,
}
const getMapZoomLocalStorageKey = (viewId: string) => {
return `mapView.${viewId}.zoom`
}
const getMapCenterLocalStorageKey = (viewId: string) => `mapView.${viewId}.center`
const expandForm = (row: RowType, state?: Record<string, any>) => {
const rowId = extractPkFromRow(row.row, meta.value!.columns!)
if (rowId) {
router.push({
query: {
...route.query,
rowId,
},
})
} else {
expandedFormRow.value = row
expandedFormRowState.value = state
expandedFormDlg.value = true
}
}
openNewRecordFormHook?.on(async () => {
const newRow = await addEmptyRow()
expandForm(newRow)
})
const expandedFormOnRowIdDlg = computed({
get() {
return !!route.query.rowId
},
set(val) {
if (!val)
router.push({
query: {
...route.query,
rowId: undefined,
},
})
},
})
const addMarker = (lat: number, long: number, row: RowType) => {
if (markersClusterGroupRef.value == null) {
throw new Error('Marker cluster is null')
}
const newMarker = L.marker([lat, long], {
alt: `${lat}, ${long}`,
}).on('click', () => {
expandForm(row)
})
markersClusterGroupRef.value?.addLayer(newMarker)
}
const resetZoomAndCenterBasedOnLocalStorage = () => {
if (mapMetaData?.value?.fk_view_id == null) {
return
}
const initialZoomLevel = parseInt(localStorage.getItem(getMapZoomLocalStorageKey(mapMetaData.value.fk_view_id)) || '10')
const initialCenterLocalStorageStr = localStorage.getItem(getMapCenterLocalStorageKey(mapMetaData.value.fk_view_id))
const initialCenter = initialCenterLocalStorageStr ? JSON.parse(initialCenterLocalStorageStr) : fallBackCenterLocation
myMapRef?.value?.setView([initialCenter.lat, initialCenter.lng], initialZoomLevel)
}
onBeforeMount(async () => {
await loadMapMeta()
await loadMapData()
})
onMounted(async () => {
const myMap = L.map(mapContainerRef.value!, {
center: new LatLng(10, 10),
zoom: 2,
})
myMapRef.value = myMap
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&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"> <script setup lang="ts">
import { IsPublicInj, inject, ref, useSharedView, useSidebar, useSmartsheetStoreOrThrow, useUIPermission } from '#imports' 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)) const isPublic = inject(IsPublicInj, ref(false))
@ -18,7 +18,7 @@ const { allowCSVDownload } = useSharedView()
style="z-index: 7" style="z-index: 7"
> >
<LazySmartsheetToolbarViewActions <LazySmartsheetToolbarViewActions
v-if="(isGrid || isGallery || isKanban) && !isPublic && isUIAllowed('dataInsert')" v-if="(isGrid || isGallery || isKanban || isMap) && !isPublic && isUIAllowed('dataInsert')"
:show-system-fields="false" :show-system-fields="false"
class="ml-1" class="ml-1"
/> />
@ -29,15 +29,17 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarKanbanStackEditOrAdd v-if="isKanban" /> <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" /> <LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarRowHeight v-if="isGrid" /> <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)" /> <LazySmartsheetToolbarExport v-if="(!isPublic && !isUIAllowed('dataInsert')) || (isPublic && allowCSVDownload)" />
<div class="flex-1" /> <div class="flex-1" />

13
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 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>(() => { const uiTypesOptions = computed<typeof uiTypes>(() => {
return [ 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) ...(!isEdit.value && meta?.value?.columns?.every((c) => !c.pk)
? [ ? [
{ {
@ -80,8 +84,12 @@ const reloadMetaAndData = async () => {
} }
} }
const saving = ref(false)
async function onSubmit() { async function onSubmit() {
saving.value = true
const saved = await addOrUpdate(reloadMetaAndData, props.columnPosition) const saved = await addOrUpdate(reloadMetaAndData, props.columnPosition)
saving.value = false
if (!saved) return if (!saved) return
@ -178,6 +186,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<LazySmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" /> <LazySmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" />
<LazySmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" /> <LazySmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" />
<LazySmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" v-model:value="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" /> <LazySmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" v-model:value="formState" />
<LazySmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" 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" /> <LazySmartsheetColumnCheckboxOptions v-if="formState.uidt === UITypes.Checkbox" v-model:value="formState" />
@ -234,7 +243,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
{{ $t('general.cancel') }} {{ $t('general.cancel') }}
</a-button> </a-button>
<a-button html-type="submit" type="primary" @click.prevent="onSubmit"> <a-button html-type="submit" type="primary" :loading="saving" @click.prevent="onSubmit">
<!-- Save --> <!-- Save -->
{{ $t('general.save') }} {{ $t('general.save') }}
</a-button> </a-button>

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

@ -3,6 +3,7 @@ import type { TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { import {
CellClickHookInj,
FieldsInj, FieldsInj,
IsFormInj, IsFormInj,
IsKanbanInj, IsKanbanInj,
@ -49,6 +50,9 @@ const meta = toRef(props, 'meta')
const router = useRouter() const router = useRouter()
// override cell click hook to avoid unexpected behavior at form fields
provide(CellClickHookInj, null)
const fields = computedInject(FieldsInj, (_fields) => { const fields = computedInject(FieldsInj, (_fields) => {
if (props.useMetaFields) { if (props.useMetaFields) {
return (meta.value.columns ?? []).filter((col) => !isSystemColumn(col)) return (meta.value.columns ?? []).filter((col) => !isSystemColumn(col))
@ -132,7 +136,6 @@ reloadHook.on(() => {
if (isNew.value) return if (isNew.value) return
loadRow() loadRow()
}) })
provide(ReloadRowDataHookInj, reloadHook) provide(ReloadRowDataHookInj, reloadHook)
if (isKanban.value) { if (isKanban.value) {
@ -147,9 +150,7 @@ if (isKanban.value) {
const cellWrapperEl = ref<HTMLElement>() const cellWrapperEl = ref<HTMLElement>()
onMounted(() => { onMounted(() => {
setTimeout(() => { setTimeout(() => (cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus())
;(cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus()
})
}) })
</script> </script>

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

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

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

@ -2,12 +2,12 @@
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import { useNuxtApp, useSmartsheetStoreOrThrow, viewIcons } from '#imports' import { useNuxtApp, useSmartsheetStoreOrThrow, viewIcons } from '#imports'
const emits = defineEmits<Emits>()
interface Emits { interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string }): void (event: 'openModal', data: { type: ViewTypes; title?: string }): void
} }
const emits = defineEmits<Emits>()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const { isSqlView } = useSmartsheetStoreOrThrow() const { isSqlView } = useSmartsheetStoreOrThrow()
@ -20,7 +20,6 @@ function onOpenModal(type: ViewTypes, title = '') {
<template> <template>
<a-menu :selected-keys="[]" class="flex flex-col"> <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"> <h3 class="px-3 text-xs font-semibold flex items-center gap-4 text-gray-500">
{{ $t('activity.createView') }} {{ $t('activity.createView') }}
</h3> </h3>
@ -113,8 +112,29 @@ function onOpenModal(type: ViewTypes, title = '') {
</div> </div>
</a-tooltip> </a-tooltip>
</a-menu-item> </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="w-full h-4" /> <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> </div>
</a-tooltip>
</a-menu-item>
<div class="w-full h-4" />
</a-menu> </a-menu>
</template> </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 /> <LazySmartsheetSidebarToolbarDebugMeta />
<div class="dot" /> <div class="dot" />
<LazySmartsheetSidebarToolbarGeodataSwitcher />
<div class="dot" />
</template> </template>
<slot name="end" /> <slot name="end" />

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

@ -55,6 +55,8 @@ const { eventBus } = useSmartsheetStoreOrThrow()
eventBus.on((event) => { eventBus.on((event) => {
if (event === SmartsheetStoreEvents.FIELD_RELOAD) { if (event === SmartsheetStoreEvents.FIELD_RELOAD) {
loadViewColumns() loadViewColumns()
} else if (event === SmartsheetStoreEvents.MAPPED_BY_COLUMN_CHANGE) {
loadViewColumns()
} }
}) })
@ -204,6 +206,7 @@ useMenuCloseOnEsc(open)
v-model:checked="field.show" v-model:checked="field.show"
v-e="['a:fields:show-hide']" v-e="['a:fields:show-hide']"
class="shrink" class="shrink"
:disabled="field.isViewEssentialField"
@change="saveOrUpdate(field, index)" @change="saveOrUpdate(field, index)"
> >
<div class="flex items-center"> <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: case ViewTypes.GALLERY:
viewType = 'gallery' viewType = 'gallery'
break break
case ViewTypes.MAP:
viewType = 'map'
break
default: default:
viewType = 'view' viewType = 'view'
} }
@ -350,7 +353,11 @@ const copyIframeCode = async () => {
<div <div
v-if=" 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 --> <!-- Allow Download -->

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

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

39
packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue

@ -1,8 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Ref } from '@vue/reactivity'
import { import {
ActiveViewInj, ActiveViewInj,
IsLockedInj, IsLockedInj,
IsPublicInj, IsPublicInj,
MetaInj,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
inject, inject,
message, message,
@ -41,12 +43,27 @@ const showApiSnippetDrawer = ref(false)
const showErd = ref(false) const showErd = ref(false)
const quickImportDialog = ref(false) type QuickImportDialogType = 'csv' | 'excel' | 'json'
// TODO: add 'json' when it's ready
const quickImportDialogTypes: QuickImportDialogType[] = ['csv', 'excel']
const quickImportDialogs: Record<typeof quickImportDialogTypes[number], Ref<boolean>> = quickImportDialogTypes.reduce(
(acc: any, curr) => {
acc[curr] = ref(false)
return acc
},
{},
) as Record<QuickImportDialogType, Ref<boolean>>
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { isSharedBase } = useProject() const { isSharedBase } = useProject()
const meta = inject(MetaInj, ref())
const currentBaseId = computed(() => meta.value?.base_id)
const Icon = computed(() => { const Icon = computed(() => {
switch (selectedView.value?.lock_type) { switch (selectedView.value?.lock_type) {
case LockType.Personal: case LockType.Personal:
@ -173,19 +190,20 @@ useMenuCloseOnEsc(open)
</template> </template>
<template #expandIcon></template> <template #expandIcon></template>
<a-menu-item v-if="isUIAllowed('csvImport') && !isView && !isPublicView"> <template v-for="(dialog, type) in quickImportDialogs">
<a-menu-item v-if="isUIAllowed(`${type}Import`) && !isView && !isPublicView" :key="type">
<div <div
v-e="['a:actions:upload-csv']" v-e="[`a:actions:upload-${type}`]"
class="nc-project-menu-item" class="nc-project-menu-item"
:class="{ disabled: isLocked }" :class="{ disabled: isLocked }"
@click="!isLocked ? (quickImportDialog = true) : {}" @click="!isLocked ? (dialog.value = true) : {}"
> >
<MdiUploadOutline class="text-gray-500" /> <MdiUploadOutline class="text-gray-500" />
<!-- Upload CSV --> {{ `${$t('general.upload')} ${type.toUpperCase()}` }}
{{ $t('activity.uploadCSV') }}
<div class="flex items-center text-gray-400"><MdiAlpha />version</div> <div class="flex items-center text-gray-400"><MdiAlpha />version</div>
</div> </div>
</a-menu-item> </a-menu-item>
</template>
</a-sub-menu> </a-sub-menu>
</template> </template>
@ -230,7 +248,14 @@ useMenuCloseOnEsc(open)
</template> </template>
</a-dropdown> </a-dropdown>
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-data-only="true" /> <LazyDlgQuickImport
v-for="type in quickImportDialogTypes"
:key="type"
v-model="quickImportDialogs[type].value"
:import-type="type"
:base-id="currentBaseId"
:import-data-only="true"
/>
<LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" /> <LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" />

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 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>() const reloadEventHook = createEventHook<void | boolean>()
@ -48,6 +48,7 @@ const reloadViewMetaEventHook = createEventHook<void | boolean>()
const openNewRecordFormHook = createEventHook<void>() const openNewRecordFormHook = createEventHook<void>()
useProvideKanbanViewStore(meta, activeView) useProvideKanbanViewStore(meta, activeView)
useProvideMapViewStore(meta, activeView)
// todo: move to store // todo: move to store
provide(MetaInj, meta) provide(MetaInj, meta)
@ -81,6 +82,8 @@ provide(
<LazySmartsheetForm v-else-if="isForm && !$route.query.reload" /> <LazySmartsheetForm v-else-if="isForm && !$route.query.reload" />
<LazySmartsheetKanban v-else-if="isKanban" /> <LazySmartsheetKanban v-else-if="isKanban" />
<LazySmartsheetMap v-else-if="isMap" />
</div> </div>
</div> </div>
</template> </template>

16
packages/nc-gui/components/template/Editor.vue

@ -405,10 +405,14 @@ async function importTemplate() {
const tableId = meta.value?.id const tableId = meta.value?.id
const projectName = project.value.title! const projectName = project.value.title!
const table_names = data.tables.map((t: Record<string, any>) => t.table_name)
await Promise.all( await Promise.all(
Object.keys(importData).map((key: string) => Object.keys(importData).map((key: string) =>
(async (k) => { (async (k) => {
if (!table_names.includes(k)) {
return
}
const data = importData[k] const data = importData[k]
const total = data.length const total = data.length
@ -458,7 +462,7 @@ async function importTemplate() {
// Successfully imported table data // Successfully imported table data
message.success(t('msg.success.tableDataImported')) message.success(t('msg.success.tableDataImported'))
} catch (e: any) { } catch (e: any) {
message.error(e.message) message.error(await extractSdkResponseErrorMsg(e))
} finally { } finally {
isImporting.value = false isImporting.value = false
} }
@ -633,6 +637,16 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
</span> </span>
</template> </template>
<template #extra>
<a-tooltip bottom>
<template #title>
<!-- TODO: i18n -->
<span>Delete Table</span>
</template>
<mdi-delete-outline v-if="data.tables.length > 1" class="text-lg mr-8" @click.stop="deleteTable(tableIdx)" />
</a-tooltip>
</template>
<a-table <a-table
v-if="srcDestMapping" v-if="srcDestMapping"
class="template-form" class="template-form"

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
}

2
packages/nc-gui/composables/useMultiSelect/index.ts

@ -145,14 +145,12 @@ export function useMultiSelect(
return return
} }
editEnabled.value = false
isMouseDown = true isMouseDown = true
selectedRange.startRange({ row, col }) selectedRange.startRange({ row, col })
} }
const handleCellClick = (event: MouseEvent, row: number, col: number) => { const handleCellClick = (event: MouseEvent, row: number, col: number) => {
isMouseDown = true isMouseDown = true
editEnabled.value = false
selectedRange.startRange({ row, col }) selectedRange.startRange({ row, col })
selectedRange.endRange({ row, col }) selectedRange.endRange({ row, col })
makeActive(row, col) makeActive(row, col)

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

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

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

@ -1,5 +1,5 @@
import { isSystemColumn } from 'nocodb-sdk' import { ViewTypes, isSystemColumn } from 'nocodb-sdk'
import type { ColumnType, TableType, ViewType } from 'nocodb-sdk' import type { ColumnType, MapType, TableType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import { IsPublicInj, computed, inject, ref, useNuxtApp, useProject, useUIPermission, watch } from '#imports' import { IsPublicInj, computed, inject, ref, useNuxtApp, useProject, useUIPermission, watch } from '#imports'
import type { Field } from '~/lib' import type { Field } from '~/lib'
@ -25,6 +25,13 @@ export function useViewColumns(
() => isPublic.value || !isUIAllowed('hideAllColumns') || !isUIAllowed('showAllColumns') || isSharedBase.value, () => 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>>(() => { const metaColumnById = computed<Record<string, ColumnType>>(() => {
if (!meta.value?.columns) return {} if (!meta.value?.columns) return {}
@ -62,8 +69,10 @@ export function useViewColumns(
title: column.title, title: column.title,
fk_column_id: column.id, fk_column_id: column.id,
...currentColumnField, ...currentColumnField,
show: currentColumnField.show || isColumnViewEssential(currentColumnField),
order: currentColumnField.order || order++, order: currentColumnField.order || order++,
system: isSystemColumn(metaColumnById?.value?.[currentColumnField.fk_column_id!]), system: isSystemColumn(metaColumnById?.value?.[currentColumnField.fk_column_id!]),
isViewEssentialField: isColumnViewEssential(column),
} }
}) })
.sort((a: Field, b: Field) => a.order - b.order) .sort((a: Field, b: Field) => a.order - b.order)
@ -98,7 +107,7 @@ export function useViewColumns(
if (isLocalMode.value) { if (isLocalMode.value) {
fields.value = fields.value?.map((field: Field) => ({ fields.value = fields.value?.map((field: Field) => ({
...field, ...field,
show: false, show: !!field.isViewEssentialField,
})) }))
reloadData?.() reloadData?.()
return return

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

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

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

@ -74,7 +74,8 @@
"insertBefore": "Insert Before", "insertBefore": "Insert Before",
"hideField": "Hide Field", "hideField": "Hide Field",
"sortAsc": "Sort Ascending", "sortAsc": "Sort Ascending",
"sortDesc": "Sort Descending" "sortDesc": "Sort Descending",
"geoDataField": "GeoData Field"
}, },
"objects": { "objects": {
"project": "Project", "project": "Project",
@ -98,7 +99,8 @@
"gallery": "Gallery", "gallery": "Gallery",
"form": "Form", "form": "Form",
"kanban": "Kanban", "kanban": "Kanban",
"calendar": "Calendar" "calendar": "Calendar",
"map": "Map"
}, },
"user": "User", "user": "User",
"users": "Users", "users": "Users",
@ -136,6 +138,7 @@
"Currency": "Currency", "Currency": "Currency",
"Percent": "Percent", "Percent": "Percent",
"Duration": "Duration", "Duration": "Duration",
"GeoData": "GeoData",
"Rating": "Rating", "Rating": "Rating",
"Formula": "Formula", "Formula": "Formula",
"Rollup": "Rollup", "Rollup": "Rollup",
@ -254,6 +257,9 @@
"barcodeFormat": "Barcode format", "barcodeFormat": "Barcode format",
"qrCodeValueTooLong": "Too many characters for a QR code", "qrCodeValueTooLong": "Too many characters for a QR code",
"barcodeValueTooLong": "Too many characters for a barcode", "barcodeValueTooLong": "Too many characters for a barcode",
"yourLocation": "Your Location",
"lng": "Lng",
"lat": "Lat",
"aggregateFunction": "Aggregate function", "aggregateFunction": "Aggregate function",
"dbCreateIfNotExists": "Database : create if not exists", "dbCreateIfNotExists": "Database : create if not exists",
"clientKey": "Client Key", "clientKey": "Client Key",
@ -453,6 +459,10 @@
"stackedBy": "Stacked By", "stackedBy": "Stacked By",
"chooseGroupingField": "Choose a Grouping Field", "chooseGroupingField": "Choose a Grouping Field",
"addOrEditStack": "Add / Edit Stack" "addOrEditStack": "Add / Edit Stack"
},
"map": {
"mappedBy": "Mapped By",
"chooseMappingField": "Choose a Mapping Field"
} }
}, },
"tooltip": { "tooltip": {
@ -519,6 +529,11 @@
"orgCreator": "Creator can create new projects and access any invited project.", "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." "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", "footerInfo": "Rows per page",
"upload": "Select file to Upload", "upload": "Select file to Upload",
"upload_sub": "or drag and drop file", "upload_sub": "or drag and drop file",
@ -601,6 +616,7 @@
"gallery": "Add Gallery View", "gallery": "Add Gallery View",
"form": "Add Form View", "form": "Add Form View",
"kanban": "Add Kanban View", "kanban": "Add Kanban View",
"map": "Add Map View",
"calendar": "Add Calendar View" "calendar": "Add Calendar View"
}, },
"tablesMetadataInSync": "Tables metadata is in Sync", "tablesMetadataInSync": "Tables metadata is in Sync",

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

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

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

@ -32,6 +32,7 @@ export interface Field {
title: string title: string
fk_column_id?: string fk_column_id?: string
system?: boolean system?: boolean
isViewEssentialField?: boolean
} }
export type Roles<T extends Role | ProjectRole = Role | ProjectRole> = Record<T | string, 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", "jsep": "^1.3.6",
"just-clone": "^6.1.1", "just-clone": "^6.1.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"leaflet": "^1.9.2",
"leaflet.markercluster": "^1.5.3",
"locale-codes": "^1.3.1", "locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"nocodb-sdk": "file:../nocodb-sdk", "nocodb-sdk": "file:../nocodb-sdk",
@ -41,6 +43,7 @@
"vue-dompurify-html": "^3.0.0", "vue-dompurify-html": "^3.0.0",
"vue-github-button": "^3.0.3", "vue-github-button": "^3.0.3",
"vue-i18n": "^9.2.2", "vue-i18n": "^9.2.2",
"vue3-contextmenu": "^0.2.12",
"vue3-text-clamp": "^0.1.1", "vue3-text-clamp": "^0.1.1",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
@ -69,6 +72,9 @@
"@nuxt/image-edge": "^1.0.0-27657146.da85542", "@nuxt/image-edge": "^1.0.0-27657146.da85542",
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/dagre": "^0.7.48", "@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/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0", "@types/sortablejs": "^1.13.0",
"@types/tinycolor2": "^1.4.3", "@types/tinycolor2": "^1.4.3",
@ -3131,7 +3137,8 @@
"node_modules/@types/file-saver": { "node_modules/@types/file-saver": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", "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": { "node_modules/@types/form-data": {
"version": "0.0.33", "version": "0.0.33",
@ -3142,6 +3149,12 @@
"@types/node": "*" "@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": { "node_modules/@types/json-schema": {
"version": "7.0.11", "version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
@ -3154,6 +3167,24 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "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": { "node_modules/@types/mdast": {
"version": "3.0.10", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@ -10661,6 +10692,19 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/less": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/less/-/less-4.1.3.tgz", "resolved": "https://registry.npmjs.org/less/-/less-4.1.3.tgz",
@ -11274,6 +11318,11 @@
"minipass": "^2.9.0" "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": { "node_modules/mkdir": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/mkdir/-/mkdir-0.0.2.tgz", "resolved": "https://registry.npmjs.org/mkdir/-/mkdir-0.0.2.tgz",
@ -17172,6 +17221,16 @@
"vue": "^3.0.0" "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": { "node_modules/vue3-text-clamp": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/vue3-text-clamp/-/vue3-text-clamp-0.1.1.tgz", "resolved": "https://registry.npmjs.org/vue3-text-clamp/-/vue3-text-clamp-0.1.1.tgz",
@ -20061,7 +20120,8 @@
"@types/file-saver": { "@types/file-saver": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", "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": { "@types/form-data": {
"version": "0.0.33", "version": "0.0.33",
@ -20072,6 +20132,12 @@
"@types/node": "*" "@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": { "@types/json-schema": {
"version": "7.0.11", "version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
@ -20084,6 +20150,24 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "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": { "@types/mdast": {
"version": "3.0.10", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", "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": { "less": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/less/-/less-4.1.3.tgz", "resolved": "https://registry.npmjs.org/less/-/less-4.1.3.tgz",
@ -25974,6 +26069,11 @@
"minipass": "^2.9.0" "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": { "mkdir": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/mkdir/-/mkdir-0.0.2.tgz", "resolved": "https://registry.npmjs.org/mkdir/-/mkdir-0.0.2.tgz",
@ -30167,6 +30267,16 @@
"is-plain-object": "3.0.1" "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": { "vue3-text-clamp": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/vue3-text-clamp/-/vue3-text-clamp-0.1.1.tgz", "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", "jsep": "^1.3.6",
"just-clone": "^6.1.1", "just-clone": "^6.1.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"leaflet": "^1.9.2",
"leaflet.markercluster": "^1.5.3",
"locale-codes": "^1.3.1", "locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"nocodb-sdk": "file:../nocodb-sdk", "nocodb-sdk": "file:../nocodb-sdk",
@ -64,6 +66,7 @@
"vue-dompurify-html": "^3.0.0", "vue-dompurify-html": "^3.0.0",
"vue-github-button": "^3.0.3", "vue-github-button": "^3.0.3",
"vue-i18n": "^9.2.2", "vue-i18n": "^9.2.2",
"vue3-contextmenu": "^0.2.12",
"vue3-text-clamp": "^0.1.1", "vue3-text-clamp": "^0.1.1",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
@ -92,6 +95,9 @@
"@nuxt/image-edge": "^1.0.0-27657146.da85542", "@nuxt/image-edge": "^1.0.0-27657146.da85542",
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/dagre": "^0.7.48", "@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/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0", "@types/sortablejs": "^1.13.0",
"@types/tinycolor2": "^1.4.3", "@types/tinycolor2": "^1.4.3",

4
packages/nc-gui/pages/[projectType]/[projectId]/index/index/index.vue

@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { UploadChangeParam, UploadFile } from 'ant-design-vue' import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import type { BaseType } from 'nocodb-sdk'
import { import {
message, message,
ref, ref,
@ -98,6 +99,7 @@ function openQuickImportDialog(type: QuickImportTypes, file: File) {
'modelValue': isOpen, 'modelValue': isOpen,
'importType': type, 'importType': type,
'onUpdate:modelValue': closeDialog, 'onUpdate:modelValue': closeDialog,
'baseId': bases.value?.filter((base: BaseType) => base.enabled)[0].id,
}) })
vNode.value?.component?.exposed?.handleChange({ vNode.value?.component?.exposed?.handleChange({
@ -128,7 +130,7 @@ function openCreateTable() {
const { close } = useDialog(resolveComponent('DlgTableCreate'), { const { close } = useDialog(resolveComponent('DlgTableCreate'), {
'modelValue': isOpen, 'modelValue': isOpen,
'onUpdate:modelValue': closeDialog, 'onUpdate:modelValue': closeDialog,
'baseId': bases.value[0].id, 'baseId': bases.value?.filter((base: BaseType) => base.enabled)[0].id,
}) })
function closeDialog() { function closeDialog() {

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>

9
packages/nc-gui/pages/index/index/create.vue

@ -37,9 +37,13 @@ const formState = reactive({
title: '', title: '',
}) })
const creating = ref(false)
const createProject = async () => { const createProject = async () => {
$e('a:project:create:xcdb') $e('a:project:create:xcdb')
try { try {
creating.value = true
const result = await api.project.create({ const result = await api.project.create({
title: formState.title, title: formState.title,
}) })
@ -47,6 +51,8 @@ const createProject = async () => {
await navigateTo(`/nc/${result.id}`) await navigateTo(`/nc/${result.id}`)
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} finally {
creating.value = false
} }
} }
@ -90,7 +96,8 @@ onMounted(async () => {
</a-form-item> </a-form-item>
<div class="text-center"> <div class="text-center">
<button class="scaling-btn bg-opacity-100" type="submit"> <a-spin v-if="creating" spinning />
<button v-else class="scaling-btn bg-opacity-100" type="submit">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<MaterialSymbolsRocketLaunchOutline /> <MaterialSymbolsRocketLaunchOutline />
{{ $t('general.create') }} {{ $t('general.create') }}

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 isPhoneNumber = (column: ColumnType) => column.uidt === UITypes.PhoneNumber
export const isDecimal = (column: ColumnType) => column.uidt === UITypes.Decimal export const isDecimal = (column: ColumnType) => column.uidt === UITypes.Decimal
export const isDuration = (column: ColumnType) => column.uidt === UITypes.Duration 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 isPercent = (column: ColumnType) => column.uidt === UITypes.Percent
export const isSpecificDBType = (column: ColumnType) => column.uidt === UITypes.SpecificDBType export const isSpecificDBType = (column: ColumnType) => column.uidt === UITypes.SpecificDBType
export const isAutoSaved = (column: ColumnType) => export const isAutoSaved = (column: ColumnType) =>
@ -43,6 +44,7 @@ export const isAutoSaved = (column: ColumnType) =>
UITypes.AutoNumber, UITypes.AutoNumber,
UITypes.SpecificDBType, UITypes.SpecificDBType,
UITypes.Geometry, UITypes.Geometry,
UITypes.GeoData,
UITypes.Duration, UITypes.Duration,
].includes(column.uidt as UITypes) ].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 JSONIcon from '~icons/mdi/code-json'
import SpecificDBTypeIcon from '~icons/mdi/database-settings' import SpecificDBTypeIcon from '~icons/mdi/database-settings'
import Attachment from '~icons/mdi/attachment' import Attachment from '~icons/mdi/attachment'
import Marker from '~icons/mdi/map-marker'
import CheckboxMarkedOutline from '~icons/mdi/checkbox-marked-outline' import CheckboxMarkedOutline from '~icons/mdi/checkbox-marked-outline'
import FormatListBulletedSquare from '~icons/mdi/format-list-bulleted-square' import FormatListBulletedSquare from '~icons/mdi/format-list-bulleted-square'
import ArrowDownDropCircle from '~icons/mdi/arrow-down-drop-circle' import ArrowDownDropCircle from '~icons/mdi/arrow-down-drop-circle'
@ -141,6 +142,11 @@ const uiTypes = [
name: UITypes.Geometry, name: UITypes.Geometry,
icon: RulerSquareCompass, icon: RulerSquareCompass,
}, },
{
name: UITypes.GeoData,
icon: Marker,
},
{ {
name: UITypes.JSON, name: UITypes.JSON,
icon: JSONIcon, 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 './stringUtils'
export * from './memStorage' export * from './memStorage'
export * from './browserUtils' export * from './browserUtils'
export * from './geoDataUtils'
export * from './mimeTypeUtils' 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 MdiCalendarIcon from '~icons/mdi/calendar'
import MdiGalleryIcon from '~icons/mdi/camera-image' import MdiGalleryIcon from '~icons/mdi/camera-image'
import MdiKanbanIcon from '~icons/mdi/tablet-dashboard' import MdiKanbanIcon from '~icons/mdi/tablet-dashboard'
import MdiMapIcon from '~icons/mdi/map-outline'
import MdiEyeIcon from '~icons/mdi/eye-circle-outline' import MdiEyeIcon from '~icons/mdi/eye-circle-outline'
import type { Language } from '~/lib' 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'] }, [ViewTypes.FORM]: { icon: MdiFormIcon, color: themeV2Colors.pink['500'] },
calendar: { icon: MdiCalendarIcon, color: 'purple' }, calendar: { icon: MdiCalendarIcon, color: 'purple' },
[ViewTypes.GALLERY]: { icon: MdiGalleryIcon, color: 'orange' }, [ViewTypes.GALLERY]: { icon: MdiGalleryIcon, color: 'orange' },
[ViewTypes.MAP]: { icon: MdiMapIcon, color: 'blue' },
[ViewTypes.KANBAN]: { icon: MdiKanbanIcon, color: 'green' }, [ViewTypes.KANBAN]: { icon: MdiKanbanIcon, color: 'green' },
view: { icon: MdiEyeIcon, color: 'blue' }, view: { icon: MdiEyeIcon, color: 'blue' },
} }
@ -23,6 +25,7 @@ export const viewTypeAlias: Record<number, string> = {
[ViewTypes.FORM]: 'form', [ViewTypes.FORM]: 'form',
[ViewTypes.GALLERY]: 'gallery', [ViewTypes.GALLERY]: 'gallery',
[ViewTypes.KANBAN]: 'kanban', [ViewTypes.KANBAN]: 'kanban',
[ViewTypes.MAP]: 'map',
} }
export const isRtlLang = (lang: keyof typeof Language) => ['fa', 'ar'].includes(lang) export const isRtlLang = (lang: keyof typeof Language) => ['fa', 'ar'].includes(lang)

75
packages/noco-docs/content/en/setup-and-usages/column-types.md

@ -10,9 +10,9 @@ menuTitle: 'Column Types'
| Type | Description | | Type | Description |
|---|---| |---|---|
| [ID](#id) | Primary column of the table | <!-- | [ID](#id) | Primary column of the table | -->
| [LinkToAnotherRecord](#linktoanotherrecord) | Has Many or Many To Many columns | | [LinkToAnotherRecord](#linktoanotherrecord) | Has Many or Many To Many columns |
| [ForeignKey](#foreignkey)| Belongs To relation | <!-- | [ForeignKey](#foreignkey)| Belongs To relation | -->
| [SingleLineText](#singlelinetext) | For short text | | [SingleLineText](#singlelinetext) | For short text |
| [LongText](#longtext) | For lengthy string content | | [LongText](#longtext) | For lengthy string content |
| [Attachment](#attachment) | File attachment column | | [Attachment](#attachment) | File attachment column |
@ -26,28 +26,25 @@ menuTitle: 'Column Types'
| [Email](#email) | Email field | | [Email](#email) | Email field |
| [URL](#url) | Valid URL field | | [URL](#url) | Valid URL field |
| [Number](#number) | Any type of number | | [Number](#number) | Any type of number |
|[Decimal](#decimal)| Fractional number | | [Decimal](#decimal)| Fractional number |
|[Currency](#currency)| Currency value | | [Currency](#currency)| Currency value |
|[Percent](#percent)| Percentage | | [Percent](#percent)| Percentage |
|[Duration](#duration)| Duration | | [Duration](#duration)| Duration |
|[Rating](#rating)| Rating | | [Rating](#rating)| Rating |
|[Formula](#formula)| Formula based generated column | | [Formula](#formula)| Formula based generated column |
|[QR Code](#qr-code)| QR Code visualization of another referenced column | | [Rollup](#rollup)| Performs calculations and aggregations |
|[Barcode](#barcode)| Barcode visualization of another referenced column | | [DateTime](#datetime)| Date & Time selector |
| [Count](#count) | | | [QR Code](#qr-code)| QR Code visualization of another referenced column |
|[DateTime](#datetime)| Date & Time selector | | [Barcode](#barcode)| Barcode visualization of another referenced column |
|[CreateTime](#createtime)| | <!-- | [CreateTime](#createtime)| | -->
|[AutoNumber](#autonumber)| | | [Geometry](#geometry)| Geometry column |
|[Geometry](#geometry)| Geometry column | | [GeoData](#geodata)| GeoData column |
|[SpecificDBType](#specificdbtype)| Custom DB type option | | [Json](#json)| Json column |
| [SpecificDBType](#specificdbtype)| Custom DB type option |
## Database Types ## Database Types
<!-- ### ID
### ID
#### Available Database Types #### Available Database Types
@ -56,20 +53,20 @@ menuTitle: 'Column Types'
|**MySQL**|_All types are available_|int| |**MySQL**|_All types are available_|int|
|**PostgreSQL**|_All types are available_|int4| |**PostgreSQL**|_All types are available_|int4|
|**SQL Server**|_All types are available_|int| |**SQL Server**|_All types are available_|int|
|**SQLite**|_All types are available_|integer| |**SQLite**|_All types are available_|integer| -->
### LinkToAnotherRecord ### LinkToAnotherRecord
N/A For more about Link To Another Record, please visit [here](./link-to-another-record).
### ForeignKey <!-- ### ForeignKey
#### Available Database Types #### Available Database Types
|Database| Types| |Database| Types|
|-----|----------| |-----|----------|
|**MySQL**|_All types are available_| |**MySQL**|_All types are available_|
|**PostgreSQL**|_All types are available_| |**PostgreSQL**|_All types are available_|
|**SQL Server**|_All types are available_| |**SQL Server**|_All types are available_|
|**SQLite**|_All types are available_| |**SQLite**|_All types are available_| -->
### SingleLineText ### SingleLineText
@ -292,16 +289,10 @@ Encodes the value of a reference column as Barcode. Supported barcode formats: C
* Email * Email
Since it's a virtual column, the cell content (Barcode) cannot be changed directly. Since it's a virtual column, the cell content (Barcode) cannot be changed directly.
### Count
#### Available Database Types ### Rollup
|Database| Types| Default Type| For more about rollup, please visit [here](./rollup).
|-----|----------|----------|
|**MySQL**|int, smallint, mediumint, bigint, serial|int|
|**PostgreSQL**|int, integer, bigint, bigserial, int2, int4, int8, serial, serial2, serial8, smallint, smallserial|int8|
|**SQL Server**|int, bigint, smallint, tinyint|int|
|**SQLite**|int, integer, tinyint, smallint, mediumint, bigint, int2, int8|integer|
### DateTime ### DateTime
@ -314,7 +305,7 @@ Since it's a virtual column, the cell content (Barcode) cannot be changed direct
|**SQL Server**|datetime, datetime2, datetimeoffset|datetime| |**SQL Server**|datetime, datetime2, datetimeoffset|datetime|
|**SQLite**|datetime, timestamp|datetime| |**SQLite**|datetime, timestamp|datetime|
### CreateTime <!-- ### CreateTime
#### Available Database Types #### Available Database Types
@ -323,7 +314,7 @@ Since it's a virtual column, the cell content (Barcode) cannot be changed direct
|**MySQL**|datetime, timestamp, varchar|datetime| |**MySQL**|datetime, timestamp, varchar|datetime|
|**PostgreSQL**|timestamp, timestamp without time zone, timestamptz, timestamp with time zone|datetime| |**PostgreSQL**|timestamp, timestamp without time zone, timestamptz, timestamp with time zone|datetime|
|**SQL Server**|datetime, datetime2, datetimeoffset|datetime| |**SQL Server**|datetime, datetime2, datetimeoffset|datetime|
|**SQLite**|datetime, timestamp|datetime| |**SQLite**|datetime, timestamp|datetime| -->
### Geometry ### Geometry
@ -337,6 +328,20 @@ Since it's a virtual column, the cell content (Barcode) cannot be changed direct
|**SQL Server**|geometry| |**SQL Server**|geometry|
|**SQLite**|text| |**SQLite**|text|
<!-- TODO: add GeoData -->
### JSON
#### Available Database Types
|Database| Types|
|-----|----------|
|**MySQL**|json|
|**PostgreSQL**|json|
|**SQL Server**|text, ntext|
|**SQLite**|text|
### SpecificDBType ### SpecificDBType
#### Available Database Types #### Available Database Types

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

@ -139,7 +139,7 @@ export interface ViewType {
show_system_fields?: boolean; show_system_fields?: boolean;
lock_type?: 'collaborative' | 'locked' | 'personal'; lock_type?: 'collaborative' | 'locked' | 'personal';
type?: number; type?: number;
view?: FormType | GridType | GalleryType | KanbanType; view?: FormType | GridType | GalleryType | KanbanType | MapType;
} }
export interface TableInfoType { export interface TableInfoType {
@ -399,6 +399,33 @@ export interface KanbanType {
meta?: string | object; 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 { export interface FormType {
id?: string; id?: string;
title?: string; title?: string;
@ -591,6 +618,7 @@ export interface NormalColumnRequestType {
| 'Collaborator' | 'Collaborator'
| 'Date' | 'Date'
| 'Year' | 'Year'
| 'GeoData'
| 'Time' | 'Time'
| 'PhoneNumber' | 'PhoneNumber'
| 'Email' | 'Email'
@ -2692,6 +2720,57 @@ export class Api<
format: 'json', format: 'json',
...params, ...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 = { dbViewShare = {
/** /**

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

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

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

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

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

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

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

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

79
packages/nocodb/src/lib/meta/api/dataApis/bulkDataAliasApis.ts

@ -1,4 +1,5 @@
import { Request, Response, Router } from 'express'; import { Request, Response, Router } from 'express';
import { BaseModelSqlv2 } from '../../../db/sql-data-mapper/lib/sql/BaseModelSqlv2';
import Model from '../../../models/Model'; import Model from '../../../models/Model';
import Base from '../../../models/Base'; import Base from '../../../models/Base';
import NcConnectionMgrv2 from '../../../utils/common/NcConnectionMgrv2'; import NcConnectionMgrv2 from '../../../utils/common/NcConnectionMgrv2';
@ -6,70 +7,68 @@ import ncMetaAclMw from '../../helpers/ncMetaAclMw';
import { getViewAndModelFromRequestByAliasOrId } from './helpers'; import { getViewAndModelFromRequestByAliasOrId } from './helpers';
import apiMetrics from '../../helpers/apiMetrics'; import apiMetrics from '../../helpers/apiMetrics';
async function bulkDataInsert(req: Request, res: Response) { type BulkOperation =
| 'bulkInsert'
| 'bulkUpdate'
| 'bulkUpdateAll'
| 'bulkDelete'
| 'bulkDeleteAll';
async function getModelViewBase(req: Request) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
const base = await Base.get(model.base_id); const base = await Base.get(model.base_id);
return { model, view, base };
}
async function executeBulkOperation<T extends BulkOperation>(
req: Request,
res: Response,
operation: T,
options: Parameters<typeof BaseModelSqlv2.prototype[T]>
) {
const { model, view, base } = await getModelViewBase(req);
const baseModel = await Model.getBaseModelSQL({ const baseModel = await Model.getBaseModelSQL({
id: model.id, id: model.id,
viewId: view?.id, viewId: view?.id,
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
res.json(await baseModel[operation].apply(null, options));
}
res.json(await baseModel.bulkInsert(req.body, { cookie: req })); async function bulkDataInsert(req: Request, res: Response) {
await executeBulkOperation(req, res, 'bulkInsert', [
req.body,
{ cookie: req },
]);
} }
async function bulkDataUpdate(req: Request, res: Response) { async function bulkDataUpdate(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); await executeBulkOperation(req, res, 'bulkUpdate', [
const base = await Base.get(model.base_id); req.body,
{ cookie: req },
const baseModel = await Model.getBaseModelSQL({ ]);
id: model.id,
viewId: view?.id,
dbDriver: NcConnectionMgrv2.get(base),
});
res.json(await baseModel.bulkUpdate(req.body, { cookie: req }));
} }
// todo: Integrate with filterArrJson bulkDataUpdateAll // todo: Integrate with filterArrJson bulkDataUpdateAll
async function bulkDataUpdateAll(req: Request, res: Response) { async function bulkDataUpdateAll(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); await executeBulkOperation(req, res, 'bulkUpdateAll', [
const base = await Base.get(model.base_id); req.query,
req.body,
const baseModel = await Model.getBaseModelSQL({ { cookie: req },
id: model.id, ]);
viewId: view?.id,
dbDriver: NcConnectionMgrv2.get(base),
});
res.json(await baseModel.bulkUpdateAll(req.query, req.body, { cookie: req }));
} }
async function bulkDataDelete(req: Request, res: Response) { async function bulkDataDelete(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); await executeBulkOperation(req, res, 'bulkDelete', [
const base = await Base.get(model.base_id); req.body,
const baseModel = await Model.getBaseModelSQL({ { cookie: req },
id: model.id, ]);
viewId: view?.id,
dbDriver: NcConnectionMgrv2.get(base),
});
res.json(await baseModel.bulkDelete(req.body, { cookie: req }));
} }
// todo: Integrate with filterArrJson bulkDataDeleteAll // todo: Integrate with filterArrJson bulkDataDeleteAll
async function bulkDataDeleteAll(req: Request, res: Response) { async function bulkDataDeleteAll(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); await executeBulkOperation(req, res, 'bulkDeleteAll', [req.query]);
const base = await Base.get(model.base_id);
const baseModel = await Model.getBaseModelSQL({
id: model.id,
viewId: view?.id,
dbDriver: NcConnectionMgrv2.get(base),
});
res.json(await baseModel.bulkDeleteAll(req.query));
} }
const router = Router({ mergeParams: true }); const router = Router({ mergeParams: true });

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

@ -54,6 +54,7 @@ import crypto from 'crypto';
import swaggerApis from './swagger/swaggerApis'; import swaggerApis from './swagger/swaggerApis';
import importApis from './sync/importApis'; import importApis from './sync/importApis';
import syncSourceApis from './sync/syncSourceApis'; import syncSourceApis from './sync/syncSourceApis';
import mapViewApis from './mapViewApis';
const clients: { [id: string]: Socket } = {}; const clients: { [id: string]: Socket } = {};
const jobs: { [id: string]: { last_message: any } } = {}; const jobs: { [id: string]: { last_message: any } } = {};
@ -105,6 +106,7 @@ export default function (router: Router, server) {
router.use(swaggerApis); router.use(swaggerApis);
router.use(syncSourceApis); router.use(syncSourceApis);
router.use(kanbanViewApis); router.use(kanbanViewApis);
router.use(mapViewApis);
userApis(router); 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 ( if (
view.type !== ViewTypes.GRID && view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN && view.type !== ViewTypes.KANBAN &&
view.type !== ViewTypes.GALLERY view.type !== ViewTypes.GALLERY &&
view.type !== ViewTypes.MAP
) { ) {
NcError.notFound('Not found'); 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 ( if (
view.type !== ViewTypes.GRID && view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN && view.type !== ViewTypes.KANBAN &&
view.type !== ViewTypes.GALLERY view.type !== ViewTypes.GALLERY &&
view.type !== ViewTypes.MAP
) )
NcError.notFound('Not found'); NcError.notFound('Not found');
@ -67,7 +68,8 @@ async function exportCsv(req: Request, res: Response) {
if ( if (
view.type !== ViewTypes.GRID && view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN && view.type !== ViewTypes.KANBAN &&
view.type !== ViewTypes.GALLERY view.type !== ViewTypes.GALLERY &&
view.type !== ViewTypes.MAP
) )
NcError.notFound('Not found'); 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) { async function hideAllColumns(req: Request<any, any>, res) {
res.json( res.json(
await View.hideAllColumns( await View.hideAllColumns(
req.params.viewId, req.params.viewId,

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

@ -13,7 +13,8 @@ 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_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_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_025_add_row_height from './v2/nc_025_add_row_height';
import * as nc_026_add_comparison_sub_op from './v2/nc_026_add_comparison_sub_op'; import * as nc_026_map_view from './v2/nc_026_map_view';
import * as nc_027_add_comparison_sub_op from './v2/nc_027_add_comparison_sub_op';
// Create a custom migration source class // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -38,7 +39,8 @@ export default class XcMigrationSourcev2 {
'nc_023_multiple_source', 'nc_023_multiple_source',
'nc_024_barcode_column_type', 'nc_024_barcode_column_type',
'nc_025_add_row_height', 'nc_025_add_row_height',
'nc_026_add_comparison_sub_op', 'nc_026_map_view',
'nc_027_add_comparison_sub_op',
]); ]);
} }
@ -78,8 +80,10 @@ export default class XcMigrationSourcev2 {
return nc_024_barcode_column_type; return nc_024_barcode_column_type;
case 'nc_025_add_row_height': case 'nc_025_add_row_height':
return nc_025_add_row_height; return nc_025_add_row_height;
case 'nc_026_add_comparison_sub_op': case 'nc_026_map_view':
return nc_026_add_comparison_sub_op; return nc_026_map_view;
case 'nc_027_add_comparison_sub_op':
return nc_027_add_comparison_sub_op;
} }
} }
} }

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

@ -0,0 +1,55 @@
import { Knex } from 'knex';
import { MetaTable } from '../../utils/globals';
const up = async (knex: Knex) => {
await knex.schema.createTable(MetaTable.MAP_VIEW, (table) => {
table.string('fk_view_id', 20).primary();
table.foreign('fk_view_id').references(`${MetaTable.VIEWS}.id`);
table.string('base_id', 20);
table.foreign('base_id').references(`${MetaTable.BASES}.id`);
table.string('project_id', 128);
table.foreign('project_id').references(`${MetaTable.PROJECT}.id`);
table.string('uuid');
table.string('title');
table.string('fk_geo_data_col_id', 20);
table.foreign('fk_geo_data_col_id').references(`${MetaTable.COLUMNS}.id`);
table.text('meta');
table.dateTime('created_at');
table.dateTime('updated_at');
});
await knex.schema.createTable(MetaTable.MAP_VIEW_COLUMNS, (table) => {
table.string('id', 20).primary().notNullable();
table.string('base_id', 20);
table.string('project_id', 128);
table.string('fk_view_id', 20);
table.foreign('fk_view_id').references(`${MetaTable.MAP_VIEW}.fk_view_id`);
table.string('fk_column_id', 20);
table.foreign('fk_column_id').references(`${MetaTable.COLUMNS}.id`);
table.string('uuid');
table.string('label');
table.string('help');
table.boolean('show');
table.float('order');
table.timestamps(true, true);
});
};
const down = async (knex: Knex) => {
await knex.schema.dropTable(MetaTable.MAP_VIEW);
await knex.schema.dropTable(MetaTable.MAP_VIEW_COLUMNS);
};
export { up, down };

0
packages/nocodb/src/lib/migrations/v2/nc_026_add_comparison_sub_op.ts → packages/nocodb/src/lib/migrations/v2/nc_027_add_comparison_sub_op.ts

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

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

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

178
scripts/sdk/swagger.json

@ -3321,6 +3321,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": { "/api/v1/db/meta/projects/{projectId}/meta-diff": {
"parameters": [ "parameters": [
{ {
@ -7704,6 +7797,9 @@
}, },
{ {
"$ref": "#/components/schemas/Kanban" "$ref": "#/components/schemas/Kanban"
},
{
"$ref": "#/components/schemas/Map"
} }
] ]
} }
@ -8760,6 +8856,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": { "Form": {
"title": "Form", "title": "Form",
"type": "object", "type": "object",
@ -9412,6 +9589,7 @@
"Collaborator", "Collaborator",
"Date", "Date",
"Year", "Year",
"GeoData",
"Time", "Time",
"PhoneNumber", "PhoneNumber",
"Email", "Email",

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

@ -67,6 +67,14 @@ export class ExpandedFormPage extends BasePage {
case 'text': case 'text':
await field.locator('input').fill(value); await field.locator('input').fill(value);
break; 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': case 'belongsTo':
await field.locator('.nc-action-icon').click(); await field.locator('.nc-action-icon').click();
await this.dashboard.linkRecord.select(value); 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 createGridButton: Locator;
readonly createFormButton: Locator; readonly createFormButton: Locator;
readonly createKanbanButton: Locator; readonly createKanbanButton: Locator;
readonly createMapButton: Locator;
constructor(dashboard: DashboardPage) { constructor(dashboard: DashboardPage) {
super(dashboard.rootPage); super(dashboard.rootPage);
@ -17,6 +18,7 @@ export class ViewSidebarPage extends BasePage {
this.createGridButton = this.get().locator('.nc-create-grid-view:visible'); this.createGridButton = this.get().locator('.nc-create-grid-view:visible');
this.createFormButton = this.get().locator('.nc-create-form-view:visible'); this.createFormButton = this.get().locator('.nc-create-form-view:visible');
this.createKanbanButton = this.get().locator('.nc-create-kanban-view:visible'); this.createKanbanButton = this.get().locator('.nc-create-kanban-view:visible');
this.createMapButton = this.get().locator('.nc-create-map-view:visible');
} }
get() { 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 }) { private async createView({ title, locator }: { title: string; locator: Locator }) {
await locator.click(); await locator.click();
await this.rootPage.locator('input[id="form_item_title"]:visible').fill(title); 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 }); await this.createView({ title, locator: this.createKanbanButton });
} }
async createMapView({ title }: { title: string }) {
await this.createView({ title, locator: this.createMapButton });
}
// Todo: Make selection better // Todo: Make selection better
async verifyView({ title, index }: { title: string; index: number }) { async verifyView({ title, index }: { title: string; index: number }) {
await expect( 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 { RatingCellPageObject } from './RatingCell';
import { DateCellPageObject } from './DateCell'; import { DateCellPageObject } from './DateCell';
import { DateTimeCellPageObject } from './DateTimeCell'; import { DateTimeCellPageObject } from './DateTimeCell';
import { GeoDataCellPageObject } from './GeoDataCell';
export interface CellProps { export interface CellProps {
index?: number; index?: number;
@ -20,6 +21,7 @@ export class CellPageObject extends BasePage {
readonly attachment: AttachmentCellPageObject; readonly attachment: AttachmentCellPageObject;
readonly checkbox: CheckboxCellPageObject; readonly checkbox: CheckboxCellPageObject;
readonly rating: RatingCellPageObject; readonly rating: RatingCellPageObject;
readonly geoData: GeoDataCellPageObject;
readonly date: DateCellPageObject; readonly date: DateCellPageObject;
readonly dateTime: DateTimeCellPageObject; readonly dateTime: DateTimeCellPageObject;
@ -30,6 +32,7 @@ export class CellPageObject extends BasePage {
this.attachment = new AttachmentCellPageObject(this); this.attachment = new AttachmentCellPageObject(this);
this.checkbox = new CheckboxCellPageObject(this); this.checkbox = new CheckboxCellPageObject(this);
this.rating = new RatingCellPageObject(this); this.rating = new RatingCellPageObject(this);
this.geoData = new GeoDataCellPageObject(this);
this.date = new DateCellPageObject(this); this.date = new DateCellPageObject(this);
this.dateTime = new DateTimeCellPageObject(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 }) { async verifyDateCell({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) {
const _verify = async expectedValue => { const _verify = async expectedValue => {
await expect await expect

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

@ -15,9 +15,10 @@ import { ToolbarStackbyPage } from './StackBy';
import { ToolbarAddEditStackPage } from './AddEditKanbanStack'; import { ToolbarAddEditStackPage } from './AddEditKanbanStack';
import { ToolbarSearchDataPage } from './SearchData'; import { ToolbarSearchDataPage } from './SearchData';
import { RowHeight } from './RowHeight'; import { RowHeight } from './RowHeight';
import { MapPage } from '../../Map';
export class ToolbarPage extends BasePage { export class ToolbarPage extends BasePage {
readonly parent: GridPage | GalleryPage | FormPage | KanbanPage; readonly parent: GridPage | GalleryPage | FormPage | KanbanPage | MapPage;
readonly fields: ToolbarFieldsPage; readonly fields: ToolbarFieldsPage;
readonly sort: ToolbarSortPage; readonly sort: ToolbarSortPage;
readonly filter: ToolbarFilterPage; readonly filter: ToolbarFilterPage;
@ -29,7 +30,7 @@ export class ToolbarPage extends BasePage {
readonly searchData: ToolbarSearchDataPage; readonly searchData: ToolbarSearchDataPage;
readonly rowHeight: RowHeight; readonly rowHeight: RowHeight;
constructor(parent: GridPage | GalleryPage | FormPage | KanbanPage) { constructor(parent: GridPage | GalleryPage | FormPage | KanbanPage | MapPage) {
super(parent.rootPage); super(parent.rootPage);
this.parent = parent; this.parent = parent;
this.fields = new ToolbarFieldsPage(this); 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 { ViewSidebarPage } from './ViewSidebar';
import { GalleryPage } from './Gallery'; import { GalleryPage } from './Gallery';
import { KanbanPage } from './Kanban'; import { KanbanPage } from './Kanban';
import { MapPage } from './Map';
import { ImportAirtablePage } from './Import/Airtable'; import { ImportAirtablePage } from './Import/Airtable';
import { ImportTemplatePage } from './Import/ImportTemplate'; import { ImportTemplatePage } from './Import/ImportTemplate';
import { WebhookFormPage } from './WebhookForm'; import { WebhookFormPage } from './WebhookForm';
@ -24,6 +25,7 @@ export class DashboardPage extends BasePage {
readonly gallery: GalleryPage; readonly gallery: GalleryPage;
readonly form: FormPage; readonly form: FormPage;
readonly kanban: KanbanPage; readonly kanban: KanbanPage;
readonly map: MapPage;
readonly expandedForm: ExpandedFormPage; readonly expandedForm: ExpandedFormPage;
readonly webhookForm: WebhookFormPage; readonly webhookForm: WebhookFormPage;
readonly childList: ChildList; readonly childList: ChildList;
@ -43,6 +45,7 @@ export class DashboardPage extends BasePage {
this.gallery = new GalleryPage(this); this.gallery = new GalleryPage(this);
this.form = new FormPage(this); this.form = new FormPage(this);
this.kanban = new KanbanPage(this); this.kanban = new KanbanPage(this);
this.map = new MapPage(this);
this.expandedForm = new ExpandedFormPage(this); this.expandedForm = new ExpandedFormPage(this);
this.webhookForm = new WebhookFormPage(this); this.webhookForm = new WebhookFormPage(this);
this.childList = new ChildList(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' });
});
});

351
tests/playwright/tests/keyboardShortcuts.spec.ts

@ -2,6 +2,9 @@ import { expect, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard'; import { DashboardPage } from '../pages/Dashboard';
import { GridPage } from '../pages/Dashboard/Grid'; import { GridPage } from '../pages/Dashboard/Grid';
import setup from '../setup'; import setup from '../setup';
import { Api, UITypes } from 'nocodb-sdk';
let api: Api<any>;
test.describe('Verify shortcuts', () => { test.describe('Verify shortcuts', () => {
let dashboard: DashboardPage, grid: GridPage; let dashboard: DashboardPage, grid: GridPage;
@ -103,220 +106,147 @@ test.describe('Verify shortcuts', () => {
}); });
test('Clipboard support for cells', async () => { test('Clipboard support for cells', async () => {
// close 'Team & Auth' tab api = new Api({
await dashboard.closeTab({ title: 'Team & Auth' }); baseURL: `http://localhost:8080/`,
headers: {
await dashboard.treeView.createTable({ title: 'Sheet1' }); 'xc-auth': context.token,
},
});
await dashboard.grid.column.create({ const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'SingleLineText',
title: 'SingleLineText', title: 'SingleLineText',
type: 'SingleLineText', uidt: UITypes.SingleLineText,
}); },
await dashboard.grid.column.create({ {
column_name: 'LongText',
title: 'LongText', title: 'LongText',
type: 'LongText', uidt: UITypes.LongText,
}); },
await dashboard.grid.column.create({ {
column_name: 'Number',
title: 'Number', title: 'Number',
type: 'Number', uidt: UITypes.Number,
}); },
await dashboard.grid.column.create({ {
column_name: 'PhoneNumber',
title: 'PhoneNumber', title: 'PhoneNumber',
type: 'PhoneNumber', uidt: UITypes.PhoneNumber,
}); },
await dashboard.grid.column.create({ {
column_name: 'Email',
title: 'Email', title: 'Email',
type: 'Email', uidt: UITypes.Email,
}); },
await dashboard.grid.column.create({ {
column_name: 'URL',
title: 'URL', title: 'URL',
type: 'URL', uidt: UITypes.URL,
}); },
await dashboard.grid.column.create({ {
column_name: 'Decimal',
title: 'Decimal', title: 'Decimal',
type: 'Decimal', uidt: UITypes.Decimal,
}); },
await dashboard.grid.column.create({ {
column_name: 'Percent',
title: 'Percent', title: 'Percent',
type: 'Percent', uidt: UITypes.Percent,
}); },
await dashboard.grid.column.create({ {
column_name: 'Currency',
title: 'Currency', title: 'Currency',
type: 'Currency', uidt: UITypes.Currency,
}); },
await dashboard.grid.column.create({ {
column_name: 'Duration',
title: 'Duration', title: 'Duration',
type: 'Duration', uidt: UITypes.Duration,
}); },
await dashboard.grid.column.create({ {
title: 'Rating', column_name: 'SingleSelect',
type: 'Rating',
});
await dashboard.grid.column.create({
title: 'SingleSelect', title: 'SingleSelect',
type: 'SingleSelect', uidt: UITypes.SingleSelect,
}); dtxp: "'Option1','Option2'",
await dashboard.grid.column.selectOption.addOptions({ },
columnTitle: 'SingleSelect', {
options: ['Option 1', 'Option 2'], column_name: 'MultiSelect',
});
await dashboard.grid.column.create({
title: 'MultiSelect', title: 'MultiSelect',
type: 'MultiSelect', uidt: UITypes.MultiSelect,
}); dtxp: "'Option1','Option2'",
await dashboard.grid.column.selectOption.addOptions({ },
columnTitle: 'MultiSelect', {
options: ['Option 1', 'Option 2'], column_name: 'Rating',
}); title: 'Rating',
await dashboard.grid.column.create({ uidt: UITypes.Rating,
},
{
column_name: 'Checkbox',
title: 'Checkbox', title: 'Checkbox',
type: 'Checkbox', uidt: UITypes.Checkbox,
}); },
await dashboard.grid.column.create({ {
column_name: 'Date',
title: 'Date', title: 'Date',
type: 'Date', uidt: UITypes.Date,
}); },
await dashboard.grid.column.create({ {
column_name: 'Attachment',
title: 'Attachment', title: 'Attachment',
type: 'Attachment', uidt: UITypes.Attachment,
}); },
];
// ########################################
await dashboard.grid.addNewRow({
index: 0,
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'SingleLineText',
});
await dashboard.grid.cell.fillText({
index: 0,
columnHeader: 'SingleLineText',
text: 'SingleLineText',
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'LongText',
});
await dashboard.grid.cell.fillText({
index: 0,
columnHeader: 'LongText',
text: 'LongText',
});
await grid.cell.selectOption.select({ index: 0, columnHeader: 'SingleSelect', option: 'Option 1' });
await grid.cell.selectOption.select({
index: 0,
columnHeader: 'MultiSelect',
option: 'Option 2',
multiSelect: true,
});
await grid.cell.selectOption.select({
index: 0,
columnHeader: 'MultiSelect',
option: 'Option 1',
multiSelect: true,
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'Number',
});
await dashboard.grid.cell.fillText({
index: 0,
columnHeader: 'Number',
text: '123',
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'PhoneNumber',
});
await dashboard.grid.cell.fillText({
index: 0,
columnHeader: 'PhoneNumber',
text: '987654321',
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'Email',
});
await dashboard.grid.cell.fillText({
index: 0,
columnHeader: 'Email',
text: 'test@example.com',
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'URL',
});
await dashboard.grid.cell.fillText({
index: 0,
columnHeader: 'URL',
text: 'nocodb.com',
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'Decimal',
});
await dashboard.grid.cell.fillText({
index: 0,
columnHeader: 'Decimal',
text: '1.1',
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'Percent',
});
await dashboard.grid.cell.fillText({
index: 0,
columnHeader: 'Percent',
text: '80',
});
await dashboard.grid.cell.click({ const today = new Date().toISOString().slice(0, 10);
index: 0, const record = {
columnHeader: 'Currency', Id: 1,
}); SingleLineText: 'SingleLineText',
await dashboard.grid.cell.fillText({ LongText: 'LongText',
index: 0, SingleSelect: 'Option1',
columnHeader: 'Currency', MultiSelect: 'Option1,Option2',
text: '20', Number: 123,
}); PhoneNumber: '987654321',
Email: 'test@example.com',
URL: 'nocodb.com',
Rating: 4,
Decimal: 1.12,
Percent: 80,
Currency: 20,
Duration: 480,
Checkbox: 1,
Date: today,
};
try {
const project = await api.project.read(context.project.id);
const table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'Sheet1',
title: 'Sheet1',
columns: columns,
});
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, [record]);
const records = await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 1 });
console.log(records);
} catch (e) {
console.error(e);
}
// reload page
await dashboard.rootPage.reload();
await dashboard.grid.cell.click({ // close 'Team & Auth' tab
index: 0, await dashboard.closeTab({ title: 'Team & Auth' });
columnHeader: 'Duration',
});
await dashboard.grid.cell.fillText({
index: 0,
columnHeader: 'Duration',
text: '0008',
});
await dashboard.grid.cell.rating.select({ await dashboard.treeView.openTable({ title: 'Sheet1' });
index: 0,
columnHeader: 'Rating',
rating: 3,
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'Checkbox',
});
const today = new Date().toISOString().slice(0, 10); // ########################################
await dashboard.grid.cell.date.open({
index: 0,
columnHeader: 'Date',
});
await dashboard.grid.cell.date.selectDate({
date: today,
});
await dashboard.grid.cell.date.close();
await dashboard.grid.cell.attachment.addFile({ await dashboard.grid.cell.attachment.addFile({
index: 0, index: 0,
@ -345,7 +275,7 @@ test.describe('Verify shortcuts', () => {
}, },
{ position: { x: 1, y: 1 } } { position: { x: 1, y: 1 } }
); );
expect(await dashboard.grid.cell.getClipboardText()).toBe('Option 1'); expect(await dashboard.grid.cell.getClipboardText()).toBe('Option1');
await dashboard.grid.cell.copyToClipboard( await dashboard.grid.cell.copyToClipboard(
{ {
@ -354,14 +284,14 @@ test.describe('Verify shortcuts', () => {
}, },
{ position: { x: 1, y: 1 } } { position: { x: 1, y: 1 } }
); );
expect(await dashboard.grid.cell.getClipboardText()).toContain('Option 1'); expect(await dashboard.grid.cell.getClipboardText()).toContain('Option1');
expect(await dashboard.grid.cell.getClipboardText()).toContain('Option 2'); expect(await dashboard.grid.cell.getClipboardText()).toContain('Option2');
await dashboard.grid.cell.copyToClipboard({ await dashboard.grid.cell.copyToClipboard({
index: 0, index: 0,
columnHeader: 'Title', columnHeader: 'SingleLineText',
}); });
expect(await dashboard.grid.cell.getClipboardText()).toBe('Row 0'); expect(await dashboard.grid.cell.getClipboardText()).toBe('SingleLineText');
await dashboard.grid.cell.copyToClipboard({ await dashboard.grid.cell.copyToClipboard({
index: 0, index: 0,
@ -375,23 +305,29 @@ test.describe('Verify shortcuts', () => {
}); });
expect(await dashboard.grid.cell.getClipboardText()).toBe('987654321'); expect(await dashboard.grid.cell.getClipboardText()).toBe('987654321');
await dashboard.grid.cell.copyToClipboard({ await dashboard.grid.cell.copyToClipboard(
{
index: 0, index: 0,
columnHeader: 'Email', columnHeader: 'Email',
}); },
{ position: { x: 1, y: 1 } }
);
expect(await dashboard.grid.cell.getClipboardText()).toBe('test@example.com'); expect(await dashboard.grid.cell.getClipboardText()).toBe('test@example.com');
await dashboard.grid.cell.copyToClipboard({ await dashboard.grid.cell.copyToClipboard(
{
index: 0, index: 0,
columnHeader: 'URL', columnHeader: 'URL',
}); },
{ position: { x: 1, y: 1 } }
);
expect(await dashboard.grid.cell.getClipboardText()).toBe('nocodb.com'); expect(await dashboard.grid.cell.getClipboardText()).toBe('nocodb.com');
await dashboard.grid.cell.copyToClipboard({ await dashboard.grid.cell.copyToClipboard({
index: 0, index: 0,
columnHeader: 'Decimal', columnHeader: 'Decimal',
}); });
expect(await dashboard.grid.cell.getClipboardText()).toBe('1.1'); expect(await dashboard.grid.cell.getClipboardText()).toBe('1.12');
await dashboard.grid.cell.copyToClipboard({ await dashboard.grid.cell.copyToClipboard({
index: 0, index: 0,
@ -403,13 +339,14 @@ test.describe('Verify shortcuts', () => {
index: 0, index: 0,
columnHeader: 'Currency', columnHeader: 'Currency',
}); });
expect(await dashboard.grid.cell.getClipboardText()).toBe('20'); // convert from string to integer
expect(parseInt(await dashboard.grid.cell.getClipboardText())).toBe(20);
await dashboard.grid.cell.copyToClipboard({ await dashboard.grid.cell.copyToClipboard({
index: 0, index: 0,
columnHeader: 'Duration', columnHeader: 'Duration',
}); });
expect(await dashboard.grid.cell.getClipboardText()).toBe('480'); expect(parseInt(await dashboard.grid.cell.getClipboardText())).toBe(480);
await dashboard.grid.cell.copyToClipboard( await dashboard.grid.cell.copyToClipboard(
{ {
@ -427,7 +364,7 @@ test.describe('Verify shortcuts', () => {
}, },
{ position: { x: 1, y: 1 } } { position: { x: 1, y: 1 } }
); );
await new Promise(resolve => setTimeout(resolve, 5000)); // await new Promise(resolve => setTimeout(resolve, 5000));
expect(await dashboard.grid.cell.getClipboardText()).toBe('true'); expect(await dashboard.grid.cell.getClipboardText()).toBe('true');
await dashboard.grid.cell.click({ await dashboard.grid.cell.click({

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