Browse Source

Merge branch 'develop' into fix/expand-locked-grid-view

pull/4153/head
mateonunez 2 years ago
parent
commit
8493e72cbe
  1. 8
      .github/workflows/release-docker.yml
  2. 4
      .github/workflows/release-draft.yml
  3. 2
      .github/workflows/release-executables.yml
  4. 6
      .github/workflows/release-nightly-dev.yml
  5. 9
      .github/workflows/release-nocodb.yml
  6. 4
      .github/workflows/release-pr.yml
  7. 1
      packages/nc-gui/.eslintrc.js
  8. 2
      packages/nc-gui/components/cell/Checkbox.vue
  9. 4
      packages/nc-gui/components/cell/DatePicker.vue
  10. 2
      packages/nc-gui/components/cell/DateTimePicker.vue
  11. 6
      packages/nc-gui/components/cell/Text.vue
  12. 2
      packages/nc-gui/components/cell/TimePicker.vue
  13. 2
      packages/nc-gui/components/cell/YearPicker.vue
  14. 2
      packages/nc-gui/components/cell/attachment/utils.ts
  15. 395
      packages/nc-gui/components/dlg/QuickImport.vue
  16. 2
      packages/nc-gui/components/shared-view/Gallery.vue
  17. 2
      packages/nc-gui/components/shared-view/Grid.vue
  18. 2
      packages/nc-gui/components/shared-view/Kanban.vue
  19. 4
      packages/nc-gui/components/smartsheet/Cell.vue
  20. 4
      packages/nc-gui/components/smartsheet/Gallery.vue
  21. 27
      packages/nc-gui/components/smartsheet/Grid.vue
  22. 3
      packages/nc-gui/components/smartsheet/Kanban.vue
  23. 4
      packages/nc-gui/components/smartsheet/Row.vue
  24. 24
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  25. 26
      packages/nc-gui/components/smartsheet/expanded-form/Detached.vue
  26. 2
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  27. 2
      packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue
  28. 2
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  29. 2
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  30. 10
      packages/nc-gui/components/tabs/Smartsheet.vue
  31. 403
      packages/nc-gui/components/template/Editor.vue
  32. 2
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  33. 4
      packages/nc-gui/components/virtual-cell/Formula.vue
  34. 2
      packages/nc-gui/components/virtual-cell/HasMany.vue
  35. 2
      packages/nc-gui/components/virtual-cell/Lookup.vue
  36. 2
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  37. 41
      packages/nc-gui/components/virtual-cell/components/ItemChip.vue
  38. 4
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  39. 43
      packages/nc-gui/composables/useDialog/index.ts
  40. 44
      packages/nc-gui/composables/useExpandedFormDetached/index.ts
  41. 2
      packages/nc-gui/composables/useMultiSelect/index.ts
  42. 8
      packages/nc-gui/composables/useRoles/index.ts
  43. 14
      packages/nc-gui/composables/useSmartsheetStore.ts
  44. 19
      packages/nc-gui/composables/useViewData.ts
  45. 2
      packages/nc-gui/context/index.ts
  46. 34
      packages/nc-gui/lang/fr.json
  47. 26
      packages/nc-gui/lang/it.json
  48. 372
      packages/nc-gui/lang/pl.json
  49. 14
      packages/nc-gui/lang/zh-Hans.json
  50. 2
      packages/nc-gui/layouts/base.vue
  51. 5
      packages/nc-gui/lib/types.ts
  52. 33
      packages/nc-gui/pages/[projectType]/form/[viewId]/index.vue
  53. 10
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/survey.vue
  54. 18
      packages/nc-gui/pages/[projectType]/view/[viewId].vue
  55. 34
      packages/nc-gui/utils/dateTimeUtils.ts
  56. 9
      packages/nc-gui/utils/formulaUtils.ts
  57. 331
      packages/nc-gui/utils/parsers/CSVTemplateAdapter.ts
  58. 464
      packages/nc-gui/utils/parsers/ExcelTemplateAdapter.ts
  59. 3
      packages/nc-gui/utils/parsers/ExcelUrlTemplateAdapter.ts
  60. 68
      packages/nc-gui/utils/parsers/JSONTemplateAdapter.ts
  61. 3
      packages/nc-gui/utils/parsers/JSONUrlTemplateAdapter.ts
  62. 92
      packages/nc-gui/utils/parsers/parserHelpers.ts
  63. 2
      packages/noco-docs/content/en/setup-and-usages/formulas.md
  64. 26
      packages/noco-docs/content/en/setup-and-usages/table-operations.md
  65. 787
      packages/nocodb-sdk/src/lib/Api.ts
  66. 1
      packages/nocodb/src/lib/meta/api/columnApis.ts
  67. 17
      packages/nocodb/src/lib/meta/api/projectApis.ts
  68. 7
      packages/nocodb/src/lib/meta/api/sync/helpers/job.ts
  69. 58
      scripts/cypress/integration/common/1a_table_operations.js
  70. 15
      scripts/cypress/integration/common/1b_table_column_operations.js
  71. 30
      scripts/cypress/integration/common/2b_table_with_m2m_column.js
  72. 23
      scripts/cypress/integration/common/3b_formula_column.js
  73. 8
      scripts/cypress/integration/common/3f_link_to_another_record.js

8
.github/workflows/release-docker.yml

@ -60,10 +60,10 @@ jobs:
DOCKER_REPOSITORY=${DOCKER_REPOSITORY}-timely
fi
fi
echo "::set-output name=DOCKER_REPOSITORY::${DOCKER_REPOSITORY}"
echo "::set-output name=DOCKER_BUILD_TAG::${DOCKER_BUILD_TAG}"
echo ${DOCKER_REPOSITORY}
echo ${DOCKER_BUILD_TAG}
echo "DOCKER_REPOSITORY=${DOCKER_REPOSITORY}" >> $GITHUB_OUTPUT
echo "DOCKER_BUILD_TAG=${DOCKER_BUILD_TAG}" >> $GITHUB_OUTPUT
echo DOCKER_REPOSITORY: ${DOCKER_REPOSITORY}
echo DOCKER_BUILD_TAG: ${DOCKER_BUILD_TAG}
- name: Checkout
uses: actions/checkout@v3

4
.github/workflows/release-draft.yml

@ -53,7 +53,7 @@ jobs:
if [[ ${{ github.event.inputs.tagHeadSHA || inputs.tagHeadSHA }} == "Y" ]]; then
TARGET_SHA=$(git rev-list -n 1 HEAD | tail -1)
fi
echo "::set-output name=TARGET_SHA::${TARGET_SHA}"
echo "TARGET_SHA=${TARGET_SHA}" >> $GITHUB_OUTPUT
echo "Setting TARGET_SHA: ${TARGET_SHA}"
- name: Create tag
uses: actions/github-script@v3
@ -66,7 +66,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
ref: "refs/tags/${{ github.event.inputs.tag || inputs.tag }}",
sha: "${{steps.get-sha.outputs.TARGET_SHA}}"
sha: "${{ steps.get-sha.outputs.TARGET_SHA }}"
})
- uses: actions/setup-node@v3
with:

2
.github/workflows/release-executables.yml

@ -194,7 +194,7 @@ jobs:
cp ./mac-dist/Noco-macos-x64 ./mac-dist/nocodb
tar -czf ./mac-dist/nocodb.tar.gz ./mac-dist/nocodb
rm ./mac-dist/nocodb
echo "::set-output name=CHECKSUM::$(shasum -a 256 ./mac-dist/nocodb.tar.gz | awk '{print $1}')"
echo "CHECKSUM=$(shasum -a 256 ./mac-dist/nocodb.tar.gz | awk '{print $1}')" >> $GITHUB_OUTPUT
id: compress

6
.github/workflows/release-nightly-dev.yml

@ -26,9 +26,9 @@ jobs:
if [[ ${{ github.event_name }} == 'workflow_dispatch' ]]; then
IS_DAILY='N'
fi
echo "::set-output name=NIGHTLY_BUILD_TAG::${TAG_NAME}"
echo "::set-output name=IS_DAILY::${IS_DAILY}"
echo "::set-output name=CURRENT_VERSION::${CURRENT_VERSION}"
echo "NIGHTLY_BUILD_TAG=${TAG_NAME}" >> $GITHUB_OUTPUT
echo "IS_DAILY=${IS_DAILY}" >> $GITHUB_OUTPUT
echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_OUTPUT
- name: verify-tag
run: |
echo ${{ steps.tag-step.outputs.NIGHTLY_BUILD_TAG }}

9
.github/workflows/release-nocodb.yml

@ -45,13 +45,12 @@ jobs:
TARGET_TAG=$(echo ${PREV_TAG} | awk -F. -v OFS=. '{$NF += 1 ; print}')
fi
echo target version: ${TARGET_TAG}
echo previous version: ${PREV_TAG}
echo "::set-output name=target_tag::${TARGET_TAG}"
echo "::set-output name=prev_tag::${PREV_TAG}"
echo "TARGET_TAG=${TARGET_TAG}" >> $GITHUB_OUTPUT
echo "PREV_TAG=${PREV_TAG}" >> $GITHUB_OUTPUT
- name: Verify
run : |
echo ${{ steps.process-input.outputs.target_tag }}
echo TARGET_TAG: ${{ steps.process-input.outputs.target_tag }}
echo PREV_TAG: ${{ steps.process-input.outputs.prev_tag }}
# Merge develop to master
pr-to-master:

4
.github/workflows/release-pr.yml

