Browse Source

Merge branch 'develop' into qr-prototyping-cleanedup

pull/4142/head
Daniel Spaude 2 years ago
parent
commit
ae24d19389
No known key found for this signature in database
GPG Key ID: 654A3D1FA4F35FFE
  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. 4
      .gitignore
  8. 4
      packages/nc-cli/package-lock.json
  9. 9
      packages/nc-cli/package.json
  10. 4
      packages/nc-cli/src/lib/mgr/NewMgr.ts
  11. 1
      packages/nc-gui/.eslintrc.js
  12. 1
      packages/nc-gui/components.d.ts
  13. 102
      packages/nc-gui/components/api-client/Headers.vue
  14. 2
      packages/nc-gui/components/cell/Checkbox.vue
  15. 4
      packages/nc-gui/components/cell/DatePicker.vue
  16. 2
      packages/nc-gui/components/cell/DateTimePicker.vue
  17. 7
      packages/nc-gui/components/cell/Duration.vue
  18. 6
      packages/nc-gui/components/cell/Text.vue
  19. 2
      packages/nc-gui/components/cell/TimePicker.vue
  20. 2
      packages/nc-gui/components/cell/YearPicker.vue
  21. 16
      packages/nc-gui/components/cell/attachment/Modal.vue
  22. 2
      packages/nc-gui/components/cell/attachment/utils.ts
  23. 395
      packages/nc-gui/components/dlg/QuickImport.vue
  24. 7
      packages/nc-gui/components/erd/ConfigPanel.vue
  25. 6
      packages/nc-gui/components/erd/Flow.vue
  26. 4
      packages/nc-gui/components/erd/HistogramPanel.vue
  27. 4
      packages/nc-gui/components/erd/RelationEdge.vue
  28. 2
      packages/nc-gui/components/erd/TableNode.vue
  29. 2
      packages/nc-gui/components/general/Overlay.vue
  30. 2
      packages/nc-gui/components/general/language/Menu.vue
  31. 2
      packages/nc-gui/components/shared-view/Gallery.vue
  32. 2
      packages/nc-gui/components/shared-view/Grid.vue
  33. 2
      packages/nc-gui/components/shared-view/Kanban.vue
  34. 4
      packages/nc-gui/components/smartsheet/Cell.vue
  35. 8
      packages/nc-gui/components/smartsheet/Gallery.vue
  36. 27
      packages/nc-gui/components/smartsheet/Grid.vue
  37. 3
      packages/nc-gui/components/smartsheet/Kanban.vue
  38. 4
      packages/nc-gui/components/smartsheet/Row.vue
  39. 24
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  40. 26
      packages/nc-gui/components/smartsheet/expanded-form/Detached.vue
  41. 2
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  42. 6
      packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue
  43. 7
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  44. 2
      packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue
  45. 2
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  46. 2
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  47. 10
      packages/nc-gui/components/tabs/Smartsheet.vue
  48. 403
      packages/nc-gui/components/template/Editor.vue
  49. 2
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  50. 4
      packages/nc-gui/components/virtual-cell/Formula.vue
  51. 2
      packages/nc-gui/components/virtual-cell/HasMany.vue
  52. 2
      packages/nc-gui/components/virtual-cell/Lookup.vue
  53. 2
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  54. 41
      packages/nc-gui/components/virtual-cell/components/ItemChip.vue
  55. 4
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  56. 43
      packages/nc-gui/composables/useDialog/index.ts
  57. 44
      packages/nc-gui/composables/useExpandedFormDetached/index.ts
  58. 2
      packages/nc-gui/composables/useMultiSelect/index.ts
  59. 8
      packages/nc-gui/composables/useRoles/index.ts
  60. 2
      packages/nc-gui/composables/useSharedFormViewStore.ts
  61. 14
      packages/nc-gui/composables/useSmartsheetStore.ts
  62. 19
      packages/nc-gui/composables/useViewData.ts
  63. 2
      packages/nc-gui/context/index.ts
  64. 34
      packages/nc-gui/lang/fr.json
  65. 26
      packages/nc-gui/lang/it.json
  66. 372
      packages/nc-gui/lang/pl.json
  67. 14
      packages/nc-gui/lang/zh-Hans.json
  68. 2
      packages/nc-gui/layouts/base.vue
  69. 5
      packages/nc-gui/lib/types.ts
  70. 5
      packages/nc-gui/package-lock.json
  71. 33
      packages/nc-gui/pages/[projectType]/form/[viewId]/index.vue
  72. 24
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/survey.vue
  73. 18
      packages/nc-gui/pages/[projectType]/view/[viewId].vue
  74. 5
      packages/nc-gui/pages/index/index/user.vue
  75. 34
      packages/nc-gui/utils/dateTimeUtils.ts
  76. 9
      packages/nc-gui/utils/formulaUtils.ts
  77. 331
      packages/nc-gui/utils/parsers/CSVTemplateAdapter.ts
  78. 464
      packages/nc-gui/utils/parsers/ExcelTemplateAdapter.ts
  79. 3
      packages/nc-gui/utils/parsers/ExcelUrlTemplateAdapter.ts
  80. 68
      packages/nc-gui/utils/parsers/JSONTemplateAdapter.ts
  81. 3
      packages/nc-gui/utils/parsers/JSONUrlTemplateAdapter.ts
  82. 92
      packages/nc-gui/utils/parsers/parserHelpers.ts
  83. 2
      packages/nc-gui/utils/viewUtils.ts
  84. 2
      packages/nc-lib-gui/package.json
  85. 1
      packages/noco-docs/content/en/getting-started/installation.md
  86. 2
      packages/noco-docs/content/en/setup-and-usages/formulas.md
  87. 26
      packages/noco-docs/content/en/setup-and-usages/table-operations.md
  88. 14968
      packages/nocodb-sdk/package-lock.json
  89. 11
      packages/nocodb-sdk/package.json
  90. 787
      packages/nocodb-sdk/src/lib/Api.ts
  91. 23
      packages/nocodb/package-lock.json
  92. 4
      packages/nocodb/package.json
  93. 5
      packages/nocodb/src/lib/Noco.ts
  94. 20
      packages/nocodb/src/lib/cache/NocoCache.ts
  95. 40
      packages/nocodb/src/lib/cache/RedisCacheMgr.ts
  96. 40
      packages/nocodb/src/lib/cache/RedisMockCacheMgr.ts
  97. 3
      packages/nocodb/src/lib/meta/api/columnApis.ts
  98. 56
      packages/nocodb/src/lib/meta/api/projectApis.ts
  99. 199
      packages/nocodb/src/lib/meta/api/sync/helpers/EntityMap.ts
  100. 101
      packages/nocodb/src/lib/meta/api/sync/helpers/job.ts
  101. Some files were not shown because too many files have changed in this diff Show More

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 "TARGET_TAG=${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 }}

4
.gitignore vendored

@ -89,3 +89,7 @@ mongod
shared.json shared.json
/scripts/Cypress/screenshots /scripts/Cypress/screenshots
/scripts/exp/ /scripts/exp/
# NC_DBs
#=========
nc_minimal_dbs/

4
packages/nc-cli/package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "create-nocodb-app", "name": "create-nocodb-app",
"version": "0.1.26", "version": "0.1.28",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "create-nocodb-app", "name": "create-nocodb-app",
"version": "0.1.26", "version": "0.1.28",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",

9
packages/nc-cli/package.json

@ -1,6 +1,6 @@
{ {
"name": "create-nocodb-app", "name": "create-nocodb-app",
"version": "0.1.27", "version": "0.1.28",
"description": "nc-cli", "description": "nc-cli",
"main": "dist/bundle.js", "main": "dist/bundle.js",
"module": "dist/bundle.js", "module": "dist/bundle.js",
@ -14,11 +14,8 @@
}, },
"scripts": { "scripts": {
"describe": "npm-scripts-info", "describe": "npm-scripts-info",
"build": "run-s clean && run-p build:*", "build": "webpack --config webpack.config.js",
"build:main": "tsc -p tsconfig.json", "build:publish": "export NODE_OPTIONS=--openssl-legacy-provider && rm -rf dist && npm run build && npm publish .",
"build:module": "tsc -p tsconfig.module.json",
"build:obfuscate": "webpack --config webpack.config.js",
"build:obfuscate:publish": "export NODE_OPTIONS=--openssl-legacy-provider && rm -rf dist && npm run build:obfuscate && npm publish .",
"fix": "run-s fix:*", "fix": "run-s fix:*",
"fix:prettier": "prettier \"src/**/*.ts\" --write", "fix:prettier": "prettier \"src/**/*.ts\" --write",
"fix:tslint": "tslint --fix --project .", "fix:tslint": "tslint --fix --project .",

4
packages/nc-cli/src/lib/mgr/NewMgr.ts

@ -251,7 +251,7 @@ class NewMgr {
} else if (answers.projectType) { } else if (answers.projectType) {
let env = ''; let env = '';
if (answers.type !== 'sqlite3') { if (answers.type !== 'sqlite3') {
env = `--env NC_DB="${answers.type}://${answers.host}:${answers.port}?u=${answers.username}&p=${answers.password}&d=${answers.database}&t=${args._[1]}"` env = `--env NC_DB="${answers.type}://${answers.host}:${answers.port}?u=${answers.username}&p=${answers.password}&d=${answers.database}"`
} }
const linuxHost = os.type() === 'Linux' ? '--net=host' : ''; const linuxHost = os.type() === 'Linux' ? '--net=host' : '';
if (os.type() === 'Windows_NT') { if (os.type() === 'Windows_NT') {
@ -641,7 +641,7 @@ ${`Note: ${'app'.bold} - refers to your express server instance`}
`) `)
} else if (answers.projectType === 'docker') { } else if (answers.projectType === 'docker') {
const dbUrl = `${answers.type}://${answers.host}:${answers.port}?u=${answers.username}&p=${answers.password}&d=${answers.database}&t=${args._[1]}`; const dbUrl = `${answers.type}://${answers.host}:${answers.port}?u=${answers.username}&p=${answers.password}&d=${answers.database}`;
// console.log(` // console.log(`
// You can create docker container using following command // You can create docker container using following command
// //

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: '^_' },

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

@ -8,6 +8,7 @@ export {}
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert'] AAlert: typeof import('ant-design-vue/es')['Alert']
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
ABadgeRibbon: typeof import('ant-design-vue/es')['BadgeRibbon'] ABadgeRibbon: typeof import('ant-design-vue/es')['BadgeRibbon']
AButton: typeof import('ant-design-vue/es')['Button'] AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card'] ACard: typeof import('ant-design-vue/es')['Card']

102
packages/nc-gui/components/api-client/Headers.vue

@ -7,53 +7,61 @@ const props = defineProps<{
const emits = defineEmits(['update:modelValue']) const emits = defineEmits(['update:modelValue'])
interface Option {
value: string
}
const vModel = useVModel(props, 'modelValue', emits) const vModel = useVModel(props, 'modelValue', emits)
const headerList = [ const headerList = ref<Option[]>([
'A-IM', { value: 'A-IM' },
'Accept', { value: 'Accept' },
'Accept-Charset', { value: 'Accept-Charset' },
'Accept-Encoding', { value: 'Accept-Encoding' },
'Accept-Language', { value: 'Accept-Language' },
'Accept-Datetime', { value: 'Accept-Datetime' },
'Access-Control-Request-Method', { value: 'Access-Control-Request-Method' },
'Access-Control-Request-Headers', { value: 'Access-Control-Request-Headers' },
'Authorization', { value: 'Authorization' },
'Cache-Control', { value: 'Cache-Control' },
'Connection', { value: 'Connection' },
'Content-Length', { value: 'Content-Length' },
'Content-Type', { value: 'Content-Type' },
'Cookie', { value: 'Cookie' },
'Date', { value: 'Date' },
'Expect', { value: 'Expect' },
'Forwarded', { value: 'Forwarded' },
'From', { value: 'From' },
'Host', { value: 'Host' },
'If-Match', { value: 'If-Match' },
'If-Modified-Since', { value: 'If-Modified-Since' },
'If-None-Match', { value: 'If-None-Match' },
'If-Range', { value: 'If-Range' },
'If-Unmodified-Since', { value: 'If-Unmodified-Since' },
'Max-Forwards', { value: 'Max-Forwards' },
'Origin', { value: 'Origin' },
'Pragma', { value: 'Pragma' },
'Proxy-Authorization', { value: 'Proxy-Authorization' },
'Range', { value: 'Range' },
'Referer', { value: 'Referer' },
'TE', { value: 'TE' },
'User-Agent', { value: 'User-Agent' },
'Upgrade', { value: 'Upgrade' },
'Via', { value: 'Via' },
'Warning', { value: 'Warning' },
'Non-standard headers', { value: 'Non-standard headers' },
'Dnt', { value: 'Dnt' },
'X-Requested-With', { value: 'X-Requested-With' },
'X-CSRF-Token', { value: 'X-CSRF-Token' },
] ])
const addHeaderRow = () => vModel.value.push({}) const addHeaderRow = () => vModel.value.push({})
const deleteHeaderRow = (i: number) => vModel.value.splice(i, 1) const deleteHeaderRow = (i: number) => vModel.value.splice(i, 1)
const filterOption = (input: string, option: Option) => {
return option.value.toUpperCase().includes(input.toUpperCase())
}
</script> </script>
<template> <template>
@ -89,18 +97,14 @@ const deleteHeaderRow = (i: number) => vModel.value.splice(i, 1)
<td class="px-2 w-min-[400px]"> <td class="px-2 w-min-[400px]">
<a-form-item> <a-form-item>
<a-select <a-auto-complete
v-model:value="headerRow.name" v-model:value="headerRow.name"
size="large" size="large"
placeholder="Key" placeholder="Key"
class="nc-input-hook-header-key" class="nc-input-hook-header-key"
dropdown-class-name="nc-dropdown-webhook-header" :options="headerList"
show-search :filter-option="filterOption"
> />
<a-select-option v-for="header in headerList" :key="header" :value="header">
{{ header }}
</a-select-option>
</a-select>
</a-form-item> </a-form-item>
</td> </td>

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)

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

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import { import {
ColumnInj, ColumnInj,
EditModeInj, EditModeInj,
@ -28,7 +29,7 @@ const durationInMS = ref(0)
const isEdited = ref(false) const isEdited = ref(false)
const durationType = ref(column?.value?.meta?.duration || 0) const durationType = computed(() => column?.value?.meta?.duration || 0)
const durationPlaceholder = computed(() => durationOptions[durationType.value].title) const durationPlaceholder = computed(() => durationOptions[durationType.value].title)
@ -67,13 +68,15 @@ const submitDuration = () => {
} }
isEdited.value = false isEdited.value = false
} }
const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</script> </script>
<template> <template>
<div class="duration-cell-wrapper"> <div class="duration-cell-wrapper">
<input <input
v-if="editEnabled" v-if="editEnabled"
ref="durationInput" :ref="focus"
v-model="localState" v-model="localState"
class="w-full !border-none p-0" class="w-full !border-none p-0"
:class="{ '!px-2': editEnabled }" :class="{ '!px-2': editEnabled }"

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)

16
packages/nc-gui/components/cell/attachment/Modal.vue

@ -10,7 +10,7 @@ const {
open, open,
isLoading, isLoading,
isPublic, isPublic,
isReadonly, isReadonly: readOnly,
visibleItems, visibleItems,
modalVisible, modalVisible,
column, column,
@ -29,7 +29,7 @@ const dropZoneRef = ref<HTMLDivElement>()
const sortableRef = ref<HTMLDivElement>() const sortableRef = ref<HTMLDivElement>()
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly) const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, readOnly)
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop) const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
@ -68,7 +68,7 @@ function onClick(item: Record<string, any>) {
<template #title> <template #title>
<div class="flex gap-4"> <div class="flex gap-4">
<div <div
v-if="isSharedForm || (!isReadonly && isUIAllowed('tableAttachment') && !isPublic && !isLocked)" v-if="isSharedForm || (!readOnly && isUIAllowed('tableAttachment') && !isPublic && !isLocked)"
class="nc-attach-file group" class="nc-attach-file group"
@click="open" @click="open"
> >
@ -77,15 +77,15 @@ function onClick(item: Record<string, any>) {
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="isReadonly" class="text-gray-400">[Readonly]</div> <div v-if="readOnly" class="text-gray-400">[Readonly]</div>
Viewing Attachments of Viewing Attachments of
<div class="font-semibold underline">{{ column.title }}</div> <div class="font-semibold underline">{{ column?.title }}</div>
</div> </div>
</div> </div>
</template> </template>
<div ref="dropZoneRef"> <div ref="dropZoneRef">
<template v-if="isSharedForm || (!isReadonly && !dragging)"> <template v-if="isSharedForm || (!readOnly && !dragging)">
<general-overlay <general-overlay
v-model="isOverDropZone" v-model="isOverDropZone"
inline inline
@ -99,10 +99,10 @@ function onClick(item: Record<string, any>) {
<div ref="sortableRef" :class="{ dragging }" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6 relative p-6"> <div ref="sortableRef" :class="{ dragging }" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6 relative p-6">
<div v-for="(item, i) of visibleItems" :key="`${item.title}-${i}`" class="flex flex-col gap-1"> <div v-for="(item, i) of visibleItems" :key="`${item.title}-${i}`" class="flex flex-col gap-1">
<a-card class="nc-attachment-item group"> <a-card class="nc-attachment-item group">
<a-tooltip v-if="!isReadonly"> <a-tooltip v-if="!readOnly">
<template #title> Remove File </template> <template #title> Remove File </template>
<MdiCloseCircle <MdiCloseCircle
v-if="isSharedForm || (isUIAllowed('tableAttachment') && !isPublicGrid && !isLocked)" v-if="isSharedForm || (isUIAllowed('tableAttachment') && !isPublic && !isLocked)"
class="nc-attachment-remove" class="nc-attachment-remove"
@click.stop="removeFile(i)" @click.stop="removeFile(i)"
/> />

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>

7
packages/nc-gui/components/erd/ConfigPanel.vue

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Panel } from '@vue-flow/additional-components' import { Panel, PanelPosition } from '@vue-flow/additional-components'
import type { ERDConfig } from './utils' import type { ERDConfig } from './utils'
import { ref, useGlobal, useVModel } from '#imports' import { ref, useGlobal, useVModel } from '#imports'
@ -15,7 +15,10 @@ const showAdvancedOptions = ref(false)
</script> </script>
<template> <template>
<Panel class="flex flex-col bg-white border-1 rounded border-gray-200 z-50 px-3 py-1 nc-erd-context-menu" position="top-right"> <Panel
class="flex flex-col bg-white border-1 rounded border-gray-200 z-50 px-3 py-1 nc-erd-context-menu"
:position="PanelPosition.TopRight"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a-checkbox v-model:checked="config.showAllColumns" v-e="['c:erd:showAllColumns']" class="nc-erd-showColumns-checkbox" /> <a-checkbox v-model:checked="config.showAllColumns" v-e="['c:erd:showAllColumns']" class="nc-erd-showColumns-checkbox" />
<span class="select-none nc-erd-showColumns-label" style="font-size: 0.65rem" @dblclick="showAdvancedOptions = true"> <span class="select-none nc-erd-showColumns-label" style="font-size: 0.65rem" @dblclick="showAdvancedOptions = true">

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

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Background, Controls, Panel } from '@vue-flow/additional-components' import { Background, Controls, Panel, PanelPosition } from '@vue-flow/additional-components'
import { VueFlow, useVueFlow } from '@vue-flow/core' import { VueFlow, useVueFlow } from '@vue-flow/core'
import type { TableType } from 'nocodb-sdk' import type { TableType } from 'nocodb-sdk'
import type { ERDConfig } from './utils' import type { ERDConfig } from './utils'
@ -65,7 +65,7 @@ onScopeDispose($destroy)
<template> <template>
<VueFlow v-model="elements"> <VueFlow v-model="elements">
<Controls class="rounded" position="bottom-left" :show-fit-view="false" :show-interactive="false" /> <Controls class="rounded" :position="PanelPosition.BottomLeft" :show-fit-view="false" :show-interactive="false" />
<template #node-custom="{ data, dragging }"> <template #node-custom="{ data, dragging }">
<ErdTableNode :data="data" :dragging="dragging" :show-skeleton="showSkeleton" /> <ErdTableNode :data="data" :dragging="dragging" :show-skeleton="showSkeleton" />
@ -80,7 +80,7 @@ onScopeDispose($destroy)
<Transition name="layout"> <Transition name="layout">
<Panel <Panel
v-if="showSkeleton && config.showAllColumns" v-if="showSkeleton && config.showAllColumns"
position="bottom-center" :position="PanelPosition.BottomCenter"
class="color-transition z-5 cursor-pointer rounded shadow-sm text-slate-400 font-semibold px-4 py-2 bg-slate-100/50 hover:(text-slate-900 ring ring-accent ring-opacity-100 bg-slate-100/90)" class="color-transition z-5 cursor-pointer rounded shadow-sm text-slate-400 font-semibold px-4 py-2 bg-slate-100/50 hover:(text-slate-900 ring ring-accent ring-opacity-100 bg-slate-100/90)"
@click="zoomIn" @click="zoomIn"
> >

4
packages/nc-gui/components/erd/HistogramPanel.vue

@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Panel } from '@vue-flow/additional-components' import { Panel, PanelPosition } from '@vue-flow/additional-components'
</script> </script>
<template> <template>
<Panel class="text-xs bg-white border-1 rounded border-gray-200 z-50 nc-erd-histogram" position="bottom-right"> <Panel class="text-xs bg-white border-1 rounded border-gray-200 z-50 nc-erd-histogram" :position="PanelPosition.BottomRight">
<div class="flex flex-col divide-y-1"> <div class="flex flex-col divide-y-1">
<div class="flex items-center gap-1 p-2"> <div class="flex items-center gap-1 p-2">
<MdiTableLarge class="text-primary" /> <MdiTableLarge class="text-primary" />

4
packages/nc-gui/components/erd/RelationEdge.vue

@ -36,7 +36,7 @@ const edgePath = computed(() => {
const { sourceX, sourceY, targetX, targetY } = props const { sourceX, sourceY, targetX, targetY } = props
const radiusX = (sourceX - targetX) * 0.6 const radiusX = (sourceX - targetX) * 0.6
const radiusY = 50 const radiusY = 50
return [`M ${sourceX} ${sourceY} A ${radiusX} ${radiusY} 0 1 0 ${targetX} ${targetY}`] return [`M ${sourceX} ${sourceY} A ${radiusX} ${radiusY} 0 1 0 ${targetX} ${targetY}`, NaN, NaN] as const
} }
return getBezierPath({ ...props }) return getBezierPath({ ...props })
@ -94,7 +94,7 @@ export default {
<Transition name="layout"> <Transition name="layout">
<EdgeText <EdgeText
v-if="data.label?.length > 0" v-if="data.label?.length && data.label.length > 0"
:key="`edge-text-${id}.${showSkeleton}`" :key="`edge-text-${id}.${showSkeleton}`"
class="color-transition" class="color-transition"
:class="[ :class="[

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

@ -6,7 +6,7 @@ import { UITypes, isVirtualCol } from 'nocodb-sdk'
import type { NodeData } from './utils' import type { NodeData } from './utils'
import { MetaInj, computed, provide, refAutoReset, toRef, useNuxtApp, watch } from '#imports' import { MetaInj, computed, provide, refAutoReset, toRef, useNuxtApp, watch } from '#imports'
interface Props extends NodeProps<NodeData> { interface Props extends Pick<NodeProps<NodeData>, 'data' | 'dragging'> {
data: NodeData data: NodeData
showSkeleton: boolean showSkeleton: boolean
dragging: boolean dragging: boolean

2
packages/nc-gui/components/general/Overlay.vue

@ -11,7 +11,7 @@ interface Props {
target?: TeleportProps['to'] target?: TeleportProps['to']
teleportDisabled?: TeleportProps['disabled'] teleportDisabled?: TeleportProps['disabled']
transition?: boolean transition?: boolean
zIndex?: string | number zIndex?: number
} }
interface Emits { interface Emits {

2
packages/nc-gui/components/general/language/Menu.vue

@ -9,7 +9,7 @@ const { lang: currentLang } = useGlobal()
const { locale } = useI18n() const { locale } = useI18n()
const languages = $computed(() => Object.entries(Language).sort()) const languages = $computed(() => Object.entries(Language).sort() as [keyof typeof Language, Language][])
const isRtlLang = $computed(() => ['fa', 'ar'].includes(currentLang.value)) const isRtlLang = $computed(() => ['fa', 'ar'].includes(currentLang.value))

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))

8
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([]))
@ -200,7 +196,7 @@ watch(view, async (nextView) => {
:key="`carousel-${record.row.id}-${index}`" :key="`carousel-${record.row.id}-${index}`"
quality="90" quality="90"
placeholder placeholder
class="h-52 object-cover" class="h-52 object-contain"
:src="attachment.url" :src="attachment.url"
/> />
</a-carousel> </a-carousel>
@ -289,7 +285,7 @@ watch(view, async (nextView) => {
.ant-carousel.gallery-carousel :deep(.slick-dots) { .ant-carousel.gallery-carousel :deep(.slick-dots) {
position: relative; position: relative;
height: auto; height: auto;
bottom: 0px; bottom: 0;
} }
.ant-carousel.gallery-carousel :deep(.slick-dots li div > div) { .ant-carousel.gallery-carousel :deep(.slick-dots li div > div) {

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 && !isLocked" class="nc-expand" :class="{ 'nc-comment': row.rowMeta?.commentCount }">
<div
v-if="(!readOnly || hasRole('commenter', true) || hasRole('viewer', true)) && !isLocked"
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,

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

@ -224,11 +224,11 @@ function openDeleteDialog(view: ViewType) {
:key="view.id" :key="view.id"
:view="view" :view="view"
:on-validate="validate" :on-validate="validate"
class="transition-all ease-in duration-300" class="nc-view-item transition-all ease-in duration-300"
:class="{ :class="{
'bg-gray-100': isMarked === view.id, 'bg-gray-100': isMarked === view.id,
'active': activeView.id === view.id, 'active': activeView?.id === view.id,
[`nc-view-item nc-${viewTypeAlias[view.type] || view.type}-view-item`]: true, [`nc-${view.type ? viewTypeAlias[view.type] : undefined || view.type}-view-item`]: true,
}" }"
@change-view="changeView" @change-view="changeView"
@open-modal="$emit('openModal', $event)" @open-modal="$emit('openModal', $event)"

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

@ -3,6 +3,7 @@ import type { KanbanType, ViewType, ViewTypes } from 'nocodb-sdk'
import type { WritableComputedRef } from '@vue/reactivity' import type { WritableComputedRef } from '@vue/reactivity'
import { import {
IsLockedInj, IsLockedInj,
computed,
inject, inject,
message, message,
onKeyStroke, onKeyStroke,
@ -47,6 +48,8 @@ let isStopped = $ref(false)
/** Original view title when editing the view name */ /** Original view title when editing the view name */
let originalTitle = $ref<string | undefined>() let originalTitle = $ref<string | undefined>()
const viewType = computed(() => vModel.value.type as number)
/** Debounce click handler, so we can potentially enable editing view name {@see onDblClick} */ /** Debounce click handler, so we can potentially enable editing view name {@see onDblClick} */
const onClick = useDebounceFn(() => { const onClick = useDebounceFn(() => {
if (isEditing || isStopped) return if (isEditing || isStopped) return
@ -175,9 +178,9 @@ function onStopEdit() {
/> />
<component <component
:is="viewIcons[vModel.type].icon" :is="viewIcons[viewType].icon"
class="nc-view-icon group-hover:hidden" class="nc-view-icon group-hover:hidden"
:style="{ color: viewIcons[vModel.type].color }" :style="{ color: viewIcons[viewType].color }"
/> />
</div> </div>

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]
} }

2
packages/nc-gui/composables/useSharedFormViewStore.ts

@ -45,7 +45,7 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const { t } = useI18n() const { t } = useI18n()
const formState = ref({}) const formState = ref<Record<string, any>>({})
const { state: additionalState } = useProvideSmartsheetRowStore( const { state: additionalState } = useProvideSmartsheetRowStore(
meta as Ref<TableType>, meta as Ref<TableType>,

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[]

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

@ -86,8 +86,7 @@
} }
}, },
"../nocodb-sdk": { "../nocodb-sdk": {
"version": "0.98.1", "version": "0.98.2",
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",
@ -114,7 +113,6 @@
"open-cli": "^6.0.1", "open-cli": "^6.0.1",
"prettier": "^2.1.1", "prettier": "^2.1.1",
"standard-version": "^9.0.0", "standard-version": "^9.0.0",
"swagger-typescript-api": "^10.0.1",
"ts-node": "^9.0.0", "ts-node": "^9.0.0",
"typedoc": "^0.23.16", "typedoc": "^0.23.16",
"typescript": "^4.0.2" "typescript": "^4.0.2"
@ -25577,7 +25575,6 @@
"open-cli": "^6.0.1", "open-cli": "^6.0.1",
"prettier": "^2.1.1", "prettier": "^2.1.1",
"standard-version": "^9.0.0", "standard-version": "^9.0.0",
"swagger-typescript-api": "^10.0.1",
"ts-node": "^9.0.0", "ts-node": "^9.0.0",
"typedoc": "^0.23.16", "typedoc": "^0.23.16",
"typescript": "^4.0.2" "typescript": "^4.0.2"

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);
}
} }
} }

24
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)
} }
@ -218,7 +224,7 @@ onMounted(() => {
<Transition :name="`slide-${transitionName}`" :duration="transitionDuration" mode="out-in"> <Transition :name="`slide-${transitionName}`" :duration="transitionDuration" mode="out-in">
<div <div
ref="el" ref="el"
:key="field.title" :key="field?.title"
class="color-transition h-full flex flex-col mt-6 gap-4 w-full max-w-[max(33%,600px)] m-auto" class="color-transition h-full flex flex-col mt-6 gap-4 w-full max-w-[max(33%,600px)] m-auto"
> >
<div v-if="field && !submitted" class="flex flex-col gap-2"> <div v-if="field && !submitted" class="flex flex-col gap-2">
@ -233,16 +239,18 @@ onMounted(() => {
<LazySmartsheetHeaderCell <LazySmartsheetHeaderCell
v-else v-else
:class="field.uidt === UITypes.Checkbox ? 'nc-form-column-label__checkbox' : ''" :class="field.uidt === UITypes.Checkbox ? 'nc-form-column-label__checkbox' : ''"
:column="{ ...field, title: field.label || field.title }" :column="{ meta: {}, ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)" :required="isRequired(field, field.required)"
:hide-menu="true" :hide-menu="true"
/> />
</div> </div>
<div> <div v-if="field.title">
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-if="isVirtualCol(field)" v-if="isVirtualCol(field)"
v-model="formState[field.title]"
class="mt-0 nc-input" class="mt-0 nc-input"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:data-cy="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`" :data-cy="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
/> />
@ -289,7 +297,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>
@ -310,7 +318,7 @@ onMounted(() => {
? 'transform translate-y-[2px] translate-x-[2px] after:(!ring !ring-accent !ring-opacity-100)' ? 'transform translate-y-[2px] translate-x-[2px] after:(!ring !ring-accent !ring-opacity-100)'
: '', : '',
]" ]"
@click="goNext" @click="goNext()"
> >
<Transition name="fade"> <Transition name="fade">
<span v-if="!v$.localState[field.title]?.$error" class="uppercase text-white">Ok</span> <span v-if="!v$.localState[field.title]?.$error" class="uppercase text-white">Ok</span>
@ -390,7 +398,7 @@ onMounted(() => {
" "
class="p-0.5 flex items-center group color-transition" class="p-0.5 flex items-center group color-transition"
data-cy="nc-survey-form__icon-prev" data-cy="nc-survey-form__icon-prev"
@click="goPrevious" @click="goPrevious()"
> >
<MdiChevronLeft :class="isFirst ? 'text-gray-300' : 'group-hover:text-accent'" class="text-2xl md:text-md" /> <MdiChevronLeft :class="isFirst ? 'text-gray-300' : 'group-hover:text-accent'" class="text-2xl md:text-md" />
</button> </button>
@ -409,7 +417,7 @@ onMounted(() => {
" "
class="p-0.5 flex items-center group color-transition" class="p-0.5 flex items-center group color-transition"
data-cy="nc-survey-form__icon-next" data-cy="nc-survey-form__icon-next"
@click="goNext" @click="goNext()"
> >
<MdiChevronRight <MdiChevronRight
:class="[isLast || v$.localState[field.title]?.$error ? 'text-gray-300' : 'group-hover:text-accent']" :class="[isLast || v$.localState[field.title]?.$error ? 'text-gray-300' : 'group-hover:text-accent']"

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)

5
packages/nc-gui/pages/index/index/user.vue

@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { RuleObject } from 'ant-design-vue/es/form'
import { message, navigateTo, reactive, ref, useApi, useGlobal, useI18n, useRouter } from '#imports' import { message, navigateTo, reactive, ref, useApi, useGlobal, useI18n, useRouter } from '#imports'
const router = useRouter() const router = useRouter()
@ -17,7 +18,7 @@ const form = reactive({
passwordRepeat: '', passwordRepeat: '',
}) })
const formRules = { const formRules: Record<string, RuleObject[]> = {
currentPassword: [ currentPassword: [
// Current password is required // Current password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') }, { required: true, message: t('msg.error.signUpRules.passwdRequired') },
@ -34,7 +35,7 @@ const formRules = {
{ {
validator: (_: unknown, _v: string) => { validator: (_: unknown, _v: string) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (form.password === form.passwordRepeat) return resolve(true) if (form.password === form.passwordRepeat) return resolve()
reject(new Error(t('msg.error.signUpRules.passwdMismatch'))) reject(new Error(t('msg.error.signUpRules.passwdMismatch')))
}) })
}, },

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/nc-gui/utils/viewUtils.ts

@ -17,7 +17,7 @@ export const viewIcons: Record<number | string, { icon: any; color: string }> =
view: { icon: MdiEyeIcon, color: 'blue' }, view: { icon: MdiEyeIcon, color: 'blue' },
} }
export const viewTypeAlias = { export const viewTypeAlias: Record<number, string> = {
[ViewTypes.GRID]: 'grid', [ViewTypes.GRID]: 'grid',
[ViewTypes.FORM]: 'form', [ViewTypes.FORM]: 'form',
[ViewTypes.GALLERY]: 'gallery', [ViewTypes.GALLERY]: 'gallery',

2
packages/nc-lib-gui/package.json

@ -1,6 +1,6 @@
{ {
"name": "nc-lib-gui", "name": "nc-lib-gui",
"version": "0.98.1", "version": "0.98.2",
"description": "NocoDB GUI", "description": "NocoDB GUI",
"author": { "author": {
"name": "NocoDB", "name": "NocoDB",

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

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

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.

14968
packages/nocodb-sdk/package-lock.json generated

File diff suppressed because it is too large Load Diff

11
packages/nocodb-sdk/package.json

@ -1,6 +1,6 @@
{ {
"name": "nocodb-sdk", "name": "nocodb-sdk",
"version": "0.98.1", "version": "0.98.2",
"description": "NocoDB SDK", "description": "NocoDB SDK",
"main": "build/main/index.js", "main": "build/main/index.js",
"typings": "build/main/index.d.ts", "typings": "build/main/index.d.ts",
@ -9,7 +9,6 @@
"license": "MIT", "license": "MIT",
"keywords": [], "keywords": [],
"scripts": { "scripts": {
"preinstall": "npm install --package-lock-only --ignore-scripts && npx npm-force-resolutions",
"build": "npm run generate:sdk && run-p build:*", "build": "npm run generate:sdk && run-p build:*",
"build:main": "tsc -p tsconfig.json", "build:main": "tsc -p tsconfig.json",
"build:module": "tsc -p tsconfig.module.json", "build:module": "tsc -p tsconfig.module.json",
@ -38,8 +37,8 @@
"version": "standard-version", "version": "standard-version",
"reset-hard": "git clean -dfx && git reset --hard && npm i", "reset-hard": "git clean -dfx && git reset --hard && npm i",
"prepare-release": "run-s reset-hard test cov:check doc:html version doc:publish", "prepare-release": "run-s reset-hard test cov:check doc:html version doc:publish",
"generate:sdk": "swagger-typescript-api -r -p ../../scripts/sdk/swagger.json -o ./src/lib/ --axios --unwrap-response-data --module-name-first-tag --type-suffix=Type --templates ../../scripts/sdk/templates", "generate:sdk": "npx --yes swagger-typescript-api -r -p ../../scripts/sdk/swagger.json -o ./src/lib/ --axios --unwrap-response-data --module-name-first-tag --type-suffix=Type --templates ../../scripts/sdk/templates",
"generate:sdk:default": "swagger-typescript-api -r -p ../../scripts/sdk/swagger.json -o ./src/lib/ --name Api2.ts --unwrap-response-data --module-name-first-tag --type-suffix=Type --templates ../../scripts/sdk/templates" "generate:sdk:default": "npx --yes swagger-typescript-api -r -p ../../scripts/sdk/swagger.json -o ./src/lib/ --name Api2.ts --unwrap-response-data --module-name-first-tag --type-suffix=Type --templates ../../scripts/sdk/templates"
}, },
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -69,14 +68,10 @@
"open-cli": "^6.0.1", "open-cli": "^6.0.1",
"prettier": "^2.1.1", "prettier": "^2.1.1",
"standard-version": "^9.0.0", "standard-version": "^9.0.0",
"swagger-typescript-api": "^10.0.1",
"ts-node": "^9.0.0", "ts-node": "^9.0.0",
"typedoc": "^0.23.16", "typedoc": "^0.23.16",
"typescript": "^4.0.2" "typescript": "^4.0.2"
}, },
"resolutions": {
"typescript": "4.7.4"
},
"files": [ "files": [
"build/main", "build/main",
"build/module", "build/module",

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

File diff suppressed because it is too large Load Diff

23
packages/nocodb/package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "nocodb", "name": "nocodb",
"version": "0.98.1", "version": "0.98.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nocodb", "name": "nocodb",
"version": "0.98.1", "version": "0.98.2",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@google-cloud/storage": "^5.7.2", "@google-cloud/storage": "^5.7.2",
@ -71,7 +71,7 @@
"nanoid": "^3.1.20", "nanoid": "^3.1.20",
"nc-common": "0.0.6", "nc-common": "0.0.6",
"nc-help": "0.2.76", "nc-help": "0.2.76",
"nc-lib-gui": "0.98.1", "nc-lib-gui": "0.98.2",
"nc-plugin": "0.1.2", "nc-plugin": "0.1.2",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk", "nocodb-sdk": "file:../nocodb-sdk",
@ -175,8 +175,7 @@
} }
}, },
"../nocodb-sdk": { "../nocodb-sdk": {
"version": "0.98.1", "version": "0.98.2",
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",
@ -203,7 +202,6 @@
"open-cli": "^6.0.1", "open-cli": "^6.0.1",
"prettier": "^2.1.1", "prettier": "^2.1.1",
"standard-version": "^9.0.0", "standard-version": "^9.0.0",
"swagger-typescript-api": "^10.0.1",
"ts-node": "^9.0.0", "ts-node": "^9.0.0",
"typedoc": "^0.23.16", "typedoc": "^0.23.16",
"typescript": "^4.0.2" "typescript": "^4.0.2"
@ -15348,9 +15346,9 @@
} }
}, },
"node_modules/nc-lib-gui": { "node_modules/nc-lib-gui": {
"version": "0.98.1", "version": "0.98.2",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.98.1.tgz", "resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.98.2.tgz",
"integrity": "sha512-g9rbr4CEUlAf6dbTSv4lHqnAFFIRaCff428JyBWjGZcbbsQkohWeNuVmosHnpP9YEG2pJKlXez5S59K4cH/tiQ==", "integrity": "sha512-8tJ7eJmm2iUgzJDvb7wqBsbdf9PlhU2DyNy3pUWsfTBrKUcHJVPVv29/320ewzoblpnpXowYiK9cJHDDTgaq8A==",
"dependencies": { "dependencies": {
"axios": "^0.19.2", "axios": "^0.19.2",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
@ -36796,9 +36794,9 @@
} }
}, },
"nc-lib-gui": { "nc-lib-gui": {
"version": "0.98.1", "version": "0.98.2",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.98.1.tgz", "resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.98.2.tgz",
"integrity": "sha512-g9rbr4CEUlAf6dbTSv4lHqnAFFIRaCff428JyBWjGZcbbsQkohWeNuVmosHnpP9YEG2pJKlXez5S59K4cH/tiQ==", "integrity": "sha512-8tJ7eJmm2iUgzJDvb7wqBsbdf9PlhU2DyNy3pUWsfTBrKUcHJVPVv29/320ewzoblpnpXowYiK9cJHDDTgaq8A==",
"requires": { "requires": {
"axios": "^0.19.2", "axios": "^0.19.2",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
@ -36960,7 +36958,6 @@
"open-cli": "^6.0.1", "open-cli": "^6.0.1",
"prettier": "^2.1.1", "prettier": "^2.1.1",
"standard-version": "^9.0.0", "standard-version": "^9.0.0",
"swagger-typescript-api": "^10.0.1",
"ts-node": "^9.0.0", "ts-node": "^9.0.0",
"typedoc": "^0.23.16", "typedoc": "^0.23.16",
"typescript": "^4.0.2" "typescript": "^4.0.2"

4
packages/nocodb/package.json

@ -1,6 +1,6 @@
{ {
"name": "nocodb", "name": "nocodb",
"version": "0.98.1", "version": "0.98.2",
"description": "NocoDB", "description": "NocoDB",
"main": "dist/bundle.js", "main": "dist/bundle.js",
"repository": "https://github.com/nocodb/nocodb", "repository": "https://github.com/nocodb/nocodb",
@ -156,7 +156,7 @@
"nanoid": "^3.1.20", "nanoid": "^3.1.20",
"nc-common": "0.0.6", "nc-common": "0.0.6",
"nc-help": "0.2.76", "nc-help": "0.2.76",
"nc-lib-gui": "0.98.1", "nc-lib-gui": "0.98.2",
"nc-plugin": "0.1.2", "nc-plugin": "0.1.2",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk", "nocodb-sdk": "file:../nocodb-sdk",

5
packages/nocodb/src/lib/Noco.ts

@ -103,6 +103,11 @@ export default class Noco {
// todo: move // todo: move
process.env.NC_VERSION = '0090000'; process.env.NC_VERSION = '0090000';
// if env variable NC_MINIMAL_DBS is set, then disable project creation with external sources
if (process.env.NC_MINIMAL_DBS) {
process.env.NC_CONNECT_TO_EXTERNAL_DB_DISABLED = 'true';
}
this.router = express.Router(); this.router = express.Router();
this.projectRouter = express.Router(); this.projectRouter = express.Router();

20
packages/nocodb/src/lib/cache/NocoCache.ts vendored

@ -6,6 +6,7 @@ import { CacheGetType } from '../utils/globals';
export default class NocoCache { export default class NocoCache {
private static client: CacheMgr; private static client: CacheMgr;
private static cacheDisabled: boolean; private static cacheDisabled: boolean;
private static prefix: string;
public static init() { public static init() {
this.cacheDisabled = (process.env.NC_DISABLE_CACHE || false) === 'true'; this.cacheDisabled = (process.env.NC_DISABLE_CACHE || false) === 'true';
@ -17,11 +18,15 @@ export default class NocoCache {
} else { } else {
this.client = new RedisMockCacheMgr(); this.client = new RedisMockCacheMgr();
} }
// TODO(cache): fetch orgs once it's implemented
const orgs = 'noco';
this.prefix = `nc:${orgs}`;
} }
public static async set(key, value): Promise<boolean> { public static async set(key, value): Promise<boolean> {
if (this.cacheDisabled) return Promise.resolve(true); if (this.cacheDisabled) return Promise.resolve(true);
return this.client.set(key, value); return this.client.set(`${this.prefix}:${key}`, value);
} }
public static async get(key, type): Promise<any> { public static async get(key, type): Promise<any> {
@ -30,17 +35,17 @@ export default class NocoCache {
else if (type === CacheGetType.TYPE_OBJECT) return Promise.resolve(null); else if (type === CacheGetType.TYPE_OBJECT) return Promise.resolve(null);
return Promise.resolve(null); return Promise.resolve(null);
} }
return this.client.get(key, type); return this.client.get(`${this.prefix}:${key}`, type);
} }
public static async getAll(pattern: string): Promise<any[]> { public static async getAll(pattern: string): Promise<any[]> {
if (this.cacheDisabled) return Promise.resolve([]); if (this.cacheDisabled) return Promise.resolve([]);
return this.client.getAll(pattern); return this.client.getAll(`${this.prefix}:${pattern}`);
} }
public static async del(key): Promise<boolean> { public static async del(key): Promise<boolean> {
if (this.cacheDisabled) return Promise.resolve(true); if (this.cacheDisabled) return Promise.resolve(true);
return this.client.del(key); return this.client.del(`${this.prefix}:${key}`);
} }
public static async delAll(scope: string, pattern: string): Promise<any[]> { public static async delAll(scope: string, pattern: string): Promise<any[]> {
@ -77,10 +82,15 @@ export default class NocoCache {
public static async appendToList( public static async appendToList(
scope: string, scope: string,
subListKeys: string[], subListKeys: string[],
key: string key: string
): Promise<boolean> { ): Promise<boolean> {
if (this.cacheDisabled) return Promise.resolve(true); if (this.cacheDisabled) return Promise.resolve(true);
return this.client.appendToList(scope, subListKeys, key); return this.client.appendToList(
scope,
subListKeys,
`${this.prefix}:${key}`
);
} }
public static async destroy(): Promise<boolean> { public static async destroy(): Promise<boolean> {

40
packages/nocodb/src/lib/cache/RedisCacheMgr.ts vendored

@ -6,12 +6,17 @@ const log = debug('nc:cache');
export default class RedisCacheMgr extends CacheMgr { export default class RedisCacheMgr extends CacheMgr {
client: any; client: any;
prefix: string;
constructor(config: any) { constructor(config: any) {
super(); super();
this.client = new Redis(config); this.client = new Redis(config);
// flush the existing db with selected key (Default: 0) // flush the existing db with selected key (Default: 0)
this.client.flushdb(); this.client.flushdb();
// TODO(cache): fetch orgs once it's implemented
const orgs = 'noco';
this.prefix = `nc:${orgs}`;
} }
// avoid circular structure to JSON // avoid circular structure to JSON
@ -91,10 +96,10 @@ export default class RedisCacheMgr extends CacheMgr {
// @ts-ignore // @ts-ignore
async delAll(scope: string, pattern: string): Promise<any[]> { async delAll(scope: string, pattern: string): Promise<any[]> {
// Example: model:*:<id> // Example: nc:<orgs>:model:*:<id>
const keys = await this.client.keys(`${scope}:${pattern}`); const keys = await this.client.keys(`${this.prefix}:${scope}:${pattern}`);
log( log(
`RedisCacheMgr::delAll: deleting all keys with pattern ${scope}:${pattern}` `RedisCacheMgr::delAll: deleting all keys with pattern ${this.prefix}:${scope}:${pattern}`
); );
return Promise.all( return Promise.all(
keys.map( keys.map(
@ -107,12 +112,12 @@ export default class RedisCacheMgr extends CacheMgr {
async getList(scope: string, subKeys: string[]): Promise<any[]> { async getList(scope: string, subKeys: string[]): Promise<any[]> {
// remove null from arrays // remove null from arrays
subKeys = subKeys.filter((k) => k); subKeys = subKeys.filter((k) => k);
// e.g. key = <scope>:<project_id_1>:<base_id_1>:list // e.g. key = nc:<orgs>:<scope>:<project_id_1>:<base_id_1>:list
const key = const key =
subKeys.length === 0 subKeys.length === 0
? `${scope}:list` ? `${this.prefix}:${scope}:list`
: `${scope}:${subKeys.join(':')}:list`; : `${this.prefix}:${scope}:${subKeys.join(':')}:list`;
// e.g. arr = ["<scope>:<model_id_1>", "<scope>:<model_id_2>"] // e.g. arr = ["nc:<orgs>:<scope>:<model_id_1>", "nc:<orgs>:<scope>:<model_id_2>"]
const arr = (await this.get(key, CacheGetType.TYPE_ARRAY)) || []; const arr = (await this.get(key, CacheGetType.TYPE_ARRAY)) || [];
log(`RedisCacheMgr::getList: getting list with key ${key}`); log(`RedisCacheMgr::getList: getting list with key ${key}`);
return Promise.all( return Promise.all(
@ -128,11 +133,11 @@ export default class RedisCacheMgr extends CacheMgr {
// remove null from arrays // remove null from arrays
subListKeys = subListKeys.filter((k) => k); subListKeys = subListKeys.filter((k) => k);
// construct key for List // construct key for List
// e.g. <scope>:<project_id_1>:<base_id_1>:list // e.g. nc:<orgs>:<scope>:<project_id_1>:<base_id_1>:list
const listKey = const listKey =
subListKeys.length === 0 subListKeys.length === 0
? `${scope}:list` ? `${this.prefix}:${scope}:list`
: `${scope}:${subListKeys.join(':')}:list`; : `${this.prefix}:${scope}:${subListKeys.join(':')}:list`;
if (!list.length) { if (!list.length) {
log(`RedisCacheMgr::setList: List is empty for ${listKey}. Skipping ...`); log(`RedisCacheMgr::setList: List is empty for ${listKey}. Skipping ...`);
return Promise.resolve(true); return Promise.resolve(true);
@ -142,11 +147,11 @@ export default class RedisCacheMgr extends CacheMgr {
(await this.get(listKey, CacheGetType.TYPE_ARRAY)) || []; (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || [];
for (const o of list) { for (const o of list) {
// construct key for Get // construct key for Get
// e.g. <scope>:<model_id_1> // e.g. nc:<orgs>:<scope>:<model_id_1>
let getKey = `${scope}:${o.id}`; let getKey = `${this.prefix}:${scope}:${o.id}`;
// special case - MODEL_ROLE_VISIBILITY // special case - MODEL_ROLE_VISIBILITY
if (scope === CacheScope.MODEL_ROLE_VISIBILITY) { if (scope === CacheScope.MODEL_ROLE_VISIBILITY) {
getKey = `${scope}:${o.id}:${o.role}`; getKey = `${this.prefix}:${scope}:${o.id}:${o.role}`;
} }
// set Get Key // set Get Key
log(`RedisCacheMgr::setList: setting key ${getKey}`); log(`RedisCacheMgr::setList: setting key ${getKey}`);
@ -164,10 +169,11 @@ export default class RedisCacheMgr extends CacheMgr {
key: string, key: string,
direction: string direction: string
): Promise<boolean> { ): Promise<boolean> {
key = `${this.prefix}:${key}`;
log(`RedisCacheMgr::deepDel: choose direction ${direction}`); log(`RedisCacheMgr::deepDel: choose direction ${direction}`);
if (direction === CacheDelDirection.CHILD_TO_PARENT) { if (direction === CacheDelDirection.CHILD_TO_PARENT) {
// given a child key, delete all keys in corresponding parent lists // given a child key, delete all keys in corresponding parent lists
const scopeList = await this.client.keys(`${scope}*list`); const scopeList = await this.client.keys(`${this.prefix}:${scope}*list`);
for (const listKey of scopeList) { for (const listKey of scopeList) {
// get target list // get target list
let list = (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || []; let list = (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || [];
@ -208,11 +214,11 @@ export default class RedisCacheMgr extends CacheMgr {
): Promise<boolean> { ): Promise<boolean> {
// remove null from arrays // remove null from arrays
subListKeys = subListKeys.filter((k) => k); subListKeys = subListKeys.filter((k) => k);
// e.g. key = <scope>:<project_id_1>:<base_id_1>:list // e.g. key = nc:<orgs>:<scope>:<project_id_1>:<base_id_1>:list
const listKey = const listKey =
subListKeys.length === 0 subListKeys.length === 0
? `${scope}:list` ? `${this.prefix}:${scope}:list`
: `${scope}:${subListKeys.join(':')}:list`; : `${this.prefix}:${scope}:${subListKeys.join(':')}:list`;
log(`RedisCacheMgr::appendToList: append key ${key} to ${listKey}`); log(`RedisCacheMgr::appendToList: append key ${key} to ${listKey}`);
const list = (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || []; const list = (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || [];
list.push(key); list.push(key);

40
packages/nocodb/src/lib/cache/RedisMockCacheMgr.ts vendored

@ -6,12 +6,17 @@ const log = debug('nc:cache');
export default class RedisMockCacheMgr extends CacheMgr { export default class RedisMockCacheMgr extends CacheMgr {
client: any; client: any;
prefix: string;
constructor() { constructor() {
super(); super();
this.client = new Redis(); this.client = new Redis();
// flush the existing db with selected key (Default: 0) // flush the existing db with selected key (Default: 0)
this.client.flushdb(); this.client.flushdb();
// TODO(cache): fetch orgs once it's implemented
const orgs = 'noco';
this.prefix = `nc:${orgs}`;
} }
// avoid circular structure to JSON // avoid circular structure to JSON
@ -91,10 +96,10 @@ export default class RedisMockCacheMgr extends CacheMgr {
// @ts-ignore // @ts-ignore
async delAll(scope: string, pattern: string): Promise<any[]> { async delAll(scope: string, pattern: string): Promise<any[]> {
// Example: model:*:<id> // Example: nc:<orgs>:model:*:<id>
const keys = await this.client.keys(`${scope}:${pattern}`); const keys = await this.client.keys(`${this.prefix}:${scope}:${pattern}`);
log( log(
`RedisMockCacheMgr::delAll: deleting all keys with pattern ${scope}:${pattern}` `RedisMockCacheMgr::delAll: deleting all keys with pattern ${this.prefix}:${scope}:${pattern}`
); );
return Promise.all( return Promise.all(
keys.map( keys.map(
@ -107,12 +112,12 @@ export default class RedisMockCacheMgr extends CacheMgr {
async getList(scope: string, subKeys: string[]): Promise<any[]> { async getList(scope: string, subKeys: string[]): Promise<any[]> {
// remove null from arrays // remove null from arrays
subKeys = subKeys.filter((k) => k); subKeys = subKeys.filter((k) => k);
// e.g. key = <scope>:<project_id_1>:<base_id_1>:list // e.g. key = nc:<orgs>:<scope>:<project_id_1>:<base_id_1>:list
const key = const key =
subKeys.length === 0 subKeys.length === 0
? `${scope}:list` ? `${this.prefix}:${scope}:list`
: `${scope}:${subKeys.join(':')}:list`; : `${this.prefix}:${scope}:${subKeys.join(':')}:list`;
// e.g. arr = ["<scope>:<model_id_1>", "<scope>:<model_id_2>"] // e.g. arr = ["nc:<orgs>:<scope>:<model_id_1>", "nc:<orgs>:<scope>:<model_id_2>"]
const arr = (await this.get(key, CacheGetType.TYPE_ARRAY)) || []; const arr = (await this.get(key, CacheGetType.TYPE_ARRAY)) || [];
log(`RedisMockCacheMgr::getList: getting list with key ${key}`); log(`RedisMockCacheMgr::getList: getting list with key ${key}`);
return Promise.all( return Promise.all(
@ -128,11 +133,11 @@ export default class RedisMockCacheMgr extends CacheMgr {
// remove null from arrays // remove null from arrays
subListKeys = subListKeys.filter((k) => k); subListKeys = subListKeys.filter((k) => k);
// construct key for List // construct key for List
// e.g. <scope>:<project_id_1>:<base_id_1>:list // e.g. nc:<orgs>:<scope>:<project_id_1>:<base_id_1>:list
const listKey = const listKey =
subListKeys.length === 0 subListKeys.length === 0
? `${scope}:list` ? `${this.prefix}:${scope}:list`
: `${scope}:${subListKeys.join(':')}:list`; : `${this.prefix}:${scope}:${subListKeys.join(':')}:list`;
if (!list.length) { if (!list.length) {
log( log(
`RedisMockCacheMgr::setList: List is empty for ${listKey}. Skipping ...` `RedisMockCacheMgr::setList: List is empty for ${listKey}. Skipping ...`
@ -144,11 +149,11 @@ export default class RedisMockCacheMgr extends CacheMgr {
(await this.get(listKey, CacheGetType.TYPE_ARRAY)) || []; (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || [];
for (const o of list) { for (const o of list) {
// construct key for Get // construct key for Get
// e.g. <scope>:<model_id_1> // e.g. nc:<orgs>:<scope>:<model_id_1>
let getKey = `${scope}:${o.id}`; let getKey = `${this.prefix}:${scope}:${o.id}`;
// special case - MODEL_ROLE_VISIBILITY // special case - MODEL_ROLE_VISIBILITY
if (scope === CacheScope.MODEL_ROLE_VISIBILITY) { if (scope === CacheScope.MODEL_ROLE_VISIBILITY) {
getKey = `${scope}:${o.id}:${o.role}`; getKey = `${this.prefix}:${scope}:${o.id}:${o.role}`;
} }
// set Get Key // set Get Key
log(`RedisMockCacheMgr::setList: setting key ${getKey}`); log(`RedisMockCacheMgr::setList: setting key ${getKey}`);
@ -166,10 +171,11 @@ export default class RedisMockCacheMgr extends CacheMgr {
key: string, key: string,
direction: string direction: string
): Promise<boolean> { ): Promise<boolean> {
key = `${this.prefix}:${key}`;
log(`RedisMockCacheMgr::deepDel: choose direction ${direction}`); log(`RedisMockCacheMgr::deepDel: choose direction ${direction}`);
if (direction === CacheDelDirection.CHILD_TO_PARENT) { if (direction === CacheDelDirection.CHILD_TO_PARENT) {
// given a child key, delete all keys in corresponding parent lists // given a child key, delete all keys in corresponding parent lists
const scopeList = await this.client.keys(`${scope}*list`); const scopeList = await this.client.keys(`${this.prefix}:${scope}*list`);
for (const listKey of scopeList) { for (const listKey of scopeList) {
// get target list // get target list
let list = (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || []; let list = (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || [];
@ -210,11 +216,11 @@ export default class RedisMockCacheMgr extends CacheMgr {
): Promise<boolean> { ): Promise<boolean> {
// remove null from arrays // remove null from arrays
subListKeys = subListKeys.filter((k) => k); subListKeys = subListKeys.filter((k) => k);
// e.g. key = <scope>:<project_id_1>:<base_id_1>:list // e.g. key = nc:<orgs>:<scope>:<project_id_1>:<base_id_1>:list
const listKey = const listKey =
subListKeys.length === 0 subListKeys.length === 0
? `${scope}:list` ? `${this.prefix}:${scope}:list`
: `${scope}:${subListKeys.join(':')}:list`; : `${this.prefix}:${scope}:${subListKeys.join(':')}:list`;
log(`RedisMockCacheMgr::appendToList: append key ${key} to ${listKey}`); log(`RedisMockCacheMgr::appendToList: append key ${key} to ${listKey}`);
const list = (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || []; const list = (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || [];
list.push(key); list.push(key);

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

@ -980,7 +980,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(??, ','), ?), ',')`,
@ -1160,7 +1159,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(??, ','), ?, ?), ',')`,

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

@ -24,6 +24,7 @@ import mapDefaultPrimaryValue from '../helpers/mapDefaultPrimaryValue';
import { extractAndGenerateManyToManyRelations } from './metaDiffApis'; import { extractAndGenerateManyToManyRelations } from './metaDiffApis';
import { metaApiMetrics } from '../helpers/apiMetrics'; import { metaApiMetrics } from '../helpers/apiMetrics';
import { extractPropsAndSanitize } from '../helpers/extractProps'; import { extractPropsAndSanitize } from '../helpers/extractProps';
import NcConfigFactory from '../../utils/NcConfigFactory';
const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 4); const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 4);
@ -107,16 +108,51 @@ async function projectCreate(req: Request<any, any>, res) {
const ranId = nanoid(); const ranId = nanoid();
projectBody.prefix = `nc_${ranId}__`; projectBody.prefix = `nc_${ranId}__`;
projectBody.is_meta = true; projectBody.is_meta = true;
const db = Noco.getConfig().meta?.db; if (process.env.NC_MINIMAL_DBS) {
projectBody.bases = [ // if env variable NC_MINIMAL_DBS is set, then create a SQLite file/connection for each project
{ // each file will be named as nc_<random_id>.db
type: db?.client, const fs = require('fs');
config: null, const toolDir = NcConfigFactory.getToolDir();
is_meta: true, const nanoidv2 = customAlphabet(
inflection_column: 'camelize', '1234567890abcdefghijklmnopqrstuvwxyz',
inflection_table: 'camelize', 14
}, );
]; if (!fs.existsSync(`${toolDir}/nc_minimal_dbs`)) {
fs.mkdirSync(`${toolDir}/nc_minimal_dbs`);
}
const dbId = nanoidv2();
const projectTitle = DOMPurify.sanitize(projectBody.title);
projectBody.prefix = '';
projectBody.bases = [
{
type: 'sqlite3',
config: {
client: 'sqlite3',
connection: {
client: 'sqlite3',
database: projectTitle,
connection: {
filename: `${toolDir}/nc_minimal_dbs/${projectTitle}_${dbId}.db`,
},
useNullAsDefault: true,
},
},
inflection_column: 'camelize',
inflection_table: 'camelize',
},
];
} else {
const db = Noco.getConfig().meta?.db;
projectBody.bases = [
{
type: db?.client,
config: null,
is_meta: true,
inflection_column: 'camelize',
inflection_table: 'camelize',
},
];
}
} else { } else {
if (process.env.NC_CONNECT_TO_EXTERNAL_DB_DISABLED) { if (process.env.NC_CONNECT_TO_EXTERNAL_DB_DISABLED) {
NcError.badRequest('Connecting to external db is disabled'); NcError.badRequest('Connecting to external db is disabled');

199
packages/nocodb/src/lib/meta/api/sync/helpers/EntityMap.ts

@ -0,0 +1,199 @@
import sqlite3 from 'sqlite3';
import { Readable } from 'stream';
class EntityMap {
initialized: boolean;
cols: string[];
db: any;
constructor(...args) {
this.initialized = false;
this.cols = args.map((arg) => processKey(arg));
this.db = new Promise((resolve, reject) => {
const db = new sqlite3.Database(':memory:');
const colStatement = this.cols.length > 0 ? this.cols.join(' TEXT, ') + ' TEXT' : 'mappingPlaceholder TEXT';
db.run(`CREATE TABLE mapping (${colStatement})`, (err) => {
if (err) {
console.log(err);
reject(err);
}
resolve(db)
});
});
}
async init () {
if (!this.initialized) {
this.db = await this.db;
this.initialized = true;
}
}
destroy() {
if (this.initialized && this.db) {
this.db.close();
}
}
async addRow(row) {
if (!this.initialized) {
throw 'Please initialize first!';
}
const cols = Object.keys(row).map((key) => processKey(key));
const colStatement = cols.map((key) => `'${key}'`).join(', ');
const questionMarks = cols.map(() => '?').join(', ');
const promises = [];
for (const col of cols.filter((col) => !this.cols.includes(col))) {
promises.push(new Promise((resolve, reject) => {
this.db.run(`ALTER TABLE mapping ADD '${col}' TEXT;`, (err) => {
if (err) {
console.log(err);
reject(err);
}
this.cols.push(col);
resolve(true);
});
}));
}
await Promise.all(promises);
const values = Object.values(row).map((val) => {
if (typeof val === 'object') {
return `JSON::${JSON.stringify(val)}`;
}
return val;
});
return new Promise((resolve, reject) => {
this.db.run(`INSERT INTO mapping (${colStatement}) VALUES (${questionMarks})`, values, (err) => {
if (err) {
console.log(err);
reject(err);
}
resolve(true);
});
});
}
getRow(col, val, res = []): Promise<Record<string, any>> {
if (!this.initialized) {
throw 'Please initialize first!';
}
return new Promise((resolve, reject) => {
col = processKey(col);
res = res.map((r) => processKey(r));
this.db.get(`SELECT ${res.length ? res.join(', ') : '*'} FROM mapping WHERE ${col} = ?`, [val], (err, rs) => {
if (err) {
console.log(err);
reject(err);
}
if (rs) {
rs = processResponseRow(rs);
}
resolve(rs)
});
});
}
getCount(): Promise<number> {
if (!this.initialized) {
throw 'Please initialize first!';
}
return new Promise((resolve, reject) => {
this.db.get(`SELECT COUNT(*) as count FROM mapping`, (err, rs) => {
if (err) {
console.log(err);
reject(err);
}
resolve(rs.count)
});
});
}
getStream(res = []): DBStream {
if (!this.initialized) {
throw 'Please initialize first!';
}
res = res.map((r) => processKey(r));
return new DBStream(this.db, `SELECT ${res.length ? res.join(', ') : '*'} FROM mapping`);
}
getLimit(limit, offset, res = []): Promise<Record<string, any>[]> {
if (!this.initialized) {
throw 'Please initialize first!';
}
return new Promise((resolve, reject) => {
res = res.map((r) => processKey(r));
this.db.all(`SELECT ${res.length ? res.join(', ') : '*'} FROM mapping LIMIT ${limit} OFFSET ${offset}`, (err, rs) => {
if (err) {
console.log(err);
reject(err);
}
for (let row of rs) {
row = processResponseRow(row);
}
resolve(rs)
});
});
}
}
class DBStream extends Readable {
db: any;
stmt: any;
sql: any;
constructor(db, sql) {
super({ objectMode: true});
this.db = db;
this.sql = sql;
this.stmt = this.db.prepare(this.sql);
this.on('end', () => this.stmt.finalize());
}
_read() {
let stream = this;
this.stmt.get(function (err, result) {
if (err) {
stream.emit('error', err);
} else {
if (result) {
result = processResponseRow(result);
}
stream.push(result || null)
}
});
}
}
function processResponseRow(res: any) {
for (const key of Object.keys(res)) {
if (res[key] && res[key].startsWith('JSON::')) {
try {
res[key] = JSON.parse(res[key].replace('JSON::', ''));
} catch (e) {
console.log(e);
}
}
if (revertKey(key) !== key) {
res[revertKey(key)] = res[key];
delete res[key];
}
}
return res;
}
function processKey(key) {
return key.replace(/'/g, "''").replace(/[A-Z]/g, (match) => `_${match}`);
}
function revertKey(key) {
return key.replace(/''/g, "'").replace(/_[A-Z]/g, (match) => match[1]);
}
export default EntityMap;

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

@ -14,6 +14,8 @@ import utc from 'dayjs/plugin/utc';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { importData, importLTARData } from './readAndProcessData'; import { importData, importLTARData } from './readAndProcessData';
import EntityMap from './EntityMap';
dayjs.extend(utc); dayjs.extend(utc);
const selectColors = { const selectColors = {
@ -67,32 +69,28 @@ export default async (
syncDB: AirtableSyncConfig, syncDB: AirtableSyncConfig,
progress: (data: { msg?: string; level?: any }) => void progress: (data: { msg?: string; level?: any }) => void
) => { ) => {
const sMap = { const sMapEM = new EntityMap('aTblId', 'ncId', 'ncName', 'ncParent');
mapTbl: {}, await sMapEM.init();
const sMap = {
// static mapping records between aTblId && ncId // static mapping records between aTblId && ncId
addToMappingTbl(aTblId, ncId, ncName, parent?) { async addToMappingTbl(aTblId, ncId, ncName, ncParent?) {
this.mapTbl[aTblId] = { await sMapEM.addRow({ aTblId, ncId, ncName, ncParent });
ncId: ncId,
ncParent: parent,
// name added to assist in quick debug
ncName: ncName,
};
}, },
// get NcID from airtable ID // get NcID from airtable ID
getNcIdFromAtId(aId) { async getNcIdFromAtId(aId) {
return this.mapTbl[aId]?.ncId; return (await sMapEM.getRow('aTblId', aId, ['ncId']))?.ncId;
}, },
// get nc Parent from airtable ID // get nc Parent from airtable ID
getNcParentFromAtId(aId) { async getNcParentFromAtId(aId) {
return this.mapTbl[aId]?.ncParent; return (await sMapEM.getRow('aTblId', aId, ['ncParent']))?.ncParent;
}, },
// get nc-title from airtable ID // get nc-title from airtable ID
getNcNameFromAtId(aId) { async getNcNameFromAtId(aId) {
return this.mapTbl[aId]?.ncName; return (await sMapEM.getRow('aTblId', aId, ['ncName']))?.ncName;
}, },
}; };
@ -333,8 +331,8 @@ export default async (
// let ncCol = ncTbl.columns.find(x => x.title === aTblField.cn); // let ncCol = ncTbl.columns.find(x => x.title === aTblField.cn);
// return ncCol; // return ncCol;
const ncTblId = sMap.getNcParentFromAtId(aTblFieldId); const ncTblId = await sMap.getNcParentFromAtId(aTblFieldId);
const ncColId = sMap.getNcIdFromAtId(aTblFieldId); const ncColId = await sMap.getNcIdFromAtId(aTblFieldId);
// not migrated column, skip // not migrated column, skip
if (ncColId === undefined || ncTblId === undefined) return 0; if (ncColId === undefined || ncTblId === undefined) return 0;
@ -424,7 +422,7 @@ export default async (
// retrieve additional options associated with selected data types // retrieve additional options associated with selected data types
// //
function getNocoTypeOptions(col: any): any { async function getNocoTypeOptions(col: any): Promise<any> {
switch (col.type) { switch (col.type) {
case 'select': case 'select':
case 'multiSelect': { case 'multiSelect': {
@ -446,7 +444,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({
@ -457,7 +460,7 @@ export default async (
: tinycolor.random().toHexString(), : tinycolor.random().toHexString(),
}); });
sMap.addToMappingTbl( await sMap.addToMappingTbl(
(value as any).id, (value as any).id,
undefined, undefined,
(value as any).name (value as any).name
@ -472,7 +475,7 @@ export default async (
// convert to Nc schema (basic, excluding relations) // convert to Nc schema (basic, excluding relations)
// //
function tablesPrepare(tblSchema: any[]) { async function tablesPrepare(tblSchema: any[]) {
const tables: any[] = []; const tables: any[] = [];
for (let i = 0; i < tblSchema.length; ++i) { for (let i = 0; i < tblSchema.length; ++i) {
@ -569,7 +572,7 @@ export default async (
} }
// additional column parameters when applicable // additional column parameters when applicable
const colOptions = getNocoTypeOptions(col); const colOptions = await getNocoTypeOptions(col);
switch (colOptions.type) { switch (colOptions.type) {
case 'select': case 'select':
@ -585,7 +588,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;
@ -602,7 +605,7 @@ export default async (
async function nocoCreateBaseSchema(aTblSchema) { async function nocoCreateBaseSchema(aTblSchema) {
// base schema preparation: exclude // base schema preparation: exclude
const tables: any[] = tablesPrepare(aTblSchema); const tables: any[] = await tablesPrepare(aTblSchema);
// for each table schema, create nc table // for each table schema, create nc table
for (let idx = 0; idx < tables.length; idx++) { for (let idx = 0; idx < tables.length; idx++) {
@ -696,7 +699,7 @@ export default async (
if (!nc_isLinkExists(aTblLinkColumns[i].id)) { if (!nc_isLinkExists(aTblLinkColumns[i].id)) {
// parent table ID // parent table ID
// let srcTableId = (await nc_getTableSchema(aTblSchema[idx].name)).id; // let srcTableId = (await nc_getTableSchema(aTblSchema[idx].name)).id;
const srcTableId = sMap.getNcIdFromAtId(aTblSchema[idx].id); const srcTableId = await sMap.getNcIdFromAtId(aTblSchema[idx].id);
// find child table name from symmetric column ID specified // find child table name from symmetric column ID specified
// self link, symmetricColumnId field will be undefined // self link, symmetricColumnId field will be undefined
@ -911,7 +914,7 @@ export default async (
// parent table ID // parent table ID
// let srcTableId = (await nc_getTableSchema(aTblSchema[idx].name)).id; // let srcTableId = (await nc_getTableSchema(aTblSchema[idx].name)).id;
const srcTableId = sMap.getNcIdFromAtId(aTblSchema[idx].id); const srcTableId = await sMap.getNcIdFromAtId(aTblSchema[idx].id);
const srcTableSchema = ncSchema.tablesById[srcTableId]; const srcTableSchema = ncSchema.tablesById[srcTableId];
if (aTblColumns.length) { if (aTblColumns.length) {
@ -939,10 +942,10 @@ export default async (
continue; continue;
} }
const ncRelationColumnId = sMap.getNcIdFromAtId( const ncRelationColumnId = await sMap.getNcIdFromAtId(
aTblColumns[i].typeOptions.relationColumnId aTblColumns[i].typeOptions.relationColumnId
); );
const ncLookupColumnId = sMap.getNcIdFromAtId( const ncLookupColumnId = await sMap.getNcIdFromAtId(
aTblColumns[i].typeOptions.foreignTableRollupColumnId aTblColumns[i].typeOptions.foreignTableRollupColumnId
); );
@ -1015,10 +1018,10 @@ export default async (
const srcTableId = nestedLookupTbl[0].srcTableId; const srcTableId = nestedLookupTbl[0].srcTableId;
const srcTableSchema = ncSchema.tablesById[srcTableId]; const srcTableSchema = ncSchema.tablesById[srcTableId];
const ncRelationColumnId = sMap.getNcIdFromAtId( const ncRelationColumnId = await sMap.getNcIdFromAtId(
nestedLookupTbl[0].typeOptions.relationColumnId nestedLookupTbl[0].typeOptions.relationColumnId
); );
const ncLookupColumnId = sMap.getNcIdFromAtId( const ncLookupColumnId = await sMap.getNcIdFromAtId(
nestedLookupTbl[0].typeOptions.foreignTableRollupColumnId nestedLookupTbl[0].typeOptions.foreignTableRollupColumnId
); );
@ -1101,7 +1104,7 @@ export default async (
// parent table ID // parent table ID
// let srcTableId = (await nc_getTableSchema(aTblSchema[idx].name)).id; // let srcTableId = (await nc_getTableSchema(aTblSchema[idx].name)).id;
const srcTableId = sMap.getNcIdFromAtId(aTblSchema[idx].id); const srcTableId = await sMap.getNcIdFromAtId(aTblSchema[idx].id);
const srcTableSchema = ncSchema.tablesById[srcTableId]; const srcTableSchema = ncSchema.tablesById[srcTableId];
if (aTblColumns.length) { if (aTblColumns.length) {
@ -1146,10 +1149,10 @@ export default async (
continue; continue;
} }
const ncRelationColumnId = sMap.getNcIdFromAtId( const ncRelationColumnId = await sMap.getNcIdFromAtId(
aTblColumns[i].typeOptions.relationColumnId aTblColumns[i].typeOptions.relationColumnId
); );
const ncRollupColumnId = sMap.getNcIdFromAtId( const ncRollupColumnId = await sMap.getNcIdFromAtId(
aTblColumns[i].typeOptions.foreignTableRollupColumnId aTblColumns[i].typeOptions.foreignTableRollupColumnId
); );
@ -1219,10 +1222,10 @@ export default async (
const srcTableId = nestedLookupTbl[0].srcTableId; const srcTableId = nestedLookupTbl[0].srcTableId;
const srcTableSchema = ncSchema.tablesById[srcTableId]; const srcTableSchema = ncSchema.tablesById[srcTableId];
const ncRelationColumnId = sMap.getNcIdFromAtId( const ncRelationColumnId = await sMap.getNcIdFromAtId(
nestedLookupTbl[0].typeOptions.relationColumnId nestedLookupTbl[0].typeOptions.relationColumnId
); );
const ncLookupColumnId = sMap.getNcIdFromAtId( const ncLookupColumnId = await sMap.getNcIdFromAtId(
nestedLookupTbl[0].typeOptions.foreignTableRollupColumnId nestedLookupTbl[0].typeOptions.foreignTableRollupColumnId
); );
@ -1278,7 +1281,7 @@ export default async (
); );
const pColId = aTblSchema[idx].primaryColumnId; const pColId = aTblSchema[idx].primaryColumnId;
const ncColId = sMap.getNcIdFromAtId(pColId); const ncColId = await sMap.getNcIdFromAtId(pColId);
// skip primary column configuration if we field not migrated // skip primary column configuration if we field not migrated
if (ncColId) { if (ncColId) {
@ -1288,7 +1291,7 @@ export default async (
recordPerfStats(_perfStart, 'dbTableColumn.primaryColumnSet'); recordPerfStats(_perfStart, 'dbTableColumn.primaryColumnSet');
// update schema // update schema
const ncTblId = sMap.getNcIdFromAtId(aTblSchema[idx].id); const ncTblId = await sMap.getNcIdFromAtId(aTblSchema[idx].id);
await updateNcTblSchemaById(ncTblId); await updateNcTblSchemaById(ncTblId);
} }
} }
@ -1335,6 +1338,12 @@ export default async (
for (const [key, value] of Object.entries(rec as { [key: string]: any })) { for (const [key, value] of Object.entries(rec as { [key: string]: any })) {
// retrieve datatype // retrieve datatype
const dt = table.columns.find((x) => x.title === key)?.uidt; const dt = table.columns.find((x) => x.title === key)?.uidt;
// always process LTAR, Lookup, and Rollup columns as we delete the key after processing
if (!value && dt !== UITypes.LinkToAnotherRecord && dt !== UITypes.Lookup && dt !== UITypes.Rollup) {
rec[key] = null;
continue;
}
switch (dt) { switch (dt) {
// https://www.npmjs.com/package/validator // https://www.npmjs.com/package/validator
@ -1408,7 +1417,7 @@ export default async (
case UITypes.MultiSelect: case UITypes.MultiSelect:
rec[key] = value rec[key] = value
.map((v) => { ?.map((v) => {
if (v === '') { if (v === '') {
return 'nc_empty'; return 'nc_empty';
} }
@ -1567,7 +1576,7 @@ export default async (
async function nocoConfigureFormView(sDB, aTblSchema) { async function nocoConfigureFormView(sDB, aTblSchema) {
if (!sDB.options.syncViews) return; if (!sDB.options.syncViews) return;
for (let idx = 0; idx < aTblSchema.length; idx++) { for (let idx = 0; idx < aTblSchema.length; idx++) {
const tblId = sMap.getNcIdFromAtId(aTblSchema[idx].id); const tblId = await sMap.getNcIdFromAtId(aTblSchema[idx].id);
const formViews = aTblSchema[idx].views.filter((x) => x.type === 'form'); const formViews = aTblSchema[idx].views.filter((x) => x.type === 'form');
const configuredViews = rtc.view.grid + rtc.view.gallery + rtc.view.form; const configuredViews = rtc.view.grid + rtc.view.gallery + rtc.view.form;
@ -1639,7 +1648,7 @@ export default async (
async function nocoConfigureGridView(sDB, aTblSchema) { async function nocoConfigureGridView(sDB, aTblSchema) {
for (let idx = 0; idx < aTblSchema.length; idx++) { for (let idx = 0; idx < aTblSchema.length; idx++) {
const tblId = sMap.getNcIdFromAtId(aTblSchema[idx].id); const tblId = await sMap.getNcIdFromAtId(aTblSchema[idx].id);
const gridViews = aTblSchema[idx].views.filter((x) => x.type === 'grid'); const gridViews = aTblSchema[idx].views.filter((x) => x.type === 'grid');
let viewCnt = idx; let viewCnt = idx;
@ -1955,7 +1964,7 @@ export default async (
// one of not migrated column; // one of not migrated column;
if (!colSchema) { if (!colSchema) {
updateMigrationSkipLog( updateMigrationSkipLog(
sMap.getNcNameFromAtId(viewId), await sMap.getNcNameFromAtId(viewId),
colSchema.title, colSchema.title,
colSchema.uidt, colSchema.uidt,
`filter config skipped; column not migrated` `filter config skipped; column not migrated`
@ -1970,7 +1979,7 @@ export default async (
if (datatype === UITypes.Date || datatype === UITypes.DateTime) { if (datatype === UITypes.Date || datatype === UITypes.DateTime) {
// skip filters over data datatype // skip filters over data datatype
updateMigrationSkipLog( updateMigrationSkipLog(
sMap.getNcNameFromAtId(viewId), await sMap.getNcNameFromAtId(viewId),
colSchema.title, colSchema.title,
colSchema.uidt, colSchema.uidt,
`filter config skipped; filter over date datatype not supported` `filter config skipped; filter over date datatype not supported`
@ -1990,7 +1999,7 @@ export default async (
fk_column_id: columnId, fk_column_id: columnId,
logical_op: f.conjunction, logical_op: f.conjunction,
comparison_op: filterMap[filter.operator], comparison_op: filterMap[filter.operator],
value: sMap.getNcNameFromAtId(filter.value[i]), value: await sMap.getNcNameFromAtId(filter.value[i]),
}; };
ncFilters.push(fx); ncFilters.push(fx);
} }
@ -2001,7 +2010,7 @@ export default async (
fk_column_id: columnId, fk_column_id: columnId,
logical_op: f.conjunction, logical_op: f.conjunction,
comparison_op: filterMap[filter.operator], comparison_op: filterMap[filter.operator],
value: sMap.getNcNameFromAtId(filter.value), value: await sMap.getNcNameFromAtId(filter.value),
}; };
ncFilters.push(fx); ncFilters.push(fx);
} }
@ -2097,7 +2106,7 @@ export default async (
// rest of the columns from airtable- retain order & visibility property // rest of the columns from airtable- retain order & visibility property
for (let j = 0; j < c.length; j++) { for (let j = 0; j < c.length; j++) {
const ncColumnId = sMap.getNcIdFromAtId(c[j].columnId); const ncColumnId = await sMap.getNcIdFromAtId(c[j].columnId);
const ncViewColumnId = await nc_getViewColumnId( const ncViewColumnId = await nc_getViewColumnId(
viewId, viewId,
viewType, viewType,
@ -2243,7 +2252,7 @@ export default async (
sDB: syncDB, sDB: syncDB,
logDetailed, logDetailed,
}); });
rtc.data.records += recordsMap[ncTbl.id].length; rtc.data.records += await recordsMap[ncTbl.id].getCount();
logDetailed(`Data inserted from ${ncTbl.title}`); logDetailed(`Data inserted from ${ncTbl.title}`);
} }

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save