Browse Source

Merge branch 'develop' into qr-scanner-integration-3-2022-12-20

pull/4898/head
Daniel Spaude 2 years ago
parent
commit
ffac827e54
No known key found for this signature in database
GPG Key ID: 654A3D1FA4F35FFE
  1. 3
      packages/nc-gui/components.d.ts
  2. 13
      packages/nc-gui/components/cell/DateTimePicker.vue
  3. 89
      packages/nc-gui/components/dashboard/TreeView.vue
  4. 4
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  5. 14
      packages/nc-gui/components/dashboard/settings/Metadata.vue
  6. 21
      packages/nc-gui/components/dashboard/settings/UIAcl.vue
  7. 46
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  8. 54
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  9. 6
      packages/nc-gui/components/erd/TableNode.vue
  10. 3
      packages/nc-gui/components/general/BaseLogo.vue
  11. 58
      packages/nc-gui/components/general/EmojiIcons.vue
  12. 22
      packages/nc-gui/components/general/TableIcon.vue
  13. 4
      packages/nc-gui/components/general/TruncateText.vue
  14. 26
      packages/nc-gui/components/general/ViewIcon.vue
  15. 1
      packages/nc-gui/components/smartsheet/Cell.vue
  16. 76
      packages/nc-gui/components/smartsheet/Grid.vue
  17. 10
      packages/nc-gui/components/smartsheet/column/CurrencyOptions.vue
  18. 38
      packages/nc-gui/components/smartsheet/column/DateTimeOptions.vue
  19. 1
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  20. 57
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  21. 8
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  22. 2
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  23. 21
      packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue
  24. 44
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  25. 5
      packages/nc-gui/components/smartsheet/toolbar/SharedViewList.vue
  26. 7
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  27. 9
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  28. 13
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  29. 4
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  30. 28
      packages/nc-gui/composables/useMultiSelect/cellRange.ts
  31. 167
      packages/nc-gui/composables/useMultiSelect/index.ts
  32. 7
      packages/nc-gui/composables/useSharedView.ts
  33. 4
      packages/nc-gui/composables/useTabs.ts
  34. 3
      packages/nc-gui/layouts/shared-view.vue
  35. 1
      packages/nc-gui/lib/enums.ts
  36. 3
      packages/nc-gui/lib/types.ts
  37. 6
      packages/nc-gui/nuxt.config.ts
  38. 247
      packages/nc-gui/package-lock.json
  39. 3
      packages/nc-gui/package.json
  40. 11
      packages/nc-gui/pages/[projectType]/[projectId]/index/index.vue
  41. 46
      packages/nc-gui/pages/index/index/create-external.vue
  42. 41
      packages/nc-gui/pages/index/index/index.vue
  43. 10
      packages/nc-gui/utils/dateTimeUtils.ts
  44. 23
      packages/nc-gui/utils/formulaUtils.ts
  45. 1475
      packages/nc-gui/utils/iconUtils.ts
  46. 31
      packages/nc-gui/utils/projectCreateUtils.ts
  47. 2
      packages/noco-docs/content/en/setup-and-usages/formulas.md
  48. 5
      packages/nocodb-sdk/src/lib/Api.ts
  49. 1
      packages/nocodb-sdk/src/lib/formulaHelpers.ts
  50. 1015
      packages/nocodb-sdk/src/lib/sqlUi/SnowflakeUi.ts
  51. 5
      packages/nocodb-sdk/src/lib/sqlUi/SqlUiFactory.ts
  52. 1
      packages/nocodb-sdk/src/lib/sqlUi/index.ts
  53. 1957
      packages/nocodb/package-lock.json
  54. 4
      packages/nocodb/package.json
  55. 5
      packages/nocodb/src/lib/db/sql-client/lib/SqlClientFactory.ts
  56. 2609
      packages/nocodb/src/lib/db/sql-client/lib/snowflake/SnowflakeClient.ts
  57. 5
      packages/nocodb/src/lib/db/sql-client/lib/snowflake/snowflake.queries.ts
  58. 177
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  59. 7
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/CustomKnex.ts
  60. 41
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulav2/formulaQueryBuilderv2.ts
  61. 36
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mssql.ts
  62. 21
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts
  63. 75
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/pg.ts
  64. 87
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/sqlite.ts
  65. 137
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/helpers/convertUnits.ts
  66. 3
      packages/nocodb/src/lib/db/sql-mgr/code/models/xc/ModelXcMetaFactory.ts
  67. 975
      packages/nocodb/src/lib/db/sql-mgr/code/models/xc/ModelXcMetaSnowflake.ts
  68. 2
      packages/nocodb/src/lib/meta/api/apiTokenApis.ts
  69. 57
      packages/nocodb/src/lib/meta/api/metaDiffApis.ts
  70. 3
      packages/nocodb/src/lib/meta/api/modelVisibilityApis.ts
  71. 16
      packages/nocodb/src/lib/meta/api/tableApis.ts
  72. 71
      packages/nocodb/src/lib/models/Model.ts
  73. 28
      packages/nocodb/src/lib/models/View.ts
  74. 75
      packages/nocodb/src/lib/utils/dateTimeUtils.ts
  75. 23
      packages/nocodb/src/lib/utils/modelUtils.ts
  76. 10
      scripts/sdk/swagger.json
  77. 4
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/ChildList.ts
  78. 26
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  79. 32
      tests/playwright/pages/Dashboard/Grid/index.ts
  80. 21
      tests/playwright/pages/Dashboard/TreeView.ts
  81. 21
      tests/playwright/pages/Dashboard/ViewSidebar/index.ts
  82. 67
      tests/playwright/pages/Dashboard/common/Cell/DateTimeCell.ts
  83. 60
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  84. 19
      tests/playwright/pages/ProjectsPage/index.ts
  85. 109
      tests/playwright/tests/cellSelection.spec.ts
  86. 113
      tests/playwright/tests/columnDateTime.spec.ts
  87. 40
      tests/playwright/tests/columnFormula.spec.ts
  88. 2
      tests/playwright/tests/projectOperations.spec.ts
  89. 7
      tests/playwright/tests/tableOperations.spec.ts
  90. 7
      tests/playwright/tests/viewKanban.spec.ts
  91. 6
      tests/playwright/tests/views.spec.ts

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

