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. 3
      packages/nocodb/src/lib/meta/api/columnApis.ts
  67. 17
      packages/nocodb/src/lib/meta/api/projectApis.ts
  68. 9
      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 DOCKER_REPOSITORY=${DOCKER_REPOSITORY}-timely
fi fi
fi fi
echo "::set-output name=DOCKER_REPOSITORY::${DOCKER_REPOSITORY}" echo "DOCKER_REPOSITORY=${DOCKER_REPOSITORY}" >> $GITHUB_OUTPUT
echo "::set-output name=DOCKER_BUILD_TAG::${DOCKER_BUILD_TAG}" echo "DOCKER_BUILD_TAG=${DOCKER_BUILD_TAG}" >> $GITHUB_OUTPUT
echo ${DOCKER_REPOSITORY} echo DOCKER_REPOSITORY: ${DOCKER_REPOSITORY}
echo ${DOCKER_BUILD_TAG} echo DOCKER_BUILD_TAG: ${DOCKER_BUILD_TAG}
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3

4
.github/workflows/release-draft.yml

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

2
.github/workflows/release-executables.yml

@ -194,7 +194,7 @@ jobs:
cp ./mac-dist/Noco-macos-x64 ./mac-dist/nocodb cp ./mac-dist/Noco-macos-x64 ./mac-dist/nocodb
tar -czf ./mac-dist/nocodb.tar.gz ./mac-dist/nocodb tar -czf ./mac-dist/nocodb.tar.gz ./mac-dist/nocodb
rm ./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 id: compress

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

