Browse Source

Merge branch 'develop' into feat/keyboard-manoeuvre

pull/4482/head
Wing-Kam Wong 2 years ago
parent
commit
289f8de9e5
  1. 5
      packages/nc-gui/assets/style.scss
  2. 5
      packages/nc-gui/components.d.ts
  3. 2
      packages/nc-gui/components/account/SignupSettings.vue
  4. 2
      packages/nc-gui/components/cell/Currency.vue
  5. 2
      packages/nc-gui/components/cell/Decimal.vue
  6. 2
      packages/nc-gui/components/cell/Duration.vue
  7. 2
      packages/nc-gui/components/cell/Email.vue
  8. 2
      packages/nc-gui/components/cell/Float.vue
  9. 2
      packages/nc-gui/components/cell/Integer.vue
  10. 2
      packages/nc-gui/components/cell/Percent.vue
  11. 2
      packages/nc-gui/components/cell/Text.vue
  12. 2
      packages/nc-gui/components/cell/TextArea.vue
  13. 2
      packages/nc-gui/components/cell/Url.vue
  14. 77
      packages/nc-gui/components/dlg/AirtableImport.vue
  15. 24
      packages/nc-gui/components/smartsheet/Grid.vue
  16. 9
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  17. 5
      packages/nc-gui/components/smartsheet/column/EditOrAddProvider.vue
  18. 36
      packages/nc-gui/components/smartsheet/header/Cell.vue
  19. 191
      packages/nc-gui/components/smartsheet/header/Menu.vue
  20. 29
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  21. 10
      packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts
  22. 4
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue
  23. 16
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  24. 11
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  25. 6
      packages/nc-gui/composables/useColumnCreateStore.ts
  26. 6
      packages/nc-gui/composables/useSmartsheetStore.ts
  27. 1
      packages/nc-gui/lang/en.json
  28. 13
      packages/nc-gui/layouts/default.vue
  29. 8
      packages/nc-gui/lib/enums.ts
  30. 16
      packages/nc-gui/utils/columnUtils.ts
  31. 1
      packages/noco-docs/content/en/getting-started/installation.md
  32. 48
      packages/nocodb-sdk/src/lib/Api.ts
  33. 2
      packages/nocodb/src/lib/constants/index.ts
  34. 5
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulaQueryBuilderFromString.ts
  35. 5
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulav2/formulaQueryBuilderv2.ts
  36. 2
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts
  37. 4
      packages/nocodb/src/lib/meta/NcMetaMgr.ts
  38. 4
      packages/nocodb/src/lib/meta/NcMetaMgrv2.ts
  39. 4
      packages/nocodb/src/lib/meta/api/attachmentApis.ts
  40. 50
      packages/nocodb/src/lib/meta/api/columnApis.ts
  41. 10
      packages/nocodb/src/lib/meta/api/index.ts
  42. 4
      packages/nocodb/src/lib/meta/api/publicApis/publicDataApis.ts
  43. 6
      packages/nocodb/src/lib/meta/api/sortApis.ts
  44. 2
      packages/nocodb/src/lib/meta/api/sync/helpers/readAndProcessData.ts
  45. 38
      packages/nocodb/src/lib/meta/api/sync/importApis.ts
  46. 7
      packages/nocodb/src/lib/models/Column.ts
  47. 6
      packages/nocodb/src/lib/models/GridViewColumn.ts
  48. 33
      packages/nocodb/src/lib/models/Sort.ts
  49. 42
      packages/nocodb/src/lib/models/View.ts
  50. 6
      packages/nocodb/src/lib/services/test/TestResetService/resetMysqlSakilaProject.ts
  51. 33
      packages/nocodb/src/lib/v1-legacy/plugins/adapters/storage/Local.ts
  52. 73
      scripts/sdk/swagger.json
  53. BIN
      tests/playwright/fixtures/sampleFiles/sampleImage.jpeg
  54. 1
      tests/playwright/pages/Dashboard/Form/index.ts
  55. 2
      tests/playwright/pages/SharedForm/index.ts
  56. 9
      tests/playwright/tests/columnAttachments.spec.ts
  57. 29
      tests/playwright/tests/viewForm.spec.ts

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