@ -170,8 +170,6 @@ declare module '@vue/runtime-core' {
MdiExport: typeof import('~icons/mdi/export')['default']
MdiEyeCircleOutline: typeof import('~icons/mdi/eye-circle-outline')['default']
MdiEyeOffOutline: typeof import('~icons/mdi/eye-off-outline')['default']
MdiEyeSettings: typeof import('~icons/mdi/eye-settings')['default']
MdiEyeSettingsOutline: typeof import('~icons/mdi/eye-settings-outline')['default']
MdiFileDocumentOutline: typeof import('~icons/mdi/file-document-outline')['default']
MdiFileExcel: typeof import('~icons/mdi/file-excel')['default']
MdiFileEyeOutline: typeof import('~icons/mdi/file-eye-outline')['default']
@ -203,7 +201,6 @@ declare module '@vue/runtime-core' {
MdiMagnify: typeof import('~icons/mdi/magnify')['default']
MdiMenu: typeof import('~icons/mdi/menu')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMenuIcon: typeof import('~icons/mdi/menu-icon')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default']
MdiMinusCircleOutline: typeof import('~icons/mdi/minus-circle-outline')['default']
MdiMoonFull: typeof import('~icons/mdi/moon-full')['default']

13
packages/nc-gui/components/cell/DateTimePicker.vue

@ -2,10 +2,13 @@
import dayjs from 'dayjs'
import {
ActiveCellInj,
ColumnInj,
ReadonlyInj,
dateFormats,
inject,
isDrawerOrModalExist,
ref,
timeFormats,
useProject,
useSelectedCellKeyupListener,
watch,
@ -32,7 +35,11 @@ const column = inject(ColumnInj)!
let isDateInvalid = $ref(false)
const dateFormat = isMysql(column.value.base_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
const dateTimeFormat = $computed(() => {
const dateFormat = column?.value?.meta?.date_format ?? dateFormats[0]
const timeFormat = column?.value?.meta?.time_format ?? timeFormats[0]
return `${dateFormat} ${timeFormat}`
})
let localState = $computed({
get() {
@ -54,7 +61,7 @@ let localState = $computed({
}
if (val.isValid()) {
emit('update:modelValue', val?.format(dateFormat))
emit('update:modelValue', val?.format(isMysql(column.value.base_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'))
}
},
})
@ -165,7 +172,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
:show-time="true"
:bordered="false"
class="!w-full !px-0 !border-none"
format="YYYY-MM-DD HH:mm"
:format="dateTimeFormat"
:placeholder="isDateInvalid ? 'Invalid date' : ''"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"

89
packages/nc-gui/components/dashboard/TreeView.vue

@ -1,14 +1,17 @@
<script setup lang="ts">
import type { TableType } from 'nocodb-sdk'
import type { Input } from 'ant-design-vue'
import { Dropdown, Tooltip, message } from 'ant-design-vue'
import Sortable from 'sortablejs'
import GithubButton from 'vue-github-button'
import { Icon } from '@iconify/vue'
import type { VNodeRef } from '#imports'
import {
ClientType,
Empty,
TabType,
computed,
extractSdkResponseErrorMsg,
isDrawerOrModalExist,
isMac,
reactive,
@ -27,7 +30,7 @@ import {
import MdiView from '~icons/mdi/eye-circle-outline'
import MdiTableLarge from '~icons/mdi/table-large'
const { addTab } = useTabs()
const { addTab, updateTab } = useTabs()
const { $api, $e } = useNuxtApp()
@ -77,7 +80,6 @@ const initSortable = (el: Element) => {
if (!base_id) return
if (sortables[base_id]) sortables[base_id].destroy()
Sortable.create(el as HTMLLIElement, {
handle: '.nc-drag-icon',
onEnd: async (evt) => {
const offset = tables.value.findIndex((table) => table.base_id === base_id)
@ -299,6 +301,26 @@ watch(
},
{ immediate: true },
)
const setIcon = async (icon: string, table: TableType) => {
try {
table.meta = {
...(table.meta || {}),
icon,
}
tables.value.splice(tables.value.indexOf(table), 1, { ...table })
updateTab({ id: table.id }, { meta: table.meta })
$api.dbTable.update(table.id as string, {
meta: table.meta,
})
$e('a:table:icon:navdraw', { icon })
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
@ -392,6 +414,12 @@ watch(
MSSQL
</div>
</a-menu-item>
<a-menu-item key="connect-new-source" @click="toggleDialog(true, 'dataSources', ClientType.SNOWFLAKE)">
<div class="color-transition nc-project-menu-item group">
<LogosSnowflakeIcon class="group-hover:text-accent" />
Snowflake
</div>
</a-menu-item>
</a-menu-item-group>
<a-menu-divider class="my-0" />
@ -416,10 +444,10 @@ watch(
<div v-if="bases[0] && bases[0].enabled && !bases.slice(1).filter((el) => el.enabled)?.length" class="flex-1">
<div
v-if="isUIAllowed('table-create')"
class="group flex items-center gap-2 pl-8 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
class="group flex items-center gap-2 pl-2 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
@click="openTableCreateDialog(bases[0].id)"
>
<MdiPlus />
<MdiPlus class="w-5" />
<span class="text-gray-500 group-hover:(text-primary/100) flex-1 nc-add-new-table">{{ $t('tooltip.addTable') }}</span>
@ -502,6 +530,12 @@ watch(
MSSQL
</div>
</a-menu-item>
<a-menu-item key="connect-new-source" @click="toggleDialog(true, 'dataSources', ClientType.SNOWFLAKE)">
<div class="color-transition nc-project-menu-item group">
<LogosSnowflakeIcon class="group-hover:text-accent" />
Snowflake
</div>
</a-menu-item>
</a-menu-item-group>
<a-menu-divider class="my-0" />
@ -546,26 +580,47 @@ watch(
:data-testid="`tree-view-table-${table.title}`"
@click="addTableTab(table)"
>
<GeneralTooltip class="pl-8 pr-3 py-2" modifier-key="Alt">
<GeneralTooltip class="pl-2 pr-3 py-2" modifier-key="Alt">
<template #title>{{ table.table_name }}</template>
<div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)">
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<MdiDragVertical
v-if="isUIAllowed('treeview-drag-n-drop')"
:class="`nc-child-draggable-icon-${table.title}`"
class="nc-drag-icon text-xs hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 cursor-move"
@click.stop.prevent
/>
<component
:is="icon(table)"
class="nc-view-icon text-xs"
:class="{ 'group-hover:hidden group-hover:text-gray-500': isUIAllowed('treeview-drag-n-drop') }"
/>
:is="isUIAllowed('tableIconCustomisation') ? Dropdown : 'div'"
trigger="click"
destroy-popup-on-hide
class="flex items-center"
@click.stop
>
<div class="flex items-center" @click.stop>
<component :is="isUIAllowed('tableIconCustomisation') ? Tooltip : 'div'">
<span v-if="table.meta?.icon" :key="table.meta?.icon" class="nc-table-icon flex items-center">
<Icon
:key="table.meta?.icon"
:data-testid="`nc-icon-${table.meta?.icon}`"
class="text-xl"
:icon="table.meta?.icon"
></Icon>
</span>
<component
:is="icon(table)"
v-else
class="nc-table-icon nc-view-icon w-5"
:class="{ 'group-hover:text-gray-500': isUIAllowed('treeview-drag-n-drop') }"
/>
<template v-if="isUIAllowed('tableIconCustomisation')" #title>Change icon</template>
</component>
</div>
<template v-if="isUIAllowed('tableIconCustomisation')" #overlay>
<GeneralEmojiIcons class="shadow bg-white p-2" @select-icon="setIcon($event, table)" />
</template>
</component>
</div>
<div class="nc-tbl-title flex-1">
<GeneralTruncateText>{{ table.title }}</GeneralTruncateText>
<GeneralTruncateText :key="table.title" :length="activeTable === table.id ? 18 : 20">{{
table.title
}}</GeneralTruncateText>
</div>
<a-dropdown

4
packages/nc-gui/components/dashboard/settings/DataSources.vue

@ -200,6 +200,10 @@ watch(
clientType = ClientType.MSSQL
vState.value = DataSourcesSubTab.New
break
case ClientType.SNOWFLAKE:
clientType = ClientType.SNOWFLAKE
vState.value = DataSourcesSubTab.New
break
case DataSourcesSubTab.New:
if (sources.length > 1 && !forceAwakened) {
vState.value = ''

14
packages/nc-gui/components/dashboard/settings/Metadata.vue

@ -70,8 +70,6 @@ const columns = [
// Models
title: tableHeaderRenderer(t('labels.models')),
key: 'table_name',
customRender: ({ record }: { record: { table_name: string; title?: string } }) =>
h('div', {}, record.title || record.table_name),
},
{
// Sync state
@ -97,7 +95,6 @@ const columns = [
</div>
</a-button>
</div>
<div class="max-h-600px overflow-y-auto">
<a-table
class="w-full"
@ -116,6 +113,17 @@ const columns = [
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<template #bodyCell="{ record, column }">
<div v-if="column.key === 'table_name'">
<div class="flex items-center gap-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="record" class="text-gray-500"></GeneralTableIcon>
</div>
<span class="overflow-ellipsis min-w-0 shrink-1">{{ record.title || record.table_name }}</span>
</div>
</div>
</template>
</a-table>
</div>
</div>

21
packages/nc-gui/components/dashboard/settings/UIAcl.vue

@ -10,7 +10,6 @@ import {
useI18n,
useNuxtApp,
useProject,
viewIcons,
} from '#imports'
const props = defineProps<{
@ -159,12 +158,24 @@ const columns = [
</template>
<template #bodyCell="{ record, column }">
<div v-if="column.name === 'table_name'">{{ record._ptn }}</div>
<div v-if="column.name === 'table_name'">
<div class="flex items-center gap-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon
:meta="{ meta: record.table_meta, type: record.ptype }"
class="text-gray-500"
></GeneralTableIcon>
</div>
<span class="overflow-ellipsis min-w-0 shrink-1">{{ record._ptn }}</span>
</div>
</div>
<div v-if="column.name === 'view_name'">
<div class="flex items-center">
<component :is="viewIcons[record.type].icon" :class="`text-${viewIcons[record.type].color} mr-1`" />
{{ record.title }}
<div class="flex items-center gap-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralViewIcon :meta="record" class="text-gray-500"></GeneralViewIcon>
</div>
<span class="overflow-ellipsis min-w-0 shrink-1">{{ record.title }}</span>
</div>
</div>

46
packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue

@ -83,6 +83,15 @@ const validators = computed(() => {
? {
'dataSource.connection.connection.filename': [fieldRequiredValidator()],
}
: formState.dataSource.client === ClientType.SNOWFLAKE
? {
'dataSource.connection.account': [fieldRequiredValidator()],
'dataSource.connection.username': [fieldRequiredValidator()],
'dataSource.connection.password': [fieldRequiredValidator()],
'dataSource.connection.warehouse': [fieldRequiredValidator()],
'dataSource.connection.database': [fieldRequiredValidator()],
'dataSource.connection.schema': [fieldRequiredValidator()],
}
: {
'dataSource.connection.host': [fieldRequiredValidator()],
'dataSource.connection.port': [fieldRequiredValidator()],
@ -383,6 +392,43 @@ watch(
<a-input v-model:value="(formState.dataSource.connection as SQLiteConnection).connection.filename" />
</a-form-item>
<template v-else-if="formState.dataSource.client === ClientType.SNOWFLAKE">
<!-- Account -->
<a-form-item label="Account" v-bind="validateInfos['dataSource.connection.account']">
<a-input v-model:value="formState.dataSource.connection.account" class="nc-extdb-account" />
</a-form-item>
<!-- Username -->
<a-form-item :label="$t('labels.username')" v-bind="validateInfos['dataSource.connection.username']">
<a-input v-model:value="formState.dataSource.connection.username" class="nc-extdb-host-user" />
</a-form-item>
<!-- Password -->
<a-form-item :label="$t('labels.password')" v-bind="validateInfos['dataSource.connection.password']">
<a-input-password v-model:value="formState.dataSource.connection.password" class="nc-extdb-host-password" />
</a-form-item>
<!-- Warehouse -->
<a-form-item label="Warehouse" v-bind="validateInfos['dataSource.connection.warehouse']">
<a-input v-model:value="formState.dataSource.connection.warehouse" />
</a-form-item>
<!-- Database -->
<a-form-item :label="$t('labels.database')" v-bind="validateInfos['dataSource.connection.database']">
<!-- Database : create if not exists -->
<a-input
v-model:value="formState.dataSource.connection.database"
:placeholder="$t('labels.dbCreateIfNotExists')"
class="nc-extdb-host-database"
/>
</a-form-item>
<!-- Schema name -->
<a-form-item :label="$t('labels.schemaName')" v-bind="validateInfos['dataSource.connection.schema']">
<a-input v-model:value="formState.dataSource.connection.schema" />
</a-form-item>
</template>
<template v-else>
<!-- Host Address -->
<a-form-item :label="$t('labels.hostAddress')" v-bind="validateInfos['dataSource.connection.host']">

54
packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue

@ -71,19 +71,22 @@ const customFormState = ref<ProjectCreateForm>({
const validators = computed(() => {
return {
'title': [
{
required: true,
message: 'Base name is required',
},
projectTitleValidator,
],
'title': [projectTitleValidator],
'extraParameters': [extraParameterValidator],
'dataSource.client': [fieldRequiredValidator()],
...(formState.value.dataSource.client === ClientType.SQLITE
? {
'dataSource.connection.connection.filename': [fieldRequiredValidator()],
}
: formState.value.dataSource.client === ClientType.SNOWFLAKE
? {
'dataSource.connection.account': [fieldRequiredValidator()],
'dataSource.connection.username': [fieldRequiredValidator()],
'dataSource.connection.password': [fieldRequiredValidator()],
'dataSource.connection.warehouse': [fieldRequiredValidator()],
'dataSource.connection.database': [fieldRequiredValidator()],
'dataSource.connection.schema': [fieldRequiredValidator()],
}
: {
'dataSource.connection.host': [fieldRequiredValidator()],
'dataSource.connection.port': [fieldRequiredValidator()],
@ -376,6 +379,43 @@ onMounted(async () => {
<a-input v-model:value="(formState.dataSource.connection as SQLiteConnection).connection.filename" />
</a-form-item>
<template v-else-if="formState.dataSource.client === ClientType.SNOWFLAKE">
<!-- Account -->
<a-form-item label="Account" v-bind="validateInfos['dataSource.connection.account']">
<a-input v-model:value="formState.dataSource.connection.account" class="nc-extdb-account" />
</a-form-item>
<!-- Username -->
<a-form-item :label="$t('labels.username')" v-bind="validateInfos['dataSource.connection.username']">
<a-input v-model:value="formState.dataSource.connection.username" class="nc-extdb-host-user" />
</a-form-item>
<!-- Password -->
<a-form-item :label="$t('labels.password')" v-bind="validateInfos['dataSource.connection.password']">
<a-input-password v-model:value="formState.dataSource.connection.password" class="nc-extdb-host-password" />
</a-form-item>
<!-- Warehouse -->
<a-form-item label="Warehouse" v-bind="validateInfos['dataSource.connection.warehouse']">
<a-input v-model:value="formState.dataSource.connection.warehouse" />
</a-form-item>
<!-- Database -->
<a-form-item :label="$t('labels.database')" v-bind="validateInfos['dataSource.connection.database']">
<!-- Database : create if not exists -->
<a-input
v-model:value="formState.dataSource.connection.database"
:placeholder="$t('labels.dbCreateIfNotExists')"
class="nc-extdb-host-database"
/>
</a-form-item>
<!-- Schema name -->
<a-form-item :label="$t('labels.schemaName')" v-bind="validateInfos['dataSource.connection.schema']">
<a-input v-model:value="formState.dataSource.connection.schema" />
</a-form-item>
</template>
<template v-else>
<!-- Host Address -->
<a-form-item :label="$t('labels.hostAddress')" v-bind="validateInfos['dataSource.connection.host']">

6
packages/nc-gui/components/erd/TableNode.vue

@ -59,10 +59,8 @@ watch(
:class="[showSkeleton ? '' : 'bg-primary bg-opacity-10', hasColumns ? 'border-b-1' : '']"
class="text-slate-600 text-md py-2 border-slate-500 rounded-t-lg w-full h-full px-3 font-semibold flex items-center"
>
<MdiTableLarge v-if="table.type === 'table'" class="text-primary" :class="showSkeleton ? 'text-6xl !px-2' : ''" />
<MdiEyeCircleOutline v-else class="text-primary" :class="showSkeleton ? 'text-6xl !px-2' : ''" />
<div :class="showSkeleton ? 'text-6xl' : ''" class="flex px-2">
<GeneralTableIcon class="text-primary" :class="{ '!text-6xl !w-auto mr-2': showSkeleton }" :meta="table" />
<div :class="showSkeleton ? 'text-6xl' : ''" class="flex pr-2 pl-1">
{{ table.title }}
</div>
</div>

3
packages/nc-gui/components/general/BaseLogo.vue

@ -3,6 +3,7 @@ import LogosMysqlIcon from '~icons/logos/mysql-icon'
import LogosPostgresql from '~icons/logos/postgresql'
import VscodeIconsFileTypeSqlite from '~icons/vscode-icons/file-type-sqlite'
import SimpleIconsMicrosoftsqlserver from '~icons/simple-icons/microsoftsqlserver'
import LogosSnowflakeIcon from '~icons/logos/snowflake-icon'
import MdiDatabaseOutline from '~icons/mdi/database-outline'
const { baseType } = defineProps<{ baseType?: string }>()
@ -17,6 +18,8 @@ const baseIcon = computed(() => {
return VscodeIconsFileTypeSqlite
case ClientType.MSSQL:
return SimpleIconsMicrosoftsqlserver
case ClientType.SNOWFLAKE:
return LogosSnowflakeIcon
default:
return MdiDatabaseOutline
}

58
packages/nc-gui/components/general/EmojiIcons.vue

@ -0,0 +1,58 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
import InfiniteLoading from 'v3-infinite-loading'
import { emojiIcons } from '#imports'
const emit = defineEmits(['selectIcon'])
let search = $ref('')
// keep a variable to load icons with infinite scroll
// set initial value to 60 to load first 60 icons (index - `0 - 59`)
// and next value will be 120 and shows first 120 icons ( index - `0 - 129`)
let toIndex = $ref(60)
const filteredIcons = computed(() => {
return emojiIcons.filter((icon) => !search || icon.toLowerCase().includes(search.toLowerCase())).slice(0, toIndex)
})
const load = () => {
// increment `toIndex` to include next set of icons
toIndex += Math.min(filteredIcons.value.length, toIndex + 60)
if (toIndex > filteredIcons.value.length) {
toIndex = filteredIcons.value.length
}
}
const selectIcon = (icon: string) => {
search = ''
emit('selectIcon', `emojione:${icon}`)
}
</script>
<template>
<div class="p-1 w-[280px] h-[280px] flex flex-col gap-1 justify-start nc-emoji" data-testid="nc-emoji-container">
<div @click.stop>
<input
v-model="search"
data-testid="nc-emoji-filter"
class="p-1 text-xs border-1 w-full overflow-y-auto"
placeholder="Search"
@input="toIndex = 60"
/>
</div>
<div class="flex gap-1 flex-wrap w-full flex-shrink overflow-y-auto scrollbar-thin-dull">
<div v-for="icon of filteredIcons" :key="icon" @click="selectIcon(icon)">
<span class="cursor-pointer nc-emoji-item">
<Icon class="text-xl iconify" :icon="`emojione:${icon}`"></Icon>
</span>
</div>
<InfiniteLoading @infinite="load"><span /></InfiniteLoading>
</div>
</div>
</template>
<style scoped>
.nc-emoji-item {
@apply hover:(bg-primary bg-opacity-10) active:(bg-primary !bg-opacity-20) rounded-md w-[38px] h-[38px] block flex items-center justify-center;
}
</style>

22
packages/nc-gui/components/general/TableIcon.vue

@ -0,0 +1,22 @@
<script lang="ts" setup>
import { Icon as IcIcon } from '@iconify/vue'
import type { TableType } from 'nocodb-sdk'
const { meta: tableMeta } = defineProps<{
meta: TableType
}>()
</script>
<template>
<IcIcon
v-if="tableMeta.meta?.icon"
:data-testid="`nc-icon-${tableMeta.meta?.icon}`"
class="text-lg"
:icon="tableMeta.meta?.icon"
/>
<MdiEyeCircleOutline v-else-if="tableMeta?.type === 'view'" class="w-5" />
<MdiTableLarge v-else class="w-5" />
</template>
<style scoped></style>

4
packages/nc-gui/components/general/TruncateText.vue

@ -41,7 +41,5 @@ const shortName = computed(() =>
<div v-else class="w-full" data-testid="truncate-label">
<slot />
</div>
<div ref="text" class="hidden">
<slot />
</div>
<div ref="text" class="hidden"><slot /></div>
</template>

26
packages/nc-gui/components/general/ViewIcon.vue

@ -0,0 +1,26 @@
<script lang="ts" setup>
import { Icon as IcIcon } from '@iconify/vue'
import type { TableType } from 'nocodb-sdk'
import { viewIcons } from '#imports'
const { meta: viewMeta } = defineProps<{
meta: TableType
}>()
</script>
<template>
<IcIcon
v-if="viewMeta?.meta?.icon"
:data-testid="`nc-icon-${viewMeta?.meta?.icon}`"
class="text-[16px]"
:icon="viewMeta?.meta?.icon"
/>
<component
:is="viewIcons[viewMeta.type]?.icon"
v-else
class="nc-view-icon group-hover"
:style="{ color: viewIcons[viewMeta.type]?.color }"
/>
</template>
<style scoped></style>

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

@ -163,6 +163,7 @@ const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
v-if="(isLocked || (isPublic && readOnly && !isForm) || isSystemColumn(column)) && !isAttachment(column)"
class="nc-locked-overlay"
@click.stop.prevent
@dblclick.stop.prevent
/>
</template>
</div>

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

@ -172,7 +172,7 @@ const getContainerScrollForElement = (
return scroll
}
const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyValue, isCellSelected, selectedCell } =
const { isCellSelected, activeCell, handleMouseDown, handleMouseOver, handleCellClick, clearSelectedRange, copyValue } =
useMultiSelect(
meta,
fields,
@ -201,9 +201,10 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
const altOrOptionKey = e.altKey
if (e.key === ' ') {
if (selectedCell.row !== null && !editEnabled) {
if (activeCell.row != null && !editEnabled) {
e.preventDefault()
const row = data.value[selectedCell.row]
clearSelectedRange()
const row = data.value[activeCell.row]
expandForm(row)
return true
}
@ -227,29 +228,33 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
selectedCell.row = 0
selectedCell.col = selectedCell.col ?? 0
clearSelectedRange()
activeCell.row = 0
activeCell.col = activeCell.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowDown':
e.preventDefault()
selectedCell.row = data.value.length - 1
selectedCell.col = selectedCell.col ?? 0
clearSelectedRange()
activeCell.row = data.value.length - 1
activeCell.col = activeCell.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowRight':
e.preventDefault()
selectedCell.row = selectedCell.row ?? 0
selectedCell.col = fields.value?.length - 1
clearSelectedRange()
activeCell.row = activeCell.row ?? 0
activeCell.col = fields.value?.length - 1
scrollToCell?.()
editEnabled = false
return true
case 'ArrowLeft':
e.preventDefault()
selectedCell.row = selectedCell.row ?? 0
selectedCell.col = 0
clearSelectedRange()
activeCell.row = activeCell.row ?? 0
activeCell.col = 0
scrollToCell?.()
editEnabled = false
return true
@ -279,7 +284,7 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
},
async (ctx: { row: number; col?: number; updatedColumnTitle?: string }) => {
const rowObj = data.value[ctx.row]
const columnObj = ctx.col !== null && ctx.col !== undefined ? fields.value[ctx.col] : null
const columnObj = ctx.col !== undefined ? fields.value[ctx.col] : null
if (!ctx.updatedColumnTitle && isVirtualCol(columnObj)) {
return
@ -291,10 +296,10 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
)
function scrollToCell(row?: number | null, col?: number | null) {
row = row ?? selectedCell.row
col = col ?? selectedCell.col
row = row ?? activeCell.row
col = col ?? activeCell.col
if (row !== undefined && col !== undefined && row !== null && col !== null) {
if (row !== null && col !== null) {
// get active cell
const rows = tbodyEl.value?.querySelectorAll('tr')
const cols = rows?.[row].querySelectorAll('td')
@ -455,13 +460,14 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
/** On clicking outside of table reset active cell */
const smartTable = ref(null)
onClickOutside(smartTable, (e) => {
// do nothing if context menu was open
if (contextMenu.value) return
clearSelectedRange()
if (selectedCell.col === null) return
const activeCol = fields.value[selectedCell.col]
if (activeCell.row === null || activeCell.col === null) return
const activeCol = fields.value[activeCell.col]
if (editEnabled && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return
@ -482,25 +488,29 @@ onClickOutside(smartTable, (e) => {
return
}
selectedCell.row = null
selectedCell.col = null
clearSelectedRange()
activeCell.row = null
activeCell.col = null
})
const onNavigate = (dir: NavigateDir) => {
if (selectedCell.row === null || selectedCell.col === null) return
if (activeCell.row === null || activeCell.col === null) return
editEnabled = false
clearSelectedRange()
switch (dir) {
case NavigateDir.NEXT:
if (selectedCell.row < data.value.length - 1) {
selectedCell.row++
if (activeCell.row < data.value.length - 1) {
activeCell.row++
} else {
addEmptyRow()
selectedCell.row++
activeCell.row++
}
break
case NavigateDir.PREV:
if (selectedCell.row > 0) {
selectedCell.row--
if (activeCell.row > 0) {
activeCell.row--
}
break
}
@ -782,10 +792,10 @@ const closeAddColumnDropdown = () => {
:data-key="rowIndex + columnObj.id"
:data-col="columnObj.id"
:data-title="columnObj.title"
@click="selectCell(rowIndex, colIndex)"
@mousedown="handleMouseDown($event, rowIndex, colIndex)"
@mouseover="handleMouseOver(rowIndex, colIndex)"
@click="handleCellClick($event, rowIndex, colIndex)"
@dblclick="makeEditable(row, columnObj)"
@mousedown="startSelectRange($event, rowIndex, colIndex)"
@mouseover="endSelectRange(rowIndex, colIndex)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
>
<div v-if="!switchingTab" class="w-full h-full">
@ -793,7 +803,7 @@ const closeAddColumnDropdown = () => {
v-if="isVirtualCol(columnObj)"
v-model="row.row[columnObj.title]"
:column="columnObj"
:active="selectedCell.col === colIndex && selectedCell.row === rowIndex"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
:row="row"
@navigate="onNavigate"
/>
@ -803,10 +813,10 @@ const closeAddColumnDropdown = () => {
v-model="row.row[columnObj.title]"
:column="columnObj"
:edit-enabled="
!!hasEditPermission && !!editEnabled && selectedCell.col === colIndex && selectedCell.row === rowIndex
!!hasEditPermission && !!editEnabled && activeCell.col === colIndex && activeCell.row === rowIndex
"
:row-index="rowIndex"
:active="selectedCell.col === colIndex && selectedCell.row === rowIndex"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
@update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow(row, columnObj.title, state)"
@navigate="onNavigate"
@ -872,7 +882,7 @@ const closeAddColumnDropdown = () => {
</div>
</a-menu-item>
<a-menu-item v-if="contextMenuTarget" @click="copyValue(contextMenuTarget)">
<a-menu-item v-if="contextMenuTarget" data-testid="context-menu-item-copy" @click="copyValue(contextMenuTarget)">
<div v-e="['a:row:copy']" class="nc-project-menu-item">
<!-- Copy -->
{{ $t('general.copy') }}

10
packages/nc-gui/components/smartsheet/column/CurrencyOptions.vue

@ -1,13 +1,5 @@
<script setup lang="ts">
import {
computed,
currencyCodes,
currencyLocales,
useProject,
useVModel,
validateCurrencyCode,
validateCurrencyLocale,
} from '#imports'
import { computed, currencyCodes, currencyLocales, useVModel, validateCurrencyCode, validateCurrencyLocale } from '#imports'
interface Option {
label: string

38
packages/nc-gui/components/smartsheet/column/DateTimeOptions.vue

@ -0,0 +1,38 @@
<script setup lang="ts">
import { dateFormats, timeFormats, useVModel } from '#imports'
const props = defineProps<{
value: any
}>()
const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit)
if (!vModel.value.meta?.date_format) {
if (!vModel.value.meta) vModel.value.meta = {}
vModel.value.meta.date_format = dateFormats[0]
}
if (!vModel.value.meta?.time_format) {
if (!vModel.value.meta) vModel.value.meta = {}
vModel.value.meta.time_format = timeFormats[0]
}
</script>
<template>
<a-form-item label="Date Format">
<a-select v-model:value="vModel.meta.date_format" class="nc-date-select" dropdown-class-name="nc-dropdown-date-format">
<a-select-option v-for="(format, i) of dateFormats" :key="i" :value="format">
{{ format }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Time Format">
<a-select v-model:value="vModel.meta.time_format" class="nc-time-select" dropdown-class-name="nc-dropdown-time-format">
<a-select-option v-for="(format, i) of timeFormats" :key="i" :value="format">
{{ format }}
</a-select-option>
</a-select>
</a-form-item>
</template>

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

@ -177,6 +177,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<LazySmartsheetColumnCheckboxOptions v-if="formState.uidt === UITypes.Checkbox" v-model:value="formState" />
<LazySmartsheetColumnLookupOptions v-if="!isEdit && formState.uidt === UITypes.Lookup" v-model:value="formState" />
<LazySmartsheetColumnDateOptions v-if="formState.uidt === UITypes.Date" v-model:value="formState" />
<LazySmartsheetColumnDateTimeOptions v-if="formState.uidt === UITypes.DateTime" v-model:value="formState" />
<LazySmartsheetColumnRollupOptions v-if="!isEdit && formState.uidt === UITypes.Rollup" v-model:value="formState" />
<LazySmartsheetColumnLinkedToAnotherRecordOptions
v-if="!isEdit && formState.uidt === UITypes.LinkToAnotherRecord"

57
packages/nc-gui/components/smartsheet/column/FormulaOptions.vue

@ -235,6 +235,63 @@ function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = n
},
typeErrors,
)
} else if (parsedTree.callee.name === 'DATETIME_DIFF') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add('The first parameter of DATETIME_DIFF() should have date value')
}
},
typeErrors,
)
// parsedTree.arguments[1] = date
validateAgainstType(
parsedTree.arguments[1],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add('The second parameter of DATETIME_DIFF() should have date value')
}
},
typeErrors,
)
// parsedTree.arguments[2] = ["milliseconds" | "ms" | "seconds" | "s" | "minutes" | "m" | "hours" | "h" | "days" | "d" | "weeks" | "w" | "months" | "M" | "quarters" | "Q" | "years" | "y"]
validateAgainstType(
parsedTree.arguments[2],
formulaTypes.STRING,
(v: any) => {
if (
![
'milliseconds',
'ms',
'seconds',
's',
'minutes',
'm',
'hours',
'h',
'days',
'd',
'weeks',
'w',
'months',
'M',
'quarters',
'Q',
'years',
'y',
].includes(v)
) {
typeErrors.add(
'The third parameter of DATETIME_DIFF() should have value either "milliseconds", "ms", "seconds", "s", "minutes", "m", "hours", "h", "days", "d", "weeks", "w", "months", "M", "quarters", "Q", "years", or "y"',
)
}
},
typeErrors,
)
}
}
}

8
packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue

@ -73,7 +73,13 @@ const filterOption = (value: string, option: { key: string }) => option.key.toLo
@change="onDataTypeChange"
>
<a-select-option v-for="table of refTables" :key="table.title" :value="table.id">
{{ table.title }}
<div class="flex items-center gap-2">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="table" class="text-gray-500"></GeneralTableIcon>
</div>
<span class="overflow-ellipsis min-w-0 shrink-1">{{ table.title }}</span>
</div>
</a-select-option>
</a-select>
</a-form-item>

2
packages/nc-gui/components/smartsheet/expanded-form/Header.vue

@ -77,7 +77,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<template>
<div class="flex p-2 items-center gap-2 p-4 nc-expanded-form-header">
<h5 class="text-lg font-weight-medium flex items-center gap-1 mb-0 min-w-0 overflow-x-hidden truncate">
<mdi-table-arrow-right :style="{ color: iconColor }" />
<GeneralTableIcon :style="{ color: iconColor }" :meta="meta" class="mx-2" />
<template v-if="meta">
{{ meta.title }}

21
packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue

@ -140,7 +140,7 @@ const initSortable = (el: HTMLElement) => {
if (sortable) sortable.destroy()
sortable = new Sortable(el, {
handle: '.nc-drag-icon',
// handle: '.nc-drag-icon',
ghostClass: 'ghost',
onStart: onSortStart,
onEnd: onSortEnd,
@ -213,6 +213,24 @@ function openDeleteDialog(view: ViewType) {
close(1000)
}
}
const setIcon = async (icon: string, view: ViewType) => {
try {
// modify the icon property in meta
view.meta = {
...(view.meta || {}),
icon,
}
api.dbView.update(view.id as string, {
meta: view.meta,
})
$e('a:view:icon:sidebar', { icon })
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
@ -234,6 +252,7 @@ function openDeleteDialog(view: ViewType) {
@open-modal="$emit('openModal', $event)"
@delete="openDeleteDialog"
@rename="onRename"
@select-icon="setIcon($event, view)"
/>
</a-menu>
</template>

44
packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue

@ -1,18 +1,8 @@
<script lang="ts" setup>
import type { KanbanType, ViewType, ViewTypes } from 'nocodb-sdk'
import type { WritableComputedRef } from '@vue/reactivity'
import {
IsLockedInj,
computed,
inject,
message,
onKeyStroke,
useDebounceFn,
useNuxtApp,
useUIPermission,
useVModel,
viewIcons,
} from '#imports'
import { Tooltip } from 'ant-design-vue'
import { IsLockedInj, inject, message, onKeyStroke, useDebounceFn, useNuxtApp, useUIPermission, useVModel } from '#imports'
interface Props {
view: ViewType
@ -21,9 +11,15 @@ interface Props {
interface Emits {
(event: 'update:view', data: Record<string, any>): void
(event: 'selectIcon', icon: string): void
(event: 'changeView', view: Record<string, any>): void
(event: 'rename', view: ViewType): void
(event: 'delete', view: ViewType): void
(event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string; groupingFieldColumnId?: string }): void
}
@ -48,8 +44,6 @@ let isStopped = $ref(false)
/** Original view title when editing the view name */
let originalTitle = $ref<string | undefined>()
const viewType = computed(() => vModel.value.type as number)
/** Debounce click handler, so we can potentially enable editing view name {@see onDblClick} */
const onClick = useDebounceFn(() => {
if (isEditing || isStopped) return
@ -172,17 +166,17 @@ function onStopEdit() {
@click.stop="onClick"
>
<div v-e="['a:view:open', { view: vModel.type }]" class="text-xs flex items-center w-full gap-2" data-testid="view-item">
<div class="flex w-auto" :data-testid="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`">
<MdiDrag
class="nc-drag-icon hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 !cursor-move"
@click.stop.prevent
/>
<component
:is="viewIcons[viewType].icon"
class="nc-view-icon group-hover:hidden"
:style="{ color: viewIcons[viewType].color }"
/>
<div class="flex w-auto min-w-5" :data-testid="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`">
<a-dropdown :trigger="['click']" @click.stop>
<component :is="isUIAllowed('tableIconCustomisation') ? Tooltip : 'div'">
<GeneralViewIcon :meta="props.view" class="nc-view-icon"></GeneralViewIcon>
<template v-if="isUIAllowed('tableIconCustomisation')" #title>Change icon</template>
</component>
<template v-if="isUIAllowed('viewIconCustomisation')" #overlay>
<GeneralEmojiIcons class="shadow bg-white p-2" @select-icon="emits('selectIcon', $event)" />
</template>
</a-dropdown>
</div>
<a-input v-if="isEditing" :ref="focusInput" v-model:value="vModel.title" @blur="onCancel" @keydown="onKeyDown($event)" />

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

@ -108,8 +108,9 @@ const deleteLink = async (id: string) => {
<!-- View name -->
<a-table-column key="title" :title="$t('labels.viewName')" data-index="title">
<template #default="{ text }">
<div class="text-xs" :title="text">
<template #default="{ text, record }">
<div class="text-xs flex items-center gap-1" :title="text">
<GeneralViewIcon class="w-5" :meta="record" />
{{ text }}
</div>
</template>

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

@ -4,7 +4,6 @@ import {
IsLockedInj,
IsPublicInj,
extractSdkResponseErrorMsg,
getViewIcon,
inject,
message,
ref,
@ -93,11 +92,7 @@ useMenuCloseOnEsc(open)
<a-dropdown v-model:visible="open" :trigger="['click']" overlay-class-name="nc-dropdown-actions-menu">
<a-button v-e="['c:actions']" class="nc-actions-menu-btn nc-toolbar-btn">
<div class="flex gap-2 items-center">
<component
:is="getViewIcon(selectedView?.type)?.icon"
class="nc-view-icon group-hover:hidden"
:style="{ color: getViewIcon(selectedView?.type)?.color }"
/>
<GeneralViewIcon :meta="selectedView"></GeneralViewIcon>
<span class="!text-sm font-weight-normal">
<GeneralTruncateText>{{ selectedView?.title }}</GeneralTruncateText>

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

@ -1,17 +1,12 @@
<script setup lang="ts">
import { ActiveViewInj, inject, viewIcons } from '#imports'
import { ActiveViewInj, inject } from '#imports'
const selectedView = inject(ActiveViewInj)
</script>
<template>
<div class="flex gap-2 items-center ml-2 mr-2 pr-4 pb-1 py-0.5 border-r-1 border-gray-100">
<component
:is="viewIcons[selectedView?.type].icon"
v-if="selectedView?.type"
class="nc-view-icon group-hover:hidden"
:style="{ color: viewIcons[selectedView?.type].color }"
/>
<GeneralViewIcon class="nc-view-icon" :meta="selectedView" />
<span class="!text-sm font-medium max-w-36 overflow-ellipsis overflow-hidden whitespace-nowrap">
{{ selectedView?.title }}

13
packages/nc-gui/components/tabs/auth/UserManagement.vue

@ -44,7 +44,7 @@ let isLoading = $ref(false)
let totalRows = $ref(0)
const currentPage = $ref(1)
let currentPage = $ref(1)
const currentLimit = $ref(10)
@ -58,7 +58,7 @@ const loadUsers = async (page = currentPage, limit = currentLimit) => {
const response: any = await api.auth.projectUserList(project.value?.id, {
query: {
limit,
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
offset: (page - 1) * limit,
query: searchText.value,
},
} as RequestParams)
@ -160,7 +160,14 @@ onBeforeMount(async () => {
}
})
watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
watchDebounced(
searchText,
() => {
currentPage = 1
loadUsers()
},
{ debounce: 300, maxWait: 600 },
)
const isSuperAdmin = (user: { main_roles?: string }) => {
return user.main_roles?.split(',').includes(OrgUserRoles.SUPER_ADMIN)

4
packages/nc-gui/components/virtual-cell/components/ListChildItems.vue

@ -129,7 +129,9 @@ const onClick = (row: Row) => {
>
<div class="flex items-center gap-1">
<MdiLinkVariant class="text-xs" type="primary" />
Link to '{{ relatedTableMeta.title }}'
Link to '
<GeneralTableIcon :meta="relatedTableMeta" class="-mx-1 w-5" />
{{ relatedTableMeta.title }}'
</div>
</a-button>
</div>

28
packages/nc-gui/composables/useMultiSelect/cellRange.ts

@ -1,6 +1,6 @@
export interface Cell {
row: number | null
col: number | null
row: number
col: number
}
export class CellRange {
@ -12,14 +12,22 @@ export class CellRange {
this._end = end ?? this._start
}
get start() {
isEmpty() {
return this._start == null || this._end == null
}
isSingleCell() {
return !this.isEmpty() && this._start?.col === this._end?.col && this._start?.row === this._end?.row
}
get start(): Cell {
return {
row: Math.min(this._start?.row ?? NaN, this._end?.row ?? NaN),
col: Math.min(this._start?.col ?? NaN, this._end?.col ?? NaN),
}
}
get end() {
get end(): Cell {
return {
row: Math.max(this._start?.row ?? NaN, this._end?.row ?? NaN),
col: Math.max(this._start?.col ?? NaN, this._end?.col ?? NaN),
@ -27,19 +35,11 @@ export class CellRange {
}
startRange(value: Cell) {
if (value == null) {
return
}
this._start = value
this._end = value
}
endRange(value: Cell) {
if (value == null) {
return
}
this._end = value
}
@ -47,8 +47,4 @@ export class CellRange {
this._start = null
this._end = null
}
isEmpty() {
return this._start == null || this._end == null
}
}

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

@ -4,7 +4,7 @@ import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import type { Cell } from './cellRange'
import { CellRange } from './cellRange'
import convertCellData from './convertCellData'
import type { Row } from '~/lib'
import type { Nullable, Row } from '~/lib'
import {
copyTable,
extractPkFromRow,
@ -22,11 +22,13 @@ import {
useProject,
} from '#imports'
const MAIN_MOUSE_PRESSED = 0
/**
* Utility to help with multi-selecting rows/cells in the smartsheet
*/
export function useMultiSelect(
_meta: MaybeRef<TableType>,
_meta: MaybeRef<TableType | undefined>,
fields: MaybeRef<ColumnType[]>,
data: MaybeRef<Row[]>,
_editEnabled: MaybeRef<boolean>,
@ -51,15 +53,26 @@ export function useMultiSelect(
const editEnabled = ref(_editEnabled)
const selectedCell = reactive<Cell>({ row: null, col: null })
const selectedRange = reactive(new CellRange())
let isMouseDown = $ref(false)
const selectedRange = reactive(new CellRange())
const activeCell = reactive<Nullable<Cell>>({ row: null, col: null })
const columnLength = $computed(() => unref(fields)?.length)
function makeActive(row: number, col: number) {
if (activeCell.row === row && activeCell.col === col) {
return
}
activeCell.row = row
activeCell.col = col
}
async function copyValue(ctx?: Cell) {
try {
if (!selectedRange.isEmpty()) {
if (selectedRange.start !== null && selectedRange.end !== null && !selectedRange.isSingleCell()) {
const cprows = unref(data).slice(selectedRange.start.row, selectedRange.end.row + 1) // slice the selected rows for copy
const cpcols = unref(fields).slice(selectedRange.start.col, selectedRange.end.col + 1) // slice the selected cols for copy
@ -68,8 +81,8 @@ export function useMultiSelect(
} else {
// if copy was called with context (right click position) - copy value from context
// else if there is just one selected cell, copy it's value
const cpRow = ctx?.row ?? selectedCell?.row
const cpCol = ctx?.col ?? selectedCell?.col
const cpRow = ctx?.row ?? activeCell.row
const cpCol = ctx?.col ?? activeCell.col
if (cpRow != null && cpCol != null) {
const rowObj = unref(data)[cpRow]
@ -93,29 +106,19 @@ export function useMultiSelect(
}
}
function selectCell(row: number, col: number) {
selectedRange.clear()
if (selectedCell.row === row && selectedCell.col === col) return
editEnabled.value = false
selectedCell.row = row
selectedCell.col = col
}
function endSelectRange(row: number, col: number) {
function handleMouseOver(row: number, col: number) {
if (!isMouseDown) {
return
}
selectedCell.row = null
selectedCell.col = null
selectedRange.endRange({ row, col })
}
function isCellSelected(row: number, col: number) {
if (selectedCell?.row === row && selectedCell?.col === col) {
if (activeCell.col === col && activeCell.row === row) {
return true
}
if (selectedRange.isEmpty()) {
if (selectedRange.start === null || selectedRange.end === null) {
return false
}
@ -127,46 +130,51 @@ export function useMultiSelect(
)
}
function startSelectRange(event: MouseEvent, row: number, col: number) {
function handleMouseDown(event: MouseEvent, row: number, col: number) {
// if there was a right click on selected range, don't restart the selection
const leftClickButton = 0
if (event?.button !== leftClickButton && isCellSelected(row, col)) {
if (event?.button !== MAIN_MOUSE_PRESSED && isCellSelected(row, col)) {
return
}
if (unref(editEnabled)) {
event.preventDefault()
return
}
editEnabled.value = false
isMouseDown = true
selectedRange.startRange({ row, col })
}
const handleCellClick = (event: MouseEvent, row: number, col: number) => {
isMouseDown = true
selectedRange.clear()
editEnabled.value = false
selectedRange.startRange({ row, col })
selectedRange.endRange({ row, col })
makeActive(row, col)
isMouseDown = false
}
useEventListener(document, 'mouseup', (e) => {
// if the editEnabled is false prevent the mouseup event for not select text
const handleMouseUp = (event: MouseEvent) => {
// timeout is needed, because we want to set cell as active AFTER all the child's click handler's called
// this is needed e.g. for date field edit, where two clicks had to be done - one to select cell, and another one to open date dropdown
setTimeout(() => {
makeActive(selectedRange.start.row, selectedRange.start.col)
}, 0)
// if the editEnabled is false, prevent selecting text on mouseUp
if (!unref(editEnabled)) {
e.preventDefault()
event.preventDefault()
}
isMouseDown = false
})
}
const onKeyDown = async (e: KeyboardEvent) => {
const handleKeyDown = async (e: KeyboardEvent) => {
// invoke the keyEventHandler if provided and return if it returns true
if (await keyEventHandler?.(e)) {
return true
}
if (!selectedRange.isEmpty()) {
// In case the user press tabs or arrows keys
selectedCell.row = selectedRange.start.row
selectedCell.col = selectedRange.start.col
if (activeCell.row === null || activeCell.col === null) {
return
}
if (selectedCell.row === null || selectedCell.col === null) return
/** on tab key press navigate through cells */
switch (e.key) {
case 'Tab':
@ -174,21 +182,21 @@ export function useMultiSelect(
selectedRange.clear()
if (e.shiftKey) {
if (selectedCell.col > 0) {
selectedCell.col--
if (activeCell.col > 0) {
activeCell.col--
editEnabled.value = false
} else if (selectedCell.row > 0) {
selectedCell.row--
selectedCell.col = unref(columnLength) - 1
} else if (activeCell.row > 0) {
activeCell.row--
activeCell.col = unref(columnLength) - 1
editEnabled.value = false
}
} else {
if (selectedCell.col < unref(columnLength) - 1) {
selectedCell.col++
if (activeCell.col < unref(columnLength) - 1) {
activeCell.col++
editEnabled.value = false
} else if (selectedCell.row < unref(data).length - 1) {
selectedCell.row++
selectedCell.col = 0
} else if (activeCell.row < unref(data).length - 1) {
activeCell.row++
activeCell.col = 0
editEnabled.value = false
}
}
@ -198,63 +206,68 @@ export function useMultiSelect(
case 'Enter':
e.preventDefault()
selectedRange.clear()
makeEditable(unref(data)[selectedCell.row], unref(fields)[selectedCell.col])
makeEditable(unref(data)[activeCell.row], unref(fields)[activeCell.col])
break
/** on delete key press clear cell */
case 'Delete':
e.preventDefault()
selectedRange.clear()
await clearCell(selectedCell as { row: number; col: number })
await clearCell(activeCell as { row: number; col: number })
break
/** on arrow key press navigate through cells */
case 'ArrowRight':
e.preventDefault()
selectedRange.clear()
if (selectedCell.col < unref(columnLength) - 1) {
selectedCell.col++
if (activeCell.col < unref(columnLength) - 1) {
activeCell.col++
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowLeft':
selectedRange.clear()
e.preventDefault()
if (selectedCell.col > 0) {
selectedCell.col--
selectedRange.clear()
if (activeCell.col > 0) {
activeCell.col--
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowUp':
selectedRange.clear()
e.preventDefault()
if (selectedCell.row > 0) {
selectedCell.row--
selectedRange.clear()
if (activeCell.row > 0) {
activeCell.row--
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowDown':
selectedRange.clear()
e.preventDefault()
if (selectedCell.row < unref(data).length - 1) {
selectedCell.row++
selectedRange.clear()
if (activeCell.row < unref(data).length - 1) {
activeCell.row++
scrollToActiveCell?.()
editEnabled.value = false
}
break
default:
{
const rowObj = unref(data)[selectedCell.row]
const columnObj = unref(fields)[selectedCell.col]
const rowObj = unref(data)[activeCell.row]
const columnObj = unref(fields)[activeCell.col]
if ((!unref(editEnabled) || !isTypableInputColumn(columnObj)) && (isMac() ? e.metaKey : e.ctrlKey)) {
switch (e.keyCode) {
// copy - ctrl/cmd +c
case 67:
// set clipboard context only if single cell selected
if (rowObj.row[columnObj.title!]) {
if (selectedRange.isSingleCell() && rowObj.row[columnObj.title!]) {
clipboardContext = {
value: rowObj.row[columnObj.title!],
uidt: columnObj.uidt as UITypes,
@ -264,6 +277,7 @@ export function useMultiSelect(
}
await copyValue()
break
// paste - ctrl/cmd + v
case 86:
try {
// handle belongs to column
@ -297,7 +311,7 @@ export function useMultiSelect(
(relatedTableMeta as any)!.columns!,
)
return await syncCellData?.({ ...selectedCell, updatedColumnTitle: foreignKeyColumn.title })
return await syncCellData?.({ ...activeCell, updatedColumnTitle: foreignKeyColumn.title })
}
// if it's a virtual column excluding belongs to cell type skip paste
@ -315,9 +329,9 @@ export function useMultiSelect(
isMysql.value,
)
e.preventDefault()
syncCellData?.(selectedCell)
syncCellData?.(activeCell)
} else {
clearCell(selectedCell as { row: number; col: number }, true)
clearCell(activeCell as { row: number; col: number }, true)
makeEditable(rowObj, columnObj)
}
} catch (error: any) {
@ -346,15 +360,18 @@ export function useMultiSelect(
}
}
useEventListener(document, 'keydown', onKeyDown)
const clearSelectedRange = selectedRange.clear.bind(selectedRange)
useEventListener(document, 'keydown', handleKeyDown)
useEventListener(document, 'mouseup', handleMouseUp)
return {
selectCell,
startSelectRange,
endSelectRange,
clearSelectedRange: selectedRange.clear.bind(selectedRange),
handleMouseDown,
handleMouseOver,
clearSelectedRange,
copyValue,
isCellSelected,
selectedCell,
activeCell,
handleCellClick,
}
}

7
packages/nc-gui/composables/useSharedView.ts

@ -58,8 +58,11 @@ export function useSharedView() {
'xc-password': localPassword ?? password.value,
},
})
allowCSVDownload.value = JSON.parse(viewMeta.meta)?.allowCSVDownload
try {
allowCSVDownload.value = (typeof viewMeta.meta === 'string' ? JSON.parse(viewMeta.meta) : viewMeta.meta)?.allowCSVDownload
} catch {
allowCSVDownload.value = false
}
if (localPassword) password.value = localPassword
sharedView.value = { title: '', ...viewMeta }

4
packages/nc-gui/composables/useTabs.ts

@ -41,6 +41,8 @@ const [setup, use] = useInjectionState(() => {
tab.title = currentTable.title
tab.meta = currentTable.meta
// append base alias to tab title if duplicate titles exist on other bases
if (tables.value.find((t) => t.title === currentTable?.title && t.base_id !== currentTable?.base_id))
tab.title = `${tab.title}${currentBase?.alias ? ` (${currentBase.alias})` : ``}`
@ -92,6 +94,8 @@ const [setup, use] = useInjectionState(() => {
const currentTable = tables.value.find((t) => t.id === tabMeta.id || t.title === tabMeta.id)
const currentBase = bases.value.find((b) => b.id === currentTable?.base_id)
tabMeta.meta = currentTable?.meta
// append base alias to tab title if duplicate titles exist on other bases
if (tables.value.find((t) => t.title === currentTable?.title && t.base_id !== currentTable?.base_id))
tabMeta.title = `${tabMeta.title}${currentBase?.alias ? ` (${currentBase.alias})` : ``}`

3
packages/nc-gui/layouts/shared-view.vue

@ -58,7 +58,8 @@ export default {
<MdiReload :class="{ 'animate-infinite animate-spin ': isLoading }" />
</template>
<div v-else class="text-xl font-semibold truncate text-white nc-shared-view-title">
<div v-else class="text-xl font-semibold truncate text-white nc-shared-view-title flex gap-2 items-center">
<GeneralViewIcon class="!text-xl" :meta="sharedView" />
{{ sharedView?.title }}
</div>
</div>

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

@ -20,6 +20,7 @@ export enum ClientType {
PG = 'pg',
SQLITE = 'sqlite3',
VITESS = 'vitess',
SNOWFLAKE = 'snowflake',
}
export enum Language {

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

@ -78,6 +78,7 @@ export interface TabItem {
viewId?: string
sortsState?: Map<string, any>
filterState?: Map<string, any>
meta?: Record<string, any>
}
export interface SharedViewMeta extends Record<string, any> {
@ -100,3 +101,5 @@ export interface SharedView {
export type importFileList = (UploadFile & { data: string | ArrayBuffer })[]
export type streamImportFileList = UploadFile[]
export type Nullable<T> = { [K in keyof T]: T[K] | null }

6
packages/nc-gui/nuxt.config.ts

@ -7,6 +7,8 @@ import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
import monacoEditorPlugin from 'vite-plugin-monaco-editor'
import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill'
import PurgeIcons from 'vite-plugin-purge-icons'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
modules: ['@vueuse/nuxt', 'nuxt-windicss', '@nuxt/image-edge'],
@ -138,6 +140,10 @@ export default defineNuxtConfig({
monacoEditorPlugin({
languageWorkers: ['json'],
}),
PurgeIcons({
/* PurgeIcons Options */
includedCollections: ['emojione'],
}),
],
define: {
'process.env.DEBUG': 'false',

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

@ -9,6 +9,7 @@
"license": "AGPL-3.0-or-later",
"dependencies": {
"@ckpack/vue-color": "^1.2.0",
"@iconify/vue": "^4.0.1",
"@types/file-saver": "^2.0.5",
"@vue-flow/additional-components": "^1.2.0",
"@vue-flow/core": "^1.3.0",
@ -34,6 +35,7 @@
"sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2",
"unique-names-generator": "^4.7.1",
"v3-infinite-loading": "^1.2.2",
"validator": "^13.7.0",
"vue-barcode-reader": "^1.0.3",
"vue-dompurify-html": "^3.0.0",
@ -89,6 +91,7 @@
"unplugin-icons": "^0.14.7",
"unplugin-vue-components": "^0.22.4",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-purge-icons": "^0.9.0",
"vitest": "^0.18.0",
"windicss": "^3.5.6"
}
@ -1185,6 +1188,15 @@
"@iconify/types": "*"
}
},
"node_modules/@iconify/iconify": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@iconify/iconify/-/iconify-2.2.1.tgz",
"integrity": "sha512-WJzw+3iicrF/tbjbxxRinSgy5FHdJoz/egTqwi3xCDkNRJPq482RX1iyaWrjNuY2vMNSPkQMuqHvZDXgA+WnwQ==",
"dev": true,
"funding": {
"url": "http://github.com/sponsors/cyberalien"
}
},
"node_modules/@iconify/types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz",
@ -1205,6 +1217,25 @@
"local-pkg": "^0.4.1"
}
},
"node_modules/@iconify/vue": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-4.0.1.tgz",
"integrity": "sha512-k4VwcSQpGqJpoyqENRRviFuXlVcquLvQ6BKLNJ6o2amZo7u+3HyALSO79Xyz7Sg68szQGstOk6weaKUF0DJbog==",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@iconify/vue/node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="
},
"node_modules/@intlify/bundle-utils": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-2.2.2.tgz",
@ -2549,6 +2580,49 @@
"integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
"dev": true
},
"node_modules/@purge-icons/core": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/@purge-icons/core/-/core-0.9.1.tgz",
"integrity": "sha512-sx8/a30MbbqQVEqhuMPE1wJpdVRRbEmwEPZpFzVkcDixzX4p+R2A0WVxqkb0xfHUBAVQwrSE2SeAyniIQLqbLw==",
"dev": true,
"dependencies": {
"@iconify/iconify": "2.1.2",
"axios": "^0.26.0",
"debug": "^4.3.3",
"fast-glob": "^3.2.11",
"fs-extra": "^10.0.1"
}
},
"node_modules/@purge-icons/core/node_modules/@iconify/iconify": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@iconify/iconify/-/iconify-2.1.2.tgz",
"integrity": "sha512-QcUzFeEWkE/mW+BVtEGmcWATClcCOIJFiYUD/PiCWuTcdEA297o8D4oN6Ra44WrNOHu1wqNW4J0ioaDIiqaFOQ==",
"dev": true,
"dependencies": {
"cross-fetch": "^3.1.5"
},
"funding": {
"url": "http://github.com/sponsors/cyberalien"
}
},
"node_modules/@purge-icons/core/node_modules/axios": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"dev": true,
"dependencies": {
"follow-redirects": "^1.14.8"
}
},
"node_modules/@purge-icons/generated": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@purge-icons/generated/-/generated-0.9.0.tgz",
"integrity": "sha512-s2t+1oVtGDV6KtqfCXtUOhxfeYvOdDF90IVm+nMs/6bUP0HeGZLslguuL/AibpwtfL4FA/oCsIu/RhwapgAdJw==",
"dev": true,
"dependencies": {
"@iconify/iconify": ">=2.0.0-rc.6"
}
},
"node_modules/@rollup/plugin-alias": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-4.0.2.tgz",
@ -5836,6 +5910,35 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"node_modules/cross-fetch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
"integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==",
"dev": true,
"dependencies": {
"node-fetch": "2.6.7"
}
},
"node_modules/cross-fetch/node_modules/node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dev": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -14265,6 +14368,19 @@
"rollup-plugin-inject": "^3.0.0"
}
},
"node_modules/rollup-plugin-purge-icons": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/rollup-plugin-purge-icons/-/rollup-plugin-purge-icons-0.9.1.tgz",
"integrity": "sha512-hRDKBsPUz47UMdBufki2feTmBF2ClEJlYqL7N6vpVAHSLd7V2BJUaNKOF7YYbLMofVVF+9hm44YSkYO6k9hUgg==",
"dev": true,
"dependencies": {
"@purge-icons/core": "^0.9.1",
"@purge-icons/generated": "^0.9.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/rollup-plugin-terser": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
@ -16198,6 +16314,11 @@
"node": ">= 0.4.0"
}
},
"node_modules/v3-infinite-loading": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/v3-infinite-loading/-/v3-infinite-loading-1.2.2.tgz",
"integrity": "sha512-MWJc6yChnqeUasBFJ3Enu8IGPcQgRMSTrAEtT1MsHBEx+QjwvNTaY8o+8V9DgVt1MVhQSl3MC55hsaWLJmpRMw=="
},
"node_modules/v8-compile-cache": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
@ -16796,6 +16917,23 @@
"monaco-editor": ">=0.33.0"
}
},
"node_modules/vite-plugin-purge-icons": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/vite-plugin-purge-icons/-/vite-plugin-purge-icons-0.9.1.tgz",
"integrity": "sha512-oS0Y9Iq6vGnTDVRzB8xJNhA/gGlyR0lfCICU6+9FRKdrO5PnT34fRjvd8YWEsegCrk91+w3GVZc0HJDj/dPp5Q==",
"dev": true,
"dependencies": {
"@purge-icons/core": "^0.9.1",
"@purge-icons/generated": "^0.9.0",
"rollup-plugin-purge-icons": "^0.9.1"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"vite": "^2.0.0-beta.3 || ^3.0.0"
}
},
"node_modules/vite-plugin-windicss": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/vite-plugin-windicss/-/vite-plugin-windicss-1.8.7.tgz",
@ -18632,6 +18770,12 @@
"@iconify/types": "*"
}
},
"@iconify/iconify": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@iconify/iconify/-/iconify-2.2.1.tgz",
"integrity": "sha512-WJzw+3iicrF/tbjbxxRinSgy5FHdJoz/egTqwi3xCDkNRJPq482RX1iyaWrjNuY2vMNSPkQMuqHvZDXgA+WnwQ==",
"dev": true
},
"@iconify/types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz",
@ -18652,6 +18796,21 @@
"local-pkg": "^0.4.1"
}
},
"@iconify/vue": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-4.0.1.tgz",
"integrity": "sha512-k4VwcSQpGqJpoyqENRRviFuXlVcquLvQ6BKLNJ6o2amZo7u+3HyALSO79Xyz7Sg68szQGstOk6weaKUF0DJbog==",
"requires": {
"@iconify/types": "^2.0.0"
},
"dependencies": {
"@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="
}
}
},
"@intlify/bundle-utils": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-2.2.2.tgz",
@ -19595,6 +19754,48 @@
"integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
"dev": true
},
"@purge-icons/core": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/@purge-icons/core/-/core-0.9.1.tgz",
"integrity": "sha512-sx8/a30MbbqQVEqhuMPE1wJpdVRRbEmwEPZpFzVkcDixzX4p+R2A0WVxqkb0xfHUBAVQwrSE2SeAyniIQLqbLw==",
"dev": true,
"requires": {
"@iconify/iconify": "2.1.2",
"axios": "^0.26.0",
"debug": "^4.3.3",
"fast-glob": "^3.2.11",
"fs-extra": "^10.0.1"
},
"dependencies": {
"@iconify/iconify": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@iconify/iconify/-/iconify-2.1.2.tgz",
"integrity": "sha512-QcUzFeEWkE/mW+BVtEGmcWATClcCOIJFiYUD/PiCWuTcdEA297o8D4oN6Ra44WrNOHu1wqNW4J0ioaDIiqaFOQ==",
"dev": true,
"requires": {
"cross-fetch": "^3.1.5"
}
},
"axios": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"dev": true,
"requires": {
"follow-redirects": "^1.14.8"
}
}
}
},
"@purge-icons/generated": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@purge-icons/generated/-/generated-0.9.0.tgz",
"integrity": "sha512-s2t+1oVtGDV6KtqfCXtUOhxfeYvOdDF90IVm+nMs/6bUP0HeGZLslguuL/AibpwtfL4FA/oCsIu/RhwapgAdJw==",
"dev": true,
"requires": {
"@iconify/iconify": ">=2.0.0-rc.6"
}
},
"@rollup/plugin-alias": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-4.0.2.tgz",
@ -21994,6 +22195,26 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"cross-fetch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
"integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==",
"dev": true,
"requires": {
"node-fetch": "2.6.7"
},
"dependencies": {
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dev": true,
"requires": {
"whatwg-url": "^5.0.0"
}
}
}
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -28141,6 +28362,16 @@
"rollup-plugin-inject": "^3.0.0"
}
},
"rollup-plugin-purge-icons": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/rollup-plugin-purge-icons/-/rollup-plugin-purge-icons-0.9.1.tgz",
"integrity": "sha512-hRDKBsPUz47UMdBufki2feTmBF2ClEJlYqL7N6vpVAHSLd7V2BJUaNKOF7YYbLMofVVF+9hm44YSkYO6k9hUgg==",
"dev": true,
"requires": {
"@purge-icons/core": "^0.9.1",
"@purge-icons/generated": "^0.9.0"
}
},
"rollup-plugin-terser": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
@ -29594,6 +29825,11 @@
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"dev": true
},
"v3-infinite-loading": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/v3-infinite-loading/-/v3-infinite-loading-1.2.2.tgz",
"integrity": "sha512-MWJc6yChnqeUasBFJ3Enu8IGPcQgRMSTrAEtT1MsHBEx+QjwvNTaY8o+8V9DgVt1MVhQSl3MC55hsaWLJmpRMw=="
},
"v8-compile-cache": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
@ -29905,6 +30141,17 @@
"dev": true,
"requires": {}
},
"vite-plugin-purge-icons": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/vite-plugin-purge-icons/-/vite-plugin-purge-icons-0.9.1.tgz",
"integrity": "sha512-oS0Y9Iq6vGnTDVRzB8xJNhA/gGlyR0lfCICU6+9FRKdrO5PnT34fRjvd8YWEsegCrk91+w3GVZc0HJDj/dPp5Q==",
"dev": true,
"requires": {
"@purge-icons/core": "^0.9.1",
"@purge-icons/generated": "^0.9.0",
"rollup-plugin-purge-icons": "^0.9.1"
}
},
"vite-plugin-windicss": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/vite-plugin-windicss/-/vite-plugin-windicss-1.8.7.tgz",

3
packages/nc-gui/package.json

@ -32,6 +32,7 @@
},
"dependencies": {
"@ckpack/vue-color": "^1.2.0",
"@iconify/vue": "^4.0.1",
"@types/file-saver": "^2.0.5",
"@vue-flow/additional-components": "^1.2.0",
"@vue-flow/core": "^1.3.0",
@ -57,6 +58,7 @@
"sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2",
"unique-names-generator": "^4.7.1",
"v3-infinite-loading": "^1.2.2",
"validator": "^13.7.0",
"vue-barcode-reader": "^1.0.3",
"vue-dompurify-html": "^3.0.0",
@ -112,6 +114,7 @@
"unplugin-icons": "^0.14.7",
"unplugin-vue-components": "^0.22.4",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-purge-icons": "^0.9.0",
"vitest": "^0.18.0",
"windicss": "^3.5.6"
}

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

@ -1,4 +1,5 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { TabItem } from '~/lib'
import { TabType } from '~/lib'
import { TabMetaInj, iconMap, provide, useGlobal, useSidebar, useTabs } from '#imports'
@ -46,9 +47,15 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
<a-tabs v-model:activeKey="activeTabIndex" class="nc-root-tabs" type="editable-card" @edit="onEdit">
<a-tab-pane v-for="(tab, i) of tabs" :key="i">
<template #tab>
<div class="flex items-center gap-2 max-w-[110px]">
<div class="flex items-center gap-2 max-w-[110px]" data-testid="nc-tab-title">
<div class="flex items-center">
<component :is="icon(tab)" class="text-sm" />
<Icon
v-if="tab.meta?.icon"
:icon="tab.meta?.icon"
class="text-xl"
:data-testid="`nc-tab-icon-${tab.meta?.icon}`"
/>
<component :is="icon(tab)" v-else class="text-sm" />
</div>
<a-tooltip v-if="tab.title?.length > 12" placement="bottom">

46
packages/nc-gui/pages/index/index/create-external.vue

@ -79,6 +79,15 @@ const validators = computed(() => {
? {
'dataSource.connection.connection.filename': [fieldRequiredValidator()],
}
: formState.dataSource.client === ClientType.SNOWFLAKE
? {
'dataSource.connection.account': [fieldRequiredValidator()],
'dataSource.connection.username': [fieldRequiredValidator()],
'dataSource.connection.password': [fieldRequiredValidator()],
'dataSource.connection.warehouse': [fieldRequiredValidator()],
'dataSource.connection.database': [fieldRequiredValidator()],
'dataSource.connection.schema': [fieldRequiredValidator()],
}
: {
'dataSource.connection.host': [fieldRequiredValidator()],
'dataSource.connection.port': [fieldRequiredValidator()],
@ -385,6 +394,43 @@ onMounted(async () => {
<a-input v-model:value="formState.dataSource.connection.connection.filename" />
</a-form-item>
<template v-else-if="formState.dataSource.client === ClientType.SNOWFLAKE">
<!-- Account -->
<a-form-item label="Account" v-bind="validateInfos['dataSource.connection.account']">
<a-input v-model:value="formState.dataSource.connection.account" class="nc-extdb-account" />
</a-form-item>
<!-- Username -->
<a-form-item :label="$t('labels.username')" v-bind="validateInfos['dataSource.connection.username']">
<a-input v-model:value="formState.dataSource.connection.username" class="nc-extdb-host-user" />
</a-form-item>
<!-- Password -->
<a-form-item :label="$t('labels.password')" v-bind="validateInfos['dataSource.connection.password']">
<a-input-password v-model:value="formState.dataSource.connection.password" class="nc-extdb-host-password" />
</a-form-item>
<!-- Warehouse -->
<a-form-item label="Warehouse" v-bind="validateInfos['dataSource.connection.warehouse']">
<a-input v-model:value="formState.dataSource.connection.warehouse" />
</a-form-item>
<!-- Database -->
<a-form-item :label="$t('labels.database')" v-bind="validateInfos['dataSource.connection.database']">
<!-- Database : create if not exists -->
<a-input
v-model:value="formState.dataSource.connection.database"
:placeholder="$t('labels.dbCreateIfNotExists')"
class="nc-extdb-host-database"
/>
</a-form-item>
<!-- Schema name -->
<a-form-item :label="$t('labels.schemaName')" v-bind="validateInfos['dataSource.connection.schema']">
<a-input v-model:value="formState.dataSource.connection.schema" />
</a-form-item>
</template>
<template v-else>
<!-- Host Address -->
<a-form-item :label="$t('labels.hostAddress')" v-bind="validateInfos['dataSource.connection.host']">

41
packages/nc-gui/pages/index/index/index.vue

@ -174,42 +174,11 @@ const copyProjectMeta = async () => {
<div class="flex-1" />
<a-dropdown v-if="isUIAllowed('projectCreate', true)" :trigger="['click']" overlay-class-name="nc-dropdown-create-project">
<button class="nc-new-project-menu mt-4 md:mt-0">
<span class="flex items-center w-full">
{{ $t('title.newProj') }}
<MdiMenuDown class="menu-icon" />
</span>
</button>
<template #overlay>
<a-menu class="!py-0 rounded">
<a-menu-item>
<div
v-e="['c:project:create:xcdb']"
class="nc-project-menu-item group nc-create-xc-db-project"
@click="navigateTo('/create')"
>
<MdiPlusOutline class="group-hover:text-accent" />
<div>{{ $t('activity.createProject') }}</div>
</div>
</a-menu-item>
<a-menu-item v-if="appInfo.connectToExternalDB">
<div
v-e="['c:project:create:extdb']"
class="nc-project-menu-item group nc-create-external-db-project"
@click="navigateTo('/create-external')"
>
<MdiDatabaseOutline class="group-hover:text-accent" />
<div v-html="$t('activity.createProjectExtended.extDB')" />
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<button v-if="isUIAllowed('projectCreate', true)" class="nc-new-project-menu mt-4 md:mt-0" @click="navigateTo('/create')">
<span class="flex items-center w-full">
{{ $t('title.newProj') }}
</span>
</button>
</div>
<!--

10
packages/nc-gui/utils/dateTimeUtils.ts

@ -5,19 +5,21 @@ export const timeAgo = (date: any) => {
}
export const dateFormats = [
'YYYY-MM-DD',
'YYYY/MM/DD',
'DD-MM-YYYY',
'MM-DD-YYYY',
'YYYY-MM-DD',
'DD/MM/YYYY',
'MM/DD/YYYY',
'YYYY/MM/DD',
'DD MM YYYY',
'MM DD YYYY',
'YYYY MM DD',
]
export const timeFormats = ['HH:mm', 'HH:mm:ss']
export const handleTZ = (val: any) => {
if (!val) {
if (val === undefined || val === null) {
return
}
if (typeof val !== 'string') {
@ -60,7 +62,7 @@ export function getDateFormat(v: string) {
export function getDateTimeFormat(v: string) {
for (const format of dateFormats) {
for (const timeFormat of ['HH:mm', 'HH:mm:ss', 'HH:mm:ss.SSS']) {
for (const timeFormat of timeFormats) {
const dateTimeFormat = `${format} ${timeFormat}`
if (dayjs(v, dateTimeFormat, true).isValid() as any) {
return dateTimeFormat

23
packages/nc-gui/utils/formulaUtils.ts

@ -51,6 +51,29 @@ const formulas: Record<string, any> = {
'DATEADD({column1}, -2, "year")',
],
},
DATETIME_DIFF: {
type: formulaTypes.DATE,
validation: {
args: {
min: 2,
max: 3,
},
},
description: 'Calculate the difference of two given date / datetime in specified units.',
syntax:
'DATETIME_DIFF(date | datetime, date | datetime, ["milliseconds" | "ms" | "seconds" | "s" | "minutes" | "m" | "hours" | "h" | "days" | "d" | "weeks" | "w" | "months" | "M" | "quarters" | "Q" | "years" | "y"])',
examples: [
'DATEDIFF({column1}, {column2})',
'DATEDIFF({column1}, {column2}, "seconds")',
'DATEDIFF({column1}, {column2}, "s")',
'DATEDIFF({column1}, {column2}, "years")',
'DATEDIFF({column1}, {column2}, "y")',
'DATEDIFF({column1}, {column2}, "minutes")',
'DATEDIFF({column1}, {column2}, "m")',
'DATEDIFF({column1}, {column2}, "days")',
'DATEDIFF({column1}, {column2}, "d")',
],
},
AND: {
type: formulaTypes.COND_EXP,
validation: {

1475
packages/nc-gui/utils/iconUtils.ts

File diff suppressed because it is too large Load Diff

31
packages/nc-gui/utils/projectCreateUtils.ts

@ -4,7 +4,7 @@ interface ProjectCreateForm {
title: string
dataSource: {
client: ClientType
connection: DefaultConnection | SQLiteConnection
connection: DefaultConnection | SQLiteConnection | SnowflakeConnection
searchPath?: string[]
}
inflection: {
@ -33,6 +33,15 @@ interface SQLiteConnection {
useNullAsDefault?: boolean
}
export interface SnowflakeConnection {
account: string
username: string
password: string
warehouse: string
database: string
schema: string
}
const defaultHost = 'localhost'
const testDataBaseNames = {
@ -45,7 +54,7 @@ const testDataBaseNames = {
}
export const getTestDatabaseName = (db: { client: ClientType; connection?: { database?: string } }) => {
if (db.client === ClientType.PG) return db.connection?.database
if (db.client === ClientType.PG || db.client === ClientType.SNOWFLAKE) return db.connection?.database
return testDataBaseNames[db.client as keyof typeof testDataBaseNames]
}
@ -66,12 +75,16 @@ export const clientTypes = [
text: 'SQLite',
value: ClientType.SQLITE,
},
{
text: 'SnowFlake',
value: ClientType.SNOWFLAKE,
},
]
const homeDir = ''
type ConnectionClientType =
| Exclude<ClientType, ClientType.SQLITE>
| Exclude<ClientType, ClientType.SQLITE | ClientType.SNOWFLAKE>
| 'tidb'
| 'yugabyte'
| 'citusdb'
@ -79,7 +92,9 @@ type ConnectionClientType =
| 'oracledb'
| 'greenplum'
const sampleConnectionData: { [key in ConnectionClientType]: DefaultConnection } & { [ClientType.SQLITE]: SQLiteConnection } = {
const sampleConnectionData: { [key in ConnectionClientType]: DefaultConnection } & { [ClientType.SQLITE]: SQLiteConnection } & {
[ClientType.SNOWFLAKE]: SnowflakeConnection
} = {
[ClientType.PG]: {
host: defaultHost,
port: '5432',
@ -116,6 +131,14 @@ const sampleConnectionData: { [key in ConnectionClientType]: DefaultConnection }
},
useNullAsDefault: true,
},
[ClientType.SNOWFLAKE]: {
account: 'account',
username: 'username',
password: 'password',
warehouse: 'warehouse',
database: 'database',
schema: 'schema',
},
tidb: {
host: defaultHost,
port: '4000',

2
packages/noco-docs/content/en/setup-and-usages/formulas.md

@ -97,6 +97,8 @@ Example: ({Column1} + ({Column2} * {Column3}) / (3 - $Column4$ ))
| | | `DATEADD(date, 1, 'year')` | Supposing {DATE_COL} is 2022-03-14 03:14. The result is 2023-03-14 03:14. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -1, 'year')` |
| | | `IF(NOW() < DATEADD(date,10,'day'), "true", "false")` | If the current date is less than {DATE_COL} plus 10 days, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. |
| | | `IF(NOW() < DATEADD(date,10,'day'), "true", "false")` | If the current date is less than {DATE_COL} plus 10 days, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. |
| **DATETIME_DIFF** | `DATETIME_DIFF(date, date, ["milliseconds" \| "ms" \| "seconds" \| "s" \| "minutes" \| "m" \| "hours" \| "h" \| "days" \| "d" \| "weeks" \| "w" \| "months" \| "M" \| "quarters" \| "Q" \| "years" \| "y"])` | `DATETIME_DIFF("2022/10/14", "2022/10/15", "second")` | Supposing {DATE_COL_1} is 2017-08-25 and {DATE_COL_2} is 2011-08-25. The result is 86400. | Compares two dates and returns the difference in the unit specified. Positive integers indicate the second date being in the past compared to the first and vice versa for negative ones. |
| | | `WEEKDAY(NOW(), "sunday")` | If today is Monday, it returns 1 | Get the week day of NOW() with the first day set as sunday |
| **WEEKDAY** | `WEEKDAY(date, [startDayOfWeek])` | `WEEKDAY(NOW())` | If today is Monday, it returns 0 | Returns the day of the week as an integer between 0 and 6 inclusive starting from Monday by default. You can optionally change the start day of the week by specifying in the second argument |
| | | `WEEKDAY(NOW(), "sunday")` | If today is Monday, it returns 1 | Get the week day of NOW() with the first day set as sunday |

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

@ -124,6 +124,7 @@ export interface TableType {
columnsById?: object;
slug?: string;
mm?: boolean | number;
meta?: any;
}
export interface ViewType {
@ -134,6 +135,7 @@ export interface ViewType {
fk_model_id?: string;
slug?: string;
uuid?: string;
meta?: any;
show_system_fields?: boolean;
lock_type?: 'collaborative' | 'locked' | 'personal';
type?: number;
@ -175,6 +177,7 @@ export interface TableReqType {
order?: number;
mm?: boolean;
columns: ColumnType[];
meta?: any;
}
export interface TableListType {
@ -2159,6 +2162,7 @@ export class Api<
table_name?: string;
title?: string;
project_id?: string;
meta?: any;
},
params: RequestParams = {}
) =>
@ -2313,6 +2317,7 @@ export class Api<
viewId: string,
data: {
order?: number;
meta?: any;
title?: string;
show_system_fields?: boolean;
lock_type?: 'collaborative' | 'locked' | 'personal';

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

@ -132,6 +132,7 @@ export function jsepTreeToFormula(node) {
'AVG',
'ADD',
'DATEADD',
'DATETIME_DIFF',
'WEEKDAY',
'AND',
'OR',

1015
packages/nocodb-sdk/src/lib/sqlUi/SnowflakeUi.ts

File diff suppressed because it is too large Load Diff

5
packages/nocodb-sdk/src/lib/sqlUi/SqlUiFactory.ts

@ -5,6 +5,7 @@ import { MysqlUi } from './MysqlUi';
import { OracleUi } from './OracleUi';
import { PgUi } from './PgUi';
import { SqliteUi } from './SqliteUi';
import { SnowflakeUi } from './SnowflakeUi';
// import {YugabyteUi} from "./YugabyteUi";
// import {TidbUi} from "./TidbUi";
@ -42,6 +43,10 @@ export class SqlUiFactory {
return PgUi;
}
if (connectionConfig.client === 'snowflake') {
return SnowflakeUi;
}
throw new Error('Database not supported');
}
}

1
packages/nocodb-sdk/src/lib/sqlUi/index.ts

@ -5,4 +5,5 @@ export * from './PgUi';
export * from './MssqlUi';
export * from './OracleUi';
export * from './SqliteUi';
export * from './SnowflakeUi';
export * from './SqlUiFactory';

1957
packages/nocodb/package-lock.json generated

File diff suppressed because it is too large Load Diff

4
packages/nocodb/package.json

@ -93,7 +93,7 @@
"jsep": "^1.3.6",
"jsonfile": "^6.1.0",
"jsonwebtoken": "^8.5.1",
"knex": "^2.2.0",
"knex": "2.2.0",
"lodash": "^4.17.19",
"lru-cache": "^6.0.0",
"mailersend": "^1.1.0",
@ -104,7 +104,7 @@
"multer": "^1.4.2",
"mysql2": "^2.2.5",
"nanoid": "^3.1.20",
"nc-help": "0.2.82",
"nc-help": "0.2.85",
"nc-lib-gui": "0.100.2",
"nc-plugin": "0.1.2",
"ncp": "^2.0.0",

5
packages/nocodb/src/lib/db/sql-client/lib/SqlClientFactory.ts

@ -6,6 +6,8 @@ import PgClient from './pg/PgClient';
import YugabyteClient from './pg/YugabyteClient';
import TidbClient from './mysql/TidbClient';
import VitessClient from './mysql/VitessClient';
import SfClient from './snowflake/SnowflakeClient';
import { SnowflakeClient } from 'nc-help';
class SqlClientFactory {
static create(connectionConfig) {
@ -31,6 +33,9 @@ class SqlClientFactory {
if (connectionConfig.meta.dbtype === 'yugabyte')
return new YugabyteClient(connectionConfig);
return new PgClient(connectionConfig);
} else if (connectionConfig.client === 'snowflake') {
connectionConfig.client = SnowflakeClient;
return new SfClient(connectionConfig);
}
throw new Error('Database not supported');

2609
packages/nocodb/src/lib/db/sql-client/lib/snowflake/SnowflakeClient.ts

File diff suppressed because it is too large Load Diff

5
packages/nocodb/src/lib/db/sql-client/lib/snowflake/snowflake.queries.ts

@ -0,0 +1,5 @@
// Snowflake queries
const sfQueries = {};
export default sfQueries;

177
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts

@ -100,10 +100,9 @@ class BaseModelSqlv2 {
qb.where(_wherePk(this.model.primaryKeys, id));
let data = (await this.extractRawQueryAndExec(qb))?.[0];
let data = (await this.execAndParse(qb))?.[0];
if (data) {
data = this.convertAttachmentType(data);
const proto = await this.getProto();
data.__proto__ = proto;
}
@ -162,7 +161,6 @@ class BaseModelSqlv2 {
let data = await qb.first();
if (data) {
data = this.convertAttachmentType(data);
const proto = await this.getProto();
data.__proto__ = proto;
}
@ -254,8 +252,7 @@ class BaseModelSqlv2 {
if (!ignoreViewFilterAndSort) applyPaginate(qb, rest);
const proto = await this.getProto();
let data = await this.extractRawQueryAndExec(qb);
data = this.convertAttachmentType(data);
let data = await this.execAndParse(qb);
return data?.map((d) => {
d.__proto__ = proto;
@ -323,7 +320,7 @@ class BaseModelSqlv2 {
as: 'count',
}).first();
const res = (await this.dbDriver.raw(unsanitize(qb.toQuery()))) as any;
return (this.isPg ? res.rows[0] : res[0][0] ?? res[0]).count;
return ((this.isPg || this.isSnowflake) ? res.rows[0] : res[0][0] ?? res[0]).count;
}
// todo: add support for sortArrJson and filterArrJson
@ -367,7 +364,7 @@ class BaseModelSqlv2 {
qb.groupBy(args.column_name);
if (sorts) await sortV2(sorts, qb, this.dbDriver);
applyPaginate(qb, rest);
return this.convertAttachmentType(await qb);
return await qb;
}
async multipleHmList({ colId, ids }, args: { limit?; offset? } = {}) {
@ -426,8 +423,7 @@ class BaseModelSqlv2 {
.as('list')
);
let children = await this.extractRawQueryAndExec(childQb);
children = this.convertAttachmentType(children);
let children = await this.execAndParse(childQb, childTable);
const proto = await (
await Model.getBaseModelSQL({
id: childTable.id,
@ -554,8 +550,7 @@ class BaseModelSqlv2 {
await childModel.selectObject({ qb });
let children = await this.extractRawQueryAndExec(qb);
children = this.convertAttachmentType(children);
let children = await this.execAndParse(qb, childTable);
const proto = await (
await Model.getBaseModelSQL({
@ -672,11 +667,10 @@ class BaseModelSqlv2 {
!this.isSqlite
);
let children = await this.extractRawQueryAndExec(finalQb);
let children = await this.execAndParse(finalQb, childTable);
if (this.isMySQL) {
children = children[0];
}
children = this.convertAttachmentType(children);
const proto = await (
await Model.getBaseModelSQL({
id: rtnId,
@ -741,8 +735,7 @@ class BaseModelSqlv2 {
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
let children = await this.extractRawQueryAndExec(qb);
children = this.convertAttachmentType(children);
let children = await this.execAndParse(qb, childTable);
const proto = await (
await Model.getBaseModelSQL({ id: rtnId, dbDriver: this.dbDriver })
).getProto();
@ -969,7 +962,6 @@ class BaseModelSqlv2 {
const proto = await childModel.getProto();
let data = await qb;
data = this.convertAttachmentType(data);
return data.map((c) => {
c.__proto__ = proto;
return c;
@ -1083,8 +1075,7 @@ class BaseModelSqlv2 {
applyPaginate(qb, rest);
const proto = await childModel.getProto();
let data = await this.extractRawQueryAndExec(qb);
data = this.convertAttachmentType(data);
let data = await this.execAndParse(qb, childTable);
return data.map((c) => {
c.__proto__ = proto;
@ -1202,8 +1193,7 @@ class BaseModelSqlv2 {
applyPaginate(qb, rest);
const proto = await parentModel.getProto();
let data = await this.extractRawQueryAndExec(qb);
data = this.convertAttachmentType(data);
let data = await this.execAndParse(qb, childTable);
return data.map((c) => {
c.__proto__ = proto;
@ -1535,7 +1525,7 @@ class BaseModelSqlv2 {
query.returning(
`${this.model.primaryKey.column_name} as ${this.model.primaryKey.title}`
);
response = await this.extractRawQueryAndExec(query);
response = await this.execAndParse(query);
}
const ai = this.model.columns.find((c) => c.ai);
@ -1545,7 +1535,7 @@ class BaseModelSqlv2 {
// handle if autogenerated primary key is used
if (ag) {
if (!response) await this.extractRawQueryAndExec(query);
if (!response) await this.execAndParse(query);
response = await this.readByPk(data[ag.title]);
} else if (
!response ||
@ -1555,7 +1545,7 @@ class BaseModelSqlv2 {
if (response?.length) {
id = response[0];
} else {
const res = await this.extractRawQueryAndExec(query);
const res = await this.execAndParse(query);
id = res?.id ?? res[0]?.insertId;
}
@ -1567,6 +1557,11 @@ class BaseModelSqlv2 {
.select(ai.column_name)
.max(ai.column_name, { as: 'id' })
)[0].id;
} else if (this.isSnowflake) {
id = ((
await this.dbDriver(this.tnPath)
.max(ai.column_name, { as: 'id' })
) as any)[0].id;
}
response = await this.readByPk(id);
} else {
@ -1660,7 +1655,7 @@ class BaseModelSqlv2 {
.update(updateObj)
.where(await this._wherePk(id));
await this.extractRawQueryAndExec(query);
await this.execAndParse(query);
const response = await this.readByPk(id);
await this.afterUpdate(response, trx, cookie);
@ -1679,11 +1674,13 @@ class BaseModelSqlv2 {
private getTnPath(tb: Model) {
const schema = (this.dbDriver as any).searchPath?.();
const table =
this.isMssql && schema
? this.dbDriver.raw('??.??', [schema, tb.table_name])
: tb.table_name;
return table;
if (this.isMssql && schema) {
return this.dbDriver.raw('??.??', [schema, tb.table_name]);
} else if (this.isSnowflake) {
return [this.dbDriver.client.config.connection.database, this.dbDriver.client.config.connection.schema, tb.table_name].join('.');
} else {
return tb.table_name;
}
}
public get tnPath() {
@ -1706,6 +1703,10 @@ class BaseModelSqlv2 {
return this.clientType === 'mysql2' || this.clientType === 'mysql';
}
get isSnowflake() {
return this.clientType === 'snowflake';
}
get clientType() {
return this.dbDriver.clientType();
}
@ -1808,7 +1809,19 @@ class BaseModelSqlv2 {
}
if (ai) {
// response = await this.readByPk(id)
if (this.isSqlite) {
// sqlite doesnt return id after insert
id = (
await this.dbDriver(this.tnPath)
.select(ai.column_name)
.max(ai.column_name, { as: 'id' })
)[0].id;
} else if (this.isSnowflake) {
id = ((
await this.dbDriver(this.tnPath)
.max(ai.column_name, { as: 'id' })
) as any).rows[0].id;
}
response = await this.readByPk(id);
} else {
response = data;
@ -1881,10 +1894,10 @@ class BaseModelSqlv2 {
const response =
this.isPg || this.isMssql
? await this.dbDriver
.batchInsert(this.model.table_name, insertDatas, chunkSize)
.batchInsert(this.tnPath, insertDatas, chunkSize)
.returning(this.model.primaryKey?.column_name)
: await this.dbDriver.batchInsert(
this.model.table_name,
this.tnPath,
insertDatas,
chunkSize
);
@ -1917,7 +1930,7 @@ class BaseModelSqlv2 {
continue;
}
const wherePk = await this._wherePk(pkValues);
const response = await transaction(this.model.table_name)
const response = await transaction(this.tnPath)
.update(d)
.where(wherePk);
res.push(response);
@ -1997,7 +2010,7 @@ class BaseModelSqlv2 {
const res = [];
for (const d of deleteIds) {
if (Object.keys(d).length) {
const response = await transaction(this.model.table_name)
const response = await transaction(this.tnPath)
.del()
.where(d);
res.push(response);
@ -2246,7 +2259,7 @@ class BaseModelSqlv2 {
subject: 'NocoDB Form',
html: ejs.render(formSubmissionEmailTemplate, {
data: transformedData,
tn: this.model.table_name,
tn: this.tnPath,
_tn: this.model.title,
}),
});
@ -2368,16 +2381,34 @@ class BaseModelSqlv2 {
const vTn = this.getTnPath(vTable);
await this.dbDriver(vTn).insert({
[vParentCol.column_name]: this.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(_wherePk(parentTable.primaryKeys, childId))
.first(),
[vChildCol.column_name]: this.dbDriver(childTn)
.select(childColumn.column_name)
.where(_wherePk(childTable.primaryKeys, rowId))
.first(),
});
if (this.isSnowflake) {
const parentPK = this.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(_wherePk(parentTable.primaryKeys, childId))
.first();
const childPK = this.dbDriver(childTn)
.select(childColumn.column_name)
.where(_wherePk(childTable.primaryKeys, rowId))
.first();
await this.dbDriver.raw(`INSERT INTO ?? (??, ??) SELECT (${parentPK.toQuery()}), (${childPK.toQuery()})`, [
vTn,
vParentCol.column_name,
vChildCol.column_name,
])
} else {
await this.dbDriver(vTn).insert({
[vParentCol.column_name]: this.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(_wherePk(parentTable.primaryKeys, childId))
.first(),
[vChildCol.column_name]: this.dbDriver(childTn)
.select(childColumn.column_name)
.where(_wherePk(childTable.primaryKeys, rowId))
.first(),
});
}
}
break;
case RelationTypes.HAS_MANY:
@ -2565,7 +2596,7 @@ class BaseModelSqlv2 {
} else {
groupingValues = new Set(
(
await this.dbDriver(this.model.table_name)
await this.dbDriver(this.tnPath)
.select(column.column_name)
.distinct()
).map((row) => row[column.column_name])
@ -2573,7 +2604,7 @@ class BaseModelSqlv2 {
groupingValues.add(null);
}
const qb = this.dbDriver(this.model.table_name);
const qb = this.dbDriver(this.tnPath);
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
@ -2667,7 +2698,6 @@ class BaseModelSqlv2 {
const proto = await this.getProto();
let data = await groupedQb;
data = this.convertAttachmentType(data);
const result = data?.map((d) => {
d.__proto__ = proto;
return d;
@ -2712,7 +2742,7 @@ class BaseModelSqlv2 {
if (isVirtualCol(column))
NcError.notImplemented('Grouping for virtual columns not implemented');
const qb = this.dbDriver(this.model.table_name)
const qb = this.dbDriver(this.tnPath)
.count('*', { as: 'count' })
.groupBy(column.column_name);
@ -2770,38 +2800,51 @@ class BaseModelSqlv2 {
return await qb;
}
private async extractRawQueryAndExec(qb: Knex.QueryBuilder) {
private async execAndParse(
qb: Knex.QueryBuilder,
childTable?: Model
) {
let query = qb.toQuery();
if (!this.isPg && !this.isMssql) {
if (!this.isPg && !this.isMssql && !this.isSnowflake) {
query = unsanitize(qb.toQuery());
} else {
query = sanitize(query);
}
return this.isPg
? (await this.dbDriver.raw(query))?.rows
: query.slice(0, 6) === 'select' && !this.isMssql
? await this.dbDriver.from(
this.dbDriver.raw(query).wrap('(', ') __nc_alias')
)
: await this.dbDriver.raw(query);
return this.convertAttachmentType(
this.isPg || this.isSnowflake
? (await this.dbDriver.raw(query))?.rows
: query.slice(0, 6) === 'select' && !this.isMssql
? await this.dbDriver.from(
this.dbDriver.raw(query).wrap('(', ') __nc_alias')
)
: await this.dbDriver.raw(query),
childTable
);
}
private _convertAttachmentType(attachmentColumns, d) {
attachmentColumns.forEach((col) => {
if (d[col.title] && typeof d[col.title] === 'string') {
d[col.title] = JSON.parse(d[col.title]);
private _convertAttachmentType(
attachmentColumns: Record<string, any>[],
d: Record<string, any>
) {
try {
if (d) {
attachmentColumns.forEach((col) => {
if (d[col.title] && typeof d[col.title] === 'string') {
d[col.title] = JSON.parse(d[col.title]);
}
});
}
});
} catch {}
return d;
}
private convertAttachmentType(data) {
private convertAttachmentType(data: Record<string, any>, childTable?: Model) {
// attachment is stored in text and parse in UI
// convertAttachmentType is used to convert the response in string to array of object in API response
if (data) {
const attachmentColumns = this.model.columns.filter(
(c) => c.uidt === UITypes.Attachment
);
const attachmentColumns = (
childTable ? childTable.columns : this.model.columns
).filter((c) => c.uidt === UITypes.Attachment);
if (attachmentColumns.length) {
if (Array.isArray(data)) {
data = data.map((d) =>

7
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/CustomKnex.ts

@ -1,4 +1,5 @@
import { Knex, knex } from 'knex';
import { SnowflakeClient } from 'nc-help';
const types = require('pg').types;
// override parsing date column to Date()
@ -993,6 +994,8 @@ function CustomKnex(arg: string | Knex.Config<any> | any): CustomKnex {
arg.useNullAsDefault = true;
}
if (arg?.client === 'snowflake') arg.client = SnowflakeClient;
const kn: any = knex(arg);
const knexRaw = kn.raw;
@ -1019,7 +1022,9 @@ function CustomKnex(arg: string | Knex.Config<any> | any): CustomKnex {
value: () => {
return typeof arg === 'string'
? arg.match(/^(\w+):/) ?? [1]
: arg.client;
: (typeof arg.client === 'string')
? arg.client
: arg.client?.prototype?.dialect || arg.client?.prototype?.driverName;
},
},
searchPath: {

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

@ -709,6 +709,47 @@ export default async function formulaQueryBuilderv2(
sql = `COALESCE(${left}, '') ${pt.operator} COALESCE(${right},'')${colAlias}`;
}
if (knex.clientType() === 'mysql2') {
sql = `IFNULL(${left} ${pt.operator} ${right}, ${
pt.operator === '='
? pt.left.type === 'Literal'
? pt.left.value === ''
: pt.right.value === ''
: pt.operator === '!='
? pt.left.type !== 'Literal'
? pt.left.value === ''
: pt.right.value === ''
: 0
}) ${colAlias}`;
} else if (
knex.clientType() === 'sqlite3' ||
knex.clientType() === 'pg' ||
knex.clientType() === 'mssql'
) {
if (pt.operator === '=') {
if (pt.left.type === 'Literal' && pt.left.value === '') {
sql = `${right} IS NULL OR CAST(${right} AS TEXT) = ''`;
} else if (pt.right.type === 'Literal' && pt.right.value === '') {
sql = `${left} IS NULL OR CAST(${left} AS TEXT) = ''`;
}
} else if (pt.operator === '!=') {
if (pt.left.type === 'Literal' && pt.left.value === '') {
sql = `${right} IS NOT NULL AND CAST(${right} AS TEXT) != ''`;
} else if (pt.right.type === 'Literal' && pt.right.value === '') {
sql = `${left} IS NOT NULL AND CAST(${left} AS TEXT) != ''`;
}
}
if (
(pt.operator === '=' || pt.operator === '!=') &&
prevBinaryOp !== 'AND' &&
prevBinaryOp !== 'OR'
) {
sql = `(CASE WHEN ${sql} THEN true ELSE false END ${colAlias})`;
} else {
sql = `${sql} ${colAlias}`;
}
}
const query = knex.raw(sql);
if (prevBinaryOp && pt.operator !== prevBinaryOp) {
query.wrap('(', ')');

36
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mssql.ts

@ -1,6 +1,7 @@
import dayjs from 'dayjs';
import { MapFnArgs } from '../mapFunctionName';
import commonFns from './commonFns';
import { convertUnits } from '../helpers/convertUnits';
import { getWeekdayByText } from '../helpers/formulaFnHelper';
const mssql = {
@ -110,6 +111,17 @@ const mssql = {
END${colAlias}`
);
},
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = fn(pt.arguments[0]);
const datetime_expr2 = fn(pt.arguments[1]);
const rawUnit = pt.arguments[2]
? fn(pt.arguments[2]).bindings[0]
: 'seconds';
const unit = convertUnits(rawUnit, 'mssql');
return knex.raw(
`DATEDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}`
);
},
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// DATEPART(WEEKDAY, DATE): sunday = 1, monday = 2, ..., saturday = 7
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
@ -123,6 +135,30 @@ const mssql = {
)} % 7 + 7) % 7 ${colAlias}`
);
},
AND: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'AND').toQuery())
.join(' AND ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
);
},
OR: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'OR').toQuery())
.join(' OR ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
);
},
};
export default mssql;

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

@ -1,6 +1,7 @@
import dayjs from 'dayjs';
import { MapFnArgs } from '../mapFunctionName';
import commonFns from './commonFns';
import { convertUnits } from '../helpers/convertUnits';
import { getWeekdayByText } from '../helpers/formulaFnHelper';
const mysql2 = {
@ -61,6 +62,26 @@ const mysql2 = {
END${colAlias}`
);
},
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = fn(pt.arguments[0]);
const datetime_expr2 = fn(pt.arguments[1]);
const unit = convertUnits(
pt.arguments[2] ? fn(pt.arguments[2]).bindings[0] : 'seconds',
'mysql'
);
if (unit === 'MICROSECOND') {
// MySQL doesn't support millisecond
// hence change from MICROSECOND to millisecond manually
return knex.raw(
`TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) div 1000 ${colAlias}`
);
}
return knex.raw(
`TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}`
);
},
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
return knex.raw(

75
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/pg.ts

@ -1,6 +1,7 @@
import dayjs from 'dayjs';
import { MapFnArgs } from '../mapFunctionName';
import commonFns from './commonFns';
import { convertUnits } from '../helpers/convertUnits';
import { getWeekdayByText } from '../helpers/formulaFnHelper';
const pg = {
@ -50,6 +51,56 @@ const pg = {
)}')::interval${colAlias}`
);
},
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = fn(pt.arguments[0]);
const datetime_expr2 = fn(pt.arguments[1]);
const rawUnit = pt.arguments[2]
? fn(pt.arguments[2]).bindings[0]
: 'seconds';
let sql;
const unit = convertUnits(rawUnit, 'pg');
switch (unit) {
case 'second':
sql = `EXTRACT(EPOCH from (${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP))::INTEGER`;
break;
case 'minute':
sql = `EXTRACT(EPOCH from (${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP))::INTEGER / 60`;
break;
case 'milliseconds':
sql = `EXTRACT(EPOCH from (${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP))::INTEGER * 1000`;
break;
case 'hour':
sql = `EXTRACT(EPOCH from (${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP))::INTEGER / 3600`;
break;
case 'week':
sql = `TRUNC(DATE_PART('day', ${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP) / 7)`;
break;
case 'month':
sql = `(
DATE_PART('year', ${datetime_expr1}::TIMESTAMP) -
DATE_PART('year', ${datetime_expr2}::TIMESTAMP)
) * 12 + (
DATE_PART('month', ${datetime_expr1}::TIMESTAMP) -
DATE_PART('month', ${datetime_expr2}::TIMESTAMP)
)`;
break;
case 'quarter':
sql = `((EXTRACT(QUARTER FROM ${datetime_expr1}::TIMESTAMP) +
DATE_PART('year', AGE(${datetime_expr1}, '1900/01/01')) * 4) - 1) -
((EXTRACT(QUARTER FROM ${datetime_expr2}::TIMESTAMP) +
DATE_PART('year', AGE(${datetime_expr2}, '1900/01/01')) * 4) - 1)`;
break;
case 'year':
sql = `DATE_PART('year', ${datetime_expr1}::TIMESTAMP) - DATE_PART('year', ${datetime_expr2}::TIMESTAMP)`;
break;
case 'day':
sql = `DATE_PART('day', ${datetime_expr1}::TIMESTAMP - ${datetime_expr2}::TIMESTAMP)`;
break;
default:
sql = '';
}
return knex.raw(`${sql} ${colAlias}`);
},
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// isodow: the day of the week as Monday (1) to Sunday (7)
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
@ -63,6 +114,30 @@ const pg = {
)} % 7 + 7) ::INTEGER % 7 ${colAlias}`
);
},
AND: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'AND').toQuery())
.join(' AND ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
);
},
OR: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'OR').toQuery())
.join(' OR ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
);
},
};
export default pg;

87
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/sqlite.ts

@ -1,7 +1,12 @@
import dayjs from 'dayjs';
import { MapFnArgs } from '../mapFunctionName';
import commonFns from './commonFns';
import { convertUnits } from '../helpers/convertUnits';
import { getWeekdayByText } from '../helpers/formulaFnHelper';
import {
convertToTargetFormat,
getDateFormat,
} from '../../../../../utils/dateTimeUtils';
const sqlite3 = {
...commonFns,
@ -77,7 +82,63 @@ const sqlite3 = {
END${colAlias}`
);
},
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
let datetime_expr1 = fn(pt.arguments[0]).bindings[0];
let datetime_expr2 = fn(pt.arguments[1]).bindings[0];
// JULIANDAY takes YYYY-MM-DD
datetime_expr1 = convertToTargetFormat(
datetime_expr1,
getDateFormat(datetime_expr1),
'YYYY-MM-DD'
);
datetime_expr2 = convertToTargetFormat(
datetime_expr2,
getDateFormat(datetime_expr2),
'YYYY-MM-DD'
);
const rawUnit = pt.arguments[2]
? fn(pt.arguments[2]).bindings[0]
: 'seconds';
let sql;
const unit = convertUnits(rawUnit, 'sqlite');
switch (unit) {
case 'seconds':
sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) * 86400)`;
break;
case 'minutes':
sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) * 1440)`;
break;
case 'hours':
sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) * 24)`;
break;
case 'milliseconds':
sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) * 86400000)`;
break;
case 'weeks':
sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) / 7)`;
break;
case 'months':
sql = `(ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) / 365))
* 12 + (ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) / 365 / 12))`;
break;
case 'quarters':
sql = `
ROUND((JULIANDAY('${datetime_expr1}')) / 365 / 4) -
ROUND((JULIANDAY('${datetime_expr2}')) / 365 / 4) +
(ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) / 365)) * 4
`;
break;
case 'years':
sql = `ROUND((JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')) / 365)`;
break;
case 'days':
sql = `JULIANDAY('${datetime_expr1}') - JULIANDAY('${datetime_expr2}')`;
break;
default:
sql = '';
}
return knex.raw(`${sql} ${colAlias}`);
},
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// strftime('%w', date) - day of week 0 - 6 with Sunday == 0
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
@ -91,6 +152,30 @@ const sqlite3 = {
)} % 7 + 7) % 7 ${colAlias}`
);
},
AND: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'AND').toQuery())
.join(' AND ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
);
},
OR: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'OR').toQuery())
.join(' OR ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
);
},
};
export default sqlite3;

137
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/helpers/convertUnits.ts

@ -0,0 +1,137 @@
export function convertUnits(
unit: string,
type: 'mysql' | 'mssql' | 'pg' | 'sqlite'
) {
switch (unit) {
case 'milliseconds':
case 'ms': {
switch (type) {
case 'mssql':
return 'millisecond';
case 'mysql':
// MySQL doesn't support millisecond
// hence change from MICROSECOND to millisecond manually
return 'MICROSECOND';
case 'pg':
case 'sqlite':
return 'milliseconds';
default:
return unit;
}
}
case 'seconds':
case 's': {
switch (type) {
case 'mssql':
case 'pg':
return 'second';
case 'mysql':
return 'SECOND';
case 'sqlite':
return 'seconds';
default:
return unit;
}
}
case 'minutes':
case 'm': {
switch (type) {
case 'mssql':
case 'pg':
return 'minute';
case 'mysql':
return 'MINUTE';
case 'sqlite':
return 'minutes';
default:
return unit;
}
}
case 'hours':
case 'h': {
switch (type) {
case 'mssql':
case 'pg':
return 'hour';
case 'mysql':
return 'HOUR';
case 'sqlite':
return 'hours';
default:
return unit;
}
}
case 'days':
case 'd': {
switch (type) {
case 'mssql':
case 'pg':
return 'day';
case 'mysql':
return 'DAY';
case 'sqlite':
return 'days';
default:
return unit;
}
}
case 'weeks':
case 'w': {
switch (type) {
case 'mssql':
case 'pg':
return 'week';
case 'mysql':
return 'WEEK';
case 'sqlite':
return 'weeks';
default:
return unit;
}
}
case 'months':
case 'M': {
switch (type) {
case 'mssql':
case 'pg':
return 'month';
case 'mysql':
return 'MONTH';
case 'sqlite':
return 'months';
default:
return unit;
}
}
case 'quarters':
case 'Q': {
switch (type) {
case 'mssql':
case 'pg':
return 'quarter';
case 'mysql':
return 'QUARTER';
case 'sqlite':
return 'quarters';
default:
return unit;
}
}
case 'years':
case 'y': {
switch (type) {
case 'mssql':
case 'pg':
return 'year';
case 'mysql':
return 'YEAR';
case 'sqlite':
return 'years';
default:
return unit;
}
}
default:
return unit;
}
}

3
packages/nocodb/src/lib/db/sql-mgr/code/models/xc/ModelXcMetaFactory.ts

@ -4,6 +4,7 @@ import ModelXcMetaMysql from './ModelXcMetaMysql';
import ModelXcMetaOracle from './ModelXcMetaOracle';
import ModelXcMetaPg from './ModelXcMetaPg';
import ModelXcMetaSqlite from './ModelXcMetaSqlite';
import ModelXcMetaSnowflake from './ModelXcMetaSnowflake';
class ModelXcMetaFactory {
public static create(connectionConfig, args): BaseModelXcMeta {
@ -20,6 +21,8 @@ class ModelXcMetaFactory {
return new ModelXcMetaPg(args);
} else if (connectionConfig.client === 'oracledb') {
return new ModelXcMetaOracle(args);
} else if (connectionConfig.client === 'snowflake') {
return new ModelXcMetaSnowflake(args);
}
throw new Error('Database not supported');

975
packages/nocodb/src/lib/db/sql-mgr/code/models/xc/ModelXcMetaSnowflake.ts

@ -0,0 +1,975 @@
import BaseModelXcMeta from './BaseModelXcMeta';
class ModelXcMetaSnowflake extends BaseModelXcMeta {
/**
* @param dir
* @param filename
* @param ctx
* @param ctx.tn
* @param ctx.columns
* @param ctx.relations
*/
constructor({ dir, filename, ctx }) {
super({ dir, filename, ctx });
}
/**
* Prepare variables used in code template
*/
prepare() {
const data: any = {};
/* run of simple variable */
data.tn = this.ctx.tn;
data.dbType = this.ctx.dbType;
/* for complex code provide a func and args - do derivation within the func cbk */
data.columns = {
func: this._renderXcColumns.bind(this),
args: {
tn: this.ctx.tn,
columns: this.ctx.columns,
relations: this.ctx.relations,
},
};
/* for complex code provide a func and args - do derivation within the func cbk */
data.hasMany = {
func: this._renderXcHasMany.bind(this),
args: {
tn: this.ctx.tn,
columns: this.ctx.columns,
hasMany: this.ctx.hasMany,
},
};
/* for complex code provide a func and args - do derivation within the func cbk */
data.belongsTo = {
func: this._renderXcBelongsTo.bind(this),
args: {
tn: this.ctx.tn,
columns: this.ctx.columns,
belongsTo: this.ctx.belongsTo,
},
};
return data;
}
_renderXcHasMany(args) {
return JSON.stringify(args.hasMany);
}
_renderXcBelongsTo(args) {
return JSON.stringify(args.belongsTo);
}
/**
*
* @param args
* @param args.columns
* @param args.relations
* @returns {string}
* @private
*/
_renderXcColumns(args) {
let str = '[\r\n';
for (let i = 0; i < args.columns.length; ++i) {
str += `{\r\n`;
str += `cn: '${args.columns[i].cn}',\r\n`;
str += `type: '${this._getAbstractType(args.columns[i])}',\r\n`;
str += `dt: '${args.columns[i].dt}',\r\n`;
if (args.columns[i].rqd) str += `rqd: ${args.columns[i].rqd},\r\n`;
if (args.columns[i].cdf) {
str += `default: "${args.columns[i].cdf}",\r\n`;
str += `columnDefault: "${args.columns[i].cdf}",\r\n`;
}
if (args.columns[i].un) str += `un: ${args.columns[i].un},\r\n`;
if (args.columns[i].pk) str += `pk: ${args.columns[i].pk},\r\n`;
if (args.columns[i].ai) str += `ai: ${args.columns[i].ai},\r\n`;
if (args.columns[i].dtxp) str += `dtxp: "${args.columns[i].dtxp}",\r\n`;
if (args.columns[i].dtxs) str += `dtxs: ${args.columns[i].dtxs},\r\n`;
str += `validate: {
func: [],
args: [],
msg: []
},`;
str += `},\r\n`;
}
str += ']\r\n';
return str;
}
_getAbstractType(column) {
let str = '';
switch (column.dt) {
case 'int':
str = 'integer';
break;
case 'integer':
str = 'integer';
break;
case 'bigint':
str = 'bigInteger';
break;
case 'bigserial':
str = 'bigserial';
break;
case 'char':
str = 'string';
break;
case 'int2':
str = 'integer';
break;
case 'int4':
str = 'integer';
break;
case 'int8':
str = 'integer';
break;
case 'int4range':
str = 'int4range';
break;
case 'int8range':
str = 'int8range';
break;
case 'serial':
str = 'serial';
break;
case 'serial2':
str = 'serial2';
break;
case 'serial8':
str = 'serial8';
break;
case 'character':
str = 'string';
break;
case 'bit':
str = 'bit';
break;
case 'bool':
str = 'boolean';
break;
case 'boolean':
str = 'boolean';
break;
case 'date':
str = 'date';
break;
case 'double precision':
str = 'double';
break;
case 'event_trigger':
str = 'event_trigger';
break;
case 'fdw_handler':
str = 'fdw_handler';
break;
case 'float4':
str = 'float';
break;
case 'float8':
str = 'float';
break;
case 'uuid':
str = 'uuid';
break;
case 'smallint':
str = 'integer';
break;
case 'smallserial':
str = 'smallserial';
break;
case 'character varying':
str = 'string';
break;
case 'text':
str = 'text';
break;
case 'real':
str = 'float';
break;
case 'time':
str = 'time';
break;
case 'time without time zone':
str = 'time';
break;
case 'timestamp':
str = 'timestamp';
break;
case 'timestamp without time zone':
str = 'timestamp';
break;
case 'timestamptz':
str = 'timestampt';
break;
case 'timestamp with time zone':
str = 'timestamp';
break;
case 'timetz':
str = 'time';
break;
case 'time with time zone':
str = 'time';
break;
case 'daterange':
str = 'daterange';
break;
case 'json':
str = 'json';
break;
case 'jsonb':
str = 'jsonb';
break;
case 'gtsvector':
str = 'gtsvector';
break;
case 'index_am_handler':
str = 'index_am_handler';
break;
case 'anyenum':
str = 'enum';
break;
case 'anynonarray':
str = 'anynonarray';
break;
case 'anyrange':
str = 'anyrange';
break;
case 'box':
str = 'box';
break;
case 'bpchar':
str = 'bpchar';
break;
case 'bytea':
str = 'bytea';
break;
case 'cid':
str = 'cid';
break;
case 'cidr':
str = 'cidr';
break;
case 'circle':
str = 'circle';
break;
case 'cstring':
str = 'cstring';
break;
case 'inet':
str = 'inet';
break;
case 'internal':
str = 'internal';
break;
case 'interval':
str = 'interval';
break;
case 'language_handler':
str = 'language_handler';
break;
case 'line':
str = 'line';
break;
case 'lsec':
str = 'lsec';
break;
case 'macaddr':
str = 'macaddr';
break;
case 'money':
str = 'float';
break;
case 'name':
str = 'name';
break;
case 'numeric':
str = 'numeric';
break;
case 'numrange':
str = 'numrange';
break;
case 'oid':
str = 'oid';
break;
case 'opaque':
str = 'opaque';
break;
case 'path':
str = 'path';
break;
case 'pg_ddl_command':
str = 'pg_ddl_command';
break;
case 'pg_lsn':
str = 'pg_lsn';
break;
case 'pg_node_tree':
str = 'pg_node_tree';
break;
case 'point':
str = 'point';
break;
case 'polygon':
str = 'polygon';
break;
case 'record':
str = 'record';
break;
case 'refcursor':
str = 'refcursor';
break;
case 'regclass':
str = 'regclass';
break;
case 'regconfig':
str = 'regconfig';
break;
case 'regdictionary':
str = 'regdictionary';
break;
case 'regnamespace':
str = 'regnamespace';
break;
case 'regoper':
str = 'regoper';
break;
case 'regoperator':
str = 'regoperator';
break;
case 'regproc':
str = 'regproc';
break;
case 'regpreocedure':
str = 'regpreocedure';
break;
case 'regrole':
str = 'regrole';
break;
case 'regtype':
str = 'regtype';
break;
case 'reltime':
str = 'reltime';
break;
case 'smgr':
str = 'smgr';
break;
case 'tid':
str = 'tid';
break;
case 'tinterval':
str = 'tinterval';
break;
case 'trigger':
str = 'trigger';
break;
case 'tsm_handler':
str = 'tsm_handler';
break;
case 'tsquery':
str = 'tsquery';
break;
case 'tsrange':
str = 'tsrange';
break;
case 'tstzrange':
str = 'tstzrange';
break;
case 'tsvector':
str = 'tsvector';
break;
case 'txid_snapshot':
str = 'txid_snapshot';
break;
case 'unknown':
str = 'unknown';
break;
case 'void':
str = 'void';
break;
case 'xid':
str = 'xid';
break;
case 'xml':
str = 'xml';
break;
default:
str += `"${column.dt}"`;
break;
}
return str;
}
getUIDataType(col): any {
switch (this.getAbstractType(col)) {
case 'integer':
return 'Number';
case 'boolean':
return 'Checkbox';
case 'float':
return 'Decimal';
case 'date':
return 'Date';
case 'datetime':
return 'DateTime';
case 'time':
return 'Time';
case 'year':
return 'Year';
case 'string':
return 'SingleLineText';
case 'text':
return 'LongText';
case 'enum':
return 'SingleSelect';
case 'set':
return 'MultiSelect';
case 'json':
return 'JSON';
case 'blob':
return 'LongText';
case 'geometry':
return 'Geometry';
default:
return 'SpecificDBType';
}
}
getAbstractType(col): any {
const dt = col.dt.toLowerCase();
switch (dt) {
case 'anyenum':
return 'enum';
case 'anynonarray':
case 'anyrange':
return dt;
case 'bit':
return 'integer';
case 'bigint':
case 'bigserial':
return 'integer';
case 'bool':
return 'boolean';
case 'bpchar':
case 'bytea':
return dt;
case 'char':
case 'character':
case 'character varying':
return 'string';
case 'cid':
case 'cidr':
case 'cstring':
return dt;
case 'date':
return 'date';
case 'daterange':
return 'string';
case 'double precision':
return 'string';
case 'event_trigger':
case 'fdw_handler':
return dt;
case 'float4':
case 'float8':
return 'float';
case 'gtsvector':
case 'index_am_handler':
case 'inet':
return dt;
case 'int':
case 'int2':
case 'int4':
case 'int8':
case 'integer':
return 'integer';
case 'int4range':
case 'int8range':
case 'internal':
case 'interval':
return 'string';
case 'json':
case 'jsonb':
return 'json';
case 'language_handler':
case 'lsec':
case 'macaddr':
case 'money':
case 'name':
case 'numeric':
case 'numrange':
case 'oid':
case 'opaque':
case 'path':
case 'pg_ddl_command':
case 'pg_lsn':
case 'pg_node_tree':
return dt;
case 'real':
return 'float';
case 'record':
case 'refcursor':
case 'regclass':
case 'regconfig':
case 'regdictionary':
case 'regnamespace':
case 'regoper':
case 'regoperator':
case 'regproc':
case 'regpreocedure':
case 'regrole':
case 'regtype':
case 'reltime':
return dt;
case 'serial':
case 'serial2':
case 'serial8':
case 'smallint':
case 'smallserial':
return 'integer';
case 'smgr':
return dt;
case 'text':
return 'text';
case 'tid':
return dt;
case 'time':
case 'time without time zone':
return 'time';
case 'timestamp':
case 'timestamp without time zone':
case 'timestamptz':
case 'timestamp with time zone':
return 'datetime';
case 'timetz':
case 'time with time zone':
return 'time';
case 'tinterval':
case 'trigger':
case 'tsm_handler':
case 'tsquery':
case 'tsrange':
case 'tstzrange':
case 'tsvector':
case 'txid_snapshot':
case 'unknown':
case 'void':
case 'xid':
case 'xml':
return dt;
case 'tinyint':
case 'mediumint':
return 'integer';
case 'float':
case 'decimal':
case 'double':
return 'float';
case 'boolean':
return 'boolean';
case 'datetime':
return 'datetime';
case 'uuid':
case 'year':
case 'varchar':
case 'nchar':
return 'string';
case 'tinytext':
case 'mediumtext':
case 'longtext':
return 'text';
case 'binary':
case 'varbinary':
return 'text';
case 'blob':
case 'tinyblob':
case 'mediumblob':
case 'longblob':
return 'blob';
case 'enum':
return 'enum';
case 'set':
return 'set';
case 'line':
case 'point':
case 'polygon':
case 'circle':
case 'box':
case 'geometry':
case 'linestring':
case 'multipoint':
case 'multilinestring':
case 'multipolygon':
return 'geometry';
}
}
_sequelizeGetType(column) {
let str = '';
switch (column.dt) {
case 'int':
str += `DataTypes.INTEGER(${column.dtxp})`;
if (column.un) str += `.UNSIGNED`;
break;
case 'tinyint':
str += `DataTypes.INTEGER(${column.dtxp})`;
if (column.un) str += `.UNSIGNED`;
break;
case 'smallint':
str += `DataTypes.INTEGER(${column.dtxp})`;
if (column.un) str += `.UNSIGNED`;
break;
case 'mediumint':
str += `DataTypes.INTEGER(${column.dtxp})`;
if (column.un) str += `.UNSIGNED`;
break;
case 'bigint':
str += `DataTypes.BIGINT`;
if (column.un) str += `.UNSIGNED`;
break;
case 'float':
str += `DataTypes.FLOAT`;
break;
case 'decimal':
str += `DataTypes.DECIMAL`;
break;
case 'double':
str += `"DOUBLE(${column.dtxp},${column.ns})"`;
break;
case 'real':
str += `DataTypes.FLOAT`;
break;
case 'bit':
str += `DataTypes.BOOLEAN`;
break;
case 'boolean':
str += `DataTypes.STRING(45)`;
break;
case 'serial':
str += `DataTypes.BIGINT`;
break;
case 'date':
str += `DataTypes.DATEONLY`;
break;
case 'datetime':
str += `DataTypes.DATE`;
break;
case 'timestamp':
str += `DataTypes.DATE`;
break;
case 'time':
str += `DataTypes.TIME`;
break;
case 'year':
str += `"YEAR"`;
break;
case 'char':
str += `DataTypes.CHAR(${column.dtxp})`;
break;
case 'varchar':
str += `DataTypes.STRING(${column.dtxp})`;
break;
case 'nchar':
str += `DataTypes.CHAR(${column.dtxp})`;
break;
case 'text':
str += `DataTypes.TEXT`;
break;
case 'tinytext':
str += `DataTypes.TEXT`;
break;
case 'mediumtext':
str += `DataTypes.TEXT`;
break;
case 'longtext':
str += `DataTypes.TEXT`;
break;
case 'binary':
str += `"BINARY(${column.dtxp})"`;
break;
case 'varbinary':
str += `"VARBINARY(${column.dtxp})"`;
break;
case 'blob':
str += `"BLOB"`;
break;
case 'tinyblob':
str += `"TINYBLOB"`;
break;
case 'mediumblob':
str += `"MEDIUMBLOB"`;
break;
case 'longblob':
str += `"LONGBLOB"`;
break;
case 'enum':
str += `DataTypes.ENUM(${column.dtxp})`;
break;
case 'set':
str += `"SET(${column.dtxp})"`;
break;
case 'geometry':
str += `DataTypes.GEOMETRY`;
break;
case 'point':
str += `"POINT"`;
break;
case 'linestring':
str += `"LINESTRING"`;
break;
case 'polygon':
str += `"POLYGON"`;
break;
case 'multipoint':
str += `"MULTIPOINT"`;
break;
case 'multilinestring':
str += `"MULTILINESTRING"`;
break;
case 'multipolygon':
str += `"MULTIPOLYGON"`;
break;
case 'json':
str += `DataTypes.JSON`;
break;
default:
str += `"${column.dt}"`;
break;
}
return str;
}
_sequelizeGetDefault(column) {
let str = '';
switch (column.dt) {
case 'int':
str += `'${column.cdf}'`;
break;
case 'tinyint':
str += `'${column.cdf}'`;
break;
case 'smallint':
str += `'${column.cdf}'`;
break;
case 'mediumint':
str += `'${column.cdf}'`;
break;
case 'bigint':
str += `'${column.cdf}'`;
break;
case 'float':
str += `'${column.cdf}'`;
break;
case 'decimal':
str += `'${column.cdf}'`;
break;
case 'double':
str += `'${column.cdf}'`;
break;
case 'real':
str += `'${column.cdf}'`;
break;
case 'bit':
str += column.cdf ? column.cdf.split('b')[1] : column.cdf;
break;
case 'boolean':
str += column.cdf;
break;
case 'serial':
str += column.cdf;
break;
case 'date':
str += `sequelize.literal('${column.cdf_sequelize}')`;
break;
case 'datetime':
str += `sequelize.literal('${column.cdf_sequelize}')`;
break;
case 'timestamp':
str += `sequelize.literal('${column.cdf_sequelize}')`;
break;
case 'time':
str += `'${column.cdf}'`;
break;
case 'year':
str += `'${column.cdf}'`;
break;
case 'char':
str += `'${column.cdf}'`;
break;
case 'varchar':
str += `'${column.cdf}'`;
break;
case 'nchar':
str += `'${column.cdf}'`;
break;
case 'text':
str += column.cdf;
break;
case 'tinytext':
str += column.cdf;
break;
case 'mediumtext':
str += column.cdf;
break;
case 'longtext':
str += column.cdf;
break;
case 'binary':
str += column.cdf;
break;
case 'varbinary':
str += column.cdf;
break;
case 'blob':
str += column.cdf;
break;
case 'tinyblob':
str += column.cdf;
break;
case 'mediumblob':
str += column.cdf;
break;
case 'longblob':
str += column.cdf;
break;
case 'enum':
str += `'${column.cdf}'`;
break;
case 'set':
str += `'${column.cdf}'`;
break;
case 'geometry':
str += `'${column.cdf}'`;
break;
case 'point':
str += `'${column.cdf}'`;
break;
case 'linestring':
str += `'${column.cdf}'`;
break;
case 'polygon':
str += `'${column.cdf}'`;
break;
case 'multipoint':
str += `'${column.cdf}'`;
break;
case 'multilinestring':
str += `'${column.cdf}'`;
break;
case 'multipolygon':
str += `'${column.cdf}'`;
break;
case 'json':
str += column.cdf;
break;
}
return str;
}
/* getXcColumnsObject(args) {
const columnsArr = [];
for (const column of args.columns) {
const columnObj = {
validate: {
func: [],
args: [],
msg: []
},
cn: column.cn,
_cn: column._cn || column.cn,
type: this._getAbstractType(column),
dt: column.dt,
uidt: column.uidt || this._getUIDataType(column),
uip: column.uip,
uicn: column.uicn,
...column
};
if (column.rqd) {
columnObj.rqd = column.rqd;
}
if (column.cdf) {
columnObj.default = column.cdf;
columnObj.columnDefault = column.cdf;
}
if (column.un) {
columnObj.un = column.un;
}
if (column.pk) {
columnObj.pk = column.pk;
}
if (column.ai) {
columnObj.ai = column.ai;
}
if (column.dtxp) {
columnObj.dtxp = column.dtxp;
}
if (column.dtxs) {
columnObj.dtxs = column.dtxs;
}
columnsArr.push(columnObj);
}
this.mapDefaultPrimaryValue(columnsArr);
return columnsArr;
}*/
/* getObject() {
return {
tn: this.ctx.tn,
_tn: this.ctx._tn,
columns: this.getXcColumnsObject(this.ctx),
pks: [],
hasMany: this.ctx.hasMany,
belongsTo: this.ctx.belongsTo,
dbType: this.ctx.dbType,
type: this.ctx.type,
}
}*/
}
export default ModelXcMetaSnowflake;

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

@ -14,7 +14,7 @@ export async function apiTokenCreate(req: Request, res: Response) {
res.json(await ApiToken.insert({ ...req.body, fk_user_id: req['user'].id }));
}
export async function apiTokenDelete(req: Request, res: Response) {
const apiToken = await ApiToken.getByToken(req.params.apiTokenId);
const apiToken = await ApiToken.getByToken(req.params.token);
if (
!req['user'].roles.includes(OrgUserRoles.SUPER_ADMIN) &&
apiToken.fk_user_id !== req['user'].id

57
packages/nocodb/src/lib/meta/api/metaDiffApis.ts

@ -44,6 +44,7 @@ type MetaDiff = {
table_name: string;
base_id: string;
type: ModelTypes;
meta?: any;
detectedChanges: Array<MetaDiffChange>;
};
@ -176,6 +177,7 @@ async function getMetaDiff(
const tableProp: MetaDiff = {
title: oldMeta.title,
meta: oldMeta.meta,
table_name: table.tn,
base_id: base.id,
type: ModelTypes.TABLE,
@ -248,6 +250,7 @@ async function getMetaDiff(
for (const model of oldTableMetas) {
changes.push({
table_name: model.table_name,
meta: model.meta,
base_id: base.id,
type: ModelTypes.TABLE,
detectedChanges: [
@ -452,6 +455,7 @@ async function getMetaDiff(
const tableProp: MetaDiff = {
title: oldMeta.title,
meta: oldMeta.meta,
table_name: view.tn,
base_id: base.id,
type: ModelTypes.VIEW,
@ -520,6 +524,7 @@ async function getMetaDiff(
for (const model of oldViewMetas) {
changes.push({
table_name: model.table_name,
meta: model.meta,
base_id: base.id,
type: ModelTypes.TABLE,
detectedChanges: [
@ -539,7 +544,7 @@ async function getMetaDiff(
export async function metaDiff(req, res) {
const project = await Project.getWithInfo(req.params.projectId);
let changes = []
let changes = [];
for (const base of project.bases) {
try {
// @ts-ignore
@ -556,7 +561,7 @@ export async function metaDiff(req, res) {
export async function baseMetaDiff(req, res) {
const project = await Project.getWithInfo(req.params.projectId);
const base = await Base.get(req.params.baseId);
let changes = []
let changes = [];
const sqlClient = NcConnectionMgrv2.getSqlClient(base);
changes = await getMetaDiff(sqlClient, project, base);
@ -572,10 +577,10 @@ export async function metaDiffSync(req, res) {
// @ts-ignore
const sqlClient = NcConnectionMgrv2.getSqlClient(base);
const changes = await getMetaDiff(sqlClient, project, base);
/* Get all relations */
// const relations = (await sqlClient.relationListAll())?.data?.list;
for (const { table_name, detectedChanges } of changes) {
// reorder changes to apply relation remove changes
// before column remove to avoid foreign key constraint error
@ -585,7 +590,7 @@ export async function metaDiffSync(req, res) {
applyChangesPriorityOrder.indexOf(a.type)
);
});
for (const change of detectedChanges) {
switch (change.type) {
case MetaDiffType.TABLE_NEW:
@ -593,15 +598,19 @@ export async function metaDiffSync(req, res) {
const columns = (
await sqlClient.columnList({ tn: table_name })
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
mapDefaultPrimaryValue(columns);
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(table_name, base.is_meta ? project.prefix : '', base),
title: getTableNameAlias(
table_name,
base.is_meta ? project.prefix : '',
base
),
type: ModelTypes.TABLE,
});
for (const column of columns) {
await Column.insert({
uidt: getColumnUiType(base, column),
@ -617,15 +626,15 @@ export async function metaDiffSync(req, res) {
const columns = (
await sqlClient.columnList({ tn: table_name })
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
mapDefaultPrimaryValue(columns);
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(table_name, project.prefix, base),
type: ModelTypes.VIEW,
});
for (const column of columns) {
await Column.insert({
uidt: getColumnUiType(base, column),
@ -657,7 +666,7 @@ export async function metaDiffSync(req, res) {
// update old
// populateParams.tableNames.push({ tn });
// populateParams.oldMetas[tn] = oldMetas.find(m => m.tn === tn);
break;
case MetaDiffType.TABLE_COLUMN_TYPE_CHANGE:
case MetaDiffType.VIEW_COLUMN_TYPE_CHANGE:
@ -698,17 +707,21 @@ export async function metaDiffSync(req, res) {
});
const parentCol = await parentModel
.getColumns()
.then((cols) => cols.find((c) => c.column_name === change.rcn));
.then((cols) =>
cols.find((c) => c.column_name === change.rcn)
);
const childCol = await childModel
.getColumns()
.then((cols) => cols.find((c) => c.column_name === change.cn));
.then((cols) =>
cols.find((c) => c.column_name === change.cn)
);
await Column.update(childCol.id, {
...childCol,
uidt: UITypes.ForeignKey,
system: true,
});
if (change.relationType === RelationTypes.BELONGS_TO) {
const title = getUniqueColumnAliasName(
childModel.columns,
@ -746,9 +759,9 @@ export async function metaDiffSync(req, res) {
}
}
}
await NcHelp.executeOperations(virtualColumnInsert, base.type);
// populate m2m relations
await extractAndGenerateManyToManyRelations(await base.getModels());
}
@ -784,7 +797,11 @@ export async function baseMetaDiffSync(req, res) {
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(table_name, base.is_meta ? project.prefix : '', base),
title: getTableNameAlias(
table_name,
base.is_meta ? project.prefix : '',
base
),
type: ModelTypes.TABLE,
});

3
packages/nocodb/src/lib/meta/api/modelVisibilityApis.ts

@ -48,7 +48,7 @@ export async function xcVisibilityMetaGet(
const roles = ['owner', 'creator', 'viewer', 'editor', 'commenter', 'guest'];
const defaultDisabled = roles.reduce((o, r) => ({ ...o, [r]: false }), {});
let models =
_models ||
(await Model.list({
@ -78,6 +78,7 @@ export async function xcVisibilityMetaGet(
ptype: model.type,
tn: view.title,
_tn: view.title,
table_meta: model.meta,
...view,
disabled: { ...defaultDisabled },
};

16
packages/nocodb/src/lib/meta/api/tableApis.ts

@ -228,9 +228,23 @@ export async function tableCreate(req: Request<any, any, TableReqType>, res) {
export async function tableUpdate(req: Request<any, any>, res) {
const model = await Model.get(req.params.tableId);
const project = await Project.getWithInfo(req.body.project_id);
const project = await Project.getWithInfo(
req.body.project_id || (req as any).ncProjectId
);
const base = project.bases.find((b) => b.id === model.base_id);
if (model.project_id !== project.id) {
NcError.badRequest('Model does not belong to project');
}
// if meta present update meta and return
// todo: allow user to update meta and other prop in single api call
if ('meta' in req.body) {
await Model.updateMeta(req.params.tableId, req.body.meta);
return res.json({ msg: 'success' });
}
if (!req.body.table_name) {
NcError.badRequest(
'Missing table name `table_name` property in request body'

71
packages/nocodb/src/lib/models/Model.ts

@ -1,4 +1,5 @@
import Noco from '../Noco';
import { parseMetaProp } from '../utils/modelUtils';
import Column from './Column';
import NocoCache from '../cache/NocoCache';
import { XKnex } from '../db/sql-data-mapper';
@ -51,6 +52,7 @@ export default class Model implements TableType {
columns?: Column[];
columnsById?: { [id: string]: Column };
views?: View[];
meta?: Record<string, any> | string;
constructor(data: Partial<TableType | Model>) {
Object.assign(this, data);
@ -175,8 +177,17 @@ export default class Model implements TableType {
}
);
// parse meta of each model
for (const model of modelList) {
model.meta = parseMetaProp(model);
}
if (base_id) {
await NocoCache.setList(CacheScope.MODEL, [project_id, base_id], modelList);
await NocoCache.setList(
CacheScope.MODEL,
[project_id, base_id],
modelList
);
} else {
await NocoCache.setList(CacheScope.MODEL, [project_id], modelList);
}
@ -210,6 +221,11 @@ export default class Model implements TableType {
MetaTable.MODELS
);
// parse meta of each model
for (const model of modelList) {
model.meta = parseMetaProp(model);
}
await NocoCache.setList(CacheScope.MODEL, [project_id], modelList);
}
@ -230,8 +246,11 @@ export default class Model implements TableType {
));
if (!modelData) {
modelData = await ncMeta.metaGet2(null, null, MetaTable.MODELS, id);
if (modelData)
if (modelData) {
modelData.meta = parseMetaProp(modelData);
await NocoCache.set(`${CacheScope.MODEL}:${modelData.id}`, modelData);
}
}
return modelData && new Model(modelData);
}
@ -257,24 +276,7 @@ export default class Model implements TableType {
));
if (!modelData) {
modelData = await ncMeta.metaGet2(null, null, MetaTable.MODELS, k);
// if (
// this.baseModels?.[modelData.base_id]?.[modelData.db_alias]?.[
// modelData.title
// ]
// ) {
// delete this.baseModels[modelData.base_id][modelData.db_alias][
// modelData.title
// ];
// }
// if (
// this.baseModels?.[modelData.base_id]?.[modelData.db_alias]?.[
// modelData.id
// ]
// ) {
// delete this.baseModels[modelData.base_id][modelData.db_alias][
// modelData.id
// ];
// }
modelData.meta = parseMetaProp(modelData);
}
if (modelData) {
await NocoCache.set(`${CacheScope.MODEL}:${modelData.id}`, modelData);
@ -308,6 +310,7 @@ export default class Model implements TableType {
table_name,
}
);
modelData.meta = parseMetaProp(modelData);
await NocoCache.set(`${CacheScope.MODEL}:${modelData.id}`, modelData);
// modelData.filters = await Filter.getFilterObject({
// viewId: modelData.id
@ -722,4 +725,32 @@ export default class Model implements TableType {
{}
);
}
// For updating table meta
static async updateMeta(
tableId: string,
meta: string | Record<string, any>,
ncMeta = Noco.ncMeta
) {
// get existing cache
const key = `${CacheScope.MODEL}:${tableId}`;
const existingCache = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
if (existingCache) {
try {
existingCache.meta = typeof meta === 'string' ? JSON.parse(meta) : meta;
// set cache
await NocoCache.set(key, existingCache);
} catch {}
}
// set meta
return await ncMeta.metaUpdate(
null,
null,
MetaTable.MODELS,
{
meta: typeof meta === 'object' ? JSON.stringify(meta) : meta,
},
tableId
);
}
}

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

@ -5,6 +5,7 @@ import {
CacheScope,
MetaTable,
} from '../utils/globals';
import { parseMetaProp, stringifyMetaProp } from '../utils/modelUtils';
import Model from './Model';
import FormView from './FormView';
import GridView from './GridView';
@ -118,6 +119,7 @@ export default class View implements ViewType {
));
if (!view) {
view = await ncMeta.metaGet2(null, null, MetaTable.VIEWS, viewId);
view.meta = parseMetaProp(view);
await NocoCache.set(`${CacheScope.VIEW}:${view.id}`, view);
}
@ -156,6 +158,7 @@ export default class View implements ViewType {
],
}
);
view.meta = parseMetaProp(view);
// todo: cache - titleOrId can be viewId so we need a different scope here
await NocoCache.set(
`${CacheScope.VIEW}:${fk_model_id}:${titleOrId}`,
@ -188,6 +191,7 @@ export default class View implements ViewType {
},
null
);
view.meta = parseMetaProp(view);
await NocoCache.set(`${CacheScope.VIEW}:${fk_model_id}:default`, view);
}
return view && new View(view);
@ -204,6 +208,9 @@ export default class View implements ViewType {
order: 'asc',
},
});
for (const view of viewsList) {
view.meta = parseMetaProp(view);
}
await NocoCache.setList(CacheScope.VIEW, [modelId], viewsList);
}
viewsList.sort(
@ -254,8 +261,11 @@ export default class View implements ViewType {
base_id: view.base_id,
created_at: view.created_at,
updated_at: view.updated_at,
meta: view.meta ?? {},
};
insertObj.meta = stringifyMetaProp(insertObj);
// get project and base id if missing
if (!(view.project_id && view.base_id)) {
const model = await Model.getByIdOrName({ id: view.fk_model_id }, ncMeta);
@ -707,11 +717,16 @@ export default class View implements ViewType {
}
}
// todo: cache
static async getByUUID(uuid: string, ncMeta = Noco.ncMeta) {
const view = await ncMeta.metaGet2(null, null, MetaTable.VIEWS, {
uuid,
});
if (view) {
view.meta = parseMetaProp(view);
}
return view && new View(view);
}
@ -740,8 +755,9 @@ export default class View implements ViewType {
viewId
);
}
if (!view.meta) {
if (!view.meta || !('allowCSVDownload' in view.meta)) {
const defaultMeta = {
...(view.meta ?? {}),
allowCSVDownload: true,
};
// get existing cache
@ -749,7 +765,7 @@ export default class View implements ViewType {
const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
if (o) {
// update data
o.meta = JSON.stringify(defaultMeta);
o.meta = defaultMeta;
// set cache
await NocoCache.set(key, o);
}
@ -838,7 +854,7 @@ export default class View implements ViewType {
'meta',
'uuid',
]);
updateObj.meta = JSON.stringify(updateObj.meta);
// get existing cache
const key = `${CacheScope.VIEW}:${viewId}`;
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
@ -854,6 +870,12 @@ export default class View implements ViewType {
// set cache
await NocoCache.set(key, o);
}
// if meta data defined then stringify it
if ('meta' in updateObj) {
updateObj.meta = stringifyMetaProp(updateObj);
}
// set meta
await ncMeta.metaUpdate(null, null, MetaTable.VIEWS, updateObj, viewId);
return this.get(viewId);

75
packages/nocodb/src/lib/utils/dateTimeUtils.ts

@ -0,0 +1,75 @@
import dayjs from 'dayjs';
export const dateFormats = [
'DD-MM-YYYY',
'MM-DD-YYYY',
'YYYY-MM-DD',
'DD/MM/YYYY',
'MM/DD/YYYY',
'YYYY/MM/DD',
'DD MM YYYY',
'MM DD YYYY',
'YYYY MM DD',
];
export function validateDateFormat(v: string) {
return dateFormats.includes(v);
}
export function validateDateWithUnknownFormat(v: string) {
for (const format of dateFormats) {
if (dayjs(v, format, true).isValid() as any) {
return true;
}
for (const timeFormat of ['HH:mm', 'HH:mm:ss', 'HH:mm:ss.SSS']) {
if (dayjs(v, `${format} ${timeFormat}`, true).isValid() as any) {
return true;
}
}
}
return false;
}
export function getDateFormat(v: string) {
for (const format of dateFormats) {
if (dayjs(v, format, true).isValid()) {
return format;
}
}
return 'YYYY/MM/DD';
}
export function getDateTimeFormat(v: string) {
for (const format of dateFormats) {
for (const timeFormat of ['HH:mm', 'HH:mm:ss', 'HH:mm:ss.SSS']) {
const dateTimeFormat = `${format} ${timeFormat}`;
if (dayjs(v, dateTimeFormat, true).isValid() as any) {
return dateTimeFormat;
}
}
}
return 'YYYY/MM/DD';
}
export function parseStringDate(v: string, dateFormat: string) {
const dayjsObj = dayjs(v);
if (dayjsObj.isValid()) {
v = dayjsObj.format('YYYY-MM-DD');
} else {
v = dayjs(v, dateFormat).format('YYYY-MM-DD');
}
return v;
}
export function convertToTargetFormat(
v: string,
oldDataFormat,
newDateFormat: string
) {
if (
!dateFormats.includes(oldDataFormat) ||
!dateFormats.includes(newDateFormat)
)
return v;
return dayjs(v, oldDataFormat).format(newDateFormat);
}

23
packages/nocodb/src/lib/utils/modelUtils.ts

@ -0,0 +1,23 @@
export function parseMetaProp(model: { meta: any }): any {
if (!model) return;
// parse meta property
try {
return typeof model.meta === 'string' ? JSON.parse(model.meta) : model.meta;
} catch {
return {};
}
}
export function stringifyMetaProp(model: { meta?: any }): string | void {
if (!model) return;
// stringify meta property
try {
return typeof model.meta === 'string'
? model.meta
: JSON.stringify(model.meta);
} catch (e) {
return '{}';
}
}

10
scripts/sdk/swagger.json

@ -1971,6 +1971,8 @@
},
"project_id": {
"type": "string"
},
"meta": {
}
}
}
@ -2192,6 +2194,8 @@
"order": {
"type": "number"
},
"meta": {
},
"title": {
"type": "string"
},
@ -7484,6 +7488,8 @@
"boolean",
"number"
]
},
"meta": {
}
},
"required": [
@ -7582,6 +7588,8 @@
"uuid": {
"type": "string"
},
"meta": {
},
"show_system_fields": {
"type": "boolean"
},
@ -7823,6 +7831,8 @@
"items": {
"$ref": "#/components/schemas/Column"
}
},
"meta": {
}
},
"required": [

4
tests/playwright/pages/Dashboard/Grid/Column/LTAR/ChildList.ts

@ -20,7 +20,7 @@ export class ChildList extends BasePage {
// button: Link to 'City'
// icon: reload
await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Child list`);
await expect(await this.get().locator(`button:has-text("Link to '${linkField}'")`).isVisible()).toBeTruthy();
await expect(await this.get().locator(`text=/Link to '.*${linkField}'/i`).isVisible()).toBeTruthy();
await expect(await this.get().locator(`[data-testid="nc-child-list-reload"]`).isVisible()).toBeTruthy();
// child list body validation (card count, card title)
@ -50,7 +50,7 @@ export class ChildList extends BasePage {
}
async openLinkRecord({ linkTableTitle }: { linkTableTitle: string }) {
const openActions = this.get().locator(`button:has-text("Link to '${linkTableTitle}'")`).click();
const openActions = this.get().locator(`text=/Link to '.*${linkTableTitle}'/i`).click();
await this.waitForResponse({
requestUrlPathToMatch: '/exclude',
httpMethodsToMatch: ['GET'],

26
tests/playwright/pages/Dashboard/Grid/Column/index.ts

@ -34,7 +34,9 @@ export class ColumnPageObject extends BasePage {
childColumn = '',
relationType = '',
rollupType = '',
format,
format = '',
dateFormat = '',
timeFormat = '',
insertAfterColumnTitle,
insertBeforeColumnTitle,
}: {
@ -47,6 +49,8 @@ export class ColumnPageObject extends BasePage {
relationType?: string;
rollupType?: string;
format?: string;
dateFormat?: string;
timeFormat?: string;
insertBeforeColumnTitle?: string;
insertAfterColumnTitle?: string;
}) {
@ -90,6 +94,14 @@ export class ColumnPageObject extends BasePage {
.click();
}
break;
case 'DateTime':
// Date Format
await this.get().locator('.nc-date-select').click();
await this.rootPage.locator('.ant-select-item').locator(`text="${dateFormat}"`).click();
// Time Format
await this.get().locator('.nc-time-select').click();
await this.rootPage.locator('.ant-select-item').locator(`text="${timeFormat}"`).click();
break;
case 'Formula':
await this.get().locator('.nc-formula-input').fill(formula);
break;
@ -222,11 +234,15 @@ export class ColumnPageObject extends BasePage {
type = 'SingleLineText',
formula = '',
format,
dateFormat = '',
timeFormat = '',
}: {
title: string;
type?: string;
formula?: string;
format?: string;
dateFormat?: string;
timeFormat?: string;
}) {
await this.getColumnHeader(title).locator('.nc-ui-dt-dropdown').click();
await this.rootPage.locator('li[role="menuitem"]:has-text("Edit")').click();
@ -245,6 +261,14 @@ export class ColumnPageObject extends BasePage {
})
.click();
break;
case 'DateTime':
// Date Format
await this.get().locator('.nc-date-select').click();
await this.rootPage.locator('.ant-select-item').locator(`text="${dateFormat}"`).click();
// Time Format
await this.get().locator('.nc-time-select').click();
await this.rootPage.locator('.ant-select-item').locator(`text="${timeFormat}"`).click();
break;
default:
break;
}

32
tests/playwright/pages/Dashboard/Grid/index.ts

@ -1,7 +1,7 @@
import { expect, Locator } from '@playwright/test';
import { DashboardPage } from '..';
import BasePage from '../../Base';
import { CellPageObject } from '../common/Cell';
import { CellPageObject, CellProps } from '../common/Cell';
import { ColumnPageObject } from './Column';
import { ToolbarPage } from '../common/Toolbar';
import { ProjectMenuObject } from '../common/ProjectMenu';
@ -286,4 +286,34 @@ export class GridPage extends BasePage {
param.role === 'creator' || param.role === 'editor' ? 1 : 0
);
}
async selectRange({ start, end }: { start: CellProps; end: CellProps }) {
const startCell = await this.cell.get({ index: start.index, columnHeader: start.columnHeader });
const endCell = await this.cell.get({ index: end.index, columnHeader: end.columnHeader });
const page = await this.dashboard.get().page();
await startCell.hover();
await page.mouse.down();
await endCell.hover();
await page.mouse.up();
}
async selectedCount() {
return this.get().locator('.cell.active').count();
}
async copyWithKeyboard() {
await this.get().press((await this.isMacOs()) ? 'Meta+C' : 'Control+C');
await this.verifyToast({ message: 'Copied to clipboard' });
return this.getClipboardText();
}
async copyWithMouse({ index, columnHeader }: CellProps) {
await this.cell.get({ index, columnHeader }).click({ button: 'right' });
await this.get().page().getByTestId('context-menu-item-copy').click();
await this.verifyToast({ message: 'Copied to clipboard' });
return this.getClipboardText();
}
}

21
tests/playwright/pages/Dashboard/TreeView.ts

@ -129,6 +129,27 @@ export class TreeViewPage extends BasePage {
await importMenu.locator(`.ant-dropdown-menu-title-content:has-text("${title}")`).click();
}
async changeTableIcon({ title, icon }: { title: string; icon: string }) {
await this.get().locator(`.nc-project-tree-tbl-${title} .nc-table-icon`).click();
await this.rootPage.getByTestId('nc-emoji-filter').type(icon);
await this.rootPage.getByTestId('nc-emoji-container').locator(`.nc-emoji-item >> svg`).first().click();
await this.rootPage.getByTestId('nc-emoji-container').isHidden();
await expect(
this.get().locator(`.nc-project-tree-tbl-${title} [data-testid="nc-icon-emojione:${icon}"]`)
).toHaveCount(1);
}
async verifyTabIcon({ title, icon }: { title: string; icon: string }) {
await new Promise(resolve => setTimeout(resolve, 1000));
await expect(
this.rootPage.locator(
`[data-testid="nc-tab-title"]:has-text("${title}") [data-testid="nc-tab-icon-emojione:${icon}"]`
)
).toBeVisible();
}
// todo: Break this into smaller methods
async validateRoleAccess(param: { role: string }) {
// Add new table button

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

@ -140,6 +140,27 @@ export class ViewSidebarPage extends BasePage {
await this.verifyToast({ message: 'View created successfully' });
}
async changeViewIcon({ title, icon }: { title: string; icon: string }) {
await this.get().locator(`[data-testid="view-sidebar-view-${title}"] .nc-view-icon`).click();
await this.rootPage.getByTestId('nc-emoji-filter').type(icon);
await this.rootPage.getByTestId('nc-emoji-container').locator(`.nc-emoji-item >> svg`).first().click();
await this.rootPage.getByTestId('nc-emoji-container').isHidden();
await expect(
this.get().locator(`[data-testid="view-sidebar-view-${title}"] [data-testid="nc-icon-emojione:${icon}"]`)
).toHaveCount(1);
}
async verifyTabIcon({ title, icon }: { title: string; icon: string }) {
await new Promise(resolve => setTimeout(resolve, 1000));
await expect(
this.rootPage.locator(
`[data-testid="nc-tab-title"]:has-text("${title}") [data-testid="nc-tab-icon-emojione:${icon}"]`
)
).toBeVisible();
}
async validateRoleAccess(param: { role: string }) {
const count = param.role === 'creator' ? 1 : 0;
await expect(this.createGridButton).toHaveCount(count);

67
tests/playwright/pages/Dashboard/common/Cell/DateTimeCell.ts

@ -0,0 +1,67 @@
import { CellPageObject } from '.';
import BasePage from '../../../Base';
export class DateTimeCellPageObject 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 open({ index, columnHeader }: { index: number; columnHeader: string }) {
await this.rootPage.locator('.nc-grid-add-new-cell').click();
await this.cell.dblclick({
index,
columnHeader,
});
}
async save() {
await this.rootPage.locator('button:has-text("Ok")').click();
}
async selectDate({
// date formats in `YYYY-MM-DD`
date,
}: {
date: string;
}) {
// title date format needs to be YYYY-MM-DD
await this.rootPage.locator(`td[title="${date}"]`).click();
}
async selectTime({
// hour: 0 - 23
// minute: 0 - 59
// second: 0 - 59
hour,
minute,
second,
}: {
hour: number;
minute: number;
second?: number | null;
}) {
await this.rootPage
.locator(`.ant-picker-time-panel-column:nth-child(1) > .ant-picker-time-panel-cell:nth-child(${hour + 1})`)
.click();
await this.rootPage
.locator(`.ant-picker-time-panel-column:nth-child(2) > .ant-picker-time-panel-cell:nth-child(${minute + 1})`)
.click();
if (second != null) {
await this.rootPage
.locator(`.ant-picker-time-panel-column:nth-child(3) > .ant-picker-time-panel-cell:nth-child(${second + 1})`)
.click();
}
}
async close() {
await this.rootPage.keyboard.press('Escape');
}
}

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

@ -7,6 +7,12 @@ import { SharedFormPage } from '../../../SharedForm';
import { CheckboxCellPageObject } from './CheckboxCell';
import { RatingCellPageObject } from './RatingCell';
import { DateCellPageObject } from './DateCell';
import { DateTimeCellPageObject } from './DateTimeCell';
export interface CellProps {
index?: number;
columnHeader: string;
}
export class CellPageObject extends BasePage {
readonly parent: GridPage | SharedFormPage;
@ -15,6 +21,7 @@ export class CellPageObject extends BasePage {
readonly checkbox: CheckboxCellPageObject;
readonly rating: RatingCellPageObject;
readonly date: DateCellPageObject;
readonly dateTime: DateTimeCellPageObject;
constructor(parent: GridPage | SharedFormPage) {
super(parent.rootPage);
@ -24,9 +31,10 @@ export class CellPageObject extends BasePage {
this.checkbox = new CheckboxCellPageObject(this);
this.rating = new RatingCellPageObject(this);
this.date = new DateCellPageObject(this);
this.dateTime = new DateTimeCellPageObject(this);
}
get({ index, columnHeader }: { index?: number; columnHeader: string }): Locator {
get({ index, columnHeader }: CellProps): Locator {
if (this.parent instanceof SharedFormPage) {
return this.parent.get().locator(`[data-testid="nc-form-input-cell-${columnHeader}"]`);
} else {
@ -34,19 +42,16 @@ export class CellPageObject extends BasePage {
}
}
async click(
{ index, columnHeader }: { index: number; columnHeader: string },
...options: Parameters<Locator['click']>
) {
async click({ index, columnHeader }: CellProps, ...options: Parameters<Locator['click']>) {
await this.get({ index, columnHeader }).click(...options);
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable');
}
async dblclick({ index, columnHeader }: { index?: number; columnHeader: string }) {
async dblclick({ index, columnHeader }: CellProps) {
return await this.get({ index, columnHeader }).dblclick();
}
async fillText({ index, columnHeader, text }: { index?: number; columnHeader: string; text: string }) {
async fillText({ index, columnHeader, text }: CellProps & { text: string }) {
await this.dblclick({
index,
columnHeader,
@ -67,7 +72,7 @@ export class CellPageObject extends BasePage {
}
}
async inCellExpand({ index, columnHeader }: { index: number; columnHeader: string }) {
async inCellExpand({ index, columnHeader }: CellProps) {
await this.get({ index, columnHeader }).hover();
await this.waitForResponse({
uiAction: this.get({ index, columnHeader }).locator('.nc-action-icon >> nth=0').click(),
@ -76,20 +81,20 @@ export class CellPageObject extends BasePage {
});
}
async inCellAdd({ index, columnHeader }: { index: number; columnHeader: string }) {
async inCellAdd({ index, columnHeader }: CellProps) {
await this.get({ index, columnHeader }).hover();
await this.get({ index, columnHeader }).locator('.nc-action-icon.nc-plus').click();
}
async verifyCellActiveSelected({ index, columnHeader }: { index: number; columnHeader: string }) {
async verifyCellActiveSelected({ index, columnHeader }: CellProps) {
await expect(this.get({ index, columnHeader })).toHaveClass(/active/);
}
async verifyCellEditable({ index, columnHeader }: { index: number; columnHeader: string }) {
async verifyCellEditable({ index, columnHeader }: CellProps) {
await this.get({ index, columnHeader }).isEditable();
}
async verify({ index, columnHeader, value }: { index: number; columnHeader: string; value: string | string[] }) {
async verify({ index, columnHeader, value }: CellProps & { value: string | string[] }) {
const _verify = async text => {
await expect
.poll(async () => {
@ -111,13 +116,27 @@ export class CellPageObject extends BasePage {
}
}
async verifyDateCell({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) {
const _verify = async expectedValue => {
await expect
.poll(async () => {
const cell = await this.get({
index,
columnHeader,
}).locator('input');
return await cell.getAttribute('title');
})
.toEqual(expectedValue);
};
await _verify(value);
}
async verifyQrCodeCell({
index,
columnHeader,
expectedSrcValue,
}: {
index: number;
columnHeader: string;
}: CellProps & {
expectedSrcValue: string;
}) {
const _verify = async expectedQrCodeImgSrc => {
@ -147,9 +166,7 @@ export class CellPageObject extends BasePage {
columnHeader,
count,
value,
}: {
index: number;
columnHeader: string;
}: CellProps & {
count?: number;
value: string[];
}) {
@ -166,7 +183,7 @@ export class CellPageObject extends BasePage {
}
}
async unlinkVirtualCell({ index, columnHeader }: { index: number; columnHeader: string }) {
async unlinkVirtualCell({ index, columnHeader }: CellProps) {
const cell = this.get({ index, columnHeader });
await cell.click();
await cell.locator('.nc-icon.unlink-icon').click();
@ -200,10 +217,7 @@ export class CellPageObject extends BasePage {
);
}
async copyToClipboard(
{ index, columnHeader }: { index: number; columnHeader: string },
...clickOptions: Parameters<Locator['click']>
) {
async copyToClipboard({ index, columnHeader }: CellProps, ...clickOptions: Parameters<Locator['click']>) {
await this.get({ index, columnHeader }).click(...clickOptions);
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable');

19
tests/playwright/pages/ProjectsPage/index.ts

@ -17,27 +17,12 @@ export class ProjectsPage extends BasePage {
}
// create project
async createProject({
name = 'sample',
type = 'xcdb',
withoutPrefix,
}: {
name?: string;
type?: string;
withoutPrefix?: boolean;
}) {
async createProject({ name = 'sample', withoutPrefix }: { name?: string; type?: string; withoutPrefix?: boolean }) {
if (!withoutPrefix) name = this.prefixTitle(name);
// Click "New Project" button
await this.get().locator('.nc-new-project-menu').click();
const createProjectMenu = await this.rootPage.locator('.nc-dropdown-create-project');
if (type === 'xcdb') {
await createProjectMenu.locator(`.ant-dropdown-menu-title-content`).nth(0).click();
} else {
await createProjectMenu.locator(`.ant-dropdown-menu-title-content`).nth(1).click();
}
await this.rootPage.locator(`.nc-metadb-project-name`).waitFor();
await this.rootPage.locator(`input.nc-metadb-project-name`).fill(name);

109
tests/playwright/tests/cellSelection.spec.ts

@ -0,0 +1,109 @@
import { expect, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import { GridPage } from '../pages/Dashboard/Grid';
import setup from '../setup';
test.describe('Verify cell selection', () => {
let dashboard: DashboardPage, grid: GridPage;
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
});
test('#1 when range is selected, it has correct number of selected cells', async () => {
await dashboard.treeView.openTable({ title: 'Customer' });
await grid.selectRange({
start: { index: 0, columnHeader: 'FirstName' },
end: { index: 2, columnHeader: 'Email' },
});
expect(await grid.selectedCount()).toBe(9);
});
test('#2 when copied with clipboard, it copies correct text', async () => {
await dashboard.treeView.openTable({ title: 'Customer' });
await grid.selectRange({
start: { index: 0, columnHeader: 'FirstName' },
end: { index: 1, columnHeader: 'LastName' },
});
expect(await grid.copyWithKeyboard()).toBe('MARY \t SMITH\n' + ' PATRICIA \t JOHNSON\n');
});
test('#3 when copied with mouse, it copies correct text', async () => {
await dashboard.treeView.openTable({ title: 'Customer' });
await grid.selectRange({
start: { index: 0, columnHeader: 'FirstName' },
end: { index: 1, columnHeader: 'LastName' },
});
expect(await grid.copyWithMouse({ index: 0, columnHeader: 'FirstName' })).toBe(
'MARY \t SMITH\n' + ' PATRICIA \t JOHNSON\n'
);
});
// FIXME: this is edge case, better be moved to integration tests
test('#4 when cell inside selection range is clicked, it clears previous selection', async () => {
await dashboard.treeView.openTable({ title: 'Country' });
await grid.selectRange({
start: { index: 0, columnHeader: 'Country' },
end: { index: 2, columnHeader: 'City List' },
});
expect(await grid.selectedCount()).toBe(9);
await grid.cell.get({ index: 0, columnHeader: 'Country' }).click();
expect(await grid.selectedCount()).toBe(1);
expect(await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' }));
});
// FIXME: this is edge case, better be moved to integration tests
test('#5 when cell outside selection range is clicked, it clears previous selection', async () => {
await dashboard.treeView.openTable({ title: 'Country' });
await grid.selectRange({
start: { index: 0, columnHeader: 'Country' },
end: { index: 2, columnHeader: 'City List' },
});
expect(await grid.selectedCount()).toBe(9);
await grid.cell.get({ index: 5, columnHeader: 'Country' }).click();
expect(await grid.selectedCount()).toBe(1);
expect(await grid.cell.verifyCellActiveSelected({ index: 5, columnHeader: 'Country' }));
});
// FIXME: this is edge case, better be moved to integration tests
test('#6 when selection ends on locked field, it still works as expected', async () => {
await dashboard.treeView.openTable({ title: 'Country' });
await dashboard.grid.toolbar.fields.toggleShowSystemFields();
await grid.selectRange({
start: { index: 2, columnHeader: 'City List' },
end: { index: 0, columnHeader: 'CountryId' },
});
expect(await grid.selectedCount()).toBe(12);
await grid.cell.get({ index: 1, columnHeader: 'Country' }).click();
expect(await grid.selectedCount()).toBe(1);
expect(await grid.cell.verifyCellActiveSelected({ index: 1, columnHeader: 'Country' }));
});
// FIXME: this is edge case, better be moved to integration tests
test('#7 when navigated with keyboard, only active cell is affected', async ({ page }) => {
await dashboard.treeView.openTable({ title: 'Country' });
await grid.selectRange({
start: { index: 0, columnHeader: 'Country' },
end: { index: 2, columnHeader: 'City List' },
});
await page.keyboard.press('ArrowRight');
expect(await grid.selectedCount()).toBe(1);
expect(await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'LastUpdate' }));
});
});

113
tests/playwright/tests/columnDateTime.spec.ts

@ -0,0 +1,113 @@
import { test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import setup from '../setup';
const dateTimeData = [
{
dateFormat: 'YYYY-MM-DD',
timeFormat: 'HH:mm',
date: '2022-12-12',
hour: 10,
minute: 20,
output: '2022-12-12 10:20',
},
{
dateFormat: 'YYYY-MM-DD',
timeFormat: 'HH:mm:ss',
date: '2022-12-11',
hour: 20,
minute: 30,
second: 40,
output: '2022-12-11 20:30:40',
},
{
dateFormat: 'YYYY/MM/DD',
timeFormat: 'HH:mm',
date: '2022-12-13',
hour: 10,
minute: 20,
output: '2022/12/13 10:20',
},
{
dateFormat: 'YYYY/MM/DD',
timeFormat: 'HH:mm:ss',
date: '2022-12-14',
hour: 5,
minute: 30,
second: 40,
output: '2022/12/14 05:30:40',
},
{
dateFormat: 'DD-MM-YYYY',
timeFormat: 'HH:mm',
date: '2022-12-10',
hour: 4,
minute: 30,
output: '10-12-2022 04:30',
},
{
dateFormat: 'DD-MM-YYYY',
timeFormat: 'HH:mm:ss',
date: '2022-12-26',
hour: 2,
minute: 30,
second: 40,
output: '26-12-2022 02:30:40',
},
];
test.describe('DateTime Column', () => {
let dashboard: DashboardPage;
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
});
test('Create DateTime Column', async () => {
await dashboard.treeView.createTable({ title: 'test_datetime' });
// Create DateTime column
await dashboard.grid.column.create({
title: 'NC_DATETIME_0',
type: 'DateTime',
dateFormat: dateTimeData[0].dateFormat,
timeFormat: dateTimeData[0].timeFormat,
});
for (let i = 0; i < dateTimeData.length; i++) {
// Edit DateTime column
await dashboard.grid.column.openEdit({
title: 'NC_DATETIME_0',
type: 'DateTime',
dateFormat: dateTimeData[i].dateFormat,
timeFormat: dateTimeData[i].timeFormat,
});
await dashboard.grid.column.save({ isUpdated: true });
await dashboard.grid.cell.dateTime.open({
index: 0,
columnHeader: 'NC_DATETIME_0',
});
await dashboard.grid.cell.dateTime.selectDate({
date: dateTimeData[i].date,
});
await dashboard.grid.cell.dateTime.selectTime({
hour: dateTimeData[i].hour,
minute: dateTimeData[i].minute,
second: dateTimeData[i].second,
});
await dashboard.grid.cell.dateTime.save();
await dashboard.grid.cell.verifyDateCell({
index: 0,
columnHeader: 'NC_DATETIME_0',
value: dateTimeData[i].output,
});
}
});
});

40
tests/playwright/tests/columnFormula.spec.ts

@ -30,6 +30,46 @@ const formulaDataByDbType = (context: NcContext) => [
formula: `WEEKDAY("2022-07-19", "sunday")`,
result: ['2', '2', '2', '2', '2'],
},
{
formula: `DATETIME_DIFF("2022/10/14", "2022/10/15")`,
result: ['-86400', '-86400', '-86400', '-86400', '-86400'],
},
{
formula: `DATETIME_DIFF("2022/10/14", "2022/10/15", "minutes")`,
result: ['-1440', '-1440', '-1440', '-1440', '-1440'],
},
{
formula: `DATETIME_DIFF("2022/10/14", "2022/10/15", "seconds")`,
result: ['-86400', '-86400', '-86400', '-86400', '-86400'],
},
{
formula: `DATETIME_DIFF("2022/10/14", "2022/10/15", "milliseconds")`,
result: ['-86400000', '-86400000', '-86400000', '-86400000', '-86400000'],
},
{
formula: `DATETIME_DIFF("2022/10/14", "2022/10/15", "hours")`,
result: ['-24', '-24', '-24', '-24', '-24'],
},
{
formula: `DATETIME_DIFF("2022/10/14", "2023/10/14", "w")`,
result: ['-52', '-52', '-52', '-52', '-52'],
},
{
formula: `DATETIME_DIFF("2022/10/14", "2023/10/14", "M")`,
result: ['-12', '-12', '-12', '-12', '-12'],
},
{
formula: `DATETIME_DIFF("2022/10/14", "2023/10/14", "Q")`,
result: ['-4', '-4', '-4', '-4', '-4'],
},
{
formula: `DATETIME_DIFF("2022/10/14", "2023/10/14", "y")`,
result: ['-1', '-1', '-1', '-1', '-1'],
},
{
formula: `DATETIME_DIFF("2022/10/14", "2023/10/14", "d")`,
result: ['-365', '-365', '-365', '-365', '-365'],
},
{
formula: `CONCAT(UPPER({City}), LOWER({City}), TRIM(' trimmed '))`,
result: [

2
tests/playwright/tests/projectOperations.spec.ts

@ -19,7 +19,7 @@ test.describe('Project operations', () => {
test('rename, delete', async () => {
await dashboard.clickHome();
await projectPage.createProject({ name: 'project-1', type: 'xcdb' });
await projectPage.createProject({ name: 'project-1' });
await dashboard.clickHome();
await projectPage.renameProject({
title: 'project-1',

7
tests/playwright/tests/tableOperations.spec.ts

@ -13,7 +13,7 @@ test.describe('Table Operations', () => {
settings = dashboard.settings;
});
test('Create, and delete table, verify in audit tab, rename City table and reorder tables', async () => {
test('Create, and delete table, verify in audit tab, rename City table, update icon and reorder tables', async () => {
await dashboard.treeView.createTable({ title: 'tablex' });
await dashboard.treeView.verifyTable({ title: 'tablex' });
@ -46,5 +46,10 @@ test.describe('Table Operations', () => {
destinationTable: 'Address',
});
await dashboard.treeView.verifyTable({ title: 'Address', index: 0 });
// verify table icon customization
await dashboard.treeView.openTable({ title: 'Address' });
await dashboard.treeView.changeTableIcon({ title: 'Address', icon: 'american-football' });
await dashboard.treeView.verifyTabIcon({ title: 'Address', icon: 'american-football' });
});
});

7
tests/playwright/tests/viewKanban.spec.ts

@ -19,7 +19,7 @@ test.describe('View', () => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'Film' });
if (isSqlite(context)) {
await dashboard.treeView.deleteTable({ title: 'FilmList' });
}
@ -237,6 +237,11 @@ test.describe('View', () => {
order: order2[i - 1],
});
await dashboard.viewSidebar.changeViewIcon({
title: 'Kanban-1',
icon: 'american-football',
});
await dashboard.viewSidebar.deleteView({ title: 'Kanban-1' });
///////////////////////////////////////////////

6
tests/playwright/tests/views.spec.ts

@ -44,11 +44,17 @@ test.describe('Views CRUD Operations', () => {
title: 'CityGallery',
newTitle: 'CityGallery2',
});
await dashboard.viewSidebar.verifyView({
title: 'CityGallery2',
index: 3,
});
await dashboard.viewSidebar.changeViewIcon({
title: 'CityGallery2',
icon: 'american-football',
});
// todo: Enable when view bug is fixed
// await dashboard.viewSidebar.reorderViews({
// sourceView: "CityGrid",

Loading…
Cancel
Save