@ -26,9 +26,9 @@ jobs:
if [[ ${{ github.event_name }} == 'workflow_dispatch' ]]; then if [[ ${{ github.event_name }} == 'workflow_dispatch' ]]; then
IS_DAILY='N' IS_DAILY='N'
fi fi
echo "::set-output name=NIGHTLY_BUILD_TAG::${TAG_NAME}" echo "NIGHTLY_BUILD_TAG=${TAG_NAME}" >> $GITHUB_OUTPUT
echo "::set-output name=IS_DAILY::${IS_DAILY}" echo "IS_DAILY=${IS_DAILY}" >> $GITHUB_OUTPUT
echo "::set-output name=CURRENT_VERSION::${CURRENT_VERSION}" echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_OUTPUT
- name: verify-tag - name: verify-tag
run: | run: |
echo ${{ steps.tag-step.outputs.NIGHTLY_BUILD_TAG }} 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}') TARGET_TAG=$(echo ${PREV_TAG} | awk -F. -v OFS=. '{$NF += 1 ; print}')
fi fi
echo target version: ${TARGET_TAG} echo "TARGET_TAG=${TARGET_TAG}" >> $GITHUB_OUTPUT
echo previous version: ${PREV_TAG} echo "PREV_TAG=${PREV_TAG}" >> $GITHUB_OUTPUT
echo "::set-output name=target_tag::${TARGET_TAG}"
echo "::set-output name=prev_tag::${PREV_TAG}"
- name: Verify - name: Verify
run : | 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 # Merge develop to master
pr-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)) CURRENT_VERSION=$(basename $(curl -fs -o/dev/null -w %{redirect_url} https://github.com/nocodb/nocodb/releases/latest))
# Construct tag name # Construct tag name
TAG_NAME=pr-${PR_NUMBER}-${CURRENT_DATE}-${CURRENT_TIME} TAG_NAME=pr-${PR_NUMBER}-${CURRENT_DATE}-${CURRENT_TIME}
echo "::set-output name=TARGET_TAG::${TAG_NAME}" echo "TAG_NAME=${TAG_NAME}" >> $GITHUB_OUTPUT
echo "::set-output name=CURRENT_VERSION::${CURRENT_VERSION}" echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_OUTPUT
- name: verify-tag - name: verify-tag
run: | run: |
echo ${{ steps.tag-step.outputs.TARGET_TAG }} echo ${{ steps.tag-step.outputs.TARGET_TAG }}

1
packages/nc-gui/.eslintrc.js

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

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

@ -38,7 +38,7 @@ const checkboxMeta = $computed(() => {
}) })
function onClick() { function onClick() {
if (!readOnly) { if (!readOnly?.value) {
vModel = !vModel 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 columnMeta = inject(ColumnInj, null)!
const readOnly = inject(ReadonlyInj, false) const readOnly = inject(ReadonlyInj, ref(false))
let isDateInvalid = $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({ const localState = $computed({
get() { get() {

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

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

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

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core' import type { VNodeRef } from '@vue/runtime-core'
import { EditModeInj, inject, useVModel } from '#imports' import { EditModeInj, ReadonlyInj, inject, ref, useVModel } from '#imports'
interface Props { interface Props {
modelValue?: string | null modelValue?: string | null
@ -12,6 +12,8 @@ const emits = defineEmits(['update:modelValue'])
const editEnabled = inject(EditModeInj) const editEnabled = inject(EditModeInj)
const readonly = inject(ReadonlyInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits) const vModel = useVModel(props, 'modelValue', emits)
const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus() const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@ -19,7 +21,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
<template> <template>
<input <input
v-if="editEnabled" v-if="!readonly && editEnabled"
:ref="focus" :ref="focus"
v-model="vModel" v-model="vModel"
class="h-full w-full outline-none bg-transparent" 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 { isMysql } = useProject()
const readOnly = inject(ReadonlyInj, false) const readOnly = inject(ReadonlyInj, ref(false))
let isTimeInvalid = $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 emit = defineEmits(['update:modelValue'])
const readOnly = inject(ReadonlyInj, false) const readOnly = inject(ReadonlyInj, ref(false))
let isYearInvalid = $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( export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
(updateModelValue: (data: string | Record<string, any>[]) => void) => { (updateModelValue: (data: string | Record<string, any>[]) => void) => {
const isReadonly = inject(ReadonlyInj, false) const isReadonly = inject(ReadonlyInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))

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

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

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

@ -7,7 +7,7 @@ const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook) provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, true) provide(ReadonlyInj, ref(true))
provide(MetaInj, meta) 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() const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook) provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, true) provide(ReadonlyInj, ref(true))
provide(MetaInj, meta) provide(MetaInj, meta)
provide(ActiveViewInj, sharedView) provide(ActiveViewInj, sharedView)
provide(FieldsInj, ref(meta.value?.columns || [])) 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(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, true) provide(ReadonlyInj, ref(true))
provide(MetaInj, meta) provide(MetaInj, meta)

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

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

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

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

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

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

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

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

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

@ -1,4 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Ref } from 'vue'
import type { TableType } from 'nocodb-sdk'
import type { Row } from '~/lib' import type { Row } from '~/lib'
import { import {
ReloadRowDataHookInj, ReloadRowDataHookInj,
@ -20,7 +22,7 @@ const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow() 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 // on changing isNew(new record insert) status sync LTAR cell values
watch(isNew, async (nextVal, prevVal) => { 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, MetaInj,
ReloadRowDataHookInj, ReloadRowDataHookInj,
computedInject, computedInject,
createEventHook,
inject,
message, message,
provide, provide,
ref, ref,

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

@ -166,7 +166,7 @@ const exportFile = async (exportType: ExportTypes) => {
</template> </template>
</a-dropdown> </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" /> <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({ const transitionDuration = computed({
get: () => shared.value.meta.transitionDuration || 250, get: () => shared.value.meta.transitionDuration || 250,
set: (duration) => { 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> </template>
</a-dropdown> </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" /> <LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" />

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

@ -7,6 +7,7 @@ import {
IsLockedInj, IsLockedInj,
MetaInj, MetaInj,
OpenNewRecordFormHookInj, OpenNewRecordFormHookInj,
ReadonlyInj,
ReloadViewDataHookInj, ReloadViewDataHookInj,
ReloadViewMetaHookInj, ReloadViewMetaHookInj,
TabMetaInj, TabMetaInj,
@ -18,6 +19,7 @@ import {
useMetas, useMetas,
useProvideKanbanViewStore, useProvideKanbanViewStore,
useProvideSmartsheetStore, useProvideSmartsheetStore,
useUIPermission,
} from '#imports' } from '#imports'
import type { TabItem } from '~/lib' import type { TabItem } from '~/lib'
@ -25,6 +27,8 @@ const props = defineProps<{
activeTab: TabItem activeTab: TabItem
}>() }>()
const { isUIAllowed } = useUIPermission()
const { metas } = useMetas() const { metas } = useMetas()
const activeTab = toRef(props, 'activeTab') const activeTab = toRef(props, 'activeTab')
@ -55,6 +59,10 @@ provide(OpenNewRecordFormHookInj, openNewRecordFormHook)
provide(FieldsInj, fields) provide(FieldsInj, fields)
provide(IsFormInj, isForm) provide(IsFormInj, isForm)
provide(TabMetaInj, activeTab) provide(TabMetaInj, activeTab)
provide(
ReadonlyInj,
computed(() => !isUIAllowed('xcDatatableEditable')),
)
</script> </script>
<template> <template>
@ -79,6 +87,8 @@ provide(TabMetaInj, activeTab)
</Transition> </Transition>
</div> </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 --> <!-- 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" /> <SmartsheetSidebar v-if="meta" class="nc-right-sidebar" />
</div> </div>

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

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

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

@ -31,7 +31,7 @@ const row = inject(RowInj)!
const active = inject(ActiveCellInj)! const active = inject(ActiveCellInj)!
const readOnly = inject(ReadonlyInj, false) const readOnly = inject(ReadonlyInj, ref(false))
const isForm = inject(IsFormInj, 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 // todo: column type doesn't have required property `error` - throws in typecheck
const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }> const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }>
const value = inject(CellValueInj) const cellValue = inject(CellValueInj)
const { isPg } = useProject() const { isPg } = useProject()
@ -20,7 +20,7 @@ const showEditFormulaWarningMessage = () => {
}, 3000) }, 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)) const urls = computed(() => replaceUrlsWithLink(result.value))
</script> </script>

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

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

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

@ -17,7 +17,7 @@ import {
const { metas, getMeta } = useMetas() const { metas, getMeta } = useMetas()
provide(ReadonlyInj, true) provide(ReadonlyInj, ref(true))
const column = inject(ColumnInj)! as Ref<ColumnType & { colOptions: LookupType }> 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 isForm = inject(IsFormInj)
const readOnly = inject(ReadonlyInj, false) const readOnly = inject(ReadonlyInj, ref(false))
const isLocked = inject(IsLockedInj) const isLocked = inject(IsLockedInj)

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

@ -1,5 +1,15 @@
<script lang="ts" setup> <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 { interface Props {
value?: string | number | boolean value?: string | number | boolean
@ -14,7 +24,7 @@ const { relatedTableMeta } = useLTARStoreOrThrow()!
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const readOnly = inject(ReadonlyInj, false) const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false)) const active = inject(ActiveCellInj, ref(false))
@ -22,7 +32,19 @@ const isForm = inject(IsFormInj)!
const isLocked = inject(IsLockedInj, ref(false)) 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>
<script lang="ts"> <script lang="ts">
@ -35,24 +57,13 @@ export default {
<div <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="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 }" :class="{ active }"
@click="expandedFormDlg = true" @click="openExpandedForm"
> >
<span class="name">{{ value }}</span> <span class="name">{{ value }}</span>
<div v-show="active || isForm" v-if="!readOnly && !isLocked && isUIAllowed('xcDatatableEditable')" class="flex items-center"> <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')" /> <MdiCloseThick class="unlink-icon text-xs text-gray-500/50 group-hover:text-gray-500" @click.stop="emit('unlink')" />
</div> </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> </div>
</template> </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 column = inject(ColumnInj)
const readonly = inject(ReadonlyInj, false) const readonly = inject(ReadonlyInj, ref(false))
const { const {
childrenList, childrenList,
@ -181,7 +181,7 @@ watch(
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg" v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg" v-model="expandedFormDlg"
:row="{ row: expandedFormRow }" :row="{ row: expandedFormRow, oldRow: expandedFormRow, rowMeta: {} }"
:meta="relatedTableMeta" :meta="relatedTableMeta"
load-row load-row
use-meta-fields use-meta-fields

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

@ -1,17 +1,22 @@
import type { VNode } from '@vue/runtime-dom' import type { AppContext, VNode } from '@vue/runtime-dom'
import { isVNode, render } from '@vue/runtime-dom' import { Suspense, isVNode, render } from '@vue/runtime-dom'
import type { ComponentPublicInstance } from '@vue/runtime-core' import type { ComponentPublicInstance } from '@vue/runtime-core'
import type { MaybeRef } from '@vueuse/core' import type { MaybeRef } from '@vueuse/core'
import { isClient } from '@vueuse/core' import { isClient } from '@vueuse/core'
import { createEventHook, h, ref, toReactive, tryOnScopeDispose, unref, useNuxtApp, watch } from '#imports' 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. * 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. * 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 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 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 * @example
* import { useDialog } from '#imports' * import { useDialog } from '#imports'
@ -39,7 +44,7 @@ import { createEventHook, h, ref, toReactive, tryOnScopeDispose, unref, useNuxtA
export function useDialog( export function useDialog(
componentOrVNode: any, componentOrVNode: any,
props: NonNullable<Parameters<typeof h>[1]> = {}, props: NonNullable<Parameters<typeof h>[1]> = {},
mountTarget?: MaybeRef<Element | ComponentPublicInstance>, { target, context }: Partial<UseDialogOptions> = {},
) { ) {
if (typeof document === 'undefined' || !isClient) { if (typeof document === 'undefined' || !isClient) {
console.warn('[useDialog]: Cannot use outside of browser!') console.warn('[useDialog]: Cannot use outside of browser!')
@ -54,24 +59,36 @@ export function useDialog(
const vNodeRef = ref<VNode>() const vNodeRef = ref<VNode>()
let _mountTarget = unref(mountTarget) const mountTarget = ref<HTMLElement>()
_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)
/** When props change, we want to re-render the element with the new prop values */ /** When props change, we want to re-render the element with the new prop values */
const stop = watch( const stop = watch(
toReactive(props), toReactive(props),
(reactiveProps) => { (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) const vNode = isVNode(componentOrVNode) ? componentOrVNode : h(componentOrVNode, reactiveProps)
vNode.appContext = useNuxtApp().vueApp._context vNode.appContext = { ...useNuxtApp().vueApp._context, ...context }
vNodeRef.value = vNode vNodeRef.value = vNode
render(vNode, domNode) // wrap in suspense to resolve potential promises
render(h(Suspense, vNode), domNode)
if (!isMounted) mountedHook.trigger() if (!isMounted) mountedHook.trigger()
}, },
@ -90,7 +107,7 @@ export function useDialog(
setTimeout(() => { setTimeout(() => {
try { try {
;(_mountTarget as HTMLElement)?.removeChild(domNode) ;(mountTarget.value as HTMLElement)?.removeChild(domNode)
} catch (e) {} } catch (e) {}
}, 100) }, 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[]>, fields: MaybeRef<any[]>,
data: MaybeRef<any[]>, data: MaybeRef<any[]>,
editEnabled: MaybeRef<boolean>, editEnabled: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean>, isPkAvail: MaybeRef<boolean | undefined>,
clearCell: Function, clearCell: Function,
makeEditable: Function, makeEditable: Function,
scrollToActiveCell?: (row?: number | null, col?: number | null) => void, 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) * * `loadProjectRoles` - a function to load the project roles for a specific project (by id)
*/ */
export const useRoles = createSharedComposable(() => { export const useRoles = createSharedComposable(() => {
const { user } = useGlobal() const { user, previewAs } = useGlobal()
const { api } = useApi() 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] return allRoles.value[role]
} }

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

@ -1,21 +1,21 @@
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk' import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue' 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( const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
( (
view: Ref<ViewType | undefined>, view: Ref<ViewType | undefined>,
meta: Ref<TableType | KanbanType | undefined>, meta: Ref<TableType | KanbanType | undefined>,
shared = false, shared = false,
initalSorts?: Ref<SortType[]>, initialSorts?: Ref<SortType[]>,
initialFilters?: Ref<FilterType[]>, initialFilters?: Ref<FilterType[]>,
) => { ) => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { sqlUi } = useProject() const { sqlUi } = useProject()
const cellRefs = useTemplateRefsList<HTMLTableDataCellElement>() const cellRefs = ref<HTMLTableDataCellElement[]>([])
// state // state
// todo: move to grid view store // todo: move to grid view store
@ -50,7 +50,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const isSqlView = computed(() => (meta.value as TableType)?.type === 'view') 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) ?? []) const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? [])
return { return {
@ -78,9 +78,9 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
export { useProvideSmartsheetStore } export { useProvideSmartsheetStore }
export function useSmartsheetStoreOrThrow() { 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 { Api, ColumnType, FormType, GalleryType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import { import {
@ -202,7 +202,6 @@ export function useViewData(
async function insertRow( async function insertRow(
currentRow: Row, currentRow: Row,
_rowIndex = formattedData.value?.length,
ltarState: Record<string, any> = {}, ltarState: Record<string, any> = {},
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {}, { metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
) { ) {
@ -266,15 +265,23 @@ export function useViewData(
) )
// audit // audit
$api.utils.auditRowUpdate(id, { $api.utils.auditRowUpdate(id, {
fk_model_id: meta.value?.id as string, fk_model_id: metaValue?.id as string,
column_name: property, column_name: property,
row_id: id, row_id: id,
value: getHTMLEncodedText(toUpdate.row[property]), value: getHTMLEncodedText(toUpdate.row[property]),
prev_value: getHTMLEncodedText(toUpdate.oldRow[property]), prev_value: getHTMLEncodedText(toUpdate.oldRow[property]),
}) })
/** update row data(to sync formula and other related columns) */ /** update row data(to sync formula and other related columns)
Object.assign(toUpdate.row, updatedRowData) * 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) Object.assign(toUpdate.oldRow, updatedRowData)
} catch (e: any) { } catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`) 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) await until(() => !(row.rowMeta?.new && row.rowMeta?.saving)).toMatch((v) => v)
if (row.rowMeta.new) { if (row.rowMeta.new) {
return await insertRow(row, formattedData.value.indexOf(row), ltarState, args) return await insertRow(row, ltarState, args)
} else { } else {
await updateRowProperty(row, property!, args) 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 IsLockedInj: InjectionKey<Ref<boolean>> = Symbol('is-locked-injection')
export const CellValueInj: InjectionKey<Ref<any>> = Symbol('cell-value-injection') export const CellValueInj: InjectionKey<Ref<any>> = Symbol('cell-value-injection')
export const ActiveViewInj: InjectionKey<Ref<ViewType>> = Symbol('active-view-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 */ /** 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 ReloadViewDataHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-data-injection')
export const ReloadViewMetaHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-meta-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", "commenter": "Commentateur",
"viewer": "Lecture seule" "viewer": "Lecture seule"
}, },
"sqlVIew": "SQL View" "sqlVIew": "Vue SQL"
}, },
"datatype": { "datatype": {
"ID": "Identifiant", "ID": "Identifiant",
@ -160,7 +160,7 @@
"isNotNull": "est non null" "isNotNull": "est non null"
}, },
"title": { "title": {
"erdView": "ERD View", "erdView": "Vue ERD",
"newProj": "Nouveau projet", "newProj": "Nouveau projet",
"myProject": "Mes projets", "myProject": "Mes projets",
"formTitle": "Intitulé du formulaire", "formTitle": "Intitulé du formulaire",
@ -188,10 +188,10 @@
"headLogin": "Connexion | Nocodb", "headLogin": "Connexion | Nocodb",
"resetPassword": "Réinitialiser le mot de passe", "resetPassword": "Réinitialiser le mot de passe",
"teamAndSettings": "Équipe & paramètres", "teamAndSettings": "Équipe & paramètres",
"apiDocs": "API Docs", "apiDocs": "Docs API",
"importFromAirtable": "Importer depuis Airtable", "importFromAirtable": "Importer depuis Airtable",
"generateToken": "Generate Token", "generateToken": "Generate Token",
"APIsAndSupport": "APIs & Support", "APIsAndSupport": "Les API et la prise en charge",
"helpCenter": "Help center", "helpCenter": "Help center",
"swaggerDocumentation": "Swagger Documentation", "swaggerDocumentation": "Swagger Documentation",
"quickImportFrom": "Quick Import From", "quickImportFrom": "Quick Import From",
@ -271,10 +271,10 @@
"accentColor": "Accent Color", "accentColor": "Accent Color",
"customTheme": "Custom Theme", "customTheme": "Custom Theme",
"requestDataSource": "Request a data source you need?", "requestDataSource": "Request a data source you need?",
"apiKey": "API Key", "apiKey": "Clé d'API",
"sharedBase": "Shared Base", "sharedBase": "Shared Base",
"importData": "Import Data", "importData": "Import Data",
"importSecondaryViews": "Import Secondary Views", "importSecondaryViews": "Importer des vues secondaires",
"importRollupColumns": "Import Rollup Columns", "importRollupColumns": "Import Rollup Columns",
"importLookupColumns": "Import Lookup Columns", "importLookupColumns": "Import Lookup Columns",
"importAttachmentColumns": "Import Attachment Columns", "importAttachmentColumns": "Import Attachment Columns",
@ -324,7 +324,7 @@
"translate": "Aider à la traduction", "translate": "Aider à la traduction",
"account": { "account": {
"authToken": "Copier le jeton d'authentification", "authToken": "Copier le jeton d'authentification",
"swagger": "Swagger: REST APIs", "swagger": "Swagger : les API REST",
"projInfo": "Copier les informations du projet", "projInfo": "Copier les informations du projet",
"themes": "Thèmes" "themes": "Thèmes"
}, },
@ -406,7 +406,7 @@
"sponsorUs": "Nous Parrainer", "sponsorUs": "Nous Parrainer",
"sendEmail": "ENVOYER UN EMAIL", "sendEmail": "ENVOYER UN EMAIL",
"addUserToProject": "Add user to project", "addUserToProject": "Add user to project",
"getApiSnippet": "Get API Snippet", "getApiSnippet": "Récupérer le Snippet API",
"clearCell": "Clear cell", "clearCell": "Clear cell",
"addFilterGroup": "Add Filter Group", "addFilterGroup": "Add Filter Group",
"linkRecord": "Link record", "linkRecord": "Link record",
@ -418,7 +418,7 @@
"erd": { "erd": {
"showColumns": "Show Columns", "showColumns": "Show Columns",
"showPkAndFk": "Show Primary and Foreign Keys", "showPkAndFk": "Show Primary and Foreign Keys",
"showSqlViews": "Show SQL Views", "showSqlViews": "Afficher les vues SQL",
"showMMTables": "Show Many to Many tables", "showMMTables": "Show Many to Many tables",
"showJunctionTableNames": "Show Junction Table Names" "showJunctionTableNames": "Show Junction Table Names"
}, },
@ -434,8 +434,8 @@
"saveChanges": "Sauvegarder les modifications", "saveChanges": "Sauvegarder les modifications",
"xcDB": "Créer un nouveau projet", "xcDB": "Créer un nouveau projet",
"extDB": "Base de données supportées MySQL, PostgreSQL, SQL Server & SQLite", "extDB": "Base de données supportées MySQL, PostgreSQL, SQL Server & SQLite",
"apiRest": "Accessible via l'API REST", "apiRest": "Accessible via les API REST",
"apiGQL": "Accessible via l'API GraphQL", "apiGQL": "Accessible via les API GraphQL",
"theme": { "theme": {
"dark": "Nuit (^⇧B)", "dark": "Nuit (^⇧B)",
"light": "Jour (^⇧B)" "light": "Jour (^⇧B)"
@ -582,7 +582,7 @@
"requriedFieldsCantBeMoved": "Required field can't be moved", "requriedFieldsCantBeMoved": "Required field can't be moved",
"updateNotAllowedWithoutPK": "Update not allowed for table which doesn't have primary key", "updateNotAllowedWithoutPK": "Update not allowed for table which doesn't have primary key",
"autoIncFieldNotEditable": "Auto increment field is not editable", "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", "deletedCache": "Deleted cache successfully",
"cacheEmpty": "Cache is empty", "cacheEmpty": "Cache is empty",
"exportedCache": "Exported Cache Successfully", "exportedCache": "Exported Cache Successfully",
@ -590,7 +590,7 @@
"noColumnsToUpdate": "No columns to update", "noColumnsToUpdate": "No columns to update",
"tableDeleted": "Deleted table successfully", "tableDeleted": "Deleted table successfully",
"generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base", "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", "deleteTableConfirmation": "Do you want to delete the table",
"showM2mTables": "Show M2M Tables", "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." "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", "rowUpdateFailed": "Row update failed",
"deleteRowFailed": "Failed to delete row", "deleteRowFailed": "Failed to delete row",
"setFormDataFailed": "Failed to set form data", "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", "tableNameRequired": "Table name is required",
"nameShouldStartWithAnAlphabetOr_": "Name should start with an alphabet or _", "nameShouldStartWithAnAlphabetOr_": "Name should start with an alphabet or _",
"followingCharactersAreNotAllowed": "Following characters are not allowed", "followingCharactersAreNotAllowed": "Following characters are not allowed",
@ -678,12 +678,12 @@
"pluginSettingsSaved": "Plugin settings saved successfully", "pluginSettingsSaved": "Plugin settings saved successfully",
"pluginTested": "Successfully tested plugin settings", "pluginTested": "Successfully tested plugin settings",
"tableRenamed": "Table renamed successfully", "tableRenamed": "Table renamed successfully",
"viewDeleted": "View deleted successfully", "viewDeleted": "Vue effacée avec succès",
"primaryColumnUpdated": "Successfully updated as primary column", "primaryColumnUpdated": "Successfully updated as primary column",
"tableDataExported": "Successfully exported all table data", "tableDataExported": "Successfully exported all table data",
"updated": "Successfully updated", "updated": "Successfully updated",
"sharedViewDeleted": "Deleted shared view successfully", "sharedViewDeleted": "Vue partagée effacée avec succès",
"viewRenamed": "View renamed successfully", "viewRenamed": "Vue renommée avec succès",
"tokenGenerated": "Token generated successfully", "tokenGenerated": "Token generated successfully",
"tokenDeleted": "Token deleted successfully", "tokenDeleted": "Token deleted successfully",
"userAddedToProject": "Successfully added user to project", "userAddedToProject": "Successfully added user to project",

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

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

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

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

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

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

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

@ -127,7 +127,7 @@ hooks.hook('page:finish', () => {
<a-tooltip placement="bottom"> <a-tooltip placement="bottom">
<template #title> Switch language</template> <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> </a-tooltip>
<div class="w-full h-full overflow-hidden"> <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 { FilterType, ViewTypes } from 'nocodb-sdk'
import type { I18n } from 'vue-i18n' import type { I18n } from 'vue-i18n'
import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider' 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 { ProjectRole, Role, TabType } from './enums'
import type { rolePermissions } from './constants' import type { rolePermissions } from './constants'
@ -94,3 +95,7 @@ export interface SharedView {
type?: ViewTypes type?: ViewTypes
meta: SharedViewMeta 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-form-view {
.nc-cell { .nc-cell {
@apply bg-white dark:bg-slate-500;
&.nc-cell-checkbox { &.nc-cell-checkbox {
@apply color-transition !border-0; @apply color-transition !border-0;
@ -91,7 +93,20 @@ p {
&.nc-input { &.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; @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, input,
textarea,
&.nc-virtual-cell, &.nc-virtual-cell,
> div { > div {
@apply bg-white dark:(bg-slate-500 text-white); @apply bg-white dark:(bg-slate-500 text-white);
@ -104,12 +119,24 @@ p {
@apply dark:(bg-slate-700 text-white); @apply dark:(bg-slate-700 text-white);
} }
} }
}
.nc-attachment-cell > div { &.nc-cell-longtext {
@apply dark:(bg-slate-100); @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]) goTo(steps.value[0])
} }
function submit() {
if (submitted.value) return
submitForm()
}
onReset(resetForm) onReset(resetForm)
onKeyStroke(['ArrowLeft', 'ArrowDown'], () => { onKeyStroke(['ArrowLeft', 'ArrowDown'], () => {
@ -169,7 +175,7 @@ onKeyStroke(['ArrowRight', 'ArrowUp'], () => {
}) })
onKeyStroke(['Enter', 'Space'], () => { onKeyStroke(['Enter', 'Space'], () => {
if (isLast.value) { if (isLast.value) {
submitForm() submit()
} else { } else {
goNext(AnimationTarget.OkButton) goNext(AnimationTarget.OkButton)
} }
@ -289,7 +295,7 @@ onMounted(() => {
type="submit" type="submit"
class="uppercase scaling-btn prose-sm" class="uppercase scaling-btn prose-sm"
data-cy="nc-survey-form__btn-submit" data-cy="nc-survey-form__btn-submit"
@click="submitForm" @click="submit"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>

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

@ -1,16 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { definePageMeta, extractSdkResponseErrorMsg, message, ref, useRoute, useSharedView } from '#imports'
ReadonlyInj,
ReloadViewDataHookInj,
createEventHook,
definePageMeta,
extractSdkResponseErrorMsg,
message,
provide,
ref,
useRoute,
useSharedView,
} from '#imports'
definePageMeta({ definePageMeta({
public: true, public: true,
@ -20,11 +9,6 @@ definePageMeta({
const route = useRoute() const route = useRoute()
const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, true)
const { loadSharedView } = useSharedView() const { loadSharedView } = useSharedView()
const showPassword = ref(false) 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) { export function validateDateWithUnknownFormat(v: string) {
let res = 0
for (const format of dateFormats) { 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) { export function getDateFormat(v: string) {
@ -51,3 +57,25 @@ export function getDateFormat(v: string) {
} }
return 'YYYY/MM/DD' 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, type: formulaTypes.NUMERIC,
validation: { validation: {
args: { args: {
rqd: 1, min: 1,
max: 2,
}, },
}, },
description: 'Nearest integer to the input parameter', description: 'Rounded number to a specified number of decimal places or the nearest integer if not specified',
syntax: 'ROUND(value)', syntax: 'ROUND(value, precision), ROUND(value)',
examples: ['ROUND(3.1415) => 3', 'ROUND({column1})'], examples: ['ROUND(3.1415) => 3', 'ROUND(3.1415, 2) => 3.14', 'ROUND({column1}, 3)'],
}, },
MOD: { MOD: {
type: formulaTypes.NUMERIC, type: formulaTypes.NUMERIC,

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

@ -1,42 +1,323 @@
import { parse } from 'papaparse' import { parse } from 'papaparse'
import TemplateGenerator from './TemplateGenerator' import type { UploadFile } from 'ant-design-vue'
import { UITypes } from 'nocodb-sdk'
export default class CSVTemplateAdapter extends TemplateGenerator { import {
fileName: string extractMultiOrSingleSelectProps,
project: object getCheckboxValue,
data: object getDateFormat,
csv: any isCheckboxType,
csvData: any isDecimalType,
columns: object isEmailType,
isMultiLineTextType,
constructor(name: string, data: object) { isUrlType,
super() validateDateWithUnknownFormat,
this.fileName = name } from '#imports'
this.csvData = data
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 = { this.project = {
title: this.fileName,
tables: [], tables: [],
} }
this.data = {} this.detectedColumnTypes = {}
this.csv = {} this.distinctValues = {}
this.columns = {} this.headers = {}
this.csvData = {} this.columnValues = {}
this.tables = {}
} }
async init() { async init() {}
this.csv = parse(this.csvData, { header: true })
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() { detectColumnType(tableIdx: number, data: []) {
this.columns = this.csv.meta.fields for (let columnIdx = 0; columnIdx < data.length; columnIdx++) {
this.data = this.csv.data // 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() { getColumns() {
return this.columns return this.project.tables.map((t: Record<string, any>) => t.columns)
} }
getData() { getData() {
return this.data 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 { UITypes } from 'nocodb-sdk'
import TemplateGenerator from './TemplateGenerator' import TemplateGenerator from './TemplateGenerator'
import { getCheckboxValue, isCheckboxType } from './parserHelpers' import {
import { getDateFormat } from '~/utils' extractMultiOrSingleSelectProps,
getCheckboxValue,
getDateFormat,
isCheckboxType,
isEmailType,
isMultiLineTextType,
isUrlType,
} from '#imports'
const excelTypeToUidt: Record<string, UITypes> = { const excelTypeToUidt: Record<string, UITypes> = {
d: UITypes.DateTime, d: UITypes.DateTime,
@ -11,17 +18,12 @@ const excelTypeToUidt: Record<string, UITypes> = {
} }
export default class ExcelTemplateAdapter extends TemplateGenerator { export default class ExcelTemplateAdapter extends TemplateGenerator {
config: { config: Record<string, any>
maxRowsToParse: number
} & Record<string, any>
name: string
excelData: any excelData: any
project: { project: {
title: string tables: Record<string, any>[]
tables: any[]
} }
data: Record<string, any> = {} data: Record<string, any> = {}
@ -30,22 +32,13 @@ export default class ExcelTemplateAdapter extends TemplateGenerator {
xlsx: typeof import('xlsx') xlsx: typeof import('xlsx')
constructor(name = '', data = {}, parserConfig = {}) { constructor(data = {}, parserConfig = {}) {
super() super()
this.config = { this.config = parserConfig
maxRowsToParse: 500,
...parserConfig,
}
this.name = name
this.excelData = data this.excelData = data
this.project = { this.project = {
title: this.name,
tables: [], tables: [],
} }
this.xlsx = {} as any this.xlsx = {} as any
} }
@ -57,236 +50,229 @@ export default class ExcelTemplateAdapter extends TemplateGenerator {
cellDates: true, cellDates: true,
} }
if (this.name.slice(-3) === 'csv') { this.wb = this.xlsx.read(new Uint8Array(this.excelData), {
this.wb = this.xlsx.read(new TextDecoder().decode(new Uint8Array(this.excelData)), { type: 'array',
type: 'string', ...options,
...options, })
})
} else {
this.wb = this.xlsx.read(new Uint8Array(this.excelData), {
type: 'array',
...options,
})
}
} }
parse() { async parse() {
const tableNamePrefixRef: Record<string, any> = {} const tableNamePrefixRef: Record<string, any> = {}
await Promise.all(
// TODO: find the upper bound / make it configurable this.wb.SheetNames.map((sheetName: string) =>
const maxSelectOptionsAllowed = 64 (async (sheet) => {
await new Promise((resolve) => {
for (let i = 0; i < this.wb.SheetNames.length; i++) { const columnNamePrefixRef: Record<string, any> = { id: 0 }
const columnNamePrefixRef: Record<string, any> = { id: 0 } let tn: string = (sheet || 'table').replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_').trim()
const sheet: any = this.wb.SheetNames[i]
let tn: string = (sheet || 'table').replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_').trim() while (tn in tableNamePrefixRef) {
tn = `${tn}${++tableNamePrefixRef[tn]}`
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("','")}'`
}
}
} }
} tableNamePrefixRef[tn] = 0
} else if (column.uidt === UITypes.Number) {
if ( const table = { table_name: tn, ref_table_name: tn, columns: [] as any[] }
rows.slice(1, this.config.maxRowsToParse).some((v: any) => { const ws: any = this.wb.Sheets[sheet]
return v && v[col] && parseInt(v[col]) !== +v[col] 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 // fix precision bug & timezone offset issues introduced by xlsx
} const basedate = new Date(1899, 11, 30, 0, 0, 0)
if ( // number of milliseconds since base date
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => { const dnthresh = basedate.getTime() + (new Date().getTimezoneOffset() - basedate.getTimezoneOffset()) * 60000
const cellId = this.xlsx.utils.encode_cell({ // number of milliseconds in a day
c: range.s.c + col, const day_ms = 24 * 60 * 60 * 1000
r: i + columnNameRowExist, // 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('$')) const column: Record<string, any> = {
}) column_name: cn,
) { ref_column_name: cn,
column.uidt = UITypes.Currency meta: {},
} uidt: UITypes.SingleLineText,
} 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 cellObj = ws[cellId] if (this.config.autoSelectFieldTypes) {
const isDate = !cellObj || (cellObj.w && cellObj.w.split(' ').length === 1) const cellId = this.xlsx.utils.encode_cell({
if (isDate && cellObj) { c: range.s.c + col,
dateFormat[getDateFormat(cellObj.w)] = (dateFormat[getDateFormat(cellObj.w)] || 0) + 1 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 table.columns.push(column)
}) }
) { this.project.tables.push(table)
column.uidt = UITypes.Date
// take the date format with the max occurrence this.data[tn] = []
column.meta.date_format = if (this.config.shouldImportData) {
Object.keys(dateFormat).reduce((x, y) => (dateFormat[x] > dateFormat[y] ? x : y)) || 'YYYY/MM/DD' let rowIndex = 0
} for (const row of rows.slice(1)) {
} const rowData: Record<string, any> = {}
table.columns.push(column) for (let i = 0; i < table.columns.length; i++) {
} if (!this.config.autoSelectFieldTypes) {
// take raw data instead of data parsed by xlsx
let rowIndex = 0 const cellId = this.xlsx.utils.encode_cell({
for (const row of rows.slice(1)) { c: range.s.c + i,
const rowData: Record<string, any> = {} r: rowIndex + +this.config.firstRowAsHeaders,
for (let i = 0; i < table.columns.length; i++) { })
if (table.columns[i].uidt === UITypes.Checkbox) { const cellObj = ws[cellId]
rowData[table.columns[i].column_name] = getCheckboxValue(row[i]) rowData[table.columns[i].column_name] = (cellObj && cellObj.w) || row[i]
} else if (table.columns[i].uidt === UITypes.Currency) { } else {
const cellId = this.xlsx.utils.encode_cell({ if (table.columns[i].uidt === UITypes.Checkbox) {
c: range.s.c + i, rowData[table.columns[i].column_name] = getCheckboxValue(row[i])
r: rowIndex + columnNameRowExist, } 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] resolve(true)
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) { })(sheetName),
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)
}
} }
getTemplate() { 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>) { constructor(url: string, parserConfig: Record<string, any>) {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const name = url?.split('/').pop() super({}, parserConfig)
super(name, parserConfig)
this.url = url this.url = url
this.excelData = null this.excelData = null
this.$api = $api this.$api = $api

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

@ -1,13 +1,5 @@
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import { import { getCheckboxValue, getColumnUIDTAndMetas } from './parserHelpers'
extractMultiOrSingleSelectProps,
getCheckboxValue,
isCheckboxType,
isDecimalType,
isEmailType,
isMultiLineTextType,
isUrlType,
} from './parserHelpers'
import TemplateGenerator from './TemplateGenerator' import TemplateGenerator from './TemplateGenerator'
const jsonTypeToUidt: Record<string, string> = { 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 { export default class JSONTemplateAdapter extends TemplateGenerator {
config: Record<string, any> config: Record<string, any>
name: string
data: Record<string, any> data: Record<string, any>
_jsonData: string | Record<string, any> _jsonData: string | Record<string, any>
jsonData: Record<string, any> jsonData: Record<string, any>
project: Record<string, any> project: {
tables: Record<string, any>[]
}
columns: object columns: object
constructor(name = 'test', data: object, parserConfig = {}) { constructor(data: object, parserConfig = {}) {
super() super()
this.config = { this.config = parserConfig
maxRowsToParse: 500,
...parserConfig,
}
this.name = name
this._jsonData = data this._jsonData = data
this.project = { this.project = {
title: this.name,
tables: [], tables: [],
} }
this.jsonData = [] this.jsonData = []
@ -75,7 +64,7 @@ export default class JSONTemplateAdapter extends TemplateGenerator {
table.columns.push(...columns) table.columns.push(...columns)
} }
if (this.config.importData) { if (this.config.shouldImportData) {
this._parseTableData(table) this._parseTableData(table)
} }
@ -100,52 +89,23 @@ export default class JSONTemplateAdapter extends TemplateGenerator {
} }
} else { } else {
const cn = path.join('_').replace(/\W/g, '_').trim() const cn = path.join('_').replace(/\W/g, '_').trim()
const column: Record<string, any> = { const column: Record<string, any> = {
column_name: cn, column_name: cn,
ref_column_name: cn, ref_column_name: cn,
uidt: UITypes.SingleLineText,
path, path,
} }
if (this.config.autoSelectFieldTypes) {
column.uidt = jsonTypeToUidt[typeof firstRowVal] || UITypes.SingleLineText column.uidt = jsonTypeToUidt[typeof firstRowVal] || UITypes.SingleLineText
const colData = jsonData.map((r: any) => extractNestedData(r, path))
const colData = jsonData.map((r: any) => extractNestedData(r, path)) Object.assign(column, getColumnUIDTAndMetas(colData, column.uidt))
Object.assign(column, this._getColumnUIDTAndMetas(colData, column.uidt)) }
columns.push(column) columns.push(column)
} }
return columns 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) { _parseTableData(tableMeta: any) {
for (const row of this.jsonData as any) { for (const row of this.jsonData as any) {
const rowData: 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>) { constructor(url: string, parserConfig: Record<string, any>) {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const name = url.split('/').pop() super({}, parserConfig)
super(name, parserConfig)
this.url = url this.url = url
this.$api = $api this.$api = $api
} }

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

@ -40,7 +40,8 @@ export const isCheckboxType: any = (values: [], col = null) => {
} }
return options return options
} }
export const getCheckboxValue = (value: number) => {
export const getCheckboxValue = (value: any) => {
return value && aggBooleanOptions[value] return value && aggBooleanOptions[value]
} }
@ -51,9 +52,10 @@ export const isMultiLineTextType = (values: [], col = null) => {
} }
export const extractMultiOrSingleSelectProps = (colData: []) => { export const extractMultiOrSingleSelectProps = (colData: []) => {
const maxSelectOptionsAllowed = 64
const colProps: any = {} const colProps: any = {}
if (colData.some((v: any) => v && (v || '').toString().includes(','))) { if (colData.some((v: any) => v && (v || '').toString().includes(','))) {
let flattenedVals = colData.flatMap((v: any) => const flattenedVals = colData.flatMap((v: any) =>
v v
? v ? v
.toString() .toString()
@ -61,23 +63,43 @@ export const extractMultiOrSingleSelectProps = (colData: []) => {
.split(/\s*,\s*/) .split(/\s*,\s*/)
: [], : [],
) )
const uniqueVals = (flattenedVals = flattenedVals.filter(
(v, i, arr) => i === arr.findIndex((v1) => v.toLowerCase() === v1.toLowerCase()), const uniqueVals = [...new Set(flattenedVals.map((v: any) => v.toString().trim().toLowerCase()))]
))
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) { if (uniqueVals.length > maxSelectOptionsAllowed) {
colProps.uidt = UITypes.MultiSelect // too many options are detected, convert the column to SingleLineText instead
colProps.dtxp = `'${uniqueVals.join("','")}'` 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 { } else {
const uniqueVals = colData const uniqueVals = [...new Set(colData.map((v: any) => v.toString().trim().toLowerCase()))]
.map((v: any) => (v || '').toString().trim())
.filter((v, i, arr) => i === arr.findIndex((v1) => v.toLowerCase() === v1.toLowerCase())) if (uniqueVals.length > maxSelectOptionsAllowed) {
if (colData.length > uniqueVals.length && uniqueVals.length <= Math.ceil(colData.length / 2)) { // too many options are detected, convert the column to SingleLineText instead
colProps.uidt = UITypes.SingleSelect colProps.uidt = UITypes.SingleLineText
colProps.dtxp = `'${uniqueVals.join("','")}'` // _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: []) => export const isDecimalType = (colData: []) =>
@ -86,10 +108,42 @@ export const isDecimalType = (colData: []) =>
}) })
export const isEmailType = (colData: []) => export const isEmailType = (colData: []) =>
!colData.some((v: any) => { colData.some((v: any) => {
return v && !isEmail(v) return v && isEmail(v)
}) })
export const isUrlType = (colData: []) => export const isUrlType = (colData: []) =>
!colData.some((v: any) => { colData.some((v: any) => {
return v && !isValidURL(v) 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 | | **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 | | **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` | | **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 | | **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"> <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> - 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` - 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 - Drag & drop or select files (at most 5 files) to upload or specify CSV file URL, and Click Import
<img width="987" alt="image" src="https://user-images.githubusercontent.com/35857179/194795517-ee272b97-e2f6-4f3c-8558-810e1c0b7955.png"> - **Auto-Select Field Types**: If it is checked, column types will be detected. Otherwise, it will default to `SingleLineText`.
- Click `Import` - **Use First Row as Headers**: If it is checked, the first row will be treated as header row.
<img width="975" alt="image" src="https://user-images.githubusercontent.com/35857179/194795574-cc95a6e0-053f-496f-8b6d-e1bc2a73c890.png"> - **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. - 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. - 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` - 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 - Drag & drop or select file (at most 1 file) to upload or specify Excel file URL and Click Import.
<img width="973" alt="image" src="https://user-images.githubusercontent.com/35857179/194795741-a2eb59ad-c95c-4c8c-9127-ab2072240439.png"> - **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. - 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> <alert>
Note: If your Excel file contains multiple sheets, each sheet will be stored in a separate table. 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

3
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') { } else if (driverType === 'pg') {
await dbDriver.raw( await dbDriver.raw(
`UPDATE ?? SET ?? = array_to_string(array_remove(string_to_array(??, ','), ?), ',')`, `UPDATE ?? SET ?? = array_to_string(array_remove(string_to_array(??, ','), ?), ',')`,
@ -1150,7 +1149,7 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
newOp.title, newOp.title,
] ]
); );
} }
} else if (driverType === 'pg') { } else if (driverType === 'pg') {
await dbDriver.raw( await dbDriver.raw(
`UPDATE ?? SET ?? = array_to_string(array_replace(string_to_array(??, ','), ?, ?), ',')`, `UPDATE ?? SET ?? = array_to_string(array_replace(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 // each file will be named as nc_<random_id>.db
const fs = require('fs'); const fs = require('fs');
const toolDir = NcConfigFactory.getToolDir(); const toolDir = NcConfigFactory.getToolDir();
const nanoidv2 = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 14); const nanoidv2 = customAlphabet(
'1234567890abcdefghijklmnopqrstuvwxyz',
14
);
if (!fs.existsSync(`${toolDir}/nc_minimal_dbs`)) { if (!fs.existsSync(`${toolDir}/nc_minimal_dbs`)) {
fs.mkdirSync(`${toolDir}/nc_minimal_dbs`); fs.mkdirSync(`${toolDir}/nc_minimal_dbs`);
} }
const dbId = nanoidv2(); const dbId = nanoidv2();
const projectTitle = DOMPurify.sanitize(projectBody.title); const projectTitle = DOMPurify.sanitize(projectBody.title);
projectBody.prefix = ''; projectBody.prefix = '';
projectBody.bases = [ projectBody.bases = [
{ {
type: "sqlite3", type: 'sqlite3',
config: { config: {
client: "sqlite3", client: 'sqlite3',
connection: { connection: {
client: "sqlite3", client: 'sqlite3',
database: projectTitle, database: projectTitle,
connection: { connection: {
filename: `${toolDir}/nc_minimal_dbs/${projectTitle}_${dbId}.db`, filename: `${toolDir}/nc_minimal_dbs/${projectTitle}_${dbId}.db`,
@ -134,8 +137,8 @@ async function projectCreate(req: Request<any, any>, res) {
useNullAsDefault: true, useNullAsDefault: true,
}, },
}, },
inflection_column: "camelize", inflection_column: 'camelize',
inflection_table: "camelize", inflection_table: 'camelize',
}, },
]; ];
} else { } else {

9
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 ) // 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; let dupNo = 1;
const defaultName = (value as any).name; 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++}`; (value as any).name = `${defaultName}_${dupNo++}`;
} }
options.push({ options.push({
@ -585,7 +590,7 @@ export default async (
.map((el) => `'${el.title.replace(/'/gi, "''")}'`) .map((el) => `'${el.title.replace(/'/gi, "''")}'`)
.join(',') || "''"; .join(',') || "''";
} }
break; break;
case undefined: case undefined:
break; break;

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); return cy.get("tbody > tr").eq(row).find("td.ant-table-cell").eq(col);
}; };
it("Open Audit tab", () => { describe("Check Audit Tab Cells", () => {
// http://localhost:8080/api/v1/db/meta/projects/p_bxp57hmks0n5o2/audits?offset=0&limit=25 it("Open Audit tab", () => {
cy.intercept("/**/audits?offset=*&limit=*").as("waitForPageLoad"); // 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); // mainPage.navigationDraw(mainPage.AUDIT).click();
// wait for column headers to appear settingsPage.openMenu(settingsPage.AUDIT);
// // wait for column headers to appear
cy.get("thead > tr > th.ant-table-cell").should("have.length", 5); //
cy.get("thead > tr > th.ant-table-cell").should("have.length", 5);
cy.wait("@waitForPageLoad");
cy.wait("@waitForPageLoad");
// Audit table entries
// [Header] Operation Type, Operation Sub Type, Description, User, Created // Audit table entries
// [0] TABLE, DELETED, delete table table-x, user@nocodb.com, ... // [Header] Operation Type, Operation Sub Type, Description, User, Created
// [1] TABLE, Created, created table table-x, user@nocodb.com, ... // [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, 0).contains("TABLE").should("exist");
getAuditCell(0, 3).contains("user@nocodb.com").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, 0).contains("TABLE").should("exist");
getAuditCell(1, 3).contains("user@nocodb.com").should("exist"); getAuditCell(1, 1).contains("CREATED").should("exist");
getAuditCell(1, 3).contains("user@nocodb.com").should("exist");
settingsPage.closeMenu(); });
});
after(() => {
settingsPage.closeMenu();
})
})
it("Table Rename operation", () => { it("Table Rename operation", () => {
cy.get(".nc-project-tree-tbl-City").should("exist").click(); 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 randVal = "Test@1234.com";
const updatedRandVal = "Updated@1234.com"; const updatedRandVal = "Updated@1234.com";
// before(() => {
// cy.restoreLocalStorage();
// cy.createTable(name);
// })
beforeEach(() => { beforeEach(() => {
cy.restoreLocalStorage(); cy.restoreLocalStorage();
}); });
@ -49,11 +44,6 @@ export const genTest = (apiType, dbType) => {
cy.saveLocalStorage(); cy.saveLocalStorage();
}); });
// // delete table
// after(() => {
// cy.deleteTable(name, dbType);
// });
it("Create Table Column", () => { it("Create Table Column", () => {
cy.createTable(name); cy.createTable(name);
mainPage.addColumn(colName, name); mainPage.addColumn(colName, name);
@ -138,8 +128,9 @@ export const genTest = (apiType, dbType) => {
.find(".nc-row-no") .find(".nc-row-no")
.should("exist") .should("exist")
.eq(0) .eq(0)
.trigger("mouseover", { force: true }); .trigger("mouseover", { force: true })
cy.get(".nc-row-expand").click({ force: true }); .get(".nc-row-expand")
.click({ force: true });
// wait for page render to complete // wait for page render to complete
cy.get('button:contains("Save row"):visible').should("exist"); 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) .getCell("Film List", 1)
.should("exist") .should("exist")
.trigger("mouseover") .trigger("mouseover")
.click(); .get(".nc-action-icon").eq(0).should("exist").click({ force: true });
cy.get(".nc-action-icon").eq(0).should("exist").click({ force: true });
// GUI-v2 Kludge: // GUI-v2 Kludge:
// validations // validations
@ -73,22 +72,22 @@ export const genTest = (apiType, dbType) => {
.then(() => { .then(() => {
// Link record form validation // Link record form validation
cy.getActiveModal(".nc-modal-link-record") cy.getActiveModal(".nc-modal-link-record")
.contains("Link record") .contains("Link record")
.should("exist"); .should("exist");
cy.getActiveModal(".nc-modal-link-record") cy.getActiveModal(".nc-modal-link-record")
.find(".nc-reload") .find(".nc-reload")
.should("exist"); .should("exist");
cy.getActiveModal(".nc-modal-link-record") cy.getActiveModal(".nc-modal-link-record")
.find('button:contains("Add new record")') .find('button:contains("Add new record")')
.should("exist"); .should("exist");
cy.getActiveModal(".nc-modal-link-record") cy.getActiveModal(".nc-modal-link-record")
.find(".ant-card") .find(".ant-card")
.eq(0) .eq(0)
.contains("ACE GOLDFINGER") .contains("ACE GOLDFINGER")
.should("exist"); .should("exist");
cy.getActiveModal(".nc-modal-link-record") cy.getActiveModal(".nc-modal-link-record")
.find("button.ant-modal-close") .find("button.ant-modal-close")
.click(); .click();
}); });
}); });
@ -98,8 +97,7 @@ export const genTest = (apiType, dbType) => {
.getCell("Film List", 1) .getCell("Film List", 1)
.should("exist") .should("exist")
.trigger("mouseover") .trigger("mouseover")
.click(); .get(".nc-action-icon").eq(0).should("exist").click({ force: true });
cy.get(".nc-action-icon").eq(0).should("exist").click({ force: true });
cy.getActiveModal(".nc-modal-child-list") cy.getActiveModal(".nc-modal-child-list")
.find(".ant-card") .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_0 = [];
let RESULT_MATH_1 = []; let RESULT_MATH_1 = [];
let RESULT_MATH_2 = []; let RESULT_MATH_2 = [];
let RESULT_MATH_3 = [];
let RESULT_WEEKDAY_0 = []; let RESULT_WEEKDAY_0 = [];
let RESULT_WEEKDAY_1 = []; let RESULT_WEEKDAY_1 = [];
let RESULT_CIRC_REF_0 = []; let RESULT_CIRC_REF_0 = [];
@ -211,9 +212,12 @@ export const genTest = (apiType, dbType) => {
Math.min(cityId[i], countryId[i]) + Math.min(cityId[i], countryId[i]) +
Math.max(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 // LOG, EXP, POWER, SQRT
// only integer verification being computed, hence trunc // only integer verification being computed, hence trunc
RESULT_MATH_2[i] = Math.trunc( RESULT_MATH_3[i] = Math.trunc(
Math.log(cityId[i]) + Math.log(cityId[i]) +
Math.exp(cityId[i]) + Math.exp(cityId[i]) +
Math.pow(cityId[i], 3) + Math.pow(cityId[i], 3) +
@ -273,23 +277,32 @@ export const genTest = (apiType, dbType) => {
rowValidation("NC_MATH_1", RESULT_MATH_1); 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", () => { it("Formula: LOG, EXP, POWER, SQRT", () => {
// if (!isXcdb()) { // if (!isXcdb()) {
if (dbType === "mysql") { if (dbType === "mysql") {
// SQLITE doesnt support LOG, EXP, POWER SQRT construct // SQLITE doesnt support LOG, EXP, POWER SQRT construct
editColumnByName( editColumnByName(
"NC_MATH_1",
"NC_MATH_2", "NC_MATH_2",
"NC_MATH_3",
`LOG({CityId}) + EXP({CityId}) + POWER({CityId}, 3) + SQRT({CountryId})` `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", () => { it("Formula: NOW, EDIT & Delete column", () => {
// if (!isXcdb()) editColumnByName("NC_MATH_2", "NC_NOW", `NOW()`); // if (!isXcdb()) editColumnByName("NC_MATH_2", "NC_NOW", `NOW()`);
if (dbType === "mysql") editColumnByName("NC_MATH_2", "NC_NOW", `NOW()`); if (dbType === "mysql") editColumnByName("NC_MATH_3", "NC_NOW", `NOW()`);
else editColumnByName("NC_MATH_1", "NC_NOW", `NOW()`); else editColumnByName("NC_MATH_2", "NC_NOW", `NOW()`);
deleteColumnByName("NC_NOW"); deleteColumnByName("NC_NOW");
cy.closeTableTab("City"); 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"); cy.intercept("GET", `/api/v1/db/data/noco/**`).as("waitForCardLoad");
addRow(3, "2c"); 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 }); cy.get(".nc-row-expand").eq(2).click({ force: true });

Loading…
Cancel
Save