@ -86,6 +86,11 @@ a {
@apply relative after:(absolute top-[-2px] right-[-2px] w-[8px] h-[8px] rounded-full bg-primary content-[''] !z-20);
}
// badge with count
.nc-count-badge {
@apply absolute flex items-center top-[-6px] right-[-6px] px-1 min-w-[14px] h-[14px] rounded-full bg-primary bg-opacity-100 text-white !text-[9px] !z-21;
}
// for highlighting toolbar menu item
.nc-active-btn > .ant-btn {
@apply bg-primary bg-opacity-20 hover:(bg-primary bg-opacity-20);

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

@ -168,6 +168,7 @@ declare module '@vue/runtime-core' {
MdiFileEyeOutline: typeof import('~icons/mdi/file-eye-outline')['default']
MdiFileImageBox: typeof import('~icons/mdi/file-image-box')['default']
MdiFilePlusOutline: typeof import('~icons/mdi/file-plus-outline')['default']
MdiFileReplaceOutline: typeof import('~icons/mdi/file-replace-outline')['default']
MdiFileUploadOutline: typeof import('~icons/mdi/file-upload-outline')['default']
MdiFilterOutline: typeof import('~icons/mdi/filter-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default']
@ -211,11 +212,15 @@ declare module '@vue/runtime-core' {
MdiShieldKeyOutline: typeof import('~icons/mdi/shield-key-outline')['default']
MdiSlack: typeof import('~icons/mdi/slack')['default']
MdiSort: typeof import('~icons/mdi/sort')['default']
MdiSortAscending: typeof import('~icons/mdi/sort-ascending')['default']
MdiSortDescending: typeof import('~icons/mdi/sort-descending')['default']
MdiStar: typeof import('~icons/mdi/star')['default']
MdiStarOutline: typeof import('~icons/mdi/star-outline')['default']
MdiStorefrontOutline: typeof import('~icons/mdi/storefront-outline')['default']
MdiTable: typeof import('~icons/mdi/table')['default']
MdiTableArrowRight: typeof import('~icons/mdi/table-arrow-right')['default']
MdiTableColumnPlusAfter: typeof import('~icons/mdi/table-column-plus-after')['default']
MdiTableColumnPlusBefore: typeof import('~icons/mdi/table-column-plus-before')['default']
MdiTableLarge: typeof import('~icons/mdi/table-large')['default']
MdiText: typeof import('~icons/mdi/text')['default']
MdiThumbUp: typeof import('~icons/mdi/thumb-up')['default']

2
packages/nc-gui/components/account/SignupSettings.vue

@ -51,6 +51,6 @@ loadSettings()
<style scoped>
:deep(.ant-checkbox-wrapper) {
@apply !flex-row-reverse !flex !justify-start gap-4;
justify-content: start;
justify-content: flex-start;
}
</style>

2
packages/nc-gui/components/cell/Currency.vue

@ -66,6 +66,8 @@ onMounted(() => {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else-if="vModel">{{ currency }}</span>

2
packages/nc-gui/components/cell/Decimal.vue

@ -35,6 +35,8 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else class="text-sm">{{ vModel }}</span>
</template>

2
packages/nc-gui/components/cell/Duration.vue

@ -89,6 +89,8 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else> {{ localState }}</span>

2
packages/nc-gui/components/cell/Email.vue

@ -35,6 +35,8 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<a v-else-if="validEmail" class="text-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank">

2
packages/nc-gui/components/cell/Float.vue

@ -35,6 +35,8 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else class="text-sm">{{ vModel }}</span>
</template>

2
packages/nc-gui/components/cell/Integer.vue

@ -39,6 +39,8 @@ function onKeyDown(evt: KeyboardEvent) {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else class="text-sm">{{ vModel }}</span>
</template>

2
packages/nc-gui/components/cell/Percent.vue

@ -33,6 +33,8 @@ const focus: VNodeRef = (el) => {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else>{{ vModel }}</span>
</template>

2
packages/nc-gui/components/cell/Text.vue

@ -34,6 +34,8 @@ const focus: VNodeRef = (el) => {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else>{{ vModel }}</span>

2
packages/nc-gui/components/cell/TextArea.vue

@ -31,6 +31,8 @@ const focus: VNodeRef = (el) => (el as HTMLTextAreaElement)?.focus()
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else>{{ vModel }}</span>

2
packages/nc-gui/components/cell/Url.vue

@ -84,6 +84,8 @@ watch(
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<nuxt-link

77
packages/nc-gui/components/dlg/AirtableImport.vue

@ -40,8 +40,12 @@ const progress = ref<Record<string, any>[]>([])
const logRef = ref<typeof AntCard>()
const enableAbort = ref(false)
let socket: Socket | null
let socketInterval: NodeJS.Timer
const syncSource = ref({
id: '',
type: 'Airtable',
@ -121,6 +125,7 @@ async function loadSyncSrc() {
srcs[0].details = srcs[0].details || {}
syncSource.value = migrateSync(srcs[0])
syncSource.value.details.syncSourceUrlOrId = srcs[0].details.shareId
socket?.emit('subscribe', syncSource.value.id)
} else {
syncSource.value = {
id: '',
@ -146,7 +151,6 @@ async function loadSyncSrc() {
}
async function sync() {
step.value = 2
try {
await $fetch(`/api/v1/db/meta/syncs/${syncSource.value.id}/trigger`, {
baseURL,
@ -156,11 +160,36 @@ async function sync() {
id: socket?.id,
},
})
socket?.emit('subscribe', syncSource.value.id)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
async function abort() {
Modal.confirm({
title: 'Are you sure you want to abort this job?',
type: 'warn',
content:
"This is a highly experimental feature and only marks job as not started, please don't abort the job unless you are sure job is stuck.",
onOk: async () => {
try {
await $fetch(`/api/v1/db/meta/syncs/${syncSource.value.id}/abort`, {
baseURL,
method: 'POST',
headers: { 'xc-auth': $state.token.value as string },
params: {
id: socket?.id,
},
})
step.value = 1
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
function migrateSync(src: any) {
if (!src.details?.options) {
src.details.options = {
@ -193,16 +222,6 @@ onMounted(async () => {
extraHeaders: { 'xc-auth': $state.token.value as string },
})
socket.on('connect_error', () => {
socket?.disconnect()
socket = null
})
// connect event does not provide data
socket.on('connect', () => {
console.log('socket connected')
})
socket.on('progress', async (d: Record<string, any>) => {
progress.value.push(d)
@ -219,13 +238,46 @@ onMounted(async () => {
}
})
socket.on('disconnect', () => {
console.log('socket disconnected')
const rcInterval = setInterval(() => {
if (socket?.connected) {
clearInterval(rcInterval)
socket?.emit('subscribe', syncSource.value.id)
} else {
socket?.connect()
}
}, 2000)
})
socket.on('job', () => {
step.value = 2
})
// connect event does not provide data
socket.on('connect', () => {
console.log('socket connected')
if (syncSource.value.id) {
socket?.emit('subscribe', syncSource.value.id)
}
})
socket?.io.on('reconnect', () => {
console.log('socket reconnected')
if (syncSource.value.id) {
socket?.emit('subscribe', syncSource.value.id)
}
})
await loadSyncSrc()
})
onBeforeUnmount(() => {
if (socket) {
socket.removeAllListeners()
socket.disconnect()
}
clearInterval(socketInterval)
})
</script>
@ -240,7 +292,7 @@ onBeforeUnmount(() => {
>
<div class="px-5">
<!-- Quick Import -->
<div class="mt-5 prose-xl font-weight-bold">{{ $t('title.quickImport') }} - AIRTABLE</div>
<div class="mt-5 prose-xl font-weight-bold" @dblclick="enableAbort = true">{{ $t('title.quickImport') }} - AIRTABLE</div>
<div v-if="step === 1">
<div class="mb-4">
@ -382,6 +434,7 @@ onBeforeUnmount(() => {
<a-button v-if="showGoToDashboardButton" class="mt-4" size="large" @click="dialogShow = false">
{{ $t('labels.goToDashboard') }}
</a-button>
<a-button v-else-if="enableAbort" class="mt-4" size="large" danger @click="abort()">ABORT</a-button>
</div>
</div>
</div>

24
packages/nc-gui/components/smartsheet/Grid.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { ColumnType, TableType, ViewType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import {
ActiveViewInj,
@ -17,6 +17,7 @@ import {
ReadonlyInj,
ReloadRowDataHookInj,
ReloadViewDataHookInj,
SmartsheetStoreEvents,
computed,
createEventHook,
enumColor,
@ -72,7 +73,7 @@ const isView = false
let editEnabled = $ref(false)
const { xWhere, isPkAvail, isSqlView } = useSmartsheetStoreOrThrow()
const { xWhere, isPkAvail, isSqlView, eventBus } = useSmartsheetStoreOrThrow()
const visibleColLength = $computed(() => fields.value?.length)
@ -583,6 +584,20 @@ watch(
},
{ immediate: true },
)
const columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
eventBus.on(async (event, payload) => {
if (event === SmartsheetStoreEvents.FIELD_ADD) {
columnOrder.value = payload
addColumnDropdown.value = true
}
})
const closeAddColumnDropdown = () => {
columnOrder.value = null
addColumnDropdown.value = false
}
</script>
<template>
@ -658,8 +673,9 @@ watch(
<template #overlay>
<SmartsheetColumnEditOrAddProvider
v-if="addColumnDropdown"
@submit="addColumnDropdown = false"
@cancel="addColumnDropdown = false"
:column-position="columnOrder"
@submit="closeAddColumnDropdown"
@cancel="closeAddColumnDropdown"
@click.stop
@keydown.stop
/>

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

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useEventListener } from '@vueuse/core'
import type { ColumnReqType } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import {
IsFormInj,
@ -13,6 +13,7 @@ import {
ref,
uiTypes,
useColumnCreateStoreOrThrow,
useEventListener,
useI18n,
useMetas,
useNuxtApp,
@ -22,6 +23,10 @@ import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
import MdiIdentifierIcon from '~icons/mdi/identifier'
const props = defineProps<{
columnPosition?: Pick<ColumnReqType, 'column_order'>
}>()
const emit = defineEmits(['submit', 'cancel'])
const { formState, generateNewColumnMeta, addOrUpdate, onAlter, onUidtOrIdTypeChange, validateInfos, isEdit } =
@ -71,7 +76,7 @@ const reloadMetaAndData = async () => {
}
async function onSubmit() {
const saved = await addOrUpdate(reloadMetaAndData)
const saved = await addOrUpdate(reloadMetaAndData, props.columnPosition)
if (!saved) return

5
packages/nc-gui/components/smartsheet/column/EditOrAddProvider.vue

@ -1,10 +1,11 @@
<script lang="ts" setup>
// todo: Remove this "Provider" component and use the "EditOrAdd" component directly
import type { ColumnType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { MetaInj, inject, ref, toRef, useProvideColumnCreateStore } from '#imports'
interface Props {
column?: ColumnType & { meta: any }
columnPosition?: Pick<ColumnReqType, 'column_order'>
}
const props = defineProps<Props>()
@ -19,5 +20,5 @@ useProvideColumnCreateStore(meta, column)
</script>
<template>
<SmartsheetColumnEditOrAdd @submit="emit('submit')" @cancel="emit('cancel')" />
<SmartsheetColumnEditOrAdd :column-position="props.columnPosition" @submit="emit('submit')" @cancel="emit('cancel')" />
</template>

36
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { ColumnInj, IsFormInj, IsKanbanInj, inject, provide, ref, toRef, useUIPermission } from '#imports'
const props = defineProps<{ column: ColumnType & { meta: any }; required?: boolean | number; hideMenu?: boolean }>()
@ -17,6 +17,18 @@ const { isUIAllowed } = useUIPermission()
provide(ColumnInj, column)
const editColumnDropdown = ref(false)
const columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
const addField = async (payload) => {
columnOrder.value = payload
editColumnDropdown.value = true
}
const closeAddColumnDropdown = () => {
columnOrder.value = null
editColumnDropdown.value = false
}
</script>
<template>
@ -25,14 +37,25 @@ const editColumnDropdown = ref(false)
:class="{ 'h-full': column, '!text-gray-400': isKanban }"
>
<SmartsheetHeaderCellIcon v-if="column" />
<span v-if="column" class="name" style="white-space: nowrap" :title="column.title">{{ column.title }}</span>
<span
v-if="column"
class="name cursor-pointer"
style="white-space: nowrap"
:title="column.title"
@dblclick="editColumnDropdown = true"
>{{ column.title }}</span
>
<span v-if="(column.rqd && !column.cdf) || required" class="text-red-500">&nbsp;*</span>
<template v-if="!hideMenu">
<div class="flex-1" />
<LazySmartsheetHeaderMenu v-if="!isForm && isUIAllowed('edit-column')" @edit="editColumnDropdown = true" />
<LazySmartsheetHeaderMenu
v-if="!isForm && isUIAllowed('edit-column')"
@add-column="addField"
@edit="editColumnDropdown = true"
/>
</template>
<a-dropdown
@ -47,10 +70,11 @@ const editColumnDropdown = ref(false)
<template #overlay>
<SmartsheetColumnEditOrAddProvider
v-if="editColumnDropdown"
:column="column"
:column="columnOrder ? null : column"
:column-position="columnOrder"
class="w-full"
@submit="editColumnDropdown = false"
@cancel="editColumnDropdown = false"
@submit="closeAddColumnDropdown"
@cancel="closeAddColumnDropdown"
@click.stop
@keydown.stop
/>

191
packages/nc-gui/components/smartsheet/header/Menu.vue

@ -1,27 +1,40 @@
<script lang="ts" setup>
import type { LinkToAnotherRecordType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import {
ActiveViewInj,
ColumnInj,
IsLockedInj,
MetaInj,
Modal,
ReloadViewDataHookInj,
SmartsheetStoreEvents,
defineEmits,
defineProps,
extractSdkResponseErrorMsg,
getUniqueColumnName,
inject,
message,
useI18n,
useMetas,
useNuxtApp,
useSmartsheetStoreOrThrow,
} from '#imports'
const { virtual = false } = defineProps<{ virtual?: boolean }>()
const emit = defineEmits(['edit'])
const emit = defineEmits(['edit', 'addColumn'])
const { eventBus } = useSmartsheetStoreOrThrow()
const column = inject(ColumnInj)
const reloadDataHook = inject(ReloadViewDataHookInj)
const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj)
const { $api, $e } = useNuxtApp()
@ -49,7 +62,7 @@ const deleteColumn = () =>
}
$e('a:column:delete')
} catch (e: any) {
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
@ -69,6 +82,131 @@ const setAsPrimaryValue = async () => {
message.error(t('msg.error.primaryColumnUpdateFailed'))
}
}
const sortByColumn = async (direction: 'asc' | 'desc') => {
try {
$e('a:sort:add', { from: 'column-menu' })
await $api.dbTableSort.create(view.value?.id as string, {
fk_column_id: column!.value.id,
direction,
push_to_top: true,
})
eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD)
reloadDataHook?.trigger()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const duplicateColumn = async () => {
let columnCreatePayload = {}
// generate duplicate column name
const duplicateColumnName = getUniqueColumnName(`${column!.value.title}_copy`, meta!.value!.columns!)
// construct column create payload
switch (column.value.uidt) {
case UITypes.LinkToAnotherRecord:
case UITypes.Lookup:
case UITypes.Rollup:
case UITypes.Formula:
return message.info('Not available at the moment')
case UITypes.SingleSelect:
case UITypes.MultiSelect:
columnCreatePayload = {
...column!.value!,
title: duplicateColumnName,
column_name: duplicateColumnName,
id: undefined,
order: undefined,
colOptions: {
options:
column.value.colOptions?.options?.map((option: Record<string, any>) => ({
...option,
id: undefined,
})) ?? [],
},
}
break
default:
columnCreatePayload = {
...column!.value!,
...(column!.value.colOptions ?? {}),
title: duplicateColumnName,
column_name: duplicateColumnName,
id: undefined,
colOptions: undefined,
order: undefined,
}
break
}
try {
const gridViewColumnList = await $api.dbViewColumn.list(view.value?.id as string)
const currentColumnIndex = gridViewColumnList.findIndex((f) => f.fk_column_id === column!.value.id)
let newColumnOrder
if (currentColumnIndex === gridViewColumnList.length - 1) {
newColumnOrder = gridViewColumnList[currentColumnIndex].order + 1
} else {
newColumnOrder = (gridViewColumnList[currentColumnIndex].order! + gridViewColumnList[currentColumnIndex + 1]?.order) / 2
}
await $api.dbTableColumn.create(meta!.value!.id!, {
...columnCreatePayload,
column_order: {
order: newColumnOrder,
view_id: view.value?.id as string,
},
})
await getMeta(meta!.value!.id!, true)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
message.success(t('msg.success.columnDuplicated'))
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
// add column before or after current column
const addColumn = async (before = false) => {
const gridViewColumnList = await $api.dbViewColumn.list(view.value?.id as string)
const currentColumnIndex = gridViewColumnList.findIndex((f) => f.fk_column_id === column!.value.id)
let newColumnOrder
if (before) {
if (currentColumnIndex === 0) {
newColumnOrder = gridViewColumnList[currentColumnIndex].order / 2
} else {
newColumnOrder = (gridViewColumnList[currentColumnIndex].order! + gridViewColumnList[currentColumnIndex - 1]?.order) / 2
}
} else {
if (currentColumnIndex === gridViewColumnList.length - 1) {
newColumnOrder = gridViewColumnList[currentColumnIndex].order + 1
} else {
newColumnOrder = (gridViewColumnList[currentColumnIndex].order! + gridViewColumnList[currentColumnIndex + 1]?.order) / 2
}
}
emit('addColumn', {
column_order: {
order: newColumnOrder,
view_id: view.value?.id as string,
},
})
}
// hide the field in view
const hideField = async () => {
const gridViewColumnList = await $api.dbViewColumn.list(view.value?.id as string)
const currentColumn = gridViewColumnList.find((f) => f.fk_column_id === column!.value.id)
await $api.dbViewColumn.update(view.value.id, currentColumn.id, { show: false })
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
}
</script>
<template>
@ -84,6 +222,53 @@ const setAsPrimaryValue = async () => {
{{ $t('general.edit') }}
</div>
</a-menu-item>
<template v-if="column.uidt !== UITypes.LinkToAnotherRecord || column.colOptions.type !== RelationTypes.BELONGS_TO">
<a-divider class="!my-0" />
<a-menu-item @click="sortByColumn('asc')">
<div class="nc-column-insert-after nc-header-menu-item">
<MdiSortAscending class="text-primary" />
Sort Ascending
</div>
</a-menu-item>
<a-menu-item @click="sortByColumn('desc')">
<div class="nc-column-insert-before nc-header-menu-item">
<MdiSortDescending class="text-primary" />
Sort Descending
</div>
</a-menu-item>
</template>
<a-divider class="!my-0" />
<a-menu-item @click="hideField">
<div class="nc-column-insert-before nc-header-menu-item">
<MdiEyeOffOutline class="text-primary" />
Hide Field
</div>
</a-menu-item>
<a-divider class="!my-0" />
<a-menu-item
v-if="column.uidt !== UITypes.LinkToAnotherRecord && column.uidt !== UITypes.Lookup && !column.pk"
@click="duplicateColumn"
>
<div class="nc-column-duplicate nc-header-menu-item">
<MdiFileReplaceOutline class="text-primary" />
Duplicate
</div>
</a-menu-item>
<a-menu-item @click="addColumn()">
<div class="nc-column-insert-after nc-header-menu-item">
<MdiTableColumnPlusAfter class="text-primary" />
Insert After
</div>
</a-menu-item>
<a-menu-item @click="addColumn(true)">
<div class="nc-column-insert-before nc-header-menu-item">
<MdiTableColumnPlusBefore class="text-primary" />
Insert before
</div>
</a-menu-item>
<a-divider class="!my-0" />
<a-menu-item v-if="!virtual" @click="setAsPrimaryValue">
<div class="nc-column-set-primary nc-header-menu-item">

29
packages/nc-gui/components/smartsheet/header/VirtualCell.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ColumnType, FormulaType, LinkToAnotherRecordType, LookupType, RollupType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType, FormulaType, LinkToAnotherRecordType, LookupType, RollupType } from 'nocodb-sdk'
import { substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
import {
ColumnInj,
@ -99,6 +99,18 @@ const tooltipMsg = computed(() => {
}
return ''
})
const columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
const addField = async (payload) => {
columnOrder.value = payload
editColumnDropdown.value = true
}
const closeAddColumnDropdown = () => {
columnOrder.value = null
editColumnDropdown.value = false
}
</script>
<template>
@ -117,7 +129,12 @@ const tooltipMsg = computed(() => {
<template v-if="!hideMenu">
<div class="flex-1" />
<LazySmartsheetHeaderMenu v-if="!isForm && isUIAllowed('edit-column')" :virtual="true" @edit="editColumnDropdown = true" />
<LazySmartsheetHeaderMenu
v-if="!isForm && isUIAllowed('edit-column')"
:virtual="true"
@add-column="addField"
@edit="editColumnDropdown = true"
/>
</template>
<a-dropdown
@ -128,14 +145,14 @@ const tooltipMsg = computed(() => {
overlay-class-name="nc-dropdown-edit-column"
>
<div />
<template #overlay>
<SmartsheetColumnEditOrAddProvider
v-if="editColumnDropdown"
:column="column"
:column="columnOrder ? null : column"
:column-position="columnOrder"
class="w-full"
@submit="editColumnDropdown = false"
@cancel="editColumnDropdown = false"
@submit="closeAddColumnDropdown"
@cancel="closeAddColumnDropdown"
@click.stop
@keydown.stop
/>

10
packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts

@ -12,7 +12,7 @@ import QrCodeScan from '~icons/mdi/qrcode-scan'
import RollupIcon from '~icons/mdi/movie-roll'
import CountIcon from '~icons/mdi/counter'
import SpecificDBTypeIcon from '~icons/mdi/database-settings'
import TableColumnPlusBefore from '~icons/mdi/table-column-plus-before'
import MdiTextSearchVariant from '~icons/mdi/text-search-variant'
const renderIcon = (column: ColumnType, relationColumn?: ColumnType) => {
switch (column.uidt) {
@ -35,13 +35,13 @@ const renderIcon = (column: ColumnType, relationColumn?: ColumnType) => {
case UITypes.Lookup:
switch ((relationColumn?.colOptions as LinkToAnotherRecordType)?.type) {
case RelationTypes.MANY_TO_MANY:
return { icon: TableColumnPlusBefore, color: 'text-accent' }
return { icon: MdiTextSearchVariant, color: 'text-accent' }
case RelationTypes.HAS_MANY:
return { icon: TableColumnPlusBefore, color: 'text-yellow-500' }
return { icon: MdiTextSearchVariant, color: 'text-yellow-500' }
case RelationTypes.BELONGS_TO:
return { icon: TableColumnPlusBefore, color: 'text-sky-500' }
return { icon: MdiTextSearchVariant, color: 'text-sky-500' }
}
return { icon: TableColumnPlusBefore, color: 'text-grey' }
return { icon: MdiTextSearchVariant, color: 'text-grey' }
case UITypes.Rollup:
switch ((relationColumn?.colOptions as LinkToAnotherRecordType)?.type) {
case RelationTypes.MANY_TO_MANY:

4
packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue

@ -71,13 +71,15 @@ useMenuCloseOnEsc(open)
<template>
<a-dropdown v-model:visible="open" :trigger="['click']" overlay-class-name="nc-dropdown-filter-menu">
<div :class="{ 'nc-badge nc-active-btn': filtersLength }">
<div :class="{ 'nc-active-btn': filtersLength }">
<a-button v-e="['c:filter']" class="nc-filter-menu-btn nc-toolbar-btn txt-sm" :disabled="isLocked">
<div class="flex items-center gap-1">
<MdiFilterOutline />
<!-- Filter -->
<span class="text-capitalize !text-sm font-weight-normal">{{ $t('activity.filter') }}</span>
<MdiMenuDown class="text-grey" />
<span v-if="filtersLength" class="nc-count-badge">{{ filtersLength }}</span>
</div>
</a-button>
</div>

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

@ -16,6 +16,7 @@ import {
resolveComponent,
useMenuCloseOnEsc,
useNuxtApp,
useSmartsheetStoreOrThrow,
useViewColumns,
watch,
} from '#imports'
@ -46,8 +47,17 @@ const {
hideAll,
saveOrUpdate,
metaColumnById,
loadViewColumns,
} = useViewColumns(activeView, meta, () => reloadDataHook.trigger())
const { eventBus } = useSmartsheetStoreOrThrow()
eventBus.on((event) => {
if (event === SmartsheetStoreEvents.FIELD_RELOAD) {
loadViewColumns()
}
})
watch(
sortedAndFilteredFields,
(v) => {
@ -56,7 +66,7 @@ watch(
{ immediate: true },
)
const isAnyFieldHidden = computed(() => filteredFieldList.value?.some((field) => !field.show))
const numberOfHiddenFields = computed(() => filteredFieldList.value?.filter((field) => !field.show)?.length)
const onMove = (_event: { moved: { newIndex: number } }) => {
// todo : sync with server
@ -128,7 +138,7 @@ useMenuCloseOnEsc(open)
<template>
<a-dropdown v-model:visible="open" :trigger="['click']" overlay-class-name="nc-dropdown-fields-menu">
<div :class="{ 'nc-badge nc-active-btn': isAnyFieldHidden }">
<div :class="{ 'nc-active-btn': numberOfHiddenFields }">
<a-button v-e="['c:fields']" class="nc-fields-menu-btn nc-toolbar-btn" :disabled="isLocked">
<div class="flex items-center gap-1">
<MdiEyeOffOutline />
@ -137,6 +147,8 @@ useMenuCloseOnEsc(open)
<span class="text-capitalize !text-sm font-weight-normal">{{ $t('objects.fields') }}</span>
<MdiMenuDown class="text-grey" />
<span v-if="numberOfHiddenFields" class="nc-count-badge">{{ numberOfHiddenFields }}</span>
</div>
</a-button>
</div>

11
packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue

@ -10,6 +10,7 @@ import {
inject,
ref,
useMenuCloseOnEsc,
useSmartsheetStoreOrThrow,
useViewSorts,
watch,
} from '#imports'
@ -19,8 +20,16 @@ const view = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj, ref(false))
const reloadDataHook = inject(ReloadViewDataHookInj)
const { eventBus } = useSmartsheetStoreOrThrow()
const { sorts, saveOrUpdate, loadSorts, addSort, deleteSort } = useViewSorts(view, () => reloadDataHook?.trigger())
eventBus.on((event) => {
if (event === SmartsheetStoreEvents.SORT_RELOAD) {
loadSorts()
}
})
const columns = computed(() => meta.value?.columns || [])
const columnByID = computed(() =>
@ -54,6 +63,8 @@ useMenuCloseOnEsc(open)
<!-- Sort -->
<span class="text-capitalize !text-sm font-weight-normal">{{ $t('activity.sort') }}</span>
<MdiMenuDown class="text-grey" />
<span v-if="sorts?.length" class="nc-count-badge">{{ sorts.length }}</span>
</div>
</a-button>
</div>

6
packages/nc-gui/composables/useColumnCreateStore.ts

@ -1,5 +1,5 @@
import clone from 'just-clone'
import type { ColumnType, TableType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
import {
@ -191,7 +191,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
if (cdf) formState.value.cdf = formState.value.cdf || null
}
const addOrUpdate = async (onSuccess: () => void) => {
const addOrUpdate = async (onSuccess: () => void, columnPosition?: Pick<ColumnReqType, 'column_order'>) => {
try {
if (!(await validate())) return
} catch (e) {
@ -228,7 +228,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
// };
// }
}
await $api.dbTableColumn.create(meta.value?.id as string, formState.value)
await $api.dbTableColumn.create(meta.value?.id as string, { ...formState.value, ...columnPosition })
/** if LTAR column then force reload related table meta */
if (formState.value.uidt === UITypes.LinkToAnotherRecord && meta.value?.id !== formState.value.childId) {

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

@ -1,7 +1,8 @@
import { ViewTypes } from 'nocodb-sdk'
import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { computed, ref, unref, useFieldQuery, useInjectionState, useNuxtApp, useProject } from '#imports'
import { computed, ref, unref, useEventBus, useFieldQuery, useInjectionState, useNuxtApp, useProject } from '#imports'
import type { SmartsheetStoreEvents } from '~/lib'
const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
(
@ -19,6 +20,8 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const { search } = useFieldQuery(view)
const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore'))
// getters
const isLocked = computed(() => view.value?.lock_type === 'locked')
const isPkAvail = computed(() => (meta.value as TableType)?.columns?.some((c) => c.pk))
@ -63,6 +66,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
sorts,
nestedFilters,
isSqlView,
eventBus,
}
},
'smartsheet-store',

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

@ -698,6 +698,7 @@
"futureRelease": "Coming soon!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

13
packages/nc-gui/layouts/default.vue

@ -8,7 +8,18 @@ const { te, t } = useI18n()
const { hasSidebar } = useSidebar('nc-left-sidebar')
const refreshSidebar = ref(false)
useTitle(route.meta?.title && te(route.meta.title) ? `${t(route.meta.title)} | NocoDB` : 'NocoDB')
watch(hasSidebar, (val) => {
if (!val) {
refreshSidebar.value = true
nextTick(() => {
refreshSidebar.value = false
})
}
})
</script>
<script lang="ts">
@ -20,7 +31,7 @@ export default {
<template>
<div class="w-full h-full">
<Teleport :to="hasSidebar ? '#nc-sidebar-left' : null" :disabled="!hasSidebar">
<slot :key="$route.name" name="sidebar" />
<slot v-if="!refreshSidebar" name="sidebar" />
</Teleport>
<a-layout-content>

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

@ -77,3 +77,11 @@ export enum TabType {
VIEW = 'view',
AUTH = 'auth',
}
export enum SmartsheetStoreEvents {
SORT_RELOAD = 'sort-reload',
FILTER_RELOAD = 'filter-reload',
DATA_RELOAD = 'data-reload',
FIELD_RELOAD = 'field-reload',
FIELD_ADD = 'field-add',
}

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

@ -1,7 +1,6 @@
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import LinkVariant from '~icons/mdi/link-variant'
import TableColumnPlusBefore from '~icons/mdi/table-column-plus-before'
import QrCodeScan from '~icons/mdi/qrcode-scan'
import FormatColorText from '~icons/mdi/format-color-text'
import TextSubject from '~icons/mdi/text-subject'
@ -28,6 +27,7 @@ import MovieRoll from '~icons/mdi/movie-roll'
import CalendarClock from '~icons/mdi/calendar-clock'
import ID from '~icons/mdi/identifier'
import RulerSquareCompass from '~icons/mdi/ruler-square-compass'
import MdiTextSearchVariant from '~icons/mdi/text-search-variant'
const uiTypes = [
{
@ -37,7 +37,7 @@ const uiTypes = [
},
{
name: UITypes.Lookup,
icon: TableColumnPlusBefore,
icon: MdiTextSearchVariant,
virtual: 1,
},
{
@ -180,4 +180,14 @@ const isColumnRequiredAndNull = (col: ColumnType, row: Record<string, any>) => {
return isColumnRequired(col) && (row[col.title!] === undefined || row[col.title!] === null)
}
export { uiTypes, getUIDTIcon, isColumnRequiredAndNull, isColumnRequired, isVirtualColRequired }
const getUniqueColumnName = (initName: string, columns: ColumnType[]) => {
let name = initName
let i = 1
while (columns.find((c) => c.title === name)) {
name = `${initName}_${i}`
i++
}
return name
}
export { uiTypes, getUIDTIcon, getUniqueColumnName, isColumnRequiredAndNull, isColumnRequired, isVirtualColRequired }

1
packages/noco-docs/content/en/getting-started/installation.md

@ -509,6 +509,7 @@ It is mandatory to configure `NC_DB` environment variables for production usecas
| NC_S3_ACCESS_KEY | No | For S3 storage plugin - AWS access key credential for accessing resource | | |
| NC_S3_ACCESS_SECRET | No | For S3 storage plugin - AWS access secret credential for accessing resource | | |
| NC_ADMIN_EMAIL | No | For updating/creating super admin with provided email and password | | |
| NC_ATTACHMENT_FIELD_SIZE | No | For setting the attachment field size(in Bytes) | Defaults to 20MB | |
| NC_ADMIN_PASSWORD | No | For updating/creating super admin with provided email and password. Your password should have at least 8 letters with one uppercase, one number and one special letter(Allowed special chars <code>$&+,:;=?@#&#124;'.^*()%!_-"</code> ) | | |
| NODE_OPTIONS | No | For passing Node.js [options](https://nodejs.org/api/cli.html#node_optionsoptions) to instance | | |
| NC_MINIMAL_DBS | No | Create a new SQLite file for each project. All the db files are stored in `nc_minimal_dbs` folder in current working directory. (This option restricts project creation on external sources) | | |

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

@ -571,8 +571,7 @@ export interface HookLogType {
updated_at?: string;
}
export type ColumnReqType =
| {
export interface NormalColumnRequestType {
uidt?:
| 'ID'
| 'SingleLineText'
@ -628,34 +627,53 @@ export type ColumnReqType =
dtxp?: string;
dtxs?: string;
au?: boolean;
''?: string;
}
| {
}
export interface LinkToAnotherColumnReqType {
uidt: 'LinkToAnotherRecord';
title: string;
virtual?: boolean;
parentId: string;
childId: string;
type: 'hm' | 'bt' | 'mm';
}
| {
}
export interface RollupColumnReqType {
uidt?: 'Rollup';
title?: string;
fk_relation_column_id?: string;
fk_rollup_column_id?: string;
rollup_function?: string;
}
| {
}
export interface LookupColumnReqType {
uidt?: 'Lookup';
title?: string;
fk_relation_column_id?: string;
fk_lookup_column_id?: string;
}
| {
}
export interface FormulaColumnReqType {
uidt?: string;
formula_raw?: string;
formula?: string;
title?: string;
}
export type ColumnReqType = (
| NormalColumnRequestType
| LinkToAnotherColumnReqType
| RollupColumnReqType
| FormulaColumnReqType
| LookupColumnReqType
) & {
column_name?: string;
title?: string;
column_order?: {
view_id?: string;
order?: number;
};
};
export interface UserInfoType {
id?: string;
@ -2600,7 +2618,13 @@ export class Api<
* @request POST:/api/v1/db/meta/views/{viewId}/sorts
* @response `200` `void` OK
*/
create: (viewId: string, data: SortType, params: RequestParams = {}) =>
create: (
viewId: string,
data: SortType & {
push_to_top?: boolean;
},
params: RequestParams = {}
) =>
this.request<void, any>({
path: `/api/v1/db/meta/views/${viewId}/sorts`,
method: 'POST',

2
packages/nocodb/src/lib/constants/index.ts

@ -1,2 +1,4 @@
export const NC_LICENSE_KEY = 'nc-license-key';
export const NC_APP_SETTINGS = 'nc-app-settings';
export const NC_ATTACHMENT_FIELD_SIZE =
+process.env['NC_ATTACHMENT_FIELD_SIZE'] || 20 * 1024 * 1024; // 20 MB

5
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulaQueryBuilderFromString.ts

@ -136,6 +136,11 @@ export default function formulaQueryBuilder(
type: 'CallExpression',
arguments: [pt.left],
};
pt.right = {
callee: { name: 'FLOAT' },
type: 'CallExpression',
arguments: [pt.right],
};
}
const query = knex.raw(

5
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulav2/formulaQueryBuilderv2.ts

@ -646,6 +646,11 @@ export default async function formulaQueryBuilderv2(
type: 'CallExpression',
arguments: [pt.left],
};
pt.right = {
callee: { name: 'FLOAT' },
type: 'CallExpression',
arguments: [pt.right],
};
}
pt.left.fnName = pt.left.fnName || 'ARITH';
pt.right.fnName = pt.right.fnName || 'ARITH';

2
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts

@ -36,7 +36,7 @@ const mysql2 = {
MID: 'SUBSTR',
FLOAT: (args: MapFnArgs) => {
return args.knex
.raw(`CAST(${args.fn(args.pt.arguments[0])} as DOUBLE)${args.colAlias}`)
.raw(`CAST(CAST(${args.fn(args.pt.arguments[0])} as CHAR) AS DOUBLE)${args.colAlias}`)
.wrap('(', ')');
},
DATEADD: ({ fn, knex, pt, colAlias }: MapFnArgs) => {

4
packages/nocodb/src/lib/meta/NcMetaMgr.ts

@ -41,6 +41,7 @@ import { defaultConnectionConfig } from '../utils/NcConfigFactory';
import xcMetaDiff from './handlers/xcMetaDiff';
import { UITypes } from 'nocodb-sdk';
import { Tele } from 'nc-help';
import { NC_ATTACHMENT_FIELD_SIZE } from '../constants';
const randomID = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 10);
const XC_PLUGIN_DET = 'XC_PLUGIN_DET';
@ -132,6 +133,9 @@ export default class NcMetaMgr {
storage: multer.diskStorage({
// dest: path.join(this.config.toolDir, 'uploads')
}),
limits: {
fieldSize: NC_ATTACHMENT_FIELD_SIZE,
},
});
// router.post(this.config.dashboardPath, upload.single('file'));
router.post(this.config.dashboardPath, upload.any());

4
packages/nocodb/src/lib/meta/NcMetaMgrv2.ts

@ -11,6 +11,7 @@ import NcPluginMgr from '../v1-legacy/plugins/NcPluginMgr';
import NcMetaIO from './NcMetaIO';
import { defaultConnectionConfig } from '../utils/NcConfigFactory';
import ncCreateLookup from './handlersv2/ncCreateLookup';
import { NC_ATTACHMENT_FIELD_SIZE } from '../constants';
// import ncGetMeta from './handlersv2/ncGetMeta';
export default class NcMetaMgrv2 {
@ -71,6 +72,9 @@ export default class NcMetaMgrv2 {
storage: multer.diskStorage({
// dest: path.join(this.config.toolDir, 'uploads')
}),
limits: {
fieldSize: NC_ATTACHMENT_FIELD_SIZE,
},
});
// router.post(this.config.dashboardPath, upload.single('file'));
router.post(this.config.dashboardPath, upload.any());

4
packages/nocodb/src/lib/meta/api/attachmentApis.ts

@ -9,6 +9,7 @@ import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import catchError from '../helpers/catchError';
import NcPluginMgrv2 from '../helpers/NcPluginMgrv2';
import { NC_ATTACHMENT_FIELD_SIZE } from '../../constants';
// const storageAdapter = new Local();
export async function upload(req: Request, res: Response) {
@ -151,6 +152,9 @@ router.post(
'/api/v1/db/storage/upload',
multer({
storage: multer.diskStorage({}),
limits: {
fieldSize: NC_ATTACHMENT_FIELD_SIZE,
},
}).any(),
ncMetaAclMw(upload, 'upload')
);

50
packages/nocodb/src/lib/meta/api/columnApis.ts

@ -15,9 +15,13 @@ import {
import {
AuditOperationSubTypes,
AuditOperationTypes,
ColumnReqType,
isVirtualCol,
LinkToAnotherColumnReqType,
LinkToAnotherRecordType,
LookupColumnReqType,
RelationTypes,
RollupColumnReqType,
substituteColumnAliasWithIdInFormula,
substituteColumnIdWithAliasInFormula,
TableType,
@ -96,7 +100,10 @@ async function createHmAndBtColumn(
}
}
export async function columnAdd(req: Request, res: Response<TableType>) {
export async function columnAdd(
req: Request<any, any, ColumnReqType & { uidt: UITypes }>,
res: Response<TableType>
) {
const table = await Model.getWithInfo({
id: req.params.tableId,
});
@ -121,7 +128,7 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
NcError.badRequest('Duplicate column alias');
}
let colBody = req.body;
let colBody: any = req.body;
switch (colBody.uidt) {
case UITypes.Rollup:
{
@ -137,7 +144,7 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
const relation = await (
await Column.get({
colId: req.body.fk_relation_column_id,
colId: (req.body as RollupColumnReqType).fk_relation_column_id,
})
).getColOptions<LinkToAnotherRecordType>();
@ -163,7 +170,8 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
const relatedTable = await relatedColumn.getModel();
if (
!(await relatedTable.getColumns()).find(
(c) => c.id === req.body.fk_rollup_column_id
(c) =>
c.id === (req.body as RollupColumnReqType).fk_rollup_column_id
)
)
throw new Error('Rollup column not found in related table');
@ -183,7 +191,7 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
const relation = await (
await Column.get({
colId: req.body.fk_relation_column_id,
colId: (req.body as LookupColumnReqType).fk_relation_column_id,
})
).getColOptions<LinkToAnotherRecordType>();
@ -209,7 +217,8 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
const relatedTable = await relatedColumn.getModel();
if (
!(await relatedTable.getColumns()).find(
(c) => c.id === req.body.fk_lookup_column_id
(c) =>
c.id === (req.body as LookupColumnReqType).fk_lookup_column_id
)
)
throw new Error('Lookup column not found in related table');
@ -227,14 +236,21 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
validateParams(['parentId', 'childId', 'type'], req.body);
// get parent and child models
const parent = await Model.getWithInfo({ id: req.body.parentId });
const child = await Model.getWithInfo({ id: req.body.childId });
const parent = await Model.getWithInfo({
id: (req.body as LinkToAnotherColumnReqType).parentId,
});
const child = await Model.getWithInfo({
id: (req.body as LinkToAnotherColumnReqType).childId,
});
let childColumn: Column;
const sqlMgr = await ProjectMgrv2.getSqlMgr({
id: base.project_id,
});
if (req.body.type === 'hm' || req.body.type === 'bt') {
if (
(req.body as LinkToAnotherColumnReqType).type === 'hm' ||
(req.body as LinkToAnotherColumnReqType).type === 'bt'
) {
// populate fk column name
const fkColName = getUniqueColumnName(
await child.getColumns(),
@ -285,7 +301,7 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
childColumn = await Column.get({ colId: id });
// ignore relation creation if virtual
if (!req.body.virtual) {
if (!(req.body as LinkToAnotherColumnReqType).virtual) {
// create relation
await sqlMgr.sqlOpPlus(base, 'relationCreate', {
childColumn: fkColName,
@ -315,11 +331,11 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
child,
parent,
childColumn,
req.body.type,
req.body.title,
req.body.virtual
(req.body as LinkToAnotherColumnReqType).type as RelationTypes,
(req.body as LinkToAnotherColumnReqType).title,
(req.body as LinkToAnotherColumnReqType).virtual
);
} else if (req.body.type === 'mm') {
} else if ((req.body as LinkToAnotherColumnReqType).type === 'mm') {
const aTn = `${project?.prefix ?? ''}_nc_m2m_${randomID()}`;
const aTnAlias = aTn;
@ -378,7 +394,7 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
columns: associateTableCols,
});
if (!req.body.virtual) {
if (!(req.body as LinkToAnotherColumnReqType).virtual) {
const rel1Args = {
...req.body,
childTable: aTn,
@ -412,7 +428,7 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
childCol,
null,
null,
req.body.virtual,
(req.body as LinkToAnotherColumnReqType).virtual,
true
);
await createHmAndBtColumn(
@ -421,7 +437,7 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
parentCol,
null,
null,
req.body.virtual,
(req.body as LinkToAnotherColumnReqType).virtual,
true
);

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

@ -55,6 +55,7 @@ import importApis from './sync/importApis';
import syncSourceApis from './sync/syncSourceApis';
const clients: { [id: string]: Socket } = {};
const jobs: { [id: string]: { last_message: any } } = {};
export default function (router: Router, server) {
initStrategies(router);
@ -138,9 +139,16 @@ export default function (router: Router, server) {
socket.on('event', (args) => {
Tele.event({ ...args, id });
});
socket.on('subscribe', (room) => {
if (room in jobs) {
socket.join(room)
socket.emit('job')
socket.emit('progress', jobs[room].last_message)
}
})
});
importApis(router, clients);
importApis(router, io, jobs);
}
function getHash(str) {

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

@ -18,6 +18,7 @@ import slash from 'slash';
import { sanitizeUrlPath } from '../attachmentApis';
import getAst from '../../../db/sql-data-mapper/lib/sql/helpers/getAst';
import { getColumnByIdOrName } from '../dataApis/helpers';
import { NC_ATTACHMENT_FIELD_SIZE } from '../../../constants';
export async function dataList(req: Request, res: Response) {
try {
@ -451,6 +452,9 @@ router.post(
'/api/v1/db/public/shared-view/:sharedViewUuid/rows',
multer({
storage: multer.diskStorage({}),
limits: {
fieldSize: NC_ATTACHMENT_FIELD_SIZE,
},
}).any(),
catchError(dataInsert)
);

6
packages/nocodb/src/lib/meta/api/sortApis.ts

@ -4,7 +4,7 @@ import Model from '../../models/Model';
import { Tele } from 'nc-help';
// @ts-ignore
import { PagedResponseImpl } from '../helpers/PagedResponse';
import { SortListType, TableReqType, TableType } from 'nocodb-sdk';
import { SortListType, SortType, TableType } from 'nocodb-sdk';
// @ts-ignore
import ProjectMgrv2 from '../../db/sql-mgr/v2/ProjectMgrv2';
// @ts-ignore
@ -28,11 +28,11 @@ export async function sortList(
}
// @ts-ignore
export async function sortCreate(req: Request<any, any, TableReqType>, res) {
export async function sortCreate(req: Request<any, any, SortType>, res) {
const sort = await Sort.insert({
...req.body,
fk_view_id: req.params.viewId,
});
} as Sort);
Tele.emit('evt', { evt_type: 'sort:created' });
res.json(sort);
}

2
packages/nocodb/src/lib/meta/api/sync/helpers/readAndProcessData.ts

@ -4,7 +4,7 @@ import EntityMap from './EntityMap';
const BULK_DATA_BATCH_SIZE = 500;
const ASSOC_BULK_DATA_BATCH_SIZE = 1000;
const BULK_PARALLEL_PROCESS = 100;
const BULK_PARALLEL_PROCESS = 5;
async function readAllData({
table,

38
packages/nocodb/src/lib/meta/api/sync/importApis.ts

@ -1,8 +1,8 @@
import { Request, Router } from 'express';
// import { Queue } from 'bullmq';
// import axios from 'axios';
import catchError from '../../helpers/catchError';
import { Socket } from 'socket.io';
import catchError, { NcError } from '../../helpers/catchError';
import { Server } from 'socket.io';
import NocoJobs from '../../../jobs/NocoJobs';
import job, { AirtableSyncConfig } from './helpers/job';
import SyncSource from '../../../models/SyncSource';
@ -17,17 +17,25 @@ enum SyncStatus {
FAILED = 'FAILED',
}
export default (router: Router, clients: { [id: string]: Socket }) => {
export default (router: Router, sv: Server, jobs: { [id: string]: { last_message: any } }) => {
// add importer job handler and progress notification job handler
NocoJobs.jobsMgr.addJobWorker(AIRTABLE_IMPORT_JOB, job);
NocoJobs.jobsMgr.addJobWorker(
AIRTABLE_PROGRESS_JOB,
({ payload, progress }) => {
clients?.[payload?.id]?.emit('progress', {
sv.to(payload?.id).emit('progress', {
msg: progress?.msg,
level: progress?.level,
status: progress?.status,
});
if (payload?.id in jobs) {
jobs[payload?.id].last_message = {
msg: progress?.msg,
level: progress?.level,
status: progress?.status,
};
}
}
);
@ -49,6 +57,7 @@ export default (router: Router, clients: { [id: string]: Socket }) => {
status: SyncStatus.COMPLETED,
},
});
delete jobs[payload?.id];
});
NocoJobs.jobsMgr.addFailureCbk(AIRTABLE_IMPORT_JOB, (payload, error: any) => {
NocoJobs.jobsMgr.add(AIRTABLE_PROGRESS_JOB, {
@ -58,6 +67,7 @@ export default (router: Router, clients: { [id: string]: Socket }) => {
status: SyncStatus.FAILED,
},
});
delete jobs[payload?.id];
});
router.post(
@ -73,6 +83,10 @@ export default (router: Router, clients: { [id: string]: Socket }) => {
router.post(
'/api/v1/db/meta/syncs/:syncId/trigger',
catchError(async (req: Request, res) => {
if (req.params.syncId in jobs) {
NcError.badRequest('Sync already in progress');
}
const syncSource = await SyncSource.get(req.params.syncId);
const user = await syncSource.getUser();
@ -90,12 +104,26 @@ export default (router: Router, clients: { [id: string]: Socket }) => {
}
NocoJobs.jobsMgr.add<AirtableSyncConfig>(AIRTABLE_IMPORT_JOB, {
id: req.query.id,
id: req.params.syncId,
...(syncSource?.details || {}),
projectId: syncSource.project_id,
authToken: token,
baseURL,
});
jobs[req.params.syncId] = {
last_message: {
msg: 'Sync started'
}
};
res.json({});
})
);
router.post(
'/api/v1/db/meta/syncs/:syncId/abort',
catchError(async (req: Request, res) => {
if (req.params.syncId in jobs) {
delete jobs[req.params.syncId];
}
res.json({});
})
);

7
packages/nocodb/src/lib/models/Column.ts

@ -5,7 +5,7 @@ import RollupColumn from './RollupColumn';
import SelectOption from './SelectOption';
import Model from './Model';
import NocoCache from '../cache/NocoCache';
import { AllowedColumnTypesForQrCode, ColumnType, UITypes } from 'nocodb-sdk';
import { ColumnReqType, AllowedColumnTypesForQrCode, ColumnType, UITypes } from 'nocodb-sdk';
import {
CacheDelDirection,
CacheGetType,
@ -74,7 +74,7 @@ export default class Column<T = any> implements ColumnType {
[key: string]: any;
fk_model_id: string;
uidt: UITypes | string;
},
} & Pick<ColumnReqType, 'column_order'>,
ncMeta = Noco.ncMeta
) {
if (!column.fk_model_id) NcError.badRequest('Missing model id');
@ -130,7 +130,6 @@ export default class Column<T = any> implements ColumnType {
}
if (!column.uidt) throw new Error('UI Datatype not found');
const order = 1;
const row = await ncMeta.metaInsert2(
null, //column.project_id || column.base_id,
null, //column.db_alias,
@ -152,8 +151,8 @@ export default class Column<T = any> implements ColumnType {
{
fk_column_id: row.id,
fk_model_id: column.fk_model_id,
order,
show: true,
column_order: column.column_order,
},
ncMeta
);

6
packages/nocodb/src/lib/models/GridViewColumn.ts

@ -70,9 +70,11 @@ export default class GridViewColumn implements GridColumnType {
const insertObj = {
fk_view_id: column.fk_view_id,
fk_column_id: column.fk_column_id,
order: await ncMeta.metaGetNextOrder(MetaTable.GRID_VIEW_COLUMNS, {
order:
column?.order ??
(await ncMeta.metaGetNextOrder(MetaTable.GRID_VIEW_COLUMNS, {
fk_view_id: column.fk_view_id,
}),
})),
show: column.show,
project_id: column.project_id,
base_id: column.base_id,

33
packages/nocodb/src/lib/models/Sort.ts

@ -34,10 +34,14 @@ export default class Sort {
});
}
public static async insert(sortObj: Partial<Sort>, ncMeta = Noco.ncMeta) {
public static async insert(
sortObj: Partial<Sort> & { push_to_top?: boolean },
ncMeta = Noco.ncMeta
) {
// todo: implement a generic function
const order =
(+(
const order = sortObj.push_to_top
? 1
: (+(
await ncMeta
.knex(MetaTable.SORT)
.max('order', { as: 'order' })
@ -62,8 +66,27 @@ export default class Sort {
insertObj.base_id = model.base_id;
}
const row = await ncMeta.metaInsert2(null, null, MetaTable.SORT, insertObj);
// increment existing order
if (sortObj.push_to_top) {
await ncMeta
.knex(MetaTable.SORT)
.where({
fk_view_id: sortObj.fk_view_id,
})
.increment('order', 1);
}
const row = await ncMeta.metaInsert2(null, null, MetaTable.SORT, insertObj);
if (sortObj.push_to_top) {
// todo: delete cache
const sortList = await ncMeta.metaList2(null, null, MetaTable.SORT, {
condition: { fk_view_id: sortObj.fk_view_id },
orderBy: {
order: 'asc',
},
});
await NocoCache.setList(CacheScope.SORT, [sortObj.fk_view_id], sortList);
} else {
await NocoCache.appendToList(
CacheScope.SORT,
[sortObj.fk_view_id],
@ -75,7 +98,7 @@ export default class Sort {
[sortObj.fk_column_id],
`${CacheScope.SORT}:${row.id}`
);
}
return this.get(row.id, ncMeta);
}

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

@ -13,7 +13,13 @@ import GalleryView from './GalleryView';
import GridViewColumn from './GridViewColumn';
import Sort from './Sort';
import Filter from './Filter';
import { isSystemColumn, UITypes, ViewType, ViewTypes } from 'nocodb-sdk';
import {
ColumnReqType,
isSystemColumn,
UITypes,
ViewType,
ViewTypes,
} from 'nocodb-sdk';
import GalleryViewColumn from './GalleryViewColumn';
import FormViewColumn from './FormViewColumn';
import KanbanViewColumn from './KanbanViewColumn';
@ -442,9 +448,9 @@ export default class View implements ViewType {
param: {
fk_column_id: any;
fk_model_id: any;
order;
order?: number;
show;
},
} & Pick<ColumnReqType, 'column_order'>,
ncMeta = Noco.ncMeta
) {
const insertObj = {
@ -456,33 +462,21 @@ export default class View implements ViewType {
const views = await this.list(param.fk_model_id, ncMeta);
for (const view of views) {
const modifiedInsertObj = { ...insertObj, fk_view_id: view.id };
if (param.column_order?.view_id === view.id) {
modifiedInsertObj.order = param.column_order?.order;
}
switch (view.type) {
case ViewTypes.GRID:
await GridViewColumn.insert(
{
...insertObj,
fk_view_id: view.id,
},
ncMeta
);
await GridViewColumn.insert(modifiedInsertObj, ncMeta);
break;
case ViewTypes.GALLERY:
await GalleryViewColumn.insert(
{
...insertObj,
fk_view_id: view.id,
},
ncMeta
);
await GalleryViewColumn.insert(modifiedInsertObj, ncMeta);
break;
case ViewTypes.KANBAN:
await KanbanViewColumn.insert(
{
...insertObj,
fk_view_id: view.id,
},
ncMeta
);
await KanbanViewColumn.insert(modifiedInsertObj, ncMeta);
break;
}
}

6
packages/nocodb/src/lib/services/test/TestResetService/resetMysqlSakilaProject.ts

@ -71,11 +71,13 @@ const isSakilaMysqlToBeReset = async (
return true;
}
if (!project) return false;
if (!project) return true;
const audits = await Audit.projectAuditList(project.id, {});
return audits?.length > 0;
// todo: Will be fixed in the data resetting revamp
console.log(`audits:resetMysqlSakilaProject:${parallelId}`, audits?.length);
return true;
};
const resetSakilaMysql = async (

33
packages/nocodb/src/lib/v1-legacy/plugins/adapters/storage/Local.ts

@ -6,7 +6,7 @@ import mkdirp from 'mkdirp';
import { IStorageAdapterV2, XcFile } from 'nc-plugin';
import NcConfigFactory from '../../../../utils/NcConfigFactory';
import request from 'request';
import axios from 'axios';
export default class Local implements IStorageAdapterV2 {
constructor() {}
@ -27,19 +27,17 @@ export default class Local implements IStorageAdapterV2 {
async fileCreateByUrl(key: string, url: string): Promise<any> {
const destPath = path.join(NcConfigFactory.getToolDir(), ...key.split('/'));
return new Promise((resolve, reject) => {
axios.get((url), { responseType: "stream", headers: {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"accept-language": "en-US,en;q=0.9",
"cache-control": "no-cache",
"pragma": "no-cache",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36",
"origin": "https://www.airtable.com/",
} })
.then(response => {
mkdirp.sync(path.dirname(destPath));
const file = fs.createWriteStream(destPath);
const sendReq = request.get(url);
// verify response code
sendReq.on('response', (response) => {
if (response.statusCode !== 200) {
return reject('Response status was ' + response.statusCode);
}
sendReq.pipe(file);
});
// close() is async, call cb after close completes
file.on('finish', () => {
file.close((err) => {
@ -50,15 +48,16 @@ export default class Local implements IStorageAdapterV2 {
});
});
// check for request errors
sendReq.on('error', (err) => {
fs.unlink(destPath, () => reject(err.message)); // delete the (partial) file and then return the error
});
file.on('error', (err) => {
// Handle errors
fs.unlink(destPath, () => reject(err.message)); // delete the (partial) file and then return the error
});
response.data.pipe(file);
})
.catch((err) => {
reject(err.message)
});
});
}

73
scripts/sdk/swagger.json

@ -2286,7 +2286,19 @@
"content": {
"application/json": {
"schema": {
"allOf": [
{
"$ref": "#/components/schemas/Sort"
},
{
"type": "object",
"properties": {
"push_to_top": {
"type": "boolean"
}
}
}
]
}
}
}
@ -8957,9 +8969,7 @@
}
}
},
"ColumnReq": {
"oneOf": [
{
"NormalColumnRequest": {
"properties": {
"uidt": {
"type": "string",
@ -9070,13 +9080,10 @@
},
"au": {
"type": "boolean"
},
"": {
"type": "string"
}
}
},
{
"LinkToAnotherColumnReq": {
"properties": {
"uidt": {
"type": "string",
@ -9087,6 +9094,9 @@
"title": {
"type": "string"
},
"virtual": {
"type": "boolean"
},
"parentId": {
"type": "string"
},
@ -9110,7 +9120,7 @@
"type"
]
},
{
"RollupColumnReq": {
"properties": {
"uidt": {
"type": "string",
@ -9132,7 +9142,7 @@
}
}
},
{
"LookupColumnReq": {
"properties": {
"uidt": {
"type": "string",
@ -9151,7 +9161,7 @@
}
}
},
{
"FormulaColumnReq": {
"properties": {
"uidt": {
"type": "string"
@ -9166,6 +9176,49 @@
"type": "string"
}
}
},
"ColumnReq": {
"allOf": [
{
"oneOf": [
{
"$ref": "#/components/schemas/NormalColumnRequest"
},
{
"$ref": "#/components/schemas/LinkToAnotherColumnReq"
},
{
"$ref": "#/components/schemas/RollupColumnReq"
},
{
"$ref": "#/components/schemas/FormulaColumnReq"
},
{
"$ref": "#/components/schemas/LookupColumnReq"
}
]
},
{
"type": "object",
"properties": {
"column_name": {
"type": "string"
},
"title": {
"type": "string"
},
"column_order": {
"type": "object",
"properties": {
"view_id": {
"type": "string"
},
"order": {
"type": "integer"
}
}
}
}
}
],
"description": "",

BIN
tests/playwright/fixtures/sampleFiles/sampleImage.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

1
tests/playwright/pages/Dashboard/Form/index.ts

@ -7,6 +7,7 @@ export class FormPage extends BasePage {
readonly dashboard: DashboardPage;
readonly toolbar: ToolbarPage;
// todo: All the locator should be private
readonly addAllButton: Locator;
readonly removeAllButton: Locator;
readonly submitButton: Locator;

2
tests/playwright/pages/SharedForm/index.ts

@ -16,7 +16,7 @@ export class SharedFormPage extends BasePage {
async submit() {
await this.waitForResponse({
uiAction: this.get().locator('[data-testid="shared-form-submit-button"]').click(),
uiAction: this.get().getByTestId('shared-form-submit-button').click(),
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: '/rows',
});

9
tests/playwright/tests/columnAttachments.spec.ts

@ -31,6 +31,15 @@ test.describe('Attachment column', () => {
columnHeader: 'testAttach',
});
}
await dashboard.grid.cell.attachment.addFile({
index: 7,
columnHeader: 'testAttach',
filePath: `${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`,
});
await dashboard.grid.cell.attachment.verifyFile({
index: 7,
columnHeader: 'testAttach',
});
await dashboard.viewSidebar.createFormView({
title: 'Form 1',

29
tests/playwright/tests/viewForm.spec.ts

@ -3,6 +3,7 @@ import { DashboardPage } from '../pages/Dashboard';
import { SettingTab } from '../pages/Dashboard/Settings';
import setup from '../setup';
import { FormPage } from '../pages/Dashboard/Form';
import { SharedFormPage } from '../pages/SharedForm';
// todo: Move most of the ui actions to page object and await on the api response
test.describe('Form view', () => {
@ -201,4 +202,32 @@ test.describe('Form view', () => {
});
await dashboard.settings.close();
});
test('Form share, verify attachment file', async () => {
await dashboard.treeView.createTable({ title: 'New' });
await dashboard.grid.column.create({
title: 'Attachment',
type: 'Attachment',
});
await dashboard.viewSidebar.createFormView({ title: 'NewForm' });
await dashboard.form.toolbar.clickShareView();
const formLink = await dashboard.form.toolbar.shareView.getShareLink();
await dashboard.rootPage.goto(formLink);
const sharedForm = new SharedFormPage(dashboard.rootPage);
await sharedForm.cell.attachment.addFile({
columnHeader: 'Attachment',
filePath: `${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`,
});
await sharedForm.cell.fillText({
columnHeader: 'Title',
text: 'Text',
});
await sharedForm.submit();
await sharedForm.verifySuccessMessage();
});
});

Loading…
Cancel
Save