@ -35,8 +35,8 @@ jobs:
CURRENT_VERSION=$(basename $(curl -fs -o/dev/null -w %{redirect_url} https://github.com/nocodb/nocodb/releases/latest))
# Construct tag name
TAG_NAME=pr-${PR_NUMBER}-${CURRENT_DATE}-${CURRENT_TIME}
echo "::set-output name=TARGET_TAG::${TAG_NAME}"
echo "::set-output name=CURRENT_VERSION::${CURRENT_VERSION}"
echo "TAG_NAME=${TAG_NAME}" >> $GITHUB_OUTPUT
echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_OUTPUT
- name: verify-tag
run: |
echo ${{ steps.tag-step.outputs.TARGET_TAG }}

1
packages/nc-gui/.eslintrc.js

@ -3,6 +3,7 @@ const baseRules = {
'no-console': 0,
'antfu/if-newline': 0,
'no-unused-vars': 0,
'@typescript-eslint/no-this-alias': 0,
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },

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

@ -38,7 +38,7 @@ const checkboxMeta = $computed(() => {
})
function onClick() {
if (!readOnly) {
if (!readOnly?.value) {
vModel = !vModel
}
}

4
packages/nc-gui/components/cell/DatePicker.vue

@ -12,11 +12,11 @@ const emit = defineEmits(['update:modelValue'])
const columnMeta = inject(ColumnInj, null)!
const readOnly = inject(ReadonlyInj, false)
const readOnly = inject(ReadonlyInj, ref(false))
let isDateInvalid = $ref(false)
const dateFormat = columnMeta?.value?.meta?.date_format ?? 'YYYY-MM-DD'
const dateFormat = $computed(() => columnMeta?.value?.meta?.date_format ?? 'YYYY-MM-DD')
const localState = $computed({
get() {

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

@ -12,7 +12,7 @@ const emit = defineEmits(['update:modelValue'])
const { isMysql } = useProject()
const readOnly = inject(ReadonlyInj, false)
const readOnly = inject(ReadonlyInj, ref(false))
let isDateInvalid = $ref(false)

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import { EditModeInj, inject, useVModel } from '#imports'
import { EditModeInj, ReadonlyInj, inject, ref, useVModel } from '#imports'
interface Props {
modelValue?: string | null
@ -12,6 +12,8 @@ const emits = defineEmits(['update:modelValue'])
const editEnabled = inject(EditModeInj)
const readonly = inject(ReadonlyInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits)
const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@ -19,7 +21,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
<template>
<input
v-if="editEnabled"
v-if="!readonly && editEnabled"
:ref="focus"
v-model="vModel"
class="h-full w-full outline-none bg-transparent"

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

@ -12,7 +12,7 @@ const emit = defineEmits(['update:modelValue'])
const { isMysql } = useProject()
const readOnly = inject(ReadonlyInj, false)
const readOnly = inject(ReadonlyInj, ref(false))
let isTimeInvalid = $ref(false)

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

@ -10,7 +10,7 @@ const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const readOnly = inject(ReadonlyInj, false)
const readOnly = inject(ReadonlyInj, ref(false))
let isYearInvalid = $ref(false)

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

@ -33,7 +33,7 @@ interface AttachmentProps extends File {
export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
(updateModelValue: (data: string | Record<string, any>[]) => void) => {
const isReadonly = inject(ReadonlyInj, false)
const isReadonly = inject(ReadonlyInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))

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

@ -1,7 +1,9 @@
<script setup lang="ts">
import type { TableType } from 'nocodb-sdk'
import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import { Upload } from 'ant-design-vue'
import {
CSVTemplateAdapter,
ExcelTemplateAdapter,
ExcelUrlTemplateAdapter,
Form,
@ -20,14 +22,15 @@ import {
useProject,
useVModel,
} from '#imports'
import type { importFileList, streamImportFileList } from '~/lib'
interface Props {
modelValue: boolean
importType: 'csv' | 'json' | 'excel'
importOnly?: boolean
importDataOnly?: boolean
}
const { importType, importOnly = false, ...rest } = defineProps<Props>()
const { importType, importDataOnly = false, ...rest } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
@ -41,7 +44,9 @@ const jsonEditorRef = ref()
const templateEditorRef = ref()
const loading = ref(false)
const preImportLoading = ref(false)
const importLoading = ref(false)
const templateData = ref()
@ -51,16 +56,20 @@ const importColumns = ref([])
const templateEditorModal = ref(false)
const isParsingData = ref(false)
const useForm = Form.useForm
const importState = reactive({
fileList: [] as (UploadFile & { data: string | ArrayBuffer })[],
fileList: [] as importFileList | streamImportFileList,
url: '',
jsonEditor: {},
parserConfig: {
maxRowsToParse: 500,
normalizeNested: true,
importData: true,
autoSelectFieldTypes: true,
firstRowAsHeaders: true,
shouldImportData: true,
},
})
@ -130,47 +139,56 @@ const modalWidth = computed(() => {
return 'max(60vw, 600px)'
})
let templateGenerator: CSVTemplateAdapter | JSONTemplateAdapter | ExcelTemplateAdapter | null
async function handlePreImport() {
loading.value = true
preImportLoading.value = true
isParsingData.value = true
if (activeKey.value === 'uploadTab') {
await parseAndExtractData(importState.fileList[0].data, importState.fileList[0].name)
if (isImportTypeCsv.value) {
await parseAndExtractData(importState.fileList as streamImportFileList)
} else {
await parseAndExtractData((importState.fileList as importFileList)[0].data)
}
} else if (activeKey.value === 'urlTab') {
try {
await validate()
await parseAndExtractData(importState.url, '')
await parseAndExtractData(importState.url)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
} else if (activeKey.value === 'jsonEditorTab') {
await parseAndExtractData(JSON.stringify(importState.jsonEditor), '')
await parseAndExtractData(JSON.stringify(importState.jsonEditor))
}
loading.value = false
}
async function handleImport() {
try {
loading.value = true
if (!templateGenerator) {
message.error(t('msg.error.templateGeneratorNotFound'))
return
}
importLoading.value = true
await templateEditorRef.value.importTemplate()
} catch (e: any) {
return message.error(await extractSdkResponseErrorMsg(e))
} finally {
loading.value = false
importLoading.value = false
}
dialogShow.value = false
}
async function parseAndExtractData(val: string | ArrayBuffer, name: string) {
// UploadFile[] for csv import (streaming)
// ArrayBuffer for excel import
// string for json import
async function parseAndExtractData(val: UploadFile[] | ArrayBuffer | string) {
try {
templateData.value = null
importData.value = null
importColumns.value = []
const templateGenerator = getAdapter(name, val)
templateGenerator = getAdapter(val)
if (!templateGenerator) {
message.error(t('msg.error.templateGeneratorNotFound'))
@ -179,16 +197,24 @@ async function parseAndExtractData(val: string | ArrayBuffer, name: string) {
await templateGenerator.init()
templateGenerator.parse()
templateData.value = templateGenerator.getTemplate()
templateData.value.tables[0].table_name = populateUniqueTableName()
importData.value = templateGenerator.getData()
if (importOnly) importColumns.value = templateGenerator.getColumns()
await templateGenerator.parse()
templateData.value = templateGenerator!.getTemplate()
if (importDataOnly) importColumns.value = templateGenerator!.getColumns()
else {
// ensure the target table name not exist in current table list
templateData.value.tables = templateData.value.tables.map((table: Record<string, any>) => ({
...table,
table_name: populateUniqueTableName(table.table_name),
}))
}
importData.value = templateGenerator!.getData()
templateEditorModal.value = true
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isParsingData.value = false
preImportLoading.value = false
}
}
@ -200,29 +226,34 @@ function rejectDrop(fileList: UploadFile[]) {
function handleChange(info: UploadChangeParam) {
const status = info.file.status
if (status !== 'uploading' && status !== 'removed') {
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
const target = importState.fileList.find((f) => f.uid === info.file.uid)
if (e.target && e.target.result) {
/** if the file was pushed into the list by `<a-upload-dragger>` we just add the data to the file */
if (target) {
target.data = e.target.result
} else if (!target) {
/** if the file was added programmatically and not with d&d, we create file infos and push it into the list */
importState.fileList.push({
...info.file,
status: 'done',
data: e.target.result,
})
if (status && status !== 'uploading' && status !== 'removed') {
if (isImportTypeCsv.value) {
if (!importState.fileList.find((f) => f.uid === info.file.uid)) {
;(importState.fileList as streamImportFileList).push({
...info.file,
status: 'done',
})
}
} else {
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
const target = (importState.fileList as importFileList).find((f) => f.uid === info.file.uid)
if (e.target && e.target.result) {
/** if the file was pushed into the list by `<a-upload-dragger>` we just add the data to the file */
if (target) {
target.data = e.target.result
} else if (!target) {
/** if the file was added programmatically and not with d&d, we create file infos and push it into the list */
importState.fileList.push({
...info.file,
status: 'done',
data: e.target.result,
})
}
}
}
reader.readAsArrayBuffer(info.file.originFileObj!)
}
reader.readAsArrayBuffer(info.file.originFileObj!)
}
if (status === 'done') {
@ -236,32 +267,50 @@ function formatJson() {
jsonEditorRef.value?.format()
}
function populateUniqueTableName() {
function populateUniqueTableName(tn: string) {
let c = 1
while (tables.value.some((t: TableType) => t.title === `Sheet${c}`)) {
c++
while (
tables.value.some((t: TableType) => {
const s = t.table_name.split('___')
let target = t.table_name
if (s.length > 1) target = s[1]
return target === `${tn}`
})
) {
tn = `${tn}_${c++}`
}
return `Sheet${c}`
return tn
}
function getAdapter(name: string, val: any) {
if (IsImportTypeExcel.value || isImportTypeCsv.value) {
function getAdapter(val: any) {
if (isImportTypeCsv.value) {
switch (activeKey.value) {
case 'uploadTab':
return new CSVTemplateAdapter(val, {
...importState.parserConfig,
importFromURL: false,
})
case 'urlTab':
return new CSVTemplateAdapter(val, {
...importState.parserConfig,
importFromURL: true,
})
}
} else if (IsImportTypeExcel.value) {
switch (activeKey.value) {
case 'uploadTab':
return new ExcelTemplateAdapter(name, val, importState.parserConfig)
return new ExcelTemplateAdapter(val, importState.parserConfig)
case 'urlTab':
return new ExcelUrlTemplateAdapter(val, importState.parserConfig)
}
} else if (isImportTypeJson.value) {
switch (activeKey.value) {
case 'uploadTab':
return new JSONTemplateAdapter(name, val, importState.parserConfig)
return new JSONTemplateAdapter(val, importState.parserConfig)
case 'urlTab':
return new JSONUrlTemplateAdapter(val, importState.parserConfig)
case 'jsonEditorTab':
return new JSONTemplateAdapter(name, val, importState.parserConfig)
return new JSONTemplateAdapter(val, importState.parserConfig)
}
}
@ -282,6 +331,15 @@ const customReqCbk = (customReqArgs: { file: any; onSuccess: () => void }) => {
})
customReqArgs.onSuccess()
}
/** check if the file size exceeds the limit */
const beforeUpload = (file: UploadFile) => {
const exceedLimit = file.size! / 1024 / 1024 > 5
if (exceedLimit) {
message.error(`File ${file.name} is too big. The accepted file size is less than 5MB.`)
}
return !exceedLimit || Upload.LIST_IGNORE
}
</script>
<template>
@ -291,115 +349,130 @@ const customReqCbk = (customReqArgs: { file: any; onSuccess: () => void }) => {
wrap-class-name="nc-modal-quick-import"
@keydown.esc="dialogShow = false"
>
<div class="px-5">
<div class="prose-xl font-weight-bold my-5">{{ importMeta.header }}</div>
<div class="mt-5">
<LazyTemplateEditor
v-if="templateEditorModal"
ref="templateEditorRef"
:project-template="templateData"
:import-data="importData"
:import-columns="importColumns"
:import-only="importOnly"
:quick-import-type="importType"
:max-rows-to-parse="importState.parserConfig.maxRowsToParse"
class="nc-quick-import-template-editor"
@import="handleImport"
/>
<a-tabs v-else v-model:activeKey="activeKey" hide-add type="editable-card" tab-position="top">
<a-tab-pane key="uploadTab" :closable="false">
<template #tab>
<!-- Upload -->
<div class="flex items-center gap-2">
<MdiFileUploadOutline />
{{ $t('general.upload') }}
<a-spin :spinning="isParsingData" tip="Parsing Data ..." size="large">
<div class="px-5">
<div class="prose-xl font-weight-bold my-5">{{ importMeta.header }}</div>
<div class="mt-5">
<LazyTemplateEditor
v-if="templateEditorModal"
ref="templateEditorRef"
:project-template="templateData"
:import-data="importData"
:import-columns="importColumns"
:import-data-only="importDataOnly"
:quick-import-type="importType"
:max-rows-to-parse="importState.parserConfig.maxRowsToParse"
class="nc-quick-import-template-editor"
@import="handleImport"
/>
<a-tabs v-else v-model:activeKey="activeKey" hide-add type="editable-card" tab-position="top">
<a-tab-pane key="uploadTab" :closable="false">
<template #tab>
<!-- Upload -->
<div class="flex items-center gap-2">
<MdiFileUploadOutline />
{{ $t('general.upload') }}
</div>
</template>
<div class="py-6">
<a-upload-dragger
v-model:fileList="importState.fileList"
name="file"
class="nc-input-import !scrollbar-thin-dull"
list-type="picture"
:accept="importMeta.acceptTypes"
:max-count="isImportTypeCsv ? 5 : 1"
:multiple="true"
:custom-request="customReqCbk"
:before-upload="beforeUpload"
@change="handleChange"
@reject="rejectDrop"
>
<MdiFilePlusOutline size="large" />
<!-- Click or drag file to this area to upload -->
<p class="ant-upload-text">{{ $t('msg.info.import.clickOrDrag') }}</p>
<p class="ant-upload-hint">
{{ importMeta.uploadHint }}
</p>
</a-upload-dragger>
</div>
</template>
<div class="py-6">
<a-upload-dragger
v-model:fileList="importState.fileList"
name="file"
class="nc-input-import !scrollbar-thin-dull"
:accept="importMeta.acceptTypes"
:max-count="1"
list-type="picture"
:custom-request="customReqCbk"
@change="handleChange"
@reject="rejectDrop"
>
<MdiFilePlusOutline size="large" />
<!-- Click or drag file to this area to upload -->
<p class="ant-upload-text">{{ $t('msg.info.import.clickOrDrag') }}</p>
<p class="ant-upload-hint">
{{ importMeta.uploadHint }}
</p>
</a-upload-dragger>
</div>
</a-tab-pane>
<a-tab-pane v-if="isImportTypeJson" key="jsonEditorTab" :closable="false">
<template #tab>
<span class="flex items-center gap-2">
<MdiCodeJson />
JSON Editor
</span>
</template>
<div class="pb-3 pt-3">
<LazyMonacoEditor ref="jsonEditorRef" v-model="importState.jsonEditor" class="min-h-60 max-h-80" />
</div>
</a-tab-pane>
<a-tab-pane v-else key="urlTab" :closable="false">
<template #tab>
<span class="flex items-center gap-2">
<MdiLinkVariant />
URL
</span>
</template>
<div class="pr-10 pt-5">
<a-form :model="importState" name="quick-import-url-form" layout="horizontal" class="mb-0">
<a-form-item :label="importMeta.urlInputLabel" v-bind="validateInfos.url">
<a-input v-model:value="importState.url" size="large" />
</a-form-item>
</a-form>
</div>
</a-tab-pane>
</a-tabs>
</div>
<div v-if="!templateEditorModal">
<a-divider />
<div class="mb-4">
<!-- Advanced Settings -->
<span class="prose-lg">{{ $t('title.advancedSettings') }}</span>
<a-form-item class="mt-4 mb-2" :label="t('msg.info.footMsg')" v-bind="validateInfos.maxRowsToParse">
<a-input-number v-model:value="importState.parserConfig.maxRowsToParse" :min="1" :max="50000" />
</a-form-item>
<!-- Flatten nested -->
<div v-if="isImportTypeJson" class="mt-3">
<a-checkbox v-model:checked="importState.parserConfig.normalizeNested">
<span class="caption">{{ $t('labels.flattenNested') }}</span>
</a-checkbox>
</div>
</a-tab-pane>
<a-tab-pane v-if="isImportTypeJson" key="jsonEditorTab" :closable="false">
<template #tab>
<span class="flex items-center gap-2">
<MdiCodeJson />
JSON Editor
</span>
</template>
<div class="pb-3 pt-3">
<LazyMonacoEditor ref="jsonEditorRef" v-model="importState.jsonEditor" class="min-h-60 max-h-80" />
</div>
</a-tab-pane>
<a-tab-pane v-else key="urlTab" :closable="false">
<template #tab>
<span class="flex items-center gap-2">
<MdiLinkVariant />
URL
</span>
</template>
<div class="pr-10 pt-5">
<a-form :model="importState" name="quick-import-url-form" layout="horizontal" class="mb-0">
<a-form-item :label="importMeta.urlInputLabel" v-bind="validateInfos.url">
<a-input v-model:value="importState.url" size="large" />
</a-form-item>
</a-form>
</div>
</a-tab-pane>
</a-tabs>
</div>
<!-- Import Data -->
<div v-if="isImportTypeJson" class="mt-4">
<a-checkbox v-model:checked="importState.parserConfig.importData">{{ $t('labels.importData') }}</a-checkbox>
<div v-if="!templateEditorModal">
<a-divider />
<div class="mb-4">
<!-- Advanced Settings -->
<span class="prose-lg">{{ $t('title.advancedSettings') }}</span>
<a-form-item class="!my-2" :label="t('msg.info.footMsg')" v-bind="validateInfos.maxRowsToParse">
<a-input-number v-model:value="importState.parserConfig.maxRowsToParse" :min="1" :max="50000" />
</a-form-item>
<a-form-item v-if="!importDataOnly" class="!my-2">
<a-checkbox v-model:checked="importState.parserConfig.autoSelectFieldTypes">
<span class="caption">Auto-Select Field Types</span>
</a-checkbox>
</a-form-item>
<a-form-item v-if="isImportTypeCsv || IsImportTypeExcel" class="!my-2">
<a-checkbox v-model:checked="importState.parserConfig.firstRowAsHeaders">
<span class="caption">Use First Row as Headers</span>
</a-checkbox>
</a-form-item>
<!-- Flatten nested -->
<a-form-item v-if="isImportTypeJson" class="!my-2">
<a-checkbox v-model:checked="importState.parserConfig.normalizeNested">
<span class="caption">{{ $t('labels.flattenNested') }}</span>
</a-checkbox>
</a-form-item>
<!-- Import Data -->
<a-form-item v-if="!importDataOnly" class="!my-2">
<a-checkbox v-model:checked="importState.parserConfig.shouldImportData">{{ $t('labels.importData') }}</a-checkbox>
</a-form-item>
</div>
</div>
</div>
</div>
</a-spin>
<template #footer>
<a-button v-if="templateEditorModal" key="back" @click="templateEditorModal = false">Back</a-button>
@ -419,14 +492,14 @@ const customReqCbk = (customReqArgs: { file: any; onSuccess: () => void }) => {
key="pre-import"
type="primary"
class="nc-btn-import"
:loading="loading"
:loading="preImportLoading"
:disabled="disablePreImportButton"
@click="handlePreImport"
>
{{ $t('activity.import') }}
</a-button>
<a-button v-else key="import" type="primary" :loading="loading" :disabled="disableImportButton" @click="handleImport">
<a-button v-else key="import" type="primary" :loading="importLoading" :disabled="disableImportButton" @click="handleImport">
{{ $t('activity.import') }}
</a-button>
</template>

2
packages/nc-gui/components/shared-view/Gallery.vue

@ -7,7 +7,7 @@ const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, true)
provide(ReadonlyInj, ref(true))
provide(MetaInj, meta)

2
packages/nc-gui/components/shared-view/Grid.vue

@ -28,7 +28,7 @@ useProvideSmartsheetStore(sharedView, meta, true, sorts, nestedFilters)
const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, true)
provide(ReadonlyInj, ref(true))
provide(MetaInj, meta)
provide(ActiveViewInj, sharedView)
provide(FieldsInj, ref(meta.value?.columns || []))

2
packages/nc-gui/components/shared-view/Kanban.vue

@ -15,7 +15,7 @@ const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, true)
provide(ReadonlyInj, ref(true))
provide(MetaInj, meta)

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

@ -1,6 +1,5 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import {
ActiveCellInj,
ColumnInj,
@ -8,6 +7,7 @@ import {
IsFormInj,
IsLockedInj,
IsPublicInj,
ReadonlyInj,
computed,
inject,
provide,
@ -47,7 +47,7 @@ provide(EditModeInj, useVModel(props, 'editEnabled', emit))
provide(ActiveCellInj, active)
if (readOnly?.value) {
provide(ReadonlyInj, readOnly.value)
provide(ReadonlyInj, readOnly)
}
const isForm = inject(IsFormInj, ref(false))

4
packages/nc-gui/components/smartsheet/Gallery.vue

@ -10,7 +10,6 @@ import {
MetaInj,
OpenNewRecordFormHookInj,
PaginationDataInj,
ReadonlyInj,
ReloadRowDataHookInj,
ReloadViewDataHookInj,
ReloadViewMetaHookInj,
@ -51,14 +50,11 @@ const {
addEmptyRow,
} = useViewData(meta, view)
const { isUIAllowed } = useUIPermission()
provide(IsFormInj, ref(false))
provide(IsGalleryInj, ref(true))
provide(IsGridInj, ref(false))
provide(PaginationDataInj, paginationData)
provide(ChangePageInj, changePage)
provide(ReadonlyInj, !isUIAllowed('xcDatatableEditable'))
const fields = inject(FieldsInj, ref([]))

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

@ -18,6 +18,7 @@ import {
ReloadViewDataHookInj,
computed,
createEventHook,
enumColor,
extractPkFromRow,
inject,
isColumnRequiredAndNull,
@ -32,6 +33,7 @@ import {
useI18n,
useMetas,
useMultiSelect,
useRoles,
useRoute,
useSmartsheetStoreOrThrow,
useUIPermission,
@ -50,12 +52,13 @@ const view = inject(ActiveViewInj, ref())
// keep a root fields variable and will get modified from
// fields menu and get used in grid and gallery
const fields = inject(FieldsInj, ref([]))
const readOnly = inject(ReadonlyInj, false)
const readOnly = inject(ReadonlyInj, ref(false))
const isLocked = inject(IsLockedInj, ref(false))
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj, createEventHook())
const { hasRole } = useRoles()
const { isUIAllowed } = useUIPermission()
const hasEditPermission = $computed(() => isUIAllowed('xcDatatableEditable'))
@ -67,7 +70,7 @@ const isView = false
let editEnabled = $ref(false)
const { xWhere, isPkAvail, cellRefs, isSqlView } = useSmartsheetStoreOrThrow()
const { xWhere, isPkAvail, isSqlView } = useSmartsheetStoreOrThrow()
const visibleColLength = $computed(() => fields.value?.length)
@ -224,8 +227,6 @@ provide(PaginationDataInj, paginationData)
provide(ChangePageInj, changePage)
provide(ReadonlyInj, !hasEditPermission)
const disableUrlOverlay = ref(false)
provide(CellUrlDisableOverlayInj, disableUrlOverlay)
@ -273,7 +274,9 @@ watch(contextMenu, () => {
const rowRefs = $ref<any[]>()
async function clearCell(ctx: { row: number; col: number }) {
async function clearCell(ctx: { row: number; col: number } | null) {
if (!ctx) return
const rowObj = data.value[ctx.row]
const columnObj = fields.value[ctx.col]
@ -561,7 +564,12 @@ watch(
<a-checkbox v-model:checked="row.rowMeta.selected" />
</div>
<span class="flex-1" />
<div v-if="!readOnly" class="nc-expand" :class="{ 'nc-comment': row.rowMeta?.commentCount }">
<div
v-if="(!readOnly || hasRole('commenter', true) || hasRole('viewer', true))"
class="nc-expand"
:class="{ 'nc-comment': row.rowMeta?.commentCount }"
>
<a-spin v-if="row.rowMeta.saving" class="!flex items-center" />
<template v-else>
<span
@ -586,9 +594,8 @@ watch(
</div>
</div>
</td>
<td
<SmartsheetTableDataCell
v-for="(columnObj, colIndex) of fields"
:ref="cellRefs.set"
:key="columnObj.id"
class="cell relative cursor-pointer nc-grid-cell"
:class="{
@ -625,13 +632,13 @@ watch(
"
:row-index="rowIndex"
:active="selected.col === colIndex && selected.row === rowIndex"
@update:edit-enabled="editEnabled = false"
@update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow(row, columnObj.title, state)"
@navigate="onNavigate"
@cancel="editEnabled = false"
/>
</div>
</td>
</SmartsheetTableDataCell>
</tr>
</template>
</LazySmartsheetRow>

3
packages/nc-gui/components/smartsheet/Kanban.vue

@ -12,7 +12,6 @@ import {
IsPublicInj,
MetaInj,
OpenNewRecordFormHookInj,
ReadonlyInj,
inject,
onBeforeMount,
onBeforeUnmount,
@ -85,8 +84,6 @@ provide(IsGridInj, ref(false))
provide(IsKanbanInj, ref(true))
provide(ReadonlyInj, !isUIAllowed('xcDatatableEditable'))
const hasEditPermission = $computed(() => isUIAllowed('xcDatatableEditable'))
const fields = inject(FieldsInj, ref([]))

4
packages/nc-gui/components/smartsheet/Row.vue

@ -1,4 +1,6 @@
<script lang="ts" setup>
import type { Ref } from 'vue'
import type { TableType } from 'nocodb-sdk'
import type { Row } from '~/lib'
import {
ReloadRowDataHookInj,
@ -20,7 +22,7 @@ const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow()
const { isNew, state, syncLTARRefs, clearLTARCell } = useProvideSmartsheetRowStore(meta, currentRow)
const { isNew, state, syncLTARRefs, clearLTARCell } = useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
// on changing isNew(new record insert) status sync LTAR cell values
watch(isNew, async (nextVal, prevVal) => {

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

@ -0,0 +1,24 @@
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, useSmartsheetStoreOrThrow } from '#imports'
const { cellRefs } = useSmartsheetStoreOrThrow()
const el = ref<HTMLTableDataCellElement>()
onMounted(() => {
cellRefs.value.push(el.value!)
})
onBeforeUnmount(() => {
const index = cellRefs.value.indexOf(el.value!)
if (index > -1) {
cellRefs.value.splice(index, 1)
}
})
</script>
<template>
<td ref="el">
<slot />
</td>
</template>

26
packages/nc-gui/components/smartsheet/expanded-form/Detached.vue

@ -0,0 +1,26 @@
<script lang="ts" setup>
import { useExpandedFormDetached } from '#imports'
const { states, close } = useExpandedFormDetached()
const shouldClose = (isVisible: boolean, i: number) => {
if (!isVisible) close(i)
}
</script>
<template>
<template v-for="(state, i) of states" :key="`expanded-form-${i}`">
<LazySmartsheetExpandedForm
v-model="state.isOpen"
:row="state.row"
:load-row="state.loadRow"
:meta="state.meta"
:row-id="state.rowId"
:state="state.state"
:use-meta-fields="state.useMetaFields"
:view="state.view"
@update:model-value="shouldClose($event, i)"
@cancel="close(i)"
/>
</template>
</template>

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

@ -9,6 +9,8 @@ import {
MetaInj,
ReloadRowDataHookInj,
computedInject,
createEventHook,
inject,
message,
provide,
ref,

2
packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue

@ -166,7 +166,7 @@ const exportFile = async (exportType: ExportTypes) => {
</template>
</a-dropdown>
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-only="true" />
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-data-only="true" />
<LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" />

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

@ -43,7 +43,7 @@ const shared = ref<SharedView>({ id: '', meta: {}, password: undefined })
const transitionDuration = computed({
get: () => shared.value.meta.transitionDuration || 250,
set: (duration) => {
shared.value.meta = { ...shared.value.meta, transitionDuration: duration }
shared.value.meta = { ...shared.value.meta, transitionDuration: duration > 5000 ? 5000 : duration }
},
})

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

@ -228,7 +228,7 @@ const { isSqlView } = useSmartsheetStoreOrThrow()
</template>
</a-dropdown>
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-only="true" />
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-data-only="true" />
<LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" />

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

@ -7,6 +7,7 @@ import {
IsLockedInj,
MetaInj,
OpenNewRecordFormHookInj,
ReadonlyInj,
ReloadViewDataHookInj,
ReloadViewMetaHookInj,
TabMetaInj,
@ -18,6 +19,7 @@ import {
useMetas,
useProvideKanbanViewStore,
useProvideSmartsheetStore,
useUIPermission,
} from '#imports'
import type { TabItem } from '~/lib'
@ -25,6 +27,8 @@ const props = defineProps<{
activeTab: TabItem
}>()
const { isUIAllowed } = useUIPermission()
const { metas } = useMetas()
const activeTab = toRef(props, 'activeTab')
@ -55,6 +59,10 @@ provide(OpenNewRecordFormHookInj, openNewRecordFormHook)
provide(FieldsInj, fields)
provide(IsFormInj, isForm)
provide(TabMetaInj, activeTab)
provide(
ReadonlyInj,
computed(() => !isUIAllowed('xcDatatableEditable')),
)
</script>
<template>
@ -79,6 +87,8 @@ provide(TabMetaInj, activeTab)
</Transition>
</div>
<LazySmartsheetExpandedFormDetached />
<!-- Lazy loading the sidebar causes issues when deleting elements, i.e. it appears as if multiple elements are removed when they are not -->
<SmartsheetSidebar v-if="meta" class="nc-right-sidebar" />
</div>

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

@ -14,22 +14,23 @@ import {
extractSdkResponseErrorMsg,
fieldRequiredValidator,
getDateFormat,
getDateTimeFormat,
getUIDTIcon,
inject,
message,
nextTick,
onMounted,
parseStringDate,
reactive,
ref,
useI18n,
useNuxtApp,
useProject,
useTabs,
useTemplateRefsList,
} from '#imports'
import { TabType } from '~/lib'
const { quickImportType, projectTemplate, importData, importColumns, importOnly, maxRowsToParse } = defineProps<Props>()
const { quickImportType, projectTemplate, importData, importColumns, importDataOnly, maxRowsToParse } = defineProps<Props>()
const emit = defineEmits(['import'])
@ -42,7 +43,7 @@ interface Props {
projectTemplate: Record<string, any>
importData: Record<string, any>
importColumns: any[]
importOnly: boolean
importDataOnly: boolean
maxRowsToParse: number
}
@ -71,11 +72,11 @@ const expansionPanel = ref<number[]>([])
const editableTn = ref<boolean[]>([])
const inputRefs = useTemplateRefsList<HTMLInputElement>()
const inputRefs = ref<HTMLInputElement[]>([])
const isImporting = ref(false)
const importingTip = ref('Importing')
const importingTips = ref<Record<string, string>>({})
const uiTypeOptions = ref<Option[]>(
(Object.keys(UITypes) as (keyof typeof UITypes)[])
@ -92,12 +93,12 @@ const uiTypeOptions = ref<Option[]>(
})),
)
const srcDestMapping = ref<Record<string, any>[]>([])
const srcDestMapping = ref<Record<string, Record<string, any>[]>>({})
const data = reactive<{
title: string | null
name: string
tables: (TableType & { ref_table_name: string; columns: (ColumnType & { _disableSelect?: boolean })[] })[]
tables: (TableType & { ref_table_name: string; columns: (ColumnType & { key: number; _disableSelect?: boolean })[] })[]
}>({
title: null,
name: 'Project Name',
@ -106,7 +107,7 @@ const data = reactive<{
const validators = computed(() =>
data.tables.reduce<Record<string, [ReturnType<typeof fieldRequiredValidator>]>>((acc, table, tableIdx) => {
acc[`tables.${tableIdx}.ref_table_name`] = [fieldRequiredValidator()]
acc[`tables.${tableIdx}.table_name`] = [fieldRequiredValidator()]
hasSelectColumn.value[tableIdx] = false
table.columns?.forEach((column, columnIdx) => {
@ -123,24 +124,36 @@ const validators = computed(() =>
const { validate, validateInfos } = useForm(data, validators)
const isValid = computed(() => {
if (importOnly) {
for (const record of srcDestMapping.value) {
if (!fieldsValidation(record)) {
return false
const isValid = ref(!importDataOnly)
watch(
() => srcDestMapping.value,
() => {
let res = true
if (importDataOnly) {
for (const tn of Object.keys(srcDestMapping.value)) {
if (!atLeastOneEnabledValidation(tn)) {
res = false
}
for (const record of srcDestMapping.value[tn]) {
if (!fieldsValidation(record, tn)) {
return false
}
}
}
}
} else {
for (const [_, o] of Object.entries(validateInfos)) {
if (o?.validateStatus) {
if (o.validateStatus === 'error') {
return false
} else {
for (const [_, o] of Object.entries(validateInfos)) {
if (o?.validateStatus) {
if (o.validateStatus === 'error') {
res = false
}
}
}
}
}
return true
})
isValid.value = res
},
{ deep: true },
)
const prevEditableTn = ref<string[]>([])
@ -150,7 +163,11 @@ onMounted(() => {
// used to record the previous EditableTn values
// for checking the table duplication in current import
// and updating the key in importData
prevEditableTn.value = data.tables.map((t) => t.ref_table_name)
prevEditableTn.value = data.tables.map((t) => t.table_name)
if (importDataOnly) {
mapDefaultColumns()
}
nextTick(() => {
inputRefs.value[0]?.focus()
@ -183,7 +200,7 @@ function parseTemplate({ tables = [], ...rest }: Props['projectTemplate']) {
}),
...v.map((v: any) => ({
column_name: v.title,
ref_table_name: {
table_name: {
...v,
},
})),
@ -202,20 +219,20 @@ function deleteTable(tableIdx: number) {
data.tables.splice(tableIdx, 1)
}
function deleteTableColumn(tableIdx: number, columnIdx: number) {
data.tables[tableIdx].columns?.splice(columnIdx, 1)
function deleteTableColumn(tableIdx: number, columnKey: number) {
const columnIdx = data.tables[tableIdx].columns.findIndex((c: ColumnType & { key: number }) => c.key === columnKey)
data.tables[tableIdx].columns.splice(columnIdx, 1)
}
function addNewColumnRow(table: Record<string, any>, uidt?: string) {
table.columns.push({
key: table.columns.length,
column_name: `title${table.columns.length + 1}`,
function addNewColumnRow(tableIdx: number, uidt: string) {
data.tables[tableIdx].columns.push({
key: data.tables[tableIdx].columns.length,
column_name: `title${data.tables[tableIdx].columns.length + 1}`,
uidt,
})
nextTick(() => {
const input = inputRefs.value[table.columns.length - 1]
const input = inputRefs.value[data.tables[tableIdx].columns.length - 1]
input.focus()
input.select()
})
@ -229,7 +246,12 @@ function remapColNames(batchData: any[], columns: ColumnType[]) {
const dateFormatMap: Record<number, string> = {}
return batchData.map((data) =>
(columns || []).reduce((aggObj, col: Record<string, any>) => {
let d = data[col.ref_column_name || col.column_name]
// for excel & json, if the column name is changed in TemplateEditor,
// then only col.column_name exists in data, else col.ref_column_name
// for csv, col.column_name always exists in data
// since it streams the data in getData() with the updated col.column_name
const key = col.column_name in data ? col.column_name : col.ref_column_name
let d = data[key]
if (col.uidt === UITypes.Date && d) {
let dateFormat
if (col?.meta?.date_format) {
@ -241,12 +263,10 @@ function remapColNames(batchData: any[], columns: ColumnType[]) {
dateFormat = getDateFormat(d)
dateFormatMap[col.key] = dateFormat
}
d = dayjs(d).utc().format(dateFormat)
d = parseStringDate(d, dateFormat)
} else if (col.uidt === UITypes.DateTime && d) {
// TODO: handle more formats for DateTime
d = dayjs(data[col.ref_column_name || col.column_name])
.utc()
.format('YYYY-MM-DD HH:mm')
const dateTimeFormat = getDateTimeFormat(data[key])
d = dayjs(data[key], dateTimeFormat).format('YYYY-MM-DD HH:mm')
}
return {
...aggObj,
@ -256,10 +276,11 @@ function remapColNames(batchData: any[], columns: ColumnType[]) {
)
}
function missingRequiredColumnsValidation() {
function missingRequiredColumnsValidation(tn: string) {
const missingRequiredColumns = columns.value.filter(
(c: Record<string, any>) =>
(c.pk ? !c.ai && !c.cdf : !c.cdf && c.rqd) && !srcDestMapping.value.some((r) => r.destCn === c.title),
(c.pk ? !c.ai && !c.cdf : !c.cdf && c.rqd) &&
!srcDestMapping.value[tn].some((r: Record<string, any>) => r.destCn === c.title),
)
if (missingRequiredColumns.length) {
@ -270,153 +291,156 @@ function missingRequiredColumnsValidation() {
return true
}
function atLeastOneEnabledValidation() {
if (srcDestMapping.value.filter((v) => v.enabled === true).length === 0) {
function atLeastOneEnabledValidation(tn: string) {
if (srcDestMapping.value[tn].filter((v: Record<string, any>) => v.enabled === true).length === 0) {
message.error(t('msg.error.selectAtleastOneColumn'))
return false
}
return true
}
function fieldsValidation(record: Record<string, any>) {
function fieldsValidation(record: Record<string, any>, tn: string) {
// if it is not selected, then pass validation
if (!record.enabled) {
return true
}
const tableName = meta.value?.title || ''
if (!record.destCn) {
message.error(`${t('msg.error.columnDescriptionNotFound')} ${record.srcCn}`)
return false
}
if (srcDestMapping.value.filter((v) => v.destCn === record.destCn).length > 1) {
if (srcDestMapping.value[tn].filter((v: Record<string, any>) => v.destCn === record.destCn).length > 1) {
message.error(t('msg.error.duplicateMappingFound'))
return false
}
const v = columns.value.find((c) => c.title === record.destCn) as Record<string, any>
// check if the input contains null value for a required column
if (v.pk ? !v.ai && !v.cdf : !v.cdf && v.rqd) {
if (
importData[tableName]
.slice(0, maxRowsToParse)
.some((r: Record<string, any>) => r[record.srcCn] === null || r[record.srcCn] === undefined || r[record.srcCn] === '')
) {
message.error(t('msg.error.nullValueViolatesNotNull'))
}
}
switch (v.uidt) {
case UITypes.Number:
for (const tableName of Object.keys(importData)) {
// check if the input contains null value for a required column
if (v.pk ? !v.ai && !v.cdf : !v.cdf && v.rqd) {
if (
importData[tableName]
.slice(0, maxRowsToParse)
.some(
(r: Record<string, any>) => r[record.sourceCn] !== null && r[record.srcCn] !== undefined && isNaN(+r[record.srcCn]),
)
.some((r: Record<string, any>) => r[record.srcCn] === null || r[record.srcCn] === undefined || r[record.srcCn] === '')
) {
message.error(t('msg.error.sourceHasInvalidNumbers'))
return false
message.error(t('msg.error.nullValueViolatesNotNull'))
}
}
break
case UITypes.Checkbox:
if (
importData[tableName].slice(0, maxRowsToParse).some((r: Record<string, any>) => {
if (r[record.srcCn] !== null && r[record.srcCn] !== undefined) {
let input = r[record.srcCn]
if (typeof input === 'string') {
input = input.replace(/["']/g, '').toLowerCase().trim()
return !(
input === 'false' ||
input === 'no' ||
input === 'n' ||
input === '0' ||
input === 'true' ||
input === 'yes' ||
input === 'y' ||
input === '1'
)
}
switch (v.uidt) {
case UITypes.Number:
if (
importData[tableName]
.slice(0, maxRowsToParse)
.some(
(r: Record<string, any>) => r[record.sourceCn] !== null && r[record.srcCn] !== undefined && isNaN(+r[record.srcCn]),
)
) {
message.error(t('msg.error.sourceHasInvalidNumbers'))
return false
}
return input !== 1 && input !== 0 && input !== true && input !== false
}
break
case UITypes.Checkbox:
if (
importData[tableName].slice(0, maxRowsToParse).some((r: Record<string, any>) => {
if (r[record.srcCn] !== null && r[record.srcCn] !== undefined) {
let input = r[record.srcCn]
if (typeof input === 'string') {
input = input.replace(/["']/g, '').toLowerCase().trim()
return !(
input === 'false' ||
input === 'no' ||
input === 'n' ||
input === '0' ||
input === 'true' ||
input === 'yes' ||
input === 'y' ||
input === '1'
)
}
return input !== 1 && input !== 0 && input !== true && input !== false
}
return false
})
) {
message.error(t('msg.error.sourceHasInvalidBoolean'))
return false
})
) {
message.error(t('msg.error.sourceHasInvalidBoolean'))
return false
}
break
}
break
}
}
return true
}
function updateImportTips(projectName: string, tableName: string, progress: number, total: number) {
importingTips.value[
`${projectName}-${tableName}`
] = `Importing data to ${projectName} - ${tableName}: ${progress}/${total} records`
}
async function importTemplate() {
if (importOnly) {
// validate required columns
if (!missingRequiredColumnsValidation()) return
if (importDataOnly) {
for (const table of data.tables) {
// validate required columns
if (!missingRequiredColumnsValidation(table.table_name)) return
// validate at least one column needs to be selected
if (!atLeastOneEnabledValidation()) return
// validate at least one column needs to be selected
if (!atLeastOneEnabledValidation(table.table_name)) return
}
try {
isImporting.value = true
const tableName = meta.value?.title
// only one file is allowed currently
const data = importData[Object.keys(importData)[0]]
const projectName = project.value.title!
const total = data.length
for (let i = 0, progress = 0; i < total; i += maxRowsToParse) {
const batchData = data.slice(i, i + maxRowsToParse).map((row: Record<string, any>) =>
srcDestMapping.value.reduce((res: Record<string, any>, col: Record<string, any>) => {
if (col.enabled && col.destCn) {
const v = columns.value.find((c: Record<string, any>) => c.title === col.destCn) as Record<string, any>
let input = row[col.srcCn]
// parse potential boolean values
if (v.uidt === UITypes.Checkbox) {
input = input.replace(/["']/g, '').toLowerCase().trim()
if (input === 'false' || input === 'no' || input === 'n') {
input = '0'
} else if (input === 'true' || input === 'yes' || input === 'y') {
input = '1'
}
} else if (v.uidt === UITypes.Number) {
if (input === '') {
input = null
}
} else if (v.uidt === UITypes.SingleSelect || v.uidt === UITypes.MultiSelect) {
if (input === '') {
input = null
}
}
res[col.destCn] = input
await Promise.all(
Object.keys(importData).map((key: string) =>
(async (k) => {
const data = importData[k]
const total = data.length
for (let i = 0, progress = 0; i < total; i += maxRowsToParse) {
const batchData = data.slice(i, i + maxRowsToParse).map((row: Record<string, any>) =>
srcDestMapping.value[k].reduce((res: Record<string, any>, col: Record<string, any>) => {
if (col.enabled && col.destCn) {
const v = columns.value.find((c: Record<string, any>) => c.title === col.destCn) as Record<string, any>
let input = row[col.srcCn]
// parse potential boolean values
if (v.uidt === UITypes.Checkbox) {
input = input.replace(/["']/g, '').toLowerCase().trim()
if (input === 'false' || input === 'no' || input === 'n') {
input = '0'
} else if (input === 'true' || input === 'yes' || input === 'y') {
input = '1'
}
} else if (v.uidt === UITypes.Number) {
if (input === '') {
input = null
}
} else if (v.uidt === UITypes.SingleSelect || v.uidt === UITypes.MultiSelect) {
if (input === '') {
input = null
}
} else if (v.uidt === UITypes.Date) {
input = parseStringDate(input, v.meta.date_format)
}
res[col.destCn] = input
}
return res
}, {}),
)
await $api.dbTableRow.bulkCreate('noco', projectName, tableName!, batchData)
updateImportTips(projectName, tableName!, progress, total)
progress += batchData.length
}
return res
}, {}),
)
await $api.dbTableRow.bulkCreate('noco', projectName, tableName!, batchData)
importingTip.value = `Importing data to ${projectName}: ${progress}/${total} records`
progress += batchData.length
}
})(key),
),
)
// reload table
reloadHook.trigger()
@ -473,8 +497,8 @@ async function importTemplate() {
}
const tableMeta = await $api.dbTable.create(project?.value?.id as string, {
table_name: table.ref_table_name,
// leave title empty to get a generated one based on ref_table_name
table_name: table.table_name,
// leave title empty to get a generated one based on table_name
title: '',
columns: table.columns || [],
})
@ -493,22 +517,25 @@ async function importTemplate() {
}
// bulk insert data
if (importData) {
let total = 0
let progress = 0
const offset = maxRowsToParse
const projectName = project.value.title as string
await Promise.all(
data.tables.map((table: Record<string, any>) =>
(async (tableMeta) => {
let progress = 0
let total = 0
// use ref_table_name here instead of table_name
// since importData[talbeMeta.table_name] would be empty after renaming
const data = importData[tableMeta.ref_table_name]
if (data) {
total += data.length
for (let i = 0; i < data.length; i += offset) {
importingTip.value = `Importing data to ${projectName}: ${progress}/${total} records`
updateImportTips(projectName, tableMeta.title, progress, total)
const batchData = remapColNames(data.slice(i, i + offset), tableMeta.columns)
await $api.dbTableRow.bulkCreate('noco', projectName, tableMeta.title, batchData)
progress += batchData.length
}
updateImportTips(projectName, tableMeta.title, total, total)
}
})(table),
),
@ -530,18 +557,23 @@ async function importTemplate() {
}
function mapDefaultColumns() {
srcDestMapping.value = []
for (const col of importColumns[0]) {
const o = { srcCn: col.column_name, destCn: '', enabled: true }
if (columns.value) {
const tableColumn = columns.value.find((c: Record<string, any>) => c.title === col.column_name)
if (tableColumn) {
o.destCn = tableColumn.title as string
} else {
o.enabled = false
srcDestMapping.value = {}
for (let i = 0; i < data.tables.length; i++) {
for (const col of importColumns[i]) {
const o = { srcCn: col.column_name, destCn: '', enabled: true }
if (columns.value) {
const tableColumn = columns.value.find((c) => c.column_name === col.column_name)
if (tableColumn) {
o.destCn = tableColumn.title as string
} else {
o.enabled = false
}
}
if (!(data.tables[i].table_name in srcDestMapping.value)) {
srcDestMapping.value[data.tables[i].table_name] = []
}
srcDestMapping.value[data.tables[i].table_name].push(o)
}
srcDestMapping.value.push(o)
}
}
@ -550,24 +582,14 @@ defineExpose({
isValid,
})
onMounted(() => {
if (importOnly) {
mapDefaultColumns()
}
})
function handleEditableTnChange(idx: number) {
const oldValue = prevEditableTn.value[idx]
const newValue = data.tables[idx].ref_table_name
if (data.tables.filter((t) => t.ref_table_name === newValue).length > 1) {
const newValue = data.tables[idx].table_name
if (data.tables.filter((t) => t.table_name === newValue).length > 1) {
message.warn('Duplicate Table Name')
data.tables[idx].ref_table_name = oldValue
data.tables[idx].table_name = oldValue
} else {
prevEditableTn.value[idx] = newValue
if (oldValue !== newValue) {
// update the key name of importData
delete Object.assign(importData, { [newValue]: importData[oldValue] })[oldValue]
}
}
setEditableTn(idx, false)
}
@ -578,8 +600,13 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
</script>
<template>
<a-spin :spinning="isImporting" :tip="importingTip" size="large">
<a-card v-if="importOnly">
<a-spin :spinning="isImporting" size="large">
<template #tip>
<p v-for="(importingTip, idx) of importingTips" :key="idx" class="mt-[10px]">
{{ importingTip }}
</p>
</template>
<a-card v-if="importDataOnly">
<a-form :model="data" name="import-only">
<p v-if="data.tables && quickImportType === 'excel'" class="text-center">
{{ data.tables.length }} sheet{{ data.tables.length > 1 ? 's' : '' }}
@ -592,8 +619,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<template #header>
<span class="font-weight-bold text-lg flex items-center gap-2">
<mdi-table class="text-primary" />
{{ table.ref_table_name }}
{{ table.table_name }}
</span>
</template>
@ -601,7 +627,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
v-if="srcDestMapping"
class="template-form"
row-class-name="template-form-row"
:data-source="srcDestMapping"
:data-source="srcDestMapping[table.table_name]"
:columns="srcDestMappingColumns"
:pagination="false"
>
@ -660,21 +686,21 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
>
<a-collapse-panel v-for="(table, tableIdx) of data.tables" :key="tableIdx">
<template #header>
<a-form-item v-if="editableTn[tableIdx]" v-bind="validateInfos[`tables.${tableIdx}.ref_table_name`]" no-style>
<a-form-item v-if="editableTn[tableIdx]" v-bind="validateInfos[`tables.${tableIdx}.table_name`]" no-style>
<a-input
v-model:value="table.ref_table_name"
class="max-w-xs"
v-model:value="table.table_name"
class="max-w-xs font-weight-bold text-lg"
size="large"
hide-details
:bordered="false"
@click="$event.stopPropagation()"
@blur="handleEditableTnChange(tableIdx)"
@keydown.enter="handleEditableTnChange(tableIdx)"
/>
</a-form-item>
<span v-else class="font-weight-bold text-lg flex items-center gap-2" @click="setEditableTn(tableIdx, true)">
<mdi-table class="text-primary" />
{{ table.ref_table_name }}
{{ table.table_name }}
</span>
</template>
@ -723,7 +749,14 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'column_name'">
<a-form-item v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.${column.key}`]">
<a-input :ref="inputRefs.set" v-model:value="record.column_name" />
<a-input
:ref="
(el) => {
inputRefs[record.key] = el
}
"
v-model:value="record.column_name"
/>
</a-form-item>
</template>
@ -794,7 +827,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<span>Add Number Column</span>
</template>
<a-button class="group" @click="addNewColumnRow(table, 'Number')">
<a-button class="group" @click="addNewColumnRow(tableIdx, 'Number')">
<div class="flex items-center">
<mdi-numeric class="group-hover:!text-accent flex text-lg" />
</div>
@ -807,7 +840,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<span>Add SingleLineText Column</span>
</template>
<a-button class="group" @click="addNewColumnRow(table, 'SingleLineText')">
<a-button class="group" @click="addNewColumnRow(tableIdx, 'SingleLineText')">
<div class="flex items-center">
<mdi-alpha-a class="group-hover:!text-accent text-lg" />
</div>
@ -820,7 +853,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<span>Add LongText Column</span>
</template>
<a-button class="group" @click="addNewColumnRow(table, 'LongText')">
<a-button class="group" @click="addNewColumnRow(tableIdx, 'LongText')">
<div class="flex items-center">
<mdi-text class="group-hover:!text-accent text-lg" />
</div>
@ -833,7 +866,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<span>Add Other Column</span>
</template>
<a-button class="group" @click="addNewColumnRow(table, 'SingleLineText')">
<a-button class="group" @click="addNewColumnRow(tableIdx, 'SingleLineText')">
<div class="flex items-center gap-1">
<mdi-plus class="group-hover:!text-accent text-lg" />
</div>

2
packages/nc-gui/components/virtual-cell/BelongsTo.vue

@ -31,7 +31,7 @@ const row = inject(RowInj)!
const active = inject(ActiveCellInj)!
const readOnly = inject(ReadonlyInj, false)
const readOnly = inject(ReadonlyInj, ref(false))
const isForm = inject(IsFormInj, ref(false))

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

@ -6,7 +6,7 @@ import { CellValueInj, ColumnInj, computed, handleTZ, inject, ref, replaceUrlsWi
// todo: column type doesn't have required property `error` - throws in typecheck
const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }>
const value = inject(CellValueInj)
const cellValue = inject(CellValueInj)
const { isPg } = useProject()
@ -20,7 +20,7 @@ const showEditFormulaWarningMessage = () => {
}, 3000)
}
const result = computed(() => (isPg.value ? handleTZ(value) : value))
const result = computed(() => (isPg.value ? handleTZ(cellValue?.value) : cellValue?.value))
const urls = computed(() => replaceUrlsWithLink(result.value))
</script>

2
packages/nc-gui/components/virtual-cell/HasMany.vue

@ -27,7 +27,7 @@ const reloadRowTrigger = inject(ReloadRowDataHookInj, createEventHook())
const isForm = inject(IsFormInj)
const readOnly = inject(ReadonlyInj, false)
const readOnly = inject(ReadonlyInj, ref(false))
const isLocked = inject(IsLockedInj)

2
packages/nc-gui/components/virtual-cell/Lookup.vue

@ -17,7 +17,7 @@ import {
const { metas, getMeta } = useMetas()
provide(ReadonlyInj, true)
provide(ReadonlyInj, ref(true))
const column = inject(ColumnInj)! as Ref<ColumnType & { colOptions: LookupType }>

2
packages/nc-gui/components/virtual-cell/ManyToMany.vue

@ -28,7 +28,7 @@ const reloadRowTrigger = inject(ReloadRowDataHookInj, createEventHook())
const isForm = inject(IsFormInj)
const readOnly = inject(ReadonlyInj, false)
const readOnly = inject(ReadonlyInj, ref(false))
const isLocked = inject(IsLockedInj)

41
packages/nc-gui/components/virtual-cell/components/ItemChip.vue

@ -1,5 +1,15 @@
<script lang="ts" setup>
import { ActiveCellInj, IsFormInj, IsLockedInj, ReadonlyInj, inject, ref, useLTARStoreOrThrow, useUIPermission } from '#imports'
import {
ActiveCellInj,
IsFormInj,
IsLockedInj,
ReadonlyInj,
inject,
ref,
useExpandedFormDetached,
useLTARStoreOrThrow,
useUIPermission,
} from '#imports'
interface Props {
value?: string | number | boolean
@ -14,7 +24,7 @@ const { relatedTableMeta } = useLTARStoreOrThrow()!
const { isUIAllowed } = useUIPermission()
const readOnly = inject(ReadonlyInj, false)
const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
@ -22,7 +32,19 @@ const isForm = inject(IsFormInj)!
const isLocked = inject(IsLockedInj, ref(false))
const expandedFormDlg = ref(false)
const { open } = useExpandedFormDetached()
function openExpandedForm() {
if (!readOnly && !isLocked.value) {
open({
isOpen: true,
row: { row: item, rowMeta: {}, oldRow: { ...item } },
meta: relatedTableMeta.value,
loadRow: true,
useMetaFields: true,
})
}
}
</script>
<script lang="ts">
@ -35,24 +57,13 @@ export default {
<div
class="chip group py-1 px-2 mr-1 my-1 flex items-center bg-blue-100/60 hover:bg-blue-100/40 rounded-[2px]"
:class="{ active }"
@click="expandedFormDlg = true"
@click="openExpandedForm"
>
<span class="name">{{ value }}</span>
<div v-show="active || isForm" v-if="!readOnly && !isLocked && isUIAllowed('xcDatatableEditable')" class="flex items-center">
<MdiCloseThick class="unlink-icon text-xs text-gray-500/50 group-hover:text-gray-500" @click.stop="emit('unlink')" />
</div>
<Suspense>
<SmartsheetExpandedForm
v-if="!readOnly && !isLocked && expandedFormDlg"
v-model="expandedFormDlg"
:row="{ row: item, rowMeta: {}, oldRow: { ...item } }"
:meta="relatedTableMeta"
load-row
use-meta-fields
/>
</Suspense>
</div>
</template>

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

@ -29,7 +29,7 @@ const isPublic = inject(IsPublicInj, ref(false))
const column = inject(ColumnInj)
const readonly = inject(ReadonlyInj, false)
const readonly = inject(ReadonlyInj, ref(false))
const {
childrenList,
@ -181,7 +181,7 @@ watch(
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:row="{ row: expandedFormRow }"
:row="{ row: expandedFormRow, oldRow: expandedFormRow, rowMeta: {} }"
:meta="relatedTableMeta"
load-row
use-meta-fields

43
packages/nc-gui/composables/useDialog/index.ts

@ -1,17 +1,22 @@
import type { VNode } from '@vue/runtime-dom'
import { isVNode, render } from '@vue/runtime-dom'
import type { AppContext, VNode } from '@vue/runtime-dom'
import { Suspense, isVNode, render } from '@vue/runtime-dom'
import type { ComponentPublicInstance } from '@vue/runtime-core'
import type { MaybeRef } from '@vueuse/core'
import { isClient } from '@vueuse/core'
import { createEventHook, h, ref, toReactive, tryOnScopeDispose, unref, useNuxtApp, watch } from '#imports'
interface UseDialogOptions {
target: MaybeRef<HTMLElement | ComponentPublicInstance>
context: Partial<AppContext>
}
/**
* Programmatically create a component and attach it to the body (or a specific mount target), like a dialog or modal.
* This composable is not SSR friendly - it should be used only on the client.
*
* @param componentOrVNode The component to create and attach. Can be a VNode or a component definition.
* @param props The props to pass to the component.
* @param mountTarget The target to attach the component to. Defaults to the document body
* @param options Additional options to use {@see UseDialogOptions}
*
* @example
* import { useDialog } from '#imports'
@ -39,7 +44,7 @@ import { createEventHook, h, ref, toReactive, tryOnScopeDispose, unref, useNuxtA
export function useDialog(
componentOrVNode: any,
props: NonNullable<Parameters<typeof h>[1]> = {},
mountTarget?: MaybeRef<Element | ComponentPublicInstance>,
{ target, context }: Partial<UseDialogOptions> = {},
) {
if (typeof document === 'undefined' || !isClient) {
console.warn('[useDialog]: Cannot use outside of browser!')
@ -54,24 +59,36 @@ export function useDialog(
const vNodeRef = ref<VNode>()
let _mountTarget = unref(mountTarget)
_mountTarget = _mountTarget ? ('$el' in _mountTarget ? (_mountTarget.$el as HTMLElement) : _mountTarget) : document.body
/** if specified, append vnode to mount target instead of document.body */
_mountTarget.appendChild(domNode)
const mountTarget = ref<HTMLElement>()
/** When props change, we want to re-render the element with the new prop values */
const stop = watch(
toReactive(props),
(reactiveProps) => {
const _mountTarget = unref(target)
/**
* If it's a component instance, use the instance's root element (`$el`), otherwise use the element itself
* If no target is specified, use the document body
*/
mountTarget.value = _mountTarget
? '$el' in _mountTarget
? (_mountTarget.$el as HTMLElement)
: _mountTarget
: document.body
/** if specified, append vnode to mount target instead of document.body */
mountTarget.value.appendChild(domNode)
// if it's a vnode, just render it, otherwise wrap in `h` to create a vnode
const vNode = isVNode(componentOrVNode) ? componentOrVNode : h(componentOrVNode, reactiveProps)
vNode.appContext = useNuxtApp().vueApp._context
vNode.appContext = { ...useNuxtApp().vueApp._context, ...context }
vNodeRef.value = vNode
render(vNode, domNode)
// wrap in suspense to resolve potential promises
render(h(Suspense, vNode), domNode)
if (!isMounted) mountedHook.trigger()
},
@ -90,7 +107,7 @@ export function useDialog(
setTimeout(() => {
try {
;(_mountTarget as HTMLElement)?.removeChild(domNode)
;(mountTarget.value as HTMLElement)?.removeChild(domNode)
} catch (e) {}
}, 100)

44
packages/nc-gui/composables/useExpandedFormDetached/index.ts

@ -0,0 +1,44 @@
import type { TableType, ViewType } from 'nocodb-sdk'
import { createEventHook, ref, useInjectionState } from '#imports'
import type { Row } from '~/lib'
interface UseExpandedFormDetachedProps {
'isOpen'?: boolean
'row': Row | null
'state'?: Record<string, any> | null
'meta': TableType
'loadRow'?: boolean
'useMetaFields'?: boolean
'rowId'?: string
'view'?: ViewType
'onCancel'?: Function
'onUpdate:modelValue'?: Function
}
const [setup, use] = useInjectionState(() => {
return ref<UseExpandedFormDetachedProps[]>([])
})
export function useExpandedFormDetached() {
let states = use()!
if (!states) {
states = setup()
}
const closeHook = createEventHook<void>()
const index = ref(-1)
const open = (props: UseExpandedFormDetachedProps) => {
states.value.push(props)
index.value = states.value.length - 1
}
const close = (i?: number) => {
states.value.splice(i || index.value, 1)
if (index.value === i || !i) closeHook.trigger()
}
return { states, open, close, onClose: closeHook.on }
}

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

@ -14,7 +14,7 @@ export function useMultiSelect(
fields: MaybeRef<any[]>,
data: MaybeRef<any[]>,
editEnabled: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean | undefined>,
clearCell: Function,
makeEditable: Function,
scrollToActiveCell?: (row?: number | null, col?: number | null) => void,

8
packages/nc-gui/composables/useRoles/index.ts

@ -12,7 +12,7 @@ import type { ProjectRole, Role, Roles } from '~/lib'
* * `loadProjectRoles` - a function to load the project roles for a specific project (by id)
*/
export const useRoles = createSharedComposable(() => {
const { user } = useGlobal()
const { user, previewAs } = useGlobal()
const { api } = useApi()
@ -57,7 +57,11 @@ export const useRoles = createSharedComposable(() => {
}
}
function hasRole(role: Role | ProjectRole | string) {
function hasRole(role: Role | ProjectRole | string, includePreviewRoles = false) {
if (previewAs.value && includePreviewRoles) {
return previewAs.value === role
}
return allRoles.value[role]
}

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

@ -1,21 +1,21 @@
import { ViewTypes } from 'nocodb-sdk'
import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { computed, reactive, ref, unref, useInjectionState, useNuxtApp, useProject, useTemplateRefsList } from '#imports'
import { computed, reactive, ref, unref, useInjectionState, useNuxtApp, useProject } from '#imports'
const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
(
view: Ref<ViewType | undefined>,
meta: Ref<TableType | KanbanType | undefined>,
shared = false,
initalSorts?: Ref<SortType[]>,
initialSorts?: Ref<SortType[]>,
initialFilters?: Ref<FilterType[]>,
) => {
const { $api } = useNuxtApp()
const { sqlUi } = useProject()
const cellRefs = useTemplateRefsList<HTMLTableDataCellElement>()
const cellRefs = ref<HTMLTableDataCellElement[]>([])
// state
// todo: move to grid view store
@ -50,7 +50,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const isSqlView = computed(() => (meta.value as TableType)?.type === 'view')
const sorts = ref<SortType[]>(unref(initalSorts) ?? [])
const sorts = ref<SortType[]>(unref(initialSorts) ?? [])
const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? [])
return {
@ -78,9 +78,9 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
export { useProvideSmartsheetStore }
export function useSmartsheetStoreOrThrow() {
const smartsheetStore = useSmartsheetStore()
const state = useSmartsheetStore()
if (smartsheetStore == null) throw new Error('Please call `useSmartsheetStore` on the appropriate parent component')
if (!state) throw new Error('Please call `useProvideSmartsheetStore` on the appropriate parent component')
return smartsheetStore
return state
}

19
packages/nc-gui/composables/useViewData.ts

@ -1,4 +1,4 @@
import { ViewTypes } from 'nocodb-sdk'
import { ViewTypes, isVirtualCol } from 'nocodb-sdk'
import type { Api, ColumnType, FormType, GalleryType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import {
@ -202,7 +202,6 @@ export function useViewData(
async function insertRow(
currentRow: Row,
_rowIndex = formattedData.value?.length,
ltarState: Record<string, any> = {},
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
) {
@ -266,15 +265,23 @@ export function useViewData(
)
// audit
$api.utils.auditRowUpdate(id, {
fk_model_id: meta.value?.id as string,
fk_model_id: metaValue?.id as string,
column_name: property,
row_id: id,
value: getHTMLEncodedText(toUpdate.row[property]),
prev_value: getHTMLEncodedText(toUpdate.oldRow[property]),
})
/** update row data(to sync formula and other related columns) */
Object.assign(toUpdate.row, updatedRowData)
/** update row data(to sync formula and other related columns)
* update only virtual columns data to avoid overwriting any changes made by user
*/
Object.assign(
toUpdate.row,
metaValue!.columns!.reduce<Record<string, any>>((acc: Record<string, any>, col: ColumnType) => {
if (isVirtualCol(col)) acc[col.title!] = updatedRowData[col.title!]
return acc
}, {} as Record<string, any>),
)
Object.assign(toUpdate.oldRow, updatedRowData)
} catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`)
@ -293,7 +300,7 @@ export function useViewData(
await until(() => !(row.rowMeta?.new && row.rowMeta?.saving)).toMatch((v) => v)
if (row.rowMeta.new) {
return await insertRow(row, formattedData.value.indexOf(row), ltarState, args)
return await insertRow(row, ltarState, args)
} else {
await updateRowProperty(row, property!, args)
}

2
packages/nc-gui/context/index.ts

@ -20,7 +20,7 @@ export const IsKanbanInj: InjectionKey<Ref<boolean>> = Symbol('is-kanban-injecti
export const IsLockedInj: InjectionKey<Ref<boolean>> = Symbol('is-locked-injection')
export const CellValueInj: InjectionKey<Ref<any>> = Symbol('cell-value-injection')
export const ActiveViewInj: InjectionKey<Ref<ViewType>> = Symbol('active-view-injection')
export const ReadonlyInj: InjectionKey<boolean> = Symbol('readonly-injection')
export const ReadonlyInj: InjectionKey<Ref<boolean>> = Symbol('readonly-injection')
/** when bool is passed, it indicates if a loading spinner should be visible while reloading */
export const ReloadViewDataHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-data-injection')
export const ReloadViewMetaHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-meta-injection')

34
packages/nc-gui/lang/fr.json

@ -106,7 +106,7 @@
"commenter": "Commentateur",
"viewer": "Lecture seule"
},
"sqlVIew": "SQL View"
"sqlVIew": "Vue SQL"
},
"datatype": {
"ID": "Identifiant",
@ -160,7 +160,7 @@
"isNotNull": "est non null"
},
"title": {
"erdView": "ERD View",
"erdView": "Vue ERD",
"newProj": "Nouveau projet",
"myProject": "Mes projets",
"formTitle": "Intitulé du formulaire",
@ -188,10 +188,10 @@
"headLogin": "Connexion | Nocodb",
"resetPassword": "Réinitialiser le mot de passe",
"teamAndSettings": "Équipe & paramètres",
"apiDocs": "API Docs",
"apiDocs": "Docs API",
"importFromAirtable": "Importer depuis Airtable",
"generateToken": "Generate Token",
"APIsAndSupport": "APIs & Support",
"APIsAndSupport": "Les API et la prise en charge",
"helpCenter": "Help center",
"swaggerDocumentation": "Swagger Documentation",
"quickImportFrom": "Quick Import From",
@ -271,10 +271,10 @@
"accentColor": "Accent Color",
"customTheme": "Custom Theme",
"requestDataSource": "Request a data source you need?",
"apiKey": "API Key",
"apiKey": "Clé d'API",
"sharedBase": "Shared Base",
"importData": "Import Data",
"importSecondaryViews": "Import Secondary Views",
"importSecondaryViews": "Importer des vues secondaires",
"importRollupColumns": "Import Rollup Columns",
"importLookupColumns": "Import Lookup Columns",
"importAttachmentColumns": "Import Attachment Columns",
@ -324,7 +324,7 @@
"translate": "Aider à la traduction",
"account": {
"authToken": "Copier le jeton d'authentification",
"swagger": "Swagger: REST APIs",
"swagger": "Swagger : les API REST",
"projInfo": "Copier les informations du projet",
"themes": "Thèmes"
},
@ -406,7 +406,7 @@
"sponsorUs": "Nous Parrainer",
"sendEmail": "ENVOYER UN EMAIL",
"addUserToProject": "Add user to project",
"getApiSnippet": "Get API Snippet",
"getApiSnippet": "Récupérer le Snippet API",
"clearCell": "Clear cell",
"addFilterGroup": "Add Filter Group",
"linkRecord": "Link record",
@ -418,7 +418,7 @@
"erd": {
"showColumns": "Show Columns",
"showPkAndFk": "Show Primary and Foreign Keys",
"showSqlViews": "Show SQL Views",
"showSqlViews": "Afficher les vues SQL",
"showMMTables": "Show Many to Many tables",
"showJunctionTableNames": "Show Junction Table Names"
},
@ -434,8 +434,8 @@
"saveChanges": "Sauvegarder les modifications",
"xcDB": "Créer un nouveau projet",
"extDB": "Base de données supportées MySQL, PostgreSQL, SQL Server & SQLite",
"apiRest": "Accessible via l'API REST",
"apiGQL": "Accessible via l'API GraphQL",
"apiRest": "Accessible via les API REST",
"apiGQL": "Accessible via les API GraphQL",
"theme": {
"dark": "Nuit (^⇧B)",
"light": "Jour (^⇧B)"
@ -582,7 +582,7 @@
"requriedFieldsCantBeMoved": "Required field can't be moved",
"updateNotAllowedWithoutPK": "Update not allowed for table which doesn't have primary key",
"autoIncFieldNotEditable": "Auto increment field is not editable",
"editingPKnotSupported": "Editing primary key not supported",
"editingPKnotSupported": "Modification de la clé primaire non prise en charge",
"deletedCache": "Deleted cache successfully",
"cacheEmpty": "Cache is empty",
"exportedCache": "Exported Cache Successfully",
@ -590,7 +590,7 @@
"noColumnsToUpdate": "No columns to update",
"tableDeleted": "Deleted table successfully",
"generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base",
"deleteViewConfirmation": "Are you sure you want to delete this view?",
"deleteViewConfirmation": "Êtes-vous sûr de vouloir effacer cette vue ?",
"deleteTableConfirmation": "Do you want to delete the table",
"showM2mTables": "Show M2M Tables",
"deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack."
@ -639,7 +639,7 @@
"rowUpdateFailed": "Row update failed",
"deleteRowFailed": "Failed to delete row",
"setFormDataFailed": "Failed to set form data",
"formViewUpdateFailed": "Failed to update form view",
"formViewUpdateFailed": "Échec de la mise à jour de la vue du formulaire",
"tableNameRequired": "Table name is required",
"nameShouldStartWithAnAlphabetOr_": "Name should start with an alphabet or _",
"followingCharactersAreNotAllowed": "Following characters are not allowed",
@ -678,12 +678,12 @@
"pluginSettingsSaved": "Plugin settings saved successfully",
"pluginTested": "Successfully tested plugin settings",
"tableRenamed": "Table renamed successfully",
"viewDeleted": "View deleted successfully",
"viewDeleted": "Vue effacée avec succès",
"primaryColumnUpdated": "Successfully updated as primary column",
"tableDataExported": "Successfully exported all table data",
"updated": "Successfully updated",
"sharedViewDeleted": "Deleted shared view successfully",
"viewRenamed": "View renamed successfully",
"sharedViewDeleted": "Vue partagée effacée avec succès",
"viewRenamed": "Vue renommée avec succès",
"tokenGenerated": "Token generated successfully",
"tokenDeleted": "Token deleted successfully",
"userAddedToProject": "Successfully added user to project",

26
packages/nc-gui/lang/it.json

@ -1,6 +1,6 @@
{
"general": {
"home": "Home",
"home": "Pagina iniziale",
"load": "Carica",
"open": "Apri",
"close": "Chiudi",
@ -56,15 +56,15 @@
"notification": "Notifica",
"reference": "Riferimento",
"function": "Funzione",
"confirm": "Confirm",
"generate": "Generate",
"copy": "Copy",
"confirm": "Conferma",
"generate": "Genera",
"copy": "Copia",
"misc": "Miscellaneous",
"lock": "Lock",
"unlock": "Unlock",
"credentials": "Credentials",
"help": "Help",
"questions": "Questions",
"lock": "Blocca",
"unlock": "Sblocca",
"credentials": "Credenziali",
"help": "Aiuto",
"questions": "Domande",
"reachOut": "Reach out here",
"betaNote": "This feature is currently in beta.",
"moreInfo": "More information can be found here",
@ -109,7 +109,7 @@
"sqlVIew": "SQL View"
},
"datatype": {
"ID": "ID",
"ID": "Numero",
"ForeignKey": "Chiave straniera",
"SingleLineText": "Testo a riga singola",
"LongText": "Testo lungo",
@ -196,7 +196,7 @@
"swaggerDocumentation": "Swagger Documentation",
"quickImportFrom": "Quick Import From",
"quickImport": "Quick Import",
"advancedSettings": "Advanced Settings",
"advancedSettings": "Impostazioni Avanzate",
"codeSnippet": "Code Snippet"
},
"labels": {
@ -266,10 +266,10 @@
"onUpdate": "All'aggiornamento",
"onDelete": "All'eliminazione",
"account": "Account",
"language": "Language",
"language": "Lingua",
"primaryColor": "Primary Color",
"accentColor": "Accent Color",
"customTheme": "Custom Theme",
"customTheme": "Tema Personalizzato",
"requestDataSource": "Request a data source you need?",
"apiKey": "API Key",
"sharedBase": "Shared Base",

372
packages/nc-gui/lang/pl.json

@ -16,7 +16,7 @@
"cancel": "Anuluj",
"submit": "Składać",
"create": "Utwórz",
"duplicate": "Duplicate",
"duplicate": "Duplikuj",
"insert": "Wstawić",
"delete": "Kasować",
"update": "Aktualizacja",
@ -56,20 +56,20 @@
"notification": "Powiadomienie",
"reference": "Sprawdzenie",
"function": "Funkcjonować",
"confirm": "Confirm",
"generate": "Generate",
"copy": "Copy",
"misc": "Miscellaneous",
"lock": "Lock",
"unlock": "Unlock",
"credentials": "Credentials",
"help": "Help",
"questions": "Questions",
"reachOut": "Reach out here",
"betaNote": "This feature is currently in beta.",
"moreInfo": "More information can be found here",
"logs": "Logs",
"groupingField": "Grouping Field"
"confirm": "Zatwierdź",
"generate": "Wygeneruj",
"copy": "Kopiuj",
"misc": "Pozostałe",
"lock": "Zablokuj",
"unlock": "Odblokuj",
"credentials": "Dane uwierzytelniające",
"help": "Pomoc",
"questions": "Pytania",
"reachOut": "Skontaktuj się tutaj",
"betaNote": "Ta funkcja jest nadal w fazie beta.",
"moreInfo": "Więcej informacji można znaleźć tutaj",
"logs": "Logi",
"groupingField": "Grupowanie pola"
},
"objects": {
"project": "Projekt",
@ -106,10 +106,10 @@
"commenter": "Komentator",
"viewer": "Widz"
},
"sqlVIew": "SQL View"
"sqlVIew": "Widok SQL"
},
"datatype": {
"ID": "ID",
"ID": "Identyfikator",
"ForeignKey": "Klucz obcy",
"SingleLineText": "Tekst pojedynczy linii",
"LongText": "Długi tekst",
@ -160,7 +160,7 @@
"isNotNull": "nie jest null."
},
"title": {
"erdView": "ERD View",
"erdView": "Widok ERD",
"newProj": "Nowy projekt",
"myProject": "Moje projekty",
"formTitle": "Tytuł formy",
@ -187,17 +187,17 @@
"headCreateProject": "Utwórz projekt |. NOCODB.",
"headLogin": "Zaloguj się | NOCODB.",
"resetPassword": "Zresetuj swoje hasło",
"teamAndSettings": "Team & Settings",
"apiDocs": "API Docs",
"importFromAirtable": "Import From Airtable",
"generateToken": "Generate Token",
"APIsAndSupport": "APIs & Support",
"helpCenter": "Help center",
"swaggerDocumentation": "Swagger Documentation",
"quickImportFrom": "Quick Import From",
"quickImport": "Quick Import",
"advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet"
"teamAndSettings": "Ustawienia zespołu",
"apiDocs": "Dokumentacja API",
"importFromAirtable": "Importuj z Airtable",
"generateToken": "Wygeneruj token",
"APIsAndSupport": "API i Wsparcie",
"helpCenter": "Centrum pomocy",
"swaggerDocumentation": "Dokumentacja Swagger",
"quickImportFrom": "Szybki import z",
"quickImport": "Szybki import",
"advancedSettings": "Ustawienia zaawansowane",
"codeSnippet": "Snippet"
},
"labels": {
"notifyVia": "Powiadomić VIA.",
@ -217,7 +217,7 @@
"port": "Numer portu",
"username": "Nazwa użytkownika",
"password": "Hasło",
"schemaName": "Schema name",
"schemaName": "Nazwa schematu",
"database": "Baza danych",
"action": "Akcja",
"actions": "działania",
@ -255,7 +255,7 @@
"bookDemo": "Zarezerwuj darmowe demo",
"getAnswered": "Odpowiedzi na pytania",
"joinDiscord": "Dołącz do Discord.",
"joinCommunity": "Join NocoDB Community",
"joinCommunity": "Dołącz do społeczności NocoDB",
"joinReddit": "Dołącz /r/NocoDB",
"followNocodb": "Śledź NocoDB"
},
@ -265,38 +265,38 @@
"childColumn": "Kolumna dla dzieci",
"onUpdate": "Na aktualizacji",
"onDelete": "Na delete.",
"account": "Account",
"language": "Language",
"primaryColor": "Primary Color",
"accentColor": "Accent Color",
"customTheme": "Custom Theme",
"requestDataSource": "Request a data source you need?",
"apiKey": "API Key",
"sharedBase": "Shared Base",
"importData": "Import Data",
"importSecondaryViews": "Import Secondary Views",
"importRollupColumns": "Import Rollup Columns",
"importLookupColumns": "Import Lookup Columns",
"importAttachmentColumns": "Import Attachment Columns",
"importFormulaColumns": "Import Formula Columns",
"noData": "No Data",
"goToDashboard": "Go to Dashboard",
"importing": "Importing",
"flattenNested": "Flatten Nested",
"downloadAllowed": "Download allowed",
"weAreHiring": "We are Hiring!",
"primaryKey": "Primary key",
"hasMany": "has many",
"belongsTo": "belongs to",
"manyToMany": "have many to many relation",
"extraConnectionParameters": "Extra connection parameters",
"commentsOnly": "Comments only",
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
"account": "Konto",
"language": "Język",
"primaryColor": "Kolor podstawowy",
"accentColor": "Kolor akcentu",
"customTheme": "Motyw Niestandardowy",
"requestDataSource": "Poproś o źródło danych, którego potrzebujesz.",
"apiKey": "Klucz API",
"sharedBase": "Udostępniona baza",
"importData": "Import Danych",
"importSecondaryViews": "Importuj Drugorzędne Widoki",
"importRollupColumns": "Importuj kolumny Rollup",
"importLookupColumns": "Importuj kolumny wyszukiwania",
"importAttachmentColumns": "Importuj kolumny załączników",
"importFormulaColumns": "Importuj kolumny formuły",
"noData": "Brak danych",
"goToDashboard": "Przejdź do pulpitu",
"importing": "Importowanie",
"flattenNested": "Spłaszcz zagnieżdżone",
"downloadAllowed": "Pobieranie dozwolone",
"weAreHiring": "Zatrudniamy!",
"primaryKey": "Klucz główny",
"hasMany": "ma wiele",
"belongsTo": "należy do",
"manyToMany": "ma wiele relacji do wielu",
"extraConnectionParameters": "Dodatkowe parametry połączenia",
"commentsOnly": "Tylko komentarze",
"documentation": "Dokumentacja",
"subscribeNewsletter": "Zapisz się do naszego cotygodniowego newslettera",
"signUpWithGoogle": "Zarejestruj się przez Google",
"signInWithGoogle": "Zaloguj się przez Google",
"agreeToTos": "Rejestrując się, akceptujesz warunki korzystania z usługi",
"welcomeToNc": "Witaj w NocoDB!"
},
"activity": {
"createProject": "Utwórz projekt",
@ -309,7 +309,7 @@
"deleteProject": "Usuń Projekt.",
"refreshProject": "Odśwież projekty",
"saveProject": "Zapisz Projekt",
"deleteKanbanStack": "Delete stack?",
"deleteKanbanStack": "Usunąć stos?",
"createProjectExtended": {
"extDB": "Utwórz przez podłączenie <br> do zewnętrznej bazy danych",
"excel": "Utwórz projekt z Excel",
@ -324,7 +324,7 @@
"translate": "Pomóż przetłumaczyć",
"account": {
"authToken": "Skopiuj auth token.",
"swagger": "Swagger: REST APIs",
"swagger": "Swagger: REST API",
"projInfo": "Skopiuj informacje o projekcie.",
"themes": "Tematy"
},
@ -366,11 +366,11 @@
"deleteRow": "Usuń rząd",
"deleteSelectedRow": "Usuń wybrane wiersze",
"importExcel": "Importuj Excel.",
"importCSV": "Import CSV",
"importCSV": "Importuj z CSV",
"downloadCSV": "Pobierz jako CSV.",
"downloadExcel": "Pobierz jako XLSX",
"uploadCSV": "Prześlij CSV.",
"import": "Import",
"import": "Importuj",
"importMetadata": "Importuj metadane",
"exportMetadata": "Eksportuj metadane",
"clearMetadata": "Wyczyść metadane",
@ -405,29 +405,29 @@
"editConnJson": "Edytuj połączenie JSON.",
"sponsorUs": "Sponsor",
"sendEmail": "WYSŁAĆ EMAIL",
"addUserToProject": "Add user to project",
"getApiSnippet": "Get API Snippet",
"clearCell": "Clear cell",
"addFilterGroup": "Add Filter Group",
"linkRecord": "Link record",
"addNewRecord": "Add new record",
"useConnectionUrl": "Use Connection URL",
"toggleCommentsDraw": "Toggle comments draw",
"expandRecord": "Expand Record",
"deleteRecord": "Delete Record",
"addUserToProject": "Dodaj użytkownika do projektu",
"getApiSnippet": "Pobierz Snippet API",
"clearCell": "Wyczyść komórkę",
"addFilterGroup": "Dodaj grupę filtrów",
"linkRecord": "Połącz rekord",
"addNewRecord": "Dodaj nowy rekord",
"useConnectionUrl": "Użyj adresu URL połączenia",
"toggleCommentsDraw": "Przełącz rysowanie komentarzy",
"expandRecord": "Rozwiń Rekord",
"deleteRecord": "Usuń rekord",
"erd": {
"showColumns": "Show Columns",
"showPkAndFk": "Show Primary and Foreign Keys",
"showSqlViews": "Show SQL Views",
"showMMTables": "Show Many to Many tables",
"showJunctionTableNames": "Show Junction Table Names"
"showColumns": "Pokaż kolumny",
"showPkAndFk": "Pokaż klucze podstawowe i obce",
"showSqlViews": "Pokaż widoki SQL",
"showMMTables": "Pokaż wiele do wielu tabele",
"showJunctionTableNames": "Pokaż nazwy tabel połączeń"
},
"kanban": {
"collapseStack": "Collapse Stack",
"deleteStack": "Delete Stack",
"stackedBy": "Stacked By",
"chooseGroupingField": "Choose a Grouping Field",
"addOrEditStack": "Add / Edit Stack"
"collapseStack": "Zwiń stos",
"deleteStack": "Usuń stos",
"stackedBy": "Ułożone według",
"chooseGroupingField": "Wybierz pole grupowania",
"addOrEditStack": "Dodaj / Edytuj stos"
}
},
"tooltip": {
@ -475,8 +475,8 @@
"noItemsFound": "Nie znaleziono przedmiotów",
"defaultValue": "Domyślna wartość",
"filterByEmail": "Filtruj e-mailem",
"filterQuery": "Filter query",
"selectField": "Select field"
"filterQuery": "Filtruj zapytanie",
"selectField": "Wybierz pole"
},
"msg": {
"info": {
@ -570,30 +570,30 @@
"addDefaultColumns": "Dodaj domyślne kolumny",
"tableNameInDb": "Nazwa tabeli została zapisana w bazie danych",
"airtable": {
"credentials": "Where to find this?"
"credentials": "Gdzie to znaleźć?"
},
"import": {
"clickOrDrag": "Click or drag file to this area to upload"
"clickOrDrag": "Kliknij lub przeciągnij plik do tego obszaru, aby przesłać"
},
"metaDataRecreated": "Table metadata recreated successfully",
"invalidCredentials": "Invalid credentials",
"downloadingMoreFiles": "Downloading more files",
"copiedToClipboard": "Copied to clipboard",
"requriedFieldsCantBeMoved": "Required field can't be moved",
"updateNotAllowedWithoutPK": "Update not allowed for table which doesn't have primary key",
"autoIncFieldNotEditable": "Auto increment field is not editable",
"editingPKnotSupported": "Editing primary key not supported",
"deletedCache": "Deleted cache successfully",
"cacheEmpty": "Cache is empty",
"exportedCache": "Exported Cache Successfully",
"valueAlreadyInList": "This value is already in the list",
"noColumnsToUpdate": "No columns to update",
"tableDeleted": "Deleted table successfully",
"generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base",
"deleteViewConfirmation": "Are you sure you want to delete this view?",
"deleteTableConfirmation": "Do you want to delete the table",
"showM2mTables": "Show M2M Tables",
"deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack."
"metaDataRecreated": "Pomyślnie przywrócono metadane tabeli",
"invalidCredentials": "Nieprawidłowe dane logowania",
"downloadingMoreFiles": "Pobieranie większej liczby plików",
"copiedToClipboard": "Skopiowano do schowka",
"requriedFieldsCantBeMoved": "Wymagane pole nie może być przeniesione",
"updateNotAllowedWithoutPK": "Aktualizacja niedozwolona dla tabeli, która nie ma klucza podstawowego",
"autoIncFieldNotEditable": "Pole automatycznego przyrostu nie jest edytowalne",
"editingPKnotSupported": "Edycja klucza głównego nie jest obsługiwana",
"deletedCache": "Usunięto pamięć podręczną",
"cacheEmpty": "Pamięć podręczna jest pusta",
"exportedCache": "Wyeksportowano pamięć podręczną",
"valueAlreadyInList": "Ta wartość jest już na liście",
"noColumnsToUpdate": "Brak kolumn do aktualizacji",
"tableDeleted": "Tabela usunięta pomyślnie",
"generatePublicShareableReadonlyBase": "Generuj publicznie udostępnioną bazę tylko do odczytu",
"deleteViewConfirmation": "Czy na pewno chcesz usunąć ten widok?",
"deleteTableConfirmation": "Czy chcesz usunąć tabelę",
"showM2mTables": "Pokaż tabele M2M",
"deleteKanbanStackConfirmation": "Usunięcie tego stosu spowoduje również usunięcie wybranej opcji `{stackToBeDeleted}` z `{groupingField}`. Rekordy przeniosą się do nieskategoryzowanego stosu."
},
"error": {
"searchProject": "Twoje wyszukiwanie dla {search}, nie znaleziono żadnych wyników",
@ -609,51 +609,51 @@
"passwdRequired": "Wymagane jest hasło",
"passwdLength": "Użytkownik musi być co najmniej 8 znaków",
"passwdMismatch": "Hasła nie pasują do siebie",
"completeRuleSet": "At least 8 characters with one Uppercase, one number and one special character",
"atLeast8Char": "At least 8 characters",
"atLeastOneUppercase": "One Uppercase letter",
"atLeastOneNumber": "One Number",
"atLeastOneSpecialChar": "One special character",
"allowedSpecialCharList": "Allowed special character list"
"completeRuleSet": "Co najmniej 8 znaków z jedną wielką literą, jedną cyfrą i jednym znakiem specjalnym",
"atLeast8Char": "Co najmniej 8 znaków",
"atLeastOneUppercase": "Jedna wielka litera",
"atLeastOneNumber": "Jedna cyfra",
"atLeastOneSpecialChar": "Jeden znak specjalny",
"allowedSpecialCharList": "Dozwolona lista znaków specjalnych"
},
"invalidURL": "Invalid URL",
"internalError": "Some internal error occurred",
"templateGeneratorNotFound": "Template Generator cannot be found!",
"fileUploadFailed": "Failed to upload file",
"primaryColumnUpdateFailed": "Failed to update primary column",
"formDescriptionTooLong": "Data too long for Form Description",
"columnsRequired": "Following columns are required",
"selectAtleastOneColumn": "At least one column has to be selected",
"columnDescriptionNotFound": "Cannot find the destination column for",
"duplicateMappingFound": "Duplicate mapping found, please remove one of the mapping",
"nullValueViolatesNotNull": "Null value violates not-null constraint",
"sourceHasInvalidNumbers": "Source data contains some invalid numbers",
"sourceHasInvalidBoolean": "Source data contains some invalid boolean values",
"invalidForm": "Invalid Form",
"formValidationFailed": "Form validation failed",
"youHaveBeenSignedOut": "You have been signed out",
"failedToLoadList": "Failed to load list",
"failedToLoadChildrenList": "Failed to load children list",
"deleteFailed": "Delete failed",
"unlinkFailed": "Unlink failed",
"rowUpdateFailed": "Row update failed",
"deleteRowFailed": "Failed to delete row",
"setFormDataFailed": "Failed to set form data",
"formViewUpdateFailed": "Failed to update form view",
"tableNameRequired": "Table name is required",
"nameShouldStartWithAnAlphabetOr_": "Name should start with an alphabet or _",
"followingCharactersAreNotAllowed": "Following characters are not allowed",
"columnNameRequired": "Column name is required",
"projectNameExceeds50Characters": "Project name exceeds 50 characters",
"projectNameCannotStartWithSpace": "Project name cannot start with space",
"requiredField": "Required field",
"ipNotAllowed": "IP not allowed",
"targetFileIsNotAnAcceptedFileType": "Target file is not an accepted file type",
"theAcceptedFileTypeIsCsv": "The accepted file type is .csv",
"theAcceptedFileTypesAreXlsXlsxXlsmOdsOts": "The accepted file types are .xls, .xlsx, .xlsm, .ods, .ots",
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty."
"invalidURL": "Nieprawidłowy adres URL",
"internalError": "Wystąpił błąd wewnętrzny",
"templateGeneratorNotFound": "Nie można znaleźć generatora szablonów!",
"fileUploadFailed": "Nie udało się przesłać pliku",
"primaryColumnUpdateFailed": "Nie udało się zaktualizować kolumny głównej",
"formDescriptionTooLong": "Zbyt długi opis formularza",
"columnsRequired": "Następujące kolumny są wymagane",
"selectAtleastOneColumn": "Co najmniej jedna kolumna musi być wybrana",
"columnDescriptionNotFound": "Nie można znaleźć kolumny docelowej dla",
"duplicateMappingFound": "Znaleziono zduplikowane mapowanie, usuń jedno z mapowań",
"nullValueViolatesNotNull": "Wartość pusta narusza ograniczenie nie puste",
"sourceHasInvalidNumbers": "Dane źródłowe zawierają nieprawidłowe numery",
"sourceHasInvalidBoolean": "Dane źródłowe zawierają nieprawidłowe wartości logiczne",
"invalidForm": "Nieprawidłowy formularz",
"formValidationFailed": "Weryfikacja formularza nie powiodła się",
"youHaveBeenSignedOut": "Wylogowano",
"failedToLoadList": "Nie udało się załadować listy",
"failedToLoadChildrenList": "Nie udało się załadować listy podrzędnej",
"deleteFailed": "Usunięcie nie powiodło się",
"unlinkFailed": "Nie udało się odłączyć",
"rowUpdateFailed": "Aktualizacja wiersza nie powiodła się",
"deleteRowFailed": "Nie udało się usunąć wiersza",
"setFormDataFailed": "Nie udało się ustawić danych formularza",
"formViewUpdateFailed": "Nie udało się zaktualizować widoku formularza",
"tableNameRequired": "Nazwa tabeli jest wymagana",
"nameShouldStartWithAnAlphabetOr_": "Nazwa powinna zaczynać się od alfabetu lub od _",
"followingCharactersAreNotAllowed": "Następujące znaki są niedozwolone",
"columnNameRequired": "Nazwa kolumny jest wymagana",
"projectNameExceeds50Characters": "Nazwa projektu przekracza 50 znaków",
"projectNameCannotStartWithSpace": "Nazwa projektu nie może zaczynać się od spacji",
"requiredField": "Pole wymagane",
"ipNotAllowed": "Adres IP niedozwolony",
"targetFileIsNotAnAcceptedFileType": "Plik docelowy nie jest akceptowanym typem pliku",
"theAcceptedFileTypeIsCsv": "Akceptowany typ pliku to .csv",
"theAcceptedFileTypesAreXlsXlsxXlsmOdsOts": "Akceptowane typy plików to: .xls, .xlsx, .xlsm, .ods, .ots",
"parameterKeyCannotBeEmpty": "Klucz parametru nie może być pusty",
"duplicateParameterKeysAreNotAllowed": "Zduplikowane klucze parametrów są niedozwolone",
"fieldRequired": "{value} nie może być puste."
},
"toast": {
"exportMetadata": "Pomyślnie wyeksportowano metadane projektu",
@ -663,43 +663,43 @@
"startProject": "Projekt rozpoczął się pomyślnie",
"restartProject": "Projekt zrestartowany pomyślnie",
"deleteProject": "Projekt usunięto pomyślnie",
"authToken": "Token autoryzny skopiowany do schowka",
"authToken": "Token został skopiowany do schowka",
"projInfo": "Skopiowane informacje do schowka",
"inviteUrlCopy": "Skopiowany Zaproś URL do schowka",
"inviteUrlCopy": "Skopiowano do schowka",
"createView": "Widok utworzony pomyślnie",
"formEmailSMTP": "Aktywuj wtyczkę SMTP w App Store, aby umożliwić powiadomienie e-mail",
"collabView": "Pomyślnie przełączony na widok współpracy",
"lockedView": "Pomyślnie przełączony na zablokowany widok",
"formEmailSMTP": "Aktywuj wtyczkę SMTP w App Store, aby umożliwić powiadomienia e-mail",
"collabView": "Pomyślnie przełączono na widok współpracy",
"lockedView": "Pomyślnie przełączono na zablokowany widok",
"futureRelease": "Wkrótce!"
},
"success": {
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",
"pluginTested": "Successfully tested plugin settings",
"tableRenamed": "Table renamed successfully",
"viewDeleted": "View deleted successfully",
"primaryColumnUpdated": "Successfully updated as primary column",
"tableDataExported": "Successfully exported all table data",
"updated": "Successfully updated",
"sharedViewDeleted": "Deleted shared view successfully",
"viewRenamed": "View renamed successfully",
"tokenGenerated": "Token generated successfully",
"tokenDeleted": "Token deleted successfully",
"userAddedToProject": "Successfully added user to project",
"userDeletedFromProject": "Successfully deleted user from project",
"inviteEmailSent": "Invite Email sent successfully",
"inviteURLCopied": "Invite URL copied to clipboard",
"shareableURLCopied": "Copied shareable base URL to clipboard!",
"embeddableHTMLCodeCopied": "Copied embeddable HTML code!",
"userDetailsUpdated": "Successfully updated the user details",
"tableDataImported": "Successfully imported table data",
"webhookUpdated": "Webhook details updated successfully",
"webhookDeleted": "Hook deleted successfully",
"webhookTested": "Webhook tested successfully",
"columnUpdated": "Column updated",
"columnCreated": "Column created",
"passwordChanged": "Password changed successfully. Please login again."
"updatedUIACL": "Pomyślnie zaktualizowano UI ACL dla tabel",
"pluginUninstalled": "Wtyczka odinstalowana pomyślnie",
"pluginSettingsSaved": "Ustawienia wtyczki zapisane pomyślnie",
"pluginTested": "Pomyślnie przetestowano ustawienia wtyczki",
"tableRenamed": "Zmieniono nazwę tabeli",
"viewDeleted": "Widok usunięty pomyślnie",
"primaryColumnUpdated": "Pomyślnie zaktualizowano jako kolumnę główną",
"tableDataExported": "Pomyślnie wyeksportowano wszystkie dane tabeli",
"updated": "Zaktualizowano pomyślnie",
"sharedViewDeleted": "Usunięto udostępniony widok",
"viewRenamed": "Zmieniono nazwę widoku",
"tokenGenerated": "Token został wygenerowany pomyślnie",
"tokenDeleted": "Token usunięty pomyślnie",
"userAddedToProject": "Pomyślnie dodano użytkownika do projektu",
"userDeletedFromProject": "Użytkownik usunięty z projektu",
"inviteEmailSent": "E-mail z zaproszeniem wysłany pomyślnie",
"inviteURLCopied": "Adres URL zaproszenia skopiowany do schowka",
"shareableURLCopied": "Skopiowano adres URL do schowka!",
"embeddableHTMLCodeCopied": "Skopiowany kod HTML do osadzenia!",
"userDetailsUpdated": "Pomyślnie zaktualizowano dane użytkownika",
"tableDataImported": "Pomyślnie zaimportowano dane tabeli",
"webhookUpdated": "Szczegóły webhooka zostały zaktualizowane",
"webhookDeleted": "Webhook usunięty pomyślnie",
"webhookTested": "Webhook przetestowany pomyślnie",
"columnUpdated": "Kolumna zaktualizowana",
"columnCreated": "Kolumna utworzona",
"passwordChanged": "Hasło zostało zmienione. Zaloguj się ponownie."
}
}
}

14
packages/nc-gui/lang/zh-Hans.json

@ -160,7 +160,7 @@
"isNotNull": "不是空虚"
},
"title": {
"erdView": "ERD View",
"erdView": "实体关系图",
"newProj": "创建新项目",
"myProject": "我的项目",
"formTitle": "表格标题",
@ -265,11 +265,11 @@
"childColumn": "子列",
"onUpdate": "更新",
"onDelete": "删除",
"account": "Account",
"language": "Language",
"primaryColor": "Primary Color",
"accentColor": "Accent Color",
"customTheme": "Custom Theme",
"account": "帐户",
"language": "语言",
"primaryColor": "主色调",
"accentColor": "强调色",
"customTheme": "定制样式",
"requestDataSource": "Request a data source you need?",
"apiKey": "API Key",
"sharedBase": "Shared Base",
@ -406,7 +406,7 @@
"sponsorUs": "赞助我们",
"sendEmail": "发送邮件",
"addUserToProject": "Add user to project",
"getApiSnippet": "Get API Snippet",
"getApiSnippet": "生成代码",
"clearCell": "Clear cell",
"addFilterGroup": "Add Filter Group",
"linkRecord": "Link record",

2
packages/nc-gui/layouts/base.vue

@ -127,7 +127,7 @@ hooks.hook('page:finish', () => {
<a-tooltip placement="bottom">
<template #title> Switch language</template>
<LazyGeneralLanguage v-if="!signedIn" class="nc-lang-btn" />
<LazyGeneralLanguage v-if="!signedIn && !route.params.projectId" class="nc-lang-btn" />
</a-tooltip>
<div class="w-full h-full overflow-hidden">

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

@ -1,6 +1,7 @@
import type { FilterType, ViewTypes } from 'nocodb-sdk'
import type { I18n } from 'vue-i18n'
import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider'
import type { UploadFile } from 'ant-design-vue'
import type { ProjectRole, Role, TabType } from './enums'
import type { rolePermissions } from './constants'
@ -94,3 +95,7 @@ export interface SharedView {
type?: ViewTypes
meta: SharedViewMeta
}
export type importFileList = (UploadFile & { data: string | ArrayBuffer })[]
export type streamImportFileList = UploadFile[]

33
packages/nc-gui/pages/[projectType]/form/[viewId]/index.vue

@ -69,6 +69,8 @@ p {
.nc-form-view {
.nc-cell {
@apply bg-white dark:bg-slate-500;
&.nc-cell-checkbox {
@apply color-transition !border-0;
@ -91,7 +93,20 @@ p {
&.nc-input {
@apply w-full rounded p-2 min-h-[40px] flex items-center border-solid border-1 border-gray-300 dark:border-slate-200;
.duration-cell-wrapper {
@apply w-full;
input {
@apply !outline-none;
&::placeholder {
@apply text-gray-400 dark:text-slate-300;
}
}
}
input,
textarea,
&.nc-virtual-cell,
> div {
@apply bg-white dark:(bg-slate-500 text-white);
@ -104,12 +119,24 @@ p {
@apply dark:(bg-slate-700 text-white);
}
}
}
.nc-attachment-cell > div {
@apply dark:(bg-slate-100);
&.nc-cell-longtext {
@apply !p-0 pb-2px pr-2px;
}
textarea {
@apply px-4 py-2 rounded;
&:focus {
box-shadow: none !important;
}
}
}
}
.nc-attachment-cell > div {
@apply dark:(bg-slate-100);
}
}
}

10
packages/nc-gui/pages/[projectType]/form/[viewId]/index/survey.vue

@ -159,6 +159,12 @@ function resetForm() {
goTo(steps.value[0])
}
function submit() {
if (submitted.value) return
submitForm()
}
onReset(resetForm)
onKeyStroke(['ArrowLeft', 'ArrowDown'], () => {
@ -169,7 +175,7 @@ onKeyStroke(['ArrowRight', 'ArrowUp'], () => {
})
onKeyStroke(['Enter', 'Space'], () => {
if (isLast.value) {
submitForm()
submit()
} else {
goNext(AnimationTarget.OkButton)
}
@ -289,7 +295,7 @@ onMounted(() => {
type="submit"
class="uppercase scaling-btn prose-sm"
data-cy="nc-survey-form__btn-submit"
@click="submitForm"
@click="submit"
>
{{ $t('general.submit') }}
</button>

18
packages/nc-gui/pages/[projectType]/view/[viewId].vue

@ -1,16 +1,5 @@
<script setup lang="ts">
import {
ReadonlyInj,
ReloadViewDataHookInj,
createEventHook,
definePageMeta,
extractSdkResponseErrorMsg,
message,
provide,
ref,
useRoute,
useSharedView,
} from '#imports'
import { definePageMeta, extractSdkResponseErrorMsg, message, ref, useRoute, useSharedView } from '#imports'
definePageMeta({
public: true,
@ -20,11 +9,6 @@ definePageMeta({
const route = useRoute()
const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, true)
const { loadSharedView } = useSharedView()
const showPassword = ref(false)

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

@ -36,11 +36,17 @@ export function validateDateFormat(v: string) {
}
export function validateDateWithUnknownFormat(v: string) {
let res = 0
for (const format of dateFormats) {
res |= dayjs(v, format, true).isValid() as any
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 res
return false
}
export function getDateFormat(v: string) {
@ -51,3 +57,25 @@ export function getDateFormat(v: string) {
}
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
}

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

@ -176,12 +176,13 @@ const formulas: Record<string, any> = {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
min: 1,
max: 2,
},
},
description: 'Nearest integer to the input parameter',
syntax: 'ROUND(value)',
examples: ['ROUND(3.1415) => 3', 'ROUND({column1})'],
description: 'Rounded number to a specified number of decimal places or the nearest integer if not specified',
syntax: 'ROUND(value, precision), ROUND(value)',
examples: ['ROUND(3.1415) => 3', 'ROUND(3.1415, 2) => 3.14', 'ROUND({column1}, 3)'],
},
MOD: {
type: formulaTypes.NUMERIC,

331
packages/nc-gui/utils/parsers/CSVTemplateAdapter.ts

@ -1,42 +1,323 @@
import { parse } from 'papaparse'
import TemplateGenerator from './TemplateGenerator'
export default class CSVTemplateAdapter extends TemplateGenerator {
fileName: string
project: object
data: object
csv: any
csvData: any
columns: object
constructor(name: string, data: object) {
super()
this.fileName = name
this.csvData = data
import type { UploadFile } from 'ant-design-vue'
import { UITypes } from 'nocodb-sdk'
import {
extractMultiOrSingleSelectProps,
getCheckboxValue,
getDateFormat,
isCheckboxType,
isDecimalType,
isEmailType,
isMultiLineTextType,
isUrlType,
validateDateWithUnknownFormat,
} from '#imports'
export default class CSVTemplateAdapter {
config: Record<string, any>
source: UploadFile[] | string
detectedColumnTypes: Record<number, Record<string, number>>
distinctValues: Record<number, Set<string>>
headers: Record<number, string[]>
tables: Record<number, any>
project: {
tables: Record<string, any>[]
}
data: Record<string, any> = {}
columnValues: Record<number, []>
constructor(source: UploadFile[] | string, parserConfig = {}) {
this.config = parserConfig
this.source = source
this.project = {
title: this.fileName,
tables: [],
}
this.data = {}
this.csv = {}
this.columns = {}
this.csvData = {}
this.detectedColumnTypes = {}
this.distinctValues = {}
this.headers = {}
this.columnValues = {}
this.tables = {}
}
async init() {
this.csv = parse(this.csvData, { header: true })
async init() {}
initTemplate(tableIdx: number, tn: string, columnNames: string[]) {
const columnNameRowExist = +columnNames.every((v: any) => v === null || typeof v === 'string')
const columnNamePrefixRef: Record<string, any> = { id: 0 }
const tableObj: Record<string, any> = {
table_name: tn,
ref_table_name: tn,
columns: [],
}
this.headers[tableIdx] = []
this.tables[tableIdx] = []
for (const [columnIdx, columnName] of columnNames.entries()) {
let cn: string = ((columnNameRowExist && columnName.toString().trim()) || `field_${columnIdx + 1}`)
.replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_')
.trim()
while (cn in columnNamePrefixRef) {
cn = `${cn}${++columnNamePrefixRef[cn]}`
}
columnNamePrefixRef[cn] = 0
this.detectedColumnTypes[columnIdx] = {}
this.distinctValues[columnIdx] = new Set<string>()
this.columnValues[columnIdx] = []
tableObj.columns.push({
column_name: cn,
ref_column_name: cn,
meta: {},
uidt: UITypes.SingleLineText,
key: columnIdx,
})
this.headers[tableIdx].push(cn)
this.tables[tableIdx] = tableObj
}
}
detectInitialUidt(v: string) {
if (!isNaN(Number(v)) && !isNaN(parseFloat(v))) return UITypes.Number
if (validateDateWithUnknownFormat(v)) return UITypes.DateTime
if (['true', 'True', 'false', 'False', '1', '0', 'T', 'F', 'Y', 'N'].includes(v)) return UITypes.Checkbox
return UITypes.SingleLineText
}
parseData() {
this.columns = this.csv.meta.fields
this.data = this.csv.data
detectColumnType(tableIdx: number, data: []) {
for (let columnIdx = 0; columnIdx < data.length; columnIdx++) {
// skip null data
if (!data[columnIdx]) continue
const colData: any = [data[columnIdx]]
const colProps = { uidt: this.detectInitialUidt(data[columnIdx]) }
// TODO(import): centralise
if (isMultiLineTextType(colData)) {
colProps.uidt = UITypes.LongText
} else if (colProps.uidt === UITypes.SingleLineText) {
if (isEmailType(colData)) {
colProps.uidt = UITypes.Email
}
if (isUrlType(colData)) {
colProps.uidt = UITypes.URL
} else {
const checkboxType = isCheckboxType(colData)
if (checkboxType.length === 1) {
colProps.uidt = UITypes.Checkbox
} else {
if (data[columnIdx] && columnIdx < this.config.maxRowsToParse) {
this.columnValues[columnIdx].push(data[columnIdx])
colProps.uidt = UITypes.SingleSelect
}
}
}
} else if (colProps.uidt === UITypes.Number) {
if (isDecimalType(colData)) {
colProps.uidt = UITypes.Decimal
}
} else if (colProps.uidt === UITypes.DateTime) {
if (data[columnIdx] && columnIdx < this.config.maxRowsToParse) {
this.columnValues[columnIdx].push(data[columnIdx])
}
}
if (!(colProps.uidt in this.detectedColumnTypes[columnIdx])) {
this.detectedColumnTypes[columnIdx] = {
...this.detectedColumnTypes[columnIdx],
[colProps.uidt]: 0,
}
}
this.detectedColumnTypes[columnIdx][colProps.uidt] += 1
if (data[columnIdx]) {
this.distinctValues[columnIdx].add(data[columnIdx])
}
}
}
getPossibleUidt(columnIdx: number) {
const detectedColTypes = this.detectedColumnTypes[columnIdx]
const len = Object.keys(detectedColTypes).length
// all records are null
if (len === 0) {
return UITypes.SingleLineText
}
// handle numeric case
if (len === 2 && UITypes.Number in detectedColTypes && UITypes.Decimal in detectedColTypes) {
if (detectedColTypes[UITypes.Number] > detectedColTypes[UITypes.Decimal]) {
return UITypes.Number
}
return UITypes.Decimal
}
// if there are multiple detected column types
// then return either LongText or SingleLineText
if (len > 1) {
if (UITypes.LongText in detectedColTypes) {
return UITypes.LongText
}
return UITypes.SingleLineText
}
// otherwise, all records have the same column type
return Object.keys(detectedColTypes)[0]
}
updateTemplate(tableIdx: number) {
for (let columnIdx = 0; columnIdx < this.headers[tableIdx].length; columnIdx++) {
const uidt = this.getPossibleUidt(columnIdx)
if (this.columnValues[columnIdx].length > 0) {
if (uidt === UITypes.DateTime) {
const dateFormat: Record<string, number> = {}
if (
this.columnValues[columnIdx].slice(1, this.config.maxRowsToParse).every((v: any) => {
const isDate = v.split(' ').length === 1
if (isDate) {
dateFormat[getDateFormat(v)] = (dateFormat[getDateFormat(v)] || 0) + 1
}
return isDate
})
) {
this.tables[tableIdx].columns[columnIdx].uidt = UITypes.Date
// take the date format with the max occurrence
this.tables[tableIdx].columns[columnIdx].meta.date_format =
Object.keys(dateFormat).reduce((x, y) => (dateFormat[x] > dateFormat[y] ? x : y)) || 'YYYY/MM/DD'
} else {
// Datetime
this.tables[tableIdx].columns[columnIdx].uidt = uidt
}
} else if (uidt === UITypes.SingleSelect || uidt === UITypes.MultiSelect) {
// assume it is a SingleLineText first
this.tables[tableIdx].columns[columnIdx].uidt = UITypes.SingleLineText
// override with UITypes.SingleSelect or UITypes.MultiSelect if applicable
Object.assign(this.tables[tableIdx].columns[columnIdx], extractMultiOrSingleSelectProps(this.columnValues[columnIdx]))
} else {
this.tables[tableIdx].columns[columnIdx].uidt = uidt
}
delete this.columnValues[columnIdx]
} else {
this.tables[tableIdx].columns[columnIdx].uidt = uidt
}
}
}
async _parseTableData(tableIdx: number, source: UploadFile | string, tn: string) {
return new Promise((resolve, reject) => {
const that = this
let steppers = 0
if (that.config.shouldImportData) {
steppers = 0
const parseSource = (this.config.importFromURL ? (source as string) : (source as UploadFile).originFileObj)!
parse(parseSource, {
download: that.config.importFromURL,
worker: true,
skipEmptyLines: 'greedy',
step(row) {
steppers += 1
if (row && steppers >= +that.config.firstRowAsHeaders + 1) {
const rowData: Record<string, any> = {}
for (let columnIdx = 0; columnIdx < that.headers[tableIdx].length; columnIdx++) {
const column = that.tables[tableIdx].columns[columnIdx]
const data = (row.data as [])[columnIdx] === '' ? null : (row.data as [])[columnIdx]
if (column.uidt === UITypes.Checkbox) {
rowData[column.column_name] = getCheckboxValue(data)
rowData[column.column_name] = data
} else if (column.uidt === UITypes.SingleSelect || column.uidt === UITypes.MultiSelect) {
rowData[column.column_name] = (data || '').toString().trim() || null
} else {
// TODO(import): do parsing if necessary based on type
rowData[column.column_name] = data
}
}
that.data[tn].push(rowData)
}
},
complete() {
resolve(true)
},
error(e: Error) {
reject(e)
},
})
}
})
}
async _parseTableMeta(tableIdx: number, source: UploadFile | string) {
return new Promise((resolve, reject) => {
const that = this
let steppers = 0
const tn = ((this.config.importFromURL ? (source as string).split('/').pop() : (source as UploadFile).name) as string)
.replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_')
.trim()!
this.data[tn] = []
const parseSource = (this.config.importFromURL ? (source as string) : (source as UploadFile).originFileObj)!
parse(parseSource, {
download: that.config.importFromURL,
worker: true,
skipEmptyLines: 'greedy',
step(row) {
steppers += 1
if (row) {
if (steppers === 1) {
if (that.config.firstRowAsHeaders) {
// row.data is header
that.initTemplate(tableIdx, tn, row.data as [])
} else {
// use dummy column names as header
that.initTemplate(
tableIdx,
tn,
[...Array((row.data as []).length)].map((_, i) => `field_${i + 1}`),
)
if (that.config.autoSelectFieldTypes) {
// row.data is data
that.detectColumnType(tableIdx, row.data as [])
}
}
} else {
if (that.config.autoSelectFieldTypes) {
// row.data is data
that.detectColumnType(tableIdx, row.data as [])
}
}
}
},
async complete() {
that.updateTemplate(tableIdx)
that.project.tables.push(that.tables[tableIdx])
await that._parseTableData(tableIdx, source, tn)
resolve(true)
},
error(e: Error) {
reject(e)
},
})
})
}
async parse() {
if (this.config.importFromURL) {
await this._parseTableMeta(0, this.source as string)
} else {
await Promise.all(
(this.source as UploadFile[]).map((file: UploadFile, tableIdx: number) =>
(async (f, idx) => {
await this._parseTableMeta(idx, f)
})(file, tableIdx),
),
)
}
}
getColumns() {
return this.columns
return this.project.tables.map((t: Record<string, any>) => t.columns)
}
getData() {
return this.data
}
getTemplate() {
return this.project
}
}

464
packages/nc-gui/utils/parsers/ExcelTemplateAdapter.ts

@ -1,7 +1,14 @@
import { UITypes } from 'nocodb-sdk'
import TemplateGenerator from './TemplateGenerator'
import { getCheckboxValue, isCheckboxType } from './parserHelpers'
import { getDateFormat } from '~/utils'
import {
extractMultiOrSingleSelectProps,
getCheckboxValue,
getDateFormat,
isCheckboxType,
isEmailType,
isMultiLineTextType,
isUrlType,
} from '#imports'
const excelTypeToUidt: Record<string, UITypes> = {
d: UITypes.DateTime,
@ -11,17 +18,12 @@ const excelTypeToUidt: Record<string, UITypes> = {
}
export default class ExcelTemplateAdapter extends TemplateGenerator {
config: {
maxRowsToParse: number
} & Record<string, any>
name: string
config: Record<string, any>
excelData: any
project: {
title: string
tables: any[]
tables: Record<string, any>[]
}
data: Record<string, any> = {}
@ -30,22 +32,13 @@ export default class ExcelTemplateAdapter extends TemplateGenerator {
xlsx: typeof import('xlsx')
constructor(name = '', data = {}, parserConfig = {}) {
constructor(data = {}, parserConfig = {}) {
super()
this.config = {
maxRowsToParse: 500,
...parserConfig,
}
this.name = name
this.config = parserConfig
this.excelData = data
this.project = {
title: this.name,
tables: [],
}
this.xlsx = {} as any
}
@ -57,236 +50,229 @@ export default class ExcelTemplateAdapter extends TemplateGenerator {
cellDates: true,
}
if (this.name.slice(-3) === 'csv') {
this.wb = this.xlsx.read(new TextDecoder().decode(new Uint8Array(this.excelData)), {
type: 'string',
...options,
})
} else {
this.wb = this.xlsx.read(new Uint8Array(this.excelData), {
type: 'array',
...options,
})
}
this.wb = this.xlsx.read(new Uint8Array(this.excelData), {
type: 'array',
...options,
})
}
parse() {
async parse() {
const tableNamePrefixRef: Record<string, any> = {}
// TODO: find the upper bound / make it configurable
const maxSelectOptionsAllowed = 64
for (let i = 0; i < this.wb.SheetNames.length; i++) {
const columnNamePrefixRef: Record<string, any> = { id: 0 }
const sheet: any = this.wb.SheetNames[i]
let tn: string = (sheet || 'table').replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_').trim()
while (tn in tableNamePrefixRef) {
tn = `${tn}${++tableNamePrefixRef[tn]}`
}
tableNamePrefixRef[tn] = 0
const table = { table_name: tn, ref_table_name: tn, columns: [] as any[] }
this.data[tn] = []
const ws: any = this.wb.Sheets[sheet]
const range = this.xlsx.utils.decode_range(ws['!ref'])
let rows: any = this.xlsx.utils.sheet_to_json(ws, { header: 1, blankrows: false, defval: null })
if (this.name.slice(-3) !== 'csv') {
// fix precision bug & timezone offset issues introduced by xlsx
const basedate = new Date(1899, 11, 30, 0, 0, 0)
// number of milliseconds since base date
const dnthresh = basedate.getTime() + (new Date().getTimezoneOffset() - basedate.getTimezoneOffset()) * 60000
// number of milliseconds in a day
const day_ms = 24 * 60 * 60 * 1000
// handle date1904 property
const fixImportedDate = (date: Date) => {
const parsed = this.xlsx.SSF.parse_date_code((date.getTime() - dnthresh) / day_ms, {
date1904: this.wb.Workbook.WBProps.date1904,
})
return new Date(parsed.y, parsed.m, parsed.d, parsed.H, parsed.M, parsed.S)
}
// fix imported date
rows = rows.map((r: any) =>
r.map((v: any) => {
return v instanceof Date ? fixImportedDate(v) : v
}),
)
}
const columnNameRowExist = +rows[0].every((v: any) => v === null || typeof v === 'string')
// const colLen = Math.max()
for (let col = 0; col < rows[0].length; col++) {
let cn: string = ((columnNameRowExist && rows[0] && rows[0][col] && rows[0][col].toString().trim()) || `field_${col + 1}`)
.replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_')
.trim()
while (cn in columnNamePrefixRef) {
cn = `${cn}${++columnNamePrefixRef[cn]}`
}
columnNamePrefixRef[cn] = 0
const column: Record<string, any> = {
column_name: cn,
ref_column_name: cn,
meta: {},
}
// const cellId = `${col.toString(26).split('').map(s => (parseInt(s, 26) + 10).toString(36).toUpperCase())}2`;
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: columnNameRowExist,
})
const cellProps = ws[cellId] || {}
column.uidt = excelTypeToUidt[cellProps.t] || UITypes.SingleLineText
// todo: optimize
if (column.uidt === UITypes.SingleLineText) {
// check for long text
if (rows.some((r: any) => (r[col] || '').toString().match(/[\r\n]/) || (r[col] || '').toString().length > 255)) {
column.uidt = UITypes.LongText
} else {
const vals = rows
.slice(columnNameRowExist ? 1 : 0)
.map((r: any) => r[col])
.filter((v: any) => v !== null && v !== undefined && v.toString().trim() !== '')
const checkboxType = isCheckboxType(vals)
if (checkboxType.length === 1) {
column.uidt = UITypes.Checkbox
} else {
if (vals.some((v: any) => v && v.toString().includes(','))) {
const flattenedVals = vals.flatMap((v: any) =>
v
? v
.toString()
.trim()
.split(/\s*,\s*/)
: [],
)
// TODO: handle case sensitive case
const uniqueVals = [...new Set(flattenedVals.map((v: any) => v.toString().trim().toLowerCase()))]
if (uniqueVals.length > maxSelectOptionsAllowed) {
// too many options are detected, convert the column to SingleLineText instead
column.uidt = UITypes.SingleLineText
// _disableSelect is used to disable the <a-select-option/> in TemplateEditor
column._disableSelect = true
} else {
// assume the column type is multiple select if there are repeated values
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) {
column.uidt = UITypes.MultiSelect
}
// set dtxp here so that users can have the options even they switch the type from other types to MultiSelect
// once it's set, dtxp needs to be reset if the final column type is not MultiSelect
column.dtxp = `'${uniqueVals.join("','")}'`
}
} else {
// TODO: handle case sensitive case
const uniqueVals = [...new Set(vals.map((v: any) => v.toString().trim().toLowerCase()))]
if (uniqueVals.length > maxSelectOptionsAllowed) {
// too many options are detected, convert the column to SingleLineText instead
column.uidt = UITypes.SingleLineText
// _disableSelect is used to disable the <a-select-option/> in TemplateEditor
column._disableSelect = true
} else {
// assume the column type is single select if there are repeated values
// once it's set, dtxp needs to be reset if the final column type is not Single Select
if (vals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(vals.length / 2)) {
column.uidt = UITypes.SingleSelect
}
// set dtxp here so that users can have the options even they switch the type from other types to SingleSelect
// once it's set, dtxp needs to be reset if the final column type is not SingleSelect
column.dtxp = `'${uniqueVals.join("','")}'`
}
}
await Promise.all(
this.wb.SheetNames.map((sheetName: string) =>
(async (sheet) => {
await new Promise((resolve) => {
const columnNamePrefixRef: Record<string, any> = { id: 0 }
let tn: string = (sheet || 'table').replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_').trim()
while (tn in tableNamePrefixRef) {
tn = `${tn}${++tableNamePrefixRef[tn]}`
}
}
} else if (column.uidt === UITypes.Number) {
if (
rows.slice(1, this.config.maxRowsToParse).some((v: any) => {
return v && v[col] && parseInt(v[col]) !== +v[col]
tableNamePrefixRef[tn] = 0
const table = { table_name: tn, ref_table_name: tn, columns: [] as any[] }
const ws: any = this.wb.Sheets[sheet]
const range = this.xlsx.utils.decode_range(ws['!ref'])
let rows: any = this.xlsx.utils.sheet_to_json(ws, {
// header has to be 1 disregarding this.config.firstRowAsHeaders
// so that it generates an array of arrays
header: 1,
blankrows: false,
defval: null,
})
) {
column.uidt = UITypes.Decimal
}
if (
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: i + columnNameRowExist,
// fix precision bug & timezone offset issues introduced by xlsx
const basedate = new Date(1899, 11, 30, 0, 0, 0)
// number of milliseconds since base date
const dnthresh = basedate.getTime() + (new Date().getTimezoneOffset() - basedate.getTimezoneOffset()) * 60000
// number of milliseconds in a day
const day_ms = 24 * 60 * 60 * 1000
// handle date1904 property
const fixImportedDate = (date: Date) => {
const parsed = this.xlsx.SSF.parse_date_code((date.getTime() - dnthresh) / day_ms, {
date1904: this.wb.Workbook.WBProps.date1904,
})
return new Date(parsed.y, parsed.m, parsed.d, parsed.H, parsed.M, parsed.S)
}
const cellObj = ws[cellId]
// fix imported date
rows = rows.map((r: any) =>
r.map((v: any) => {
return v instanceof Date ? fixImportedDate(v) : v
}),
)
for (let col = 0; col < rows[0].length; col++) {
let cn: string = (
(this.config.firstRowAsHeaders && rows[0] && rows[0][col] && rows[0][col].toString().trim()) ||
`field_${col + 1}`
)
.replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_')
.trim()
while (cn in columnNamePrefixRef) {
cn = `${cn}${++columnNamePrefixRef[cn]}`
}
columnNamePrefixRef[cn] = 0
return !cellObj || (cellObj.w && cellObj.w.startsWith('$'))
})
) {
column.uidt = UITypes.Currency
}
} else if (column.uidt === UITypes.DateTime) {
// hold the possible date format found in the date
const dateFormat: Record<string, number> = {}
if (
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: i + columnNameRowExist,
})
const column: Record<string, any> = {
column_name: cn,
ref_column_name: cn,
meta: {},
uidt: UITypes.SingleLineText,
}
const cellObj = ws[cellId]
const isDate = !cellObj || (cellObj.w && cellObj.w.split(' ').length === 1)
if (isDate && cellObj) {
dateFormat[getDateFormat(cellObj.w)] = (dateFormat[getDateFormat(cellObj.w)] || 0) + 1
if (this.config.autoSelectFieldTypes) {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: +this.config.firstRowAsHeaders,
})
const cellProps = ws[cellId] || {}
column.uidt = excelTypeToUidt[cellProps.t] || UITypes.SingleLineText
if (column.uidt === UITypes.SingleLineText) {
// check for long text
if (isMultiLineTextType(rows)) {
column.uidt = UITypes.LongText
}
if (isEmailType(rows)) {
column.uidt = UITypes.Email
}
if (isUrlType(rows)) {
column.uidt = UITypes.URL
} else {
const vals = rows
.slice(+this.config.firstRowAsHeaders)
.map((r: any) => r[col])
.filter((v: any) => v !== null && v !== undefined && v.toString().trim() !== '')
const checkboxType = isCheckboxType(vals)
if (checkboxType.length === 1) {
column.uidt = UITypes.Checkbox
} else {
// Single Select / Multi Select
Object.assign(column, extractMultiOrSingleSelectProps(vals))
}
}
} else if (column.uidt === UITypes.Number) {
if (
rows.slice(1, this.config.maxRowsToParse).every((v: any) => {
return v && v[col] && parseInt(v[col]) !== +v[col]
})
) {
column.uidt = UITypes.Decimal
}
if (
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: i + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
return !cellObj || (cellObj.w && cellObj.w.startsWith('$'))
})
) {
column.uidt = UITypes.Currency
}
if (
rows.slice(1, this.config.maxRowsToParse).some((v: any, i: any) => {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: i + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
return !cellObj || (cellObj.w && !(!isNaN(Number(cellObj.w)) && !isNaN(parseFloat(cellObj.w))))
})
) {
// fallback to SingleLineText
column.uidt = UITypes.SingleLineText
}
} else if (column.uidt === UITypes.DateTime) {
// TODO(import): centralise
// hold the possible date format found in the date
const dateFormat: Record<string, number> = {}
if (
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: i + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
const isDate = !cellObj || (cellObj.w && cellObj.w.split(' ').length === 1)
if (isDate && cellObj) {
dateFormat[getDateFormat(cellObj.w)] = (dateFormat[getDateFormat(cellObj.w)] || 0) + 1
}
return isDate
})
) {
column.uidt = UITypes.Date
// take the date format with the max occurrence
column.meta.date_format =
Object.keys(dateFormat).reduce((x, y) => (dateFormat[x] > dateFormat[y] ? x : y)) || 'YYYY/MM/DD'
}
}
}
return isDate
})
) {
column.uidt = UITypes.Date
// take the date format with the max occurrence
column.meta.date_format =
Object.keys(dateFormat).reduce((x, y) => (dateFormat[x] > dateFormat[y] ? x : y)) || 'YYYY/MM/DD'
}
}
table.columns.push(column)
}
let rowIndex = 0
for (const row of rows.slice(1)) {
const rowData: Record<string, any> = {}
for (let i = 0; i < table.columns.length; i++) {
if (table.columns[i].uidt === UITypes.Checkbox) {
rowData[table.columns[i].column_name] = getCheckboxValue(row[i])
} else if (table.columns[i].uidt === UITypes.Currency) {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + i,
r: rowIndex + columnNameRowExist,
})
table.columns.push(column)
}
this.project.tables.push(table)
this.data[tn] = []
if (this.config.shouldImportData) {
let rowIndex = 0
for (const row of rows.slice(1)) {
const rowData: Record<string, any> = {}
for (let i = 0; i < table.columns.length; i++) {
if (!this.config.autoSelectFieldTypes) {
// take raw data instead of data parsed by xlsx
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + i,
r: rowIndex + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
rowData[table.columns[i].column_name] = (cellObj && cellObj.w) || row[i]
} else {
if (table.columns[i].uidt === UITypes.Checkbox) {
rowData[table.columns[i].column_name] = getCheckboxValue(row[i])
} else if (table.columns[i].uidt === UITypes.Currency) {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + i,
r: rowIndex + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
rowData[table.columns[i].column_name] =
(cellObj && cellObj.w && cellObj.w.replace(/[^\d.]+/g, '')) || row[i]
} else if (table.columns[i].uidt === UITypes.SingleSelect || table.columns[i].uidt === UITypes.MultiSelect) {
rowData[table.columns[i].column_name] = (row[i] || '').toString().trim() || null
} else if (table.columns[i].uidt === UITypes.Date) {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + i,
r: rowIndex + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
rowData[table.columns[i].column_name] = (cellObj && cellObj.w) || row[i]
} else {
// TODO: do parsing if necessary based on type
rowData[table.columns[i].column_name] = row[i]
}
}
}
this.data[tn].push(rowData)
rowIndex++
}
}
const cellObj = ws[cellId]
rowData[table.columns[i].column_name] = (cellObj && cellObj.w && cellObj.w.replace(/[^\d.]+/g, '')) || row[i]
} else if (table.columns[i].uidt === UITypes.SingleSelect || table.columns[i].uidt === UITypes.MultiSelect) {
rowData[table.columns[i].column_name] = (row[i] || '').toString().trim() || null
} else if (table.columns[i].uidt === UITypes.Date) {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + i,
r: rowIndex + columnNameRowExist,
})
const cellObj = ws[cellId]
rowData[table.columns[i].column_name] = (cellObj && cellObj.w) || row[i]
} else {
// toto: do parsing if necessary based on type
rowData[table.columns[i].column_name] = row[i]
}
}
this.data[tn].push(rowData)
rowIndex++
}
this.project.tables.push(table)
}
resolve(true)
})
})(sheetName),
),
)
}
getTemplate() {

3
packages/nc-gui/utils/parsers/ExcelUrlTemplateAdapter.ts

@ -8,8 +8,7 @@ export default class ExcelUrlTemplateAdapter extends ExcelTemplateAdapter {
constructor(url: string, parserConfig: Record<string, any>) {
const { $api } = useNuxtApp()
const name = url?.split('/').pop()
super(name, parserConfig)
super({}, parserConfig)
this.url = url
this.excelData = null
this.$api = $api

68
packages/nc-gui/utils/parsers/JSONTemplateAdapter.ts

@ -1,13 +1,5 @@
import { UITypes } from 'nocodb-sdk'
import {
extractMultiOrSingleSelectProps,
getCheckboxValue,
isCheckboxType,
isDecimalType,
isEmailType,
isMultiLineTextType,
isUrlType,
} from './parserHelpers'
import { getCheckboxValue, getColumnUIDTAndMetas } from './parserHelpers'
import TemplateGenerator from './TemplateGenerator'
const jsonTypeToUidt: Record<string, string> = {
@ -22,22 +14,19 @@ const extractNestedData: any = (obj: any, path: any) => path.reduce((val: any, k
export default class JSONTemplateAdapter extends TemplateGenerator {
config: Record<string, any>
name: string
data: Record<string, any>
_jsonData: string | Record<string, any>
jsonData: Record<string, any>
project: Record<string, any>
project: {
tables: Record<string, any>[]
}
columns: object
constructor(name = 'test', data: object, parserConfig = {}) {
constructor(data: object, parserConfig = {}) {
super()
this.config = {
maxRowsToParse: 500,
...parserConfig,
}
this.name = name
this.config = parserConfig
this._jsonData = data
this.project = {
title: this.name,
tables: [],
}
this.jsonData = []
@ -75,7 +64,7 @@ export default class JSONTemplateAdapter extends TemplateGenerator {
table.columns.push(...columns)
}
if (this.config.importData) {
if (this.config.shouldImportData) {
this._parseTableData(table)
}
@ -100,52 +89,23 @@ export default class JSONTemplateAdapter extends TemplateGenerator {
}
} else {
const cn = path.join('_').replace(/\W/g, '_').trim()
const column: Record<string, any> = {
column_name: cn,
ref_column_name: cn,
uidt: UITypes.SingleLineText,
path,
}
column.uidt = jsonTypeToUidt[typeof firstRowVal] || UITypes.SingleLineText
const colData = jsonData.map((r: any) => extractNestedData(r, path))
Object.assign(column, this._getColumnUIDTAndMetas(colData, column.uidt))
if (this.config.autoSelectFieldTypes) {
column.uidt = jsonTypeToUidt[typeof firstRowVal] || UITypes.SingleLineText
const colData = jsonData.map((r: any) => extractNestedData(r, path))
Object.assign(column, getColumnUIDTAndMetas(colData, column.uidt))
}
columns.push(column)
}
return columns
}
_getColumnUIDTAndMetas(colData: any, defaultType: any) {
const colProps = { uidt: defaultType }
// todo: optimize
if (colProps.uidt === UITypes.SingleLineText) {
// check for long text
if (isMultiLineTextType(colData)) {
colProps.uidt = UITypes.LongText
}
if (isEmailType(colData)) {
colProps.uidt = UITypes.Email
}
if (isUrlType(colData)) {
colProps.uidt = UITypes.URL
} else {
const checkboxType = isCheckboxType(colData)
if (checkboxType.length === 1) {
colProps.uidt = UITypes.Checkbox
} else {
Object.assign(colProps, extractMultiOrSingleSelectProps(colData))
}
}
} else if (colProps.uidt === UITypes.Number) {
if (isDecimalType(colData)) {
colProps.uidt = UITypes.Decimal
}
}
return colProps
}
_parseTableData(tableMeta: any) {
for (const row of this.jsonData as any) {
const rowData: any = {}

3
packages/nc-gui/utils/parsers/JSONUrlTemplateAdapter.ts

@ -7,8 +7,7 @@ export default class JSONUrlTemplateAdapter extends JSONTemplateAdapter {
constructor(url: string, parserConfig: Record<string, any>) {
const { $api } = useNuxtApp()
const name = url.split('/').pop()
super(name, parserConfig)
super({}, parserConfig)
this.url = url
this.$api = $api
}

92
packages/nc-gui/utils/parsers/parserHelpers.ts

@ -40,7 +40,8 @@ export const isCheckboxType: any = (values: [], col = null) => {
}
return options
}
export const getCheckboxValue = (value: number) => {
export const getCheckboxValue = (value: any) => {
return value && aggBooleanOptions[value]
}
@ -51,9 +52,10 @@ export const isMultiLineTextType = (values: [], col = null) => {
}
export const extractMultiOrSingleSelectProps = (colData: []) => {
const maxSelectOptionsAllowed = 64
const colProps: any = {}
if (colData.some((v: any) => v && (v || '').toString().includes(','))) {
let flattenedVals = colData.flatMap((v: any) =>
const flattenedVals = colData.flatMap((v: any) =>
v
? v
.toString()
@ -61,23 +63,43 @@ export const extractMultiOrSingleSelectProps = (colData: []) => {
.split(/\s*,\s*/)
: [],
)
const uniqueVals = (flattenedVals = flattenedVals.filter(
(v, i, arr) => i === arr.findIndex((v1) => v.toLowerCase() === v1.toLowerCase()),
))
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) {
colProps.uidt = UITypes.MultiSelect
colProps.dtxp = `'${uniqueVals.join("','")}'`
const uniqueVals = [...new Set(flattenedVals.map((v: any) => v.toString().trim().toLowerCase()))]
if (uniqueVals.length > maxSelectOptionsAllowed) {
// too many options are detected, convert the column to SingleLineText instead
colProps.uidt = UITypes.SingleLineText
// _disableSelect is used to disable the <a-select-option/> in TemplateEditor
colProps._disableSelect = true
} else {
// assume the column type is multiple select if there are repeated values
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) {
colProps.uidt = UITypes.MultiSelect
}
// set dtxp here so that users can have the options even they switch the type from other types to MultiSelect
// once it's set, dtxp needs to be reset if the final column type is not MultiSelect
colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}`
}
} else {
const uniqueVals = colData
.map((v: any) => (v || '').toString().trim())
.filter((v, i, arr) => i === arr.findIndex((v1) => v.toLowerCase() === v1.toLowerCase()))
if (colData.length > uniqueVals.length && uniqueVals.length <= Math.ceil(colData.length / 2)) {
colProps.uidt = UITypes.SingleSelect
colProps.dtxp = `'${uniqueVals.join("','")}'`
const uniqueVals = [...new Set(colData.map((v: any) => v.toString().trim().toLowerCase()))]
if (uniqueVals.length > maxSelectOptionsAllowed) {
// too many options are detected, convert the column to SingleLineText instead
colProps.uidt = UITypes.SingleLineText
// _disableSelect is used to disable the <a-select-option/> in TemplateEditor
colProps._disableSelect = true
} else {
// assume the column type is single select if there are repeated values
// once it's set, dtxp needs to be reset if the final column type is not Single Select
if (colData.length > uniqueVals.length && uniqueVals.length <= Math.ceil(colData.length / 2)) {
colProps.uidt = UITypes.SingleSelect
}
// set dtxp here so that users can have the options even they switch the type from other types to SingleSelect
// once it's set, dtxp needs to be reset if the final column type is not SingleSelect
colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}`
}
return colProps
}
return colProps
}
export const isDecimalType = (colData: []) =>
@ -86,10 +108,42 @@ export const isDecimalType = (colData: []) =>
})
export const isEmailType = (colData: []) =>
!colData.some((v: any) => {
return v && !isEmail(v)
colData.some((v: any) => {
return v && isEmail(v)
})
export const isUrlType = (colData: []) =>
!colData.some((v: any) => {
return v && !isValidURL(v)
colData.some((v: any) => {
return v && isValidURL(v)
})
export const getColumnUIDTAndMetas = (colData: [], defaultType: string) => {
const colProps = { uidt: defaultType }
if (colProps.uidt === UITypes.SingleLineText) {
// check for long text
if (isMultiLineTextType(colData)) {
colProps.uidt = UITypes.LongText
}
if (isEmailType(colData)) {
colProps.uidt = UITypes.Email
}
if (isUrlType(colData)) {
colProps.uidt = UITypes.URL
} else {
const checkboxType = isCheckboxType(colData)
if (checkboxType.length === 1) {
colProps.uidt = UITypes.Checkbox
} else {
Object.assign(colProps, extractMultiOrSingleSelectProps(colData))
}
}
} else if (colProps.uidt === UITypes.Number) {
if (isDecimalType(colData)) {
colProps.uidt = UITypes.Decimal
}
}
// TODO(import): currency
// TODO(import): date / datetime
return colProps
}

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

@ -49,7 +49,7 @@ Unlike other column types, formula cells cannot be modified by double-clicking s
| **MIN** | `MIN(value1,[value2,...])` | `MIN({Column1}, {Column2}, {Column3})` | Minimum value amongst input parameters |
| **MOD** | `MOD(value1, value2)` | `MOD({Column}, 2)` | Remainder after integer division of input parameters |
| **POWER** | `POWER(base, exponent)` | `POWER({Column}, 3)` | `base` to the `exponent` power, as in `base ^ exponent` |
| **ROUND** | `ROUND(value)` | `ROUND({Column})` | Nearest integer to the input parameter |
| **ROUND** | `ROUND(value, precision)` | `ROUND({Column}, 3)` | Round input `value` to decimal place specified by `precision` (Nearest integer if `precision` not provided) |
| **SQRT** | `SQRT(value)` | `SQRT({Column})` | Square root of the input parameter |

26
packages/noco-docs/content/en/setup-and-usages/table-operations.md

@ -140,27 +140,31 @@ You can use Quick Import when you have data from external sources such as Airtab
<img width="1505" alt="image" src="https://user-images.githubusercontent.com/35857179/194795025-afd81191-4743-435b-b802-88367d2663f9.png">
### Import Airtable into an existing project
### Import Airtable into an Existing Project
- See <a href="./import-airtable-to-sql-database-within-a-minute-for-free">here</a>
### Import CSV data into an existing project
### Import CSV data into an Existing Project
- Hover `Add new table` button in table menu, click three dots, and click `CSV file`
- Drag & drop or select file to upload or specify CSV file URL
<img width="987" alt="image" src="https://user-images.githubusercontent.com/35857179/194795517-ee272b97-e2f6-4f3c-8558-810e1c0b7955.png">
- Click `Import`
<img width="975" alt="image" src="https://user-images.githubusercontent.com/35857179/194795574-cc95a6e0-053f-496f-8b6d-e1bc2a73c890.png">
- Drag & drop or select files (at most 5 files) to upload or specify CSV file URL, and Click Import
- **Auto-Select Field Types**: If it is checked, column types will be detected. Otherwise, it will default to `SingleLineText`.
- **Use First Row as Headers**: If it is checked, the first row will be treated as header row.
- **Import Data**: If it is checked, all data will be imported. Otherwise, only table will be created.
![image](https://user-images.githubusercontent.com/35857179/197454479-1ed18dce-1d0b-4ee3-88b3-9b6a132dea2a.png)
- You can revise the table name by double clicking it, column name and column type. By default, the first column will be chosen as <a href="./primary-value" target="_blank">Primary Value</a> and cannot be deleted.
<img width="984" alt="image" src="https://user-images.githubusercontent.com/35857179/194795594-25373144-436e-4b67-9e51-ad15d70f66fd.png">
![image](https://user-images.githubusercontent.com/35857179/197454633-5b30323e-2b13-4c55-843a-948c093d373e.png)
- Click `Import` to start importing process. The table will be created and the data will be imported.
<img width="1507" alt="image" src="https://user-images.githubusercontent.com/35857179/194795642-44f8b2a4-6ba7-474d-bdb6-99ee4c2b4fd1.png">
![image](https://user-images.githubusercontent.com/35857179/197455547-2d93df5e-a7f0-4c88-af53-990067625967.png)
### Import Excel data into an existing project
### Import Excel data into an Existing Project
- Hover `Add new table` button in table menu, click three dots, and click `Microsoft Excel`
- Drag & drop or select file to upload or specify Excel file URL
<img width="973" alt="image" src="https://user-images.githubusercontent.com/35857179/194795741-a2eb59ad-c95c-4c8c-9127-ab2072240439.png">
- Drag & drop or select file (at most 1 file) to upload or specify Excel file URL and Click Import.
- **Auto-Select Field Types**: If it is checked, column types will be detected. Otherwise, it will default to `SingleLineText`.
- **Use First Row as Headers**: If it is checked, the first row will be treated as header row.
- **Import Data**: If it is checked, all data will be imported. Otherwise, only table will be created.
![image](https://user-images.githubusercontent.com/35857179/197455788-8dd8a7d1-38f3-48c3-a05e-6ab0cf25045c.png)
- You can revise the table name, column name and column type. By default, the first column will be chosen as <a href="./primary-value" target="_blank">Primary Value</a> and cannot be deleted.
<alert>
Note: If your Excel file contains multiple sheets, each sheet will be stored in a separate table.

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

File diff suppressed because it is too large Load Diff

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

@ -970,7 +970,6 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
]
);
}
} else if (driverType === 'pg') {
await dbDriver.raw(
`UPDATE ?? SET ?? = array_to_string(array_remove(string_to_array(??, ','), ?), ',')`,

17
packages/nocodb/src/lib/meta/api/projectApis.ts

@ -113,20 +113,23 @@ async function projectCreate(req: Request<any, any>, res) {
// each file will be named as nc_<random_id>.db
const fs = require('fs');
const toolDir = NcConfigFactory.getToolDir();
const nanoidv2 = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 14);
const nanoidv2 = customAlphabet(
'1234567890abcdefghijklmnopqrstuvwxyz',
14
);
if (!fs.existsSync(`${toolDir}/nc_minimal_dbs`)) {
fs.mkdirSync(`${toolDir}/nc_minimal_dbs`);
fs.mkdirSync(`${toolDir}/nc_minimal_dbs`);
}
const dbId = nanoidv2();
const projectTitle = DOMPurify.sanitize(projectBody.title);
projectBody.prefix = '';
projectBody.bases = [
{
type: "sqlite3",
type: 'sqlite3',
config: {
client: "sqlite3",
client: 'sqlite3',
connection: {
client: "sqlite3",
client: 'sqlite3',
database: projectTitle,
connection: {
filename: `${toolDir}/nc_minimal_dbs/${projectTitle}_${dbId}.db`,
@ -134,8 +137,8 @@ async function projectCreate(req: Request<any, any>, res) {
useNullAsDefault: true,
},
},
inflection_column: "camelize",
inflection_table: "camelize",
inflection_column: 'camelize',
inflection_table: 'camelize',
},
];
} else {

7
packages/nocodb/src/lib/meta/api/sync/helpers/job.ts

@ -446,7 +446,12 @@ export default async (
// TODO fix record mapping (this causes every record to map first option, we can't handle them using data api as they don't provide option id within data we might instead get the correct mapping from schema file )
let dupNo = 1;
const defaultName = (value as any).name;
while (options.find((el) => el.title.toLowerCase() === (value as any).name.toLowerCase())) {
while (
options.find(
(el) =>
el.title.toLowerCase() === (value as any).name.toLowerCase()
)
) {
(value as any).name = `${defaultName}_${dupNo++}`;
}
options.push({

58
scripts/cypress/integration/common/1a_table_operations.js

@ -43,33 +43,37 @@ export const genTest = (apiType, dbType) => {
return cy.get("tbody > tr").eq(row).find("td.ant-table-cell").eq(col);
};
it("Open Audit tab", () => {
// http://localhost:8080/api/v1/db/meta/projects/p_bxp57hmks0n5o2/audits?offset=0&limit=25
cy.intercept("/**/audits?offset=*&limit=*").as("waitForPageLoad");
// mainPage.navigationDraw(mainPage.AUDIT).click();
settingsPage.openMenu(settingsPage.AUDIT);
// wait for column headers to appear
//
cy.get("thead > tr > th.ant-table-cell").should("have.length", 5);
cy.wait("@waitForPageLoad");
// Audit table entries
// [Header] Operation Type, Operation Sub Type, Description, User, Created
// [0] TABLE, DELETED, delete table table-x, user@nocodb.com, ...
// [1] TABLE, Created, created table table-x, user@nocodb.com, ...
getAuditCell(0, 0).contains("TABLE").should("exist");
getAuditCell(0, 1).contains("DELETED").should("exist");
getAuditCell(0, 3).contains("user@nocodb.com").should("exist");
getAuditCell(1, 0).contains("TABLE").should("exist");
getAuditCell(1, 1).contains("CREATED").should("exist");
getAuditCell(1, 3).contains("user@nocodb.com").should("exist");
settingsPage.closeMenu();
});
describe("Check Audit Tab Cells", () => {
it("Open Audit tab", () => {
// http://localhost:8080/api/v1/db/meta/projects/p_bxp57hmks0n5o2/audits?offset=0&limit=25
cy.intercept("/**/audits?offset=*&limit=*").as("waitForPageLoad");
// mainPage.navigationDraw(mainPage.AUDIT).click();
settingsPage.openMenu(settingsPage.AUDIT);
// wait for column headers to appear
//
cy.get("thead > tr > th.ant-table-cell").should("have.length", 5);
cy.wait("@waitForPageLoad");
// Audit table entries
// [Header] Operation Type, Operation Sub Type, Description, User, Created
// [0] TABLE, DELETED, delete table table-x, user@nocodb.com, ...
// [1] TABLE, Created, created table table-x, user@nocodb.com, ...
getAuditCell(0, 0).contains("TABLE").should("exist");
getAuditCell(0, 1).contains("DELETED").should("exist");
getAuditCell(0, 3).contains("user@nocodb.com").should("exist");
getAuditCell(1, 0).contains("TABLE").should("exist");
getAuditCell(1, 1).contains("CREATED").should("exist");
getAuditCell(1, 3).contains("user@nocodb.com").should("exist");
});
after(() => {
settingsPage.closeMenu();
})
})
it("Table Rename operation", () => {
cy.get(".nc-project-tree-tbl-City").should("exist").click();

15
scripts/cypress/integration/common/1b_table_column_operations.js

@ -36,11 +36,6 @@ export const genTest = (apiType, dbType) => {
const randVal = "Test@1234.com";
const updatedRandVal = "Updated@1234.com";
// before(() => {
// cy.restoreLocalStorage();
// cy.createTable(name);
// })
beforeEach(() => {
cy.restoreLocalStorage();
});
@ -49,11 +44,6 @@ export const genTest = (apiType, dbType) => {
cy.saveLocalStorage();
});
// // delete table
// after(() => {
// cy.deleteTable(name, dbType);
// });
it("Create Table Column", () => {
cy.createTable(name);
mainPage.addColumn(colName, name);
@ -138,8 +128,9 @@ export const genTest = (apiType, dbType) => {
.find(".nc-row-no")
.should("exist")
.eq(0)
.trigger("mouseover", { force: true });
cy.get(".nc-row-expand").click({ force: true });
.trigger("mouseover", { force: true })
.get(".nc-row-expand")
.click({ force: true });
// wait for page render to complete
cy.get('button:contains("Save row"):visible').should("exist");

30
scripts/cypress/integration/common/2b_table_with_m2m_column.js

@ -49,8 +49,7 @@ export const genTest = (apiType, dbType) => {
.getCell("Film List", 1)
.should("exist")
.trigger("mouseover")
.click();
cy.get(".nc-action-icon").eq(0).should("exist").click({ force: true });
.get(".nc-action-icon").eq(0).should("exist").click({ force: true });
// GUI-v2 Kludge:
// validations
@ -73,22 +72,22 @@ export const genTest = (apiType, dbType) => {
.then(() => {
// Link record form validation
cy.getActiveModal(".nc-modal-link-record")
.contains("Link record")
.should("exist");
.contains("Link record")
.should("exist");
cy.getActiveModal(".nc-modal-link-record")
.find(".nc-reload")
.should("exist");
.find(".nc-reload")
.should("exist");
cy.getActiveModal(".nc-modal-link-record")
.find('button:contains("Add new record")')
.should("exist");
.find('button:contains("Add new record")')
.should("exist");
cy.getActiveModal(".nc-modal-link-record")
.find(".ant-card")
.eq(0)
.contains("ACE GOLDFINGER")
.should("exist");
.find(".ant-card")
.eq(0)
.contains("ACE GOLDFINGER")
.should("exist");
cy.getActiveModal(".nc-modal-link-record")
.find("button.ant-modal-close")
.click();
.find("button.ant-modal-close")
.click();
});
});
@ -98,8 +97,7 @@ export const genTest = (apiType, dbType) => {
.getCell("Film List", 1)
.should("exist")
.trigger("mouseover")
.click();
cy.get(".nc-action-icon").eq(0).should("exist").click({ force: true });
.get(".nc-action-icon").eq(0).should("exist").click({ force: true });
cy.getActiveModal(".nc-modal-child-list")
.find(".ant-card")

23
scripts/cypress/integration/common/3b_formula_column.js

@ -181,6 +181,7 @@ export const genTest = (apiType, dbType) => {
let RESULT_MATH_0 = [];
let RESULT_MATH_1 = [];
let RESULT_MATH_2 = [];
let RESULT_MATH_3 = [];
let RESULT_WEEKDAY_0 = [];
let RESULT_WEEKDAY_1 = [];
let RESULT_CIRC_REF_0 = [];
@ -211,9 +212,12 @@ export const genTest = (apiType, dbType) => {
Math.min(cityId[i], countryId[i]) +
Math.max(cityId[i], countryId[i]);
RESULT_MATH_2[i] =
1.23 + Math.min(2.34, 3.45) + Math.max(2.34, 3.45);
// LOG, EXP, POWER, SQRT
// only integer verification being computed, hence trunc
RESULT_MATH_2[i] = Math.trunc(
RESULT_MATH_3[i] = Math.trunc(
Math.log(cityId[i]) +
Math.exp(cityId[i]) +
Math.pow(cityId[i], 3) +
@ -273,23 +277,32 @@ export const genTest = (apiType, dbType) => {
rowValidation("NC_MATH_1", RESULT_MATH_1);
});
it("Formula: ROUND with decimals, MIN, MAX", () => {
editColumnByName(
"NC_MATH_1",
"NC_MATH_2",
`ROUND(1.2345, 2) + MIN(2.34, 3.45) + MAX(2.34, 3.45)`
);
rowValidation("NC_MATH_2", RESULT_MATH_2)
})
it("Formula: LOG, EXP, POWER, SQRT", () => {
// if (!isXcdb()) {
if (dbType === "mysql") {
// SQLITE doesnt support LOG, EXP, POWER SQRT construct
editColumnByName(
"NC_MATH_1",
"NC_MATH_2",
"NC_MATH_3",
`LOG({CityId}) + EXP({CityId}) + POWER({CityId}, 3) + SQRT({CountryId})`
);
rowValidation("NC_MATH_2", RESULT_MATH_2);
rowValidation("NC_MATH_3", RESULT_MATH_3);
}
});
it("Formula: NOW, EDIT & Delete column", () => {
// if (!isXcdb()) editColumnByName("NC_MATH_2", "NC_NOW", `NOW()`);
if (dbType === "mysql") editColumnByName("NC_MATH_2", "NC_NOW", `NOW()`);
else editColumnByName("NC_MATH_1", "NC_NOW", `NOW()`);
if (dbType === "mysql") editColumnByName("NC_MATH_3", "NC_NOW", `NOW()`);
else editColumnByName("NC_MATH_2", "NC_NOW", `NOW()`);
deleteColumnByName("NC_NOW");
cy.closeTableTab("City");

8
scripts/cypress/integration/common/3f_link_to_another_record.js

@ -285,6 +285,14 @@ export const genTest = (apiType, dbType) => {
cy.intercept("GET", `/api/v1/db/data/noco/**`).as("waitForCardLoad");
addRow(3, "2c");
// kludge; remove empty record in the end
mainPage.getCell("Title", 3).click();
mainPage.getCell("Title", 4).rightclick();
// delete row
cy.getActiveMenu(".nc-dropdown-grid-context-menu")
.find('.ant-dropdown-menu-item:contains("Delete Row")')
.first()
.click();
cy.get(".nc-row-expand").eq(2).click({ force: true });

Loading…
Cancel
Save