Browse Source

Merge branch 'develop' into fix/default-limit

pull/3622/head
Wing-Kam Wong 2 years ago
parent
commit
811c244d40
  1. 24
      .do/deploy.template.yaml
  2. 39
      .github/workflows/ci-cd.yml
  3. 15
      .github/workflows/release-nocodb.yml
  4. 2
      .run/Clear metadb.run.xml
  5. 12
      .run/Run GUI v2.run.xml
  6. 2
      .run/Run NocoDB Sqlite.run.xml
  7. 2
      .run/build.run.xml
  8. 2
      .run/dev.run.xml
  9. 7
      .run/start_nocodb.run.xml
  10. 2
      .run/watch_run_mysql.run.xml
  11. 12
      packages/nc-gui/components.d.ts
  12. 11
      packages/nc-gui/components/cell/Checkbox.vue
  13. 91
      packages/nc-gui/components/dashboard/TreeView.vue
  14. 32
      packages/nc-gui/components/dashboard/settings/Modal.vue
  15. 29
      packages/nc-gui/components/general/FullScreen.vue
  16. 2
      packages/nc-gui/components/general/MiniSidebar.vue
  17. 45
      packages/nc-gui/components/general/Tooltip.vue
  18. 34
      packages/nc-gui/components/smartsheet-column/AdvancedOptions.vue
  19. 33
      packages/nc-gui/components/smartsheet-column/EditOrAdd.vue
  20. 11
      packages/nc-gui/components/smartsheet-toolbar/ColumnFilter.vue
  21. 4
      packages/nc-gui/components/smartsheet-toolbar/ShareView.vue
  22. 4
      packages/nc-gui/components/smartsheet-toolbar/SharedViewList.vue
  23. 4
      packages/nc-gui/components/smartsheet/ApiSnippet.vue
  24. 24
      packages/nc-gui/components/smartsheet/Grid.vue
  25. 5
      packages/nc-gui/components/smartsheet/Toolbar.vue
  26. 2
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  27. 10
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  28. 3
      packages/nc-gui/components/smartsheet/sidebar/index.vue
  29. 2
      packages/nc-gui/components/smartsheet/sidebar/toolbar/DeleteTable.vue
  30. 8
      packages/nc-gui/components/smartsheet/sidebar/toolbar/ToggleDrawer.vue
  31. 3
      packages/nc-gui/components/smartsheet/sidebar/toolbar/index.vue
  32. 8
      packages/nc-gui/components/tabs/Auth.vue
  33. 2
      packages/nc-gui/components/tabs/Smartsheet.vue
  34. 4
      packages/nc-gui/components/tabs/auth/ApiTokenManagement.vue
  35. 4
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  36. 4
      packages/nc-gui/components/tabs/auth/user-management/ShareBase.vue
  37. 4
      packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue
  38. 5
      packages/nc-gui/components/webhook/Drawer.vue
  39. 8
      packages/nc-gui/components/webhook/Editor.vue
  40. 1
      packages/nc-gui/composables/index.ts
  41. 31
      packages/nc-gui/composables/useColumnCreateStore.ts
  42. 26
      packages/nc-gui/composables/useCopy.ts
  43. 87
      packages/nc-gui/composables/useProject.ts
  44. 74
      packages/nc-gui/composables/useSidebar/index.ts
  45. 4
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  46. 3
      packages/nc-gui/composables/useUIPermission/index.ts
  47. 4
      packages/nc-gui/composables/useViewData.ts
  48. 2
      packages/nc-gui/composables/useViewFilters.ts
  49. 4
      packages/nc-gui/context/index.ts
  50. 282
      packages/nc-gui/lang/ar.json
  51. 4
      packages/nc-gui/lang/ru.json
  52. 4
      packages/nc-gui/layouts/default.vue
  53. 2
      packages/nc-gui/lib/types.ts
  54. 12
      packages/nc-gui/nuxt-shim.d.ts
  55. 19
      packages/nc-gui/package-lock.json
  56. 1
      packages/nc-gui/package.json
  57. 31
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  58. 58
      packages/nc-gui/pages/[projectType]/[projectId]/index/index.vue
  59. 15
      packages/nc-gui/pages/[projectType]/[projectId]/index/index/[type]/[title]/[[viewTitle]].vue
  60. 2
      packages/nc-gui/pages/[projectType]/form/[viewId].vue
  61. 2
      packages/nc-gui/pages/index/index/[projectId].vue
  62. 2
      packages/nc-gui/pages/index/index/create-external.vue
  63. 2
      packages/nc-gui/pages/index/index/create.vue
  64. 35
      packages/nc-gui/pages/index/index/index.vue
  65. 2
      packages/nc-gui/pages/signin.vue
  66. 36
      packages/nc-gui/utils/memStorage.ts
  67. 281
      packages/nocodb-sdk/package-lock.json
  68. 2
      packages/nocodb-sdk/src/lib/Api.ts
  69. 5
      packages/nocodb-sdk/src/lib/globals.ts
  70. 2
      packages/nocodb/.gitignore
  71. 5
      packages/nocodb/package.json
  72. 70
      packages/nocodb/src/__tests__/tsconfig.json
  73. 142
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  74. 251
      packages/nocodb/src/lib/meta/api/columnApis.ts
  75. 10
      packages/nocodb/src/lib/meta/api/dataApis/bulkDataAliasApis.ts
  76. 4
      packages/nocodb/src/lib/meta/api/dataApis/dataAliasApis.ts
  77. 11
      packages/nocodb/src/lib/meta/api/dataApis/dataAliasNestedApis.ts
  78. 2
      packages/nocodb/src/lib/meta/api/dataApis/dataApis.ts
  79. 2
      packages/nocodb/src/lib/meta/api/sync/helpers/job.ts
  80. 4
      packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts
  81. 37
      packages/nocodb/src/lib/migrations/v2/nc_011.ts
  82. 34
      packages/nocodb/src/lib/migrations/v2/nc_019_add_meta_in_meta_tables.ts
  83. 10
      packages/nocodb/src/lib/models/FormView.ts
  84. 45
      packages/nocodb/src/lib/models/FormViewColumn.ts
  85. 12
      packages/nocodb/src/lib/models/Model.ts
  86. 2
      packages/nocodb/src/lib/models/Project.ts
  87. 9
      packages/nocodb/src/lib/utils/common/NcConnectionMgrv2.ts
  88. 35
      packages/nocodb/src/lib/utils/globals.ts
  89. 19
      packages/nocodb/src/lib/utils/serialize.ts
  90. 658
      packages/nocodb/tests/mysql-sakila-db/03-test-sakila-schema.sql
  91. 46449
      packages/nocodb/tests/mysql-sakila-db/04-test-sakila-data.sql
  92. 243
      packages/nocodb/tests/unit/TestDbMngr.ts
  93. 203
      packages/nocodb/tests/unit/factory/column.ts
  94. 64
      packages/nocodb/tests/unit/factory/project.ts
  95. 181
      packages/nocodb/tests/unit/factory/row.ts
  96. 42
      packages/nocodb/tests/unit/factory/table.ts
  97. 18
      packages/nocodb/tests/unit/factory/user.ts
  98. 35
      packages/nocodb/tests/unit/factory/view.ts
  99. 20
      packages/nocodb/tests/unit/index.test.ts
  100. 56
      packages/nocodb/tests/unit/init/cleanupMeta.ts
  101. Some files were not shown because too many files have changed in this diff Show More

24
.do/deploy.template.yaml

@ -0,0 +1,24 @@
spec:
name: nocodb
services:
- name: nocodb
image:
registry_type: DOCKER_HUB
registry: nocodb
repository: nocodb
tag: latest
run_command: "./server/scripts/digitalocean-postbuild.sh"
instance_size_slug: "basic-s"
health_check:
initial_delay_seconds: 10
http_path: /api/health
envs:
- key: NODE_ENV
value: "production"
- key: DATABASE_URL
scope: RUN_TIME
value: ${postgres.DATABASE_URL}
databases:
- name: postgres
engine: PG
production: false

39
.github/workflows/ci-cd.yml

@ -635,3 +635,42 @@ jobs:
name: cy-quick-pg-snapshots name: cy-quick-pg-snapshots
path: scripts/cypress/screenshots path: scripts/cypress/screenshots
retention-days: 2 retention-days: 2
unit-tests:
runs-on: ubuntu-20.04
steps:
- name: Setup Node
uses: actions/setup-node@v1
with:
node-version: 16.15.0
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Cache node modules
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
# npm cache files are stored in `~/.npm` on Linux/macOS
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: install dependencies nocodb-sdk
working-directory: ./packages/nocodb-sdk
run: npm install
- name: build nocodb-sdk
working-directory: ./packages/nocodb-sdk
run: npm run build:main
- name: Install dependencies
working-directory: ./packages/nocodb
run: npm install
- name: setup mysql
working-directory: ./
run: docker-compose -f ./scripts/docker-compose-cypress.yml up -d
- name: run unit tests
working-directory: ./packages/nocodb
run: npm run test:unit

15
.github/workflows/release-nocodb.yml

@ -103,24 +103,17 @@ jobs:
secrets: secrets:
NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}" NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}"
# Close all issues with target tags 'Fixed' & 'Resolved' # Close all issues with target tags 'Status: Ready for Next Release'
close-fixed-issues: close-issues:
needs: [release-docker, process-input] needs: [release-docker, process-input]
uses: ./.github/workflows/release-close-issue.yml uses: ./.github/workflows/release-close-issue.yml
with: with:
issue_label: 'Status: Fixed' issue_label: 'Status: Ready for Next Release'
version: ${{ needs.process-input.outputs.target_tag }}
close-resolved-issues:
needs: [close-fixed-issues, process-input]
uses: ./.github/workflows/release-close-issue.yml
with:
issue_label: 'Status: Resolved'
version: ${{ needs.process-input.outputs.target_tag }} version: ${{ needs.process-input.outputs.target_tag }}
# Publish Docs # Publish Docs
publish-docs: publish-docs:
needs: close-resolved-issues needs: close-issues
uses: ./.github/workflows/publish-docs.yml uses: ./.github/workflows/publish-docs.yml
secrets: secrets:
GH_TOKEN: "${{ secrets.GH_TOKEN }}" GH_TOKEN: "${{ secrets.GH_TOKEN }}"

2
.run/Clear metadb.run.xml

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Drop metadb" type="NodeJSConfigurationType" path-to-js-file="$PROJECT_DIR$/packages/nocodb/src/run/deleteMetaDb.js" working-dir="$PROJECT_DIR$/packages/nocodb/src/run"> <configuration default="false" name="Drop metadb" type="NodeJSConfigurationType" path-to-js-file="deleteMetaDb.js" working-dir="$PROJECT_DIR$/packages/nocodb/src/run">
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>

12
.run/Run GUI v2.run.xml

@ -1,12 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run GUI" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/packages/nc-gui/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

2
.run/Run NocoDB Sqlite.run.xml

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run NocoDB Sqlite" type="js.build_tools.npm" activateToolWindowBeforeRun="false"> <configuration default="false" name="Run::Backend" type="js.build_tools.npm" activateToolWindowBeforeRun="false">
<package-json value="$PROJECT_DIR$/packages/nocodb/package.json" /> <package-json value="$PROJECT_DIR$/packages/nocodb/package.json" />
<command value="run" /> <command value="run" />
<scripts> <scripts>

2
.run/build.run.xml

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build Nc Common" type="js.build_tools.npm"> <configuration default="false" name="Build::SDK" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/packages/nocodb-sdk/package.json" /> <package-json value="$PROJECT_DIR$/packages/nocodb-sdk/package.json" />
<command value="run" /> <command value="run" />
<scripts> <scripts>

2
.run/dev.run.xml

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run GUI v2" type="js.build_tools.npm"> <configuration default="false" name="Run::Frontend" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/packages/nc-gui/package.json" /> <package-json value="$PROJECT_DIR$/packages/nc-gui/package.json" />
<command value="run" /> <command value="run" />
<scripts> <scripts>

7
.run/start_nocodb.run.xml

@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start::IDE" type="CompoundRunConfigurationType">
<toRun name="Run::Backend" type="js.build_tools.npm" />
<toRun name="Run::Frontend" type="js.build_tools.npm" />
<method v="2" />
</configuration>
</component>

2
.run/watch_run_mysql.run.xml

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run NocoDB Mysql" type="js.build_tools.npm" activateToolWindowBeforeRun="false"> <configuration default="false" name="Run::Backend::Mysql" type="js.build_tools.npm" activateToolWindowBeforeRun="false">
<package-json value="$PROJECT_DIR$/packages/nocodb/package.json" /> <package-json value="$PROJECT_DIR$/packages/nocodb/package.json" />
<command value="run" /> <command value="run" />
<scripts> <scripts>

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

@ -79,11 +79,15 @@ declare module '@vue/runtime-core' {
ClarityImageLine: typeof import('~icons/clarity/image-line')['default'] ClarityImageLine: typeof import('~icons/clarity/image-line')['default']
ClaritySuccessLine: typeof import('~icons/clarity/success-line')['default'] ClaritySuccessLine: typeof import('~icons/clarity/success-line')['default']
EvaEmailOutline: typeof import('~icons/eva/email-outline')['default'] EvaEmailOutline: typeof import('~icons/eva/email-outline')['default']
'Ic:twotoneWidthFull': typeof import('~icons/ic/twotone-width-full')['default']
IcBaselineMoreVert: typeof import('~icons/ic/baseline-more-vert')['default'] IcBaselineMoreVert: typeof import('~icons/ic/baseline-more-vert')['default']
IcBaselineWidthFull: typeof import('~icons/ic/baseline-width-full')['default']
IcOutlineInsertDriveFile: typeof import('~icons/ic/outline-insert-drive-file')['default'] IcOutlineInsertDriveFile: typeof import('~icons/ic/outline-insert-drive-file')['default']
IcRoundEdit: typeof import('~icons/ic/round-edit')['default'] IcRoundEdit: typeof import('~icons/ic/round-edit')['default']
IcRoundKeyboardArrowDown: typeof import('~icons/ic/round-keyboard-arrow-down')['default'] IcRoundKeyboardArrowDown: typeof import('~icons/ic/round-keyboard-arrow-down')['default']
IcRoundSearch: typeof import('~icons/ic/round-search')['default'] IcRoundSearch: typeof import('~icons/ic/round-search')['default']
IcTwotoneWidthFull: typeof import('~icons/ic/twotone-width-full')['default']
IcTwotoneWidthNormal: typeof import('~icons/ic/twotone-width-normal')['default']
LogosGoogleGmail: typeof import('~icons/logos/google-gmail')['default'] LogosGoogleGmail: typeof import('~icons/logos/google-gmail')['default']
LogosRedditIcon: typeof import('~icons/logos/reddit-icon')['default'] LogosRedditIcon: typeof import('~icons/logos/reddit-icon')['default']
LogosSwagger: typeof import('~icons/logos/swagger')['default'] LogosSwagger: typeof import('~icons/logos/swagger')['default']
@ -98,6 +102,9 @@ declare module '@vue/runtime-core' {
MaterialSymbolsSendOutline: typeof import('~icons/material-symbols/send-outline')['default'] MaterialSymbolsSendOutline: typeof import('~icons/material-symbols/send-outline')['default']
MaterialSymbolsTranslate: typeof import('~icons/material-symbols/translate')['default'] MaterialSymbolsTranslate: typeof import('~icons/material-symbols/translate')['default']
MaterialSymbolsWarning: typeof import('~icons/material-symbols/warning')['default'] MaterialSymbolsWarning: typeof import('~icons/material-symbols/warning')['default']
MaterialSymbolsWidthFull: typeof import('~icons/material-symbols/width-full')['default']
MaterialSymbolsWidthWideOutline: typeof import('~icons/material-symbols/width-wide-outline')['default']
'Mdi:arrowExpandHorizontal': typeof import('~icons/mdi/arrow-expand-horizontal')['default']
MdiAccount: typeof import('~icons/mdi/account')['default'] MdiAccount: typeof import('~icons/mdi/account')['default']
MdiAccountCircle: typeof import('~icons/mdi/account-circle')['default'] MdiAccountCircle: typeof import('~icons/mdi/account-circle')['default']
MdiAccountGroup: typeof import('~icons/mdi/account-group')['default'] MdiAccountGroup: typeof import('~icons/mdi/account-group')['default']
@ -111,6 +118,7 @@ declare module '@vue/runtime-core' {
MdiAlphaA: typeof import('~icons/mdi/alpha-a')['default'] MdiAlphaA: typeof import('~icons/mdi/alpha-a')['default']
MdiApi: typeof import('~icons/mdi/api')['default'] MdiApi: typeof import('~icons/mdi/api')['default']
MdiArrowExpand: typeof import('~icons/mdi/arrow-expand')['default'] MdiArrowExpand: typeof import('~icons/mdi/arrow-expand')['default']
MdiArrowExpandHorizontal: typeof import('~icons/mdi/arrow-expand-horizontal')['default']
MdiArrowLeftBold: typeof import('~icons/mdi/arrow-left-bold')['default'] MdiArrowLeftBold: typeof import('~icons/mdi/arrow-left-bold')['default']
MdiAt: typeof import('~icons/mdi/at')['default'] MdiAt: typeof import('~icons/mdi/at')['default']
MdiBackburger: typeof import('~icons/mdi/backburger')['default'] MdiBackburger: typeof import('~icons/mdi/backburger')['default']
@ -163,6 +171,8 @@ declare module '@vue/runtime-core' {
MdiFlag: typeof import('~icons/mdi/flag')['default'] MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiFlashOutline: typeof import('~icons/mdi/flash-outline')['default'] MdiFlashOutline: typeof import('~icons/mdi/flash-outline')['default']
MdiFolder: typeof import('~icons/mdi/folder')['default'] MdiFolder: typeof import('~icons/mdi/folder')['default']
MdiFullscreen: typeof import('~icons/mdi/fullscreen')['default']
MdiFullscreenExit: typeof import('~icons/mdi/fullscreen-exit')['default']
MdiFunction: typeof import('~icons/mdi/function')['default'] MdiFunction: typeof import('~icons/mdi/function')['default']
MdiGestureDoubleTap: typeof import('~icons/mdi/gesture-double-tap')['default'] MdiGestureDoubleTap: typeof import('~icons/mdi/gesture-double-tap')['default']
MdiGithub: typeof import('~icons/mdi/github')['default'] MdiGithub: typeof import('~icons/mdi/github')['default']
@ -222,11 +232,13 @@ declare module '@vue/runtime-core' {
MdiUpload: typeof import('~icons/mdi/upload')['default'] MdiUpload: typeof import('~icons/mdi/upload')['default']
MdiUploadOutline: typeof import('~icons/mdi/upload-outline')['default'] MdiUploadOutline: typeof import('~icons/mdi/upload-outline')['default']
MdiViewListOutline: typeof import('~icons/mdi/view-list-outline')['default'] MdiViewListOutline: typeof import('~icons/mdi/view-list-outline')['default']
MdiWarning: typeof import('~icons/mdi/warning')['default']
MdiWhatsapp: typeof import('~icons/mdi/whatsapp')['default'] MdiWhatsapp: typeof import('~icons/mdi/whatsapp')['default']
MdiXml: typeof import('~icons/mdi/xml')['default'] MdiXml: typeof import('~icons/mdi/xml')['default']
MiCircleWarning: typeof import('~icons/mi/circle-warning')['default'] MiCircleWarning: typeof import('~icons/mi/circle-warning')['default']
PhFileCsv: typeof import('~icons/ph/file-csv')['default'] PhFileCsv: typeof import('~icons/ph/file-csv')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SystemUiconsExpandWidth: typeof import('~icons/system-uicons/expand-width')['default']
} }
} }

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

@ -2,18 +2,23 @@
import { ColumnInj, IsFormInj, ReadonlyInj, getMdiIcon, inject } from '#imports' import { ColumnInj, IsFormInj, ReadonlyInj, getMdiIcon, inject } from '#imports'
interface Props { interface Props {
modelValue?: boolean | undefined | number // If the previous cell value was a text, the initial checkbox value is a string type
// otherwise it can be either a boolean, or a string representing a boolean, i.e '0' or '1'
modelValue?: boolean | string | '0' | '1'
} }
interface Emits { interface Emits {
(event: 'update:modelValue', model: boolean | undefined | number): void (event: 'update:modelValue', model: boolean): void
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emits = defineEmits<Emits>() const emits = defineEmits<Emits>()
let vModel = $(useVModel(props, 'modelValue', emits)) let vModel = $computed({
get: () => !!props.modelValue && props.modelValue !== '0',
set: (val) => emits('update:modelValue', val),
})
const column = inject(ColumnInj) const column = inject(ColumnInj)

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

@ -311,55 +311,58 @@ function openTableCreateDialog() {
{ hidden: !filteredTables?.includes(table), active: activeTable === table.title }, { hidden: !filteredTables?.includes(table), active: activeTable === table.title },
`nc-project-tree-tbl nc-project-tree-tbl-${table.title}`, `nc-project-tree-tbl nc-project-tree-tbl-${table.title}`,
]" ]"
class="nc-tree-item pl-5 pr-3 py-2 text-sm cursor-pointer group" class="nc-tree-item text-sm cursor-pointer group"
:data-order="table.order" :data-order="table.order"
:data-id="table.id" :data-id="table.id"
@click="addTableTab(table)" @click="addTableTab(table)"
> >
<div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)"> <GeneralTooltip wrapper-class="pl-5 pr-3 py-2" modifier-key="Alt">
<div class="flex w-auto"> <template #title>{{ table.table_name }}</template>
<MdiDrag <div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)">
v-if="isUIAllowed('treeview-drag-n-drop')" <div class="flex w-auto">
:class="`nc-child-draggable-icon-${table.title}`" <MdiDrag
class="nc-drag-icon text-xs hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 cursor-move" v-if="isUIAllowed('treeview-drag-n-drop')"
@click.stop.prevent :class="`nc-child-draggable-icon-${table.title}`"
/> class="nc-drag-icon text-xs hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 cursor-move"
@click.stop.prevent
<component />
:is="icon(table)"
class="nc-view-icon text-xs" <component
:class="{ 'group-hover:hidden group-hover:text-gray-500': isUIAllowed('treeview-drag-n-drop') }" :is="icon(table)"
/> class="nc-view-icon text-xs"
</div> :class="{ 'group-hover:hidden group-hover:text-gray-500': isUIAllowed('treeview-drag-n-drop') }"
/>
<div class="nc-tbl-title flex-1"> </div>
<GeneralTruncateText>{{ table.title }}</GeneralTruncateText>
<div class="nc-tbl-title flex-1">
<GeneralTruncateText>{{ table.title }}</GeneralTruncateText>
</div>
<a-dropdown
v-if="!isSharedBase && !isLocked && (isUIAllowed('table-rename') || isUIAllowed('table-delete'))"
:trigger="['click']"
@click.stop
>
<MdiMenuIcon class="transition-opacity opacity-0 group-hover:opacity-100" />
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
<a-menu-item v-if="isUIAllowed('table-rename')" @click="openRenameTableDialog(table)">
<div class="nc-project-menu-item">
{{ $t('general.rename') }}
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('table-delete')" @click="deleteTable(table)">
<div class="nc-project-menu-item">
{{ $t('general.delete') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div> </div>
</GeneralTooltip>
<a-dropdown
v-if="!isSharedBase && !isLocked && (isUIAllowed('table-rename') || isUIAllowed('table-delete'))"
:trigger="['click']"
@click.stop
>
<MdiMenuIcon class="transition-opacity opacity-0 group-hover:opacity-100" />
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
<a-menu-item v-if="isUIAllowed('table-rename')" @click="openRenameTableDialog(table)">
<div class="nc-project-menu-item">
{{ $t('general.rename') }}
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('table-delete')" @click="deleteTable(table)">
<div class="nc-project-menu-item">
{{ $t('general.delete') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div> </div>
</div> </div>
</div> </div>

32
packages/nc-gui/components/dashboard/settings/Modal.vue

@ -53,20 +53,24 @@ const tabsInfo: TabGroup = {
title: t('title.teamAndAuth'), title: t('title.teamAndAuth'),
icon: TeamFillIcon, icon: TeamFillIcon,
subTabs: { subTabs: {
...(isUIAllowed('userMgmtTab') && { ...(isUIAllowed('userMgmtTab')
usersManagement: { ? {
// Users Management usersManagement: {
title: t('title.userMgmt'), // Users Management
body: UserManagement, title: t('title.userMgmt'),
}, body: UserManagement,
}), },
...(isUIAllowed('apiTokenTab') && { }
apiTokenManagement: { : {}),
// API Tokens Management ...(isUIAllowed('apiTokenTab')
title: t('title.apiTokenMgmt'), ? {
body: ApiTokenManagement, apiTokenManagement: {
}, // API Tokens Management
}), title: t('title.apiTokenMgmt'),
body: ApiTokenManagement,
},
}
: {}),
}, },
onClick: () => { onClick: () => {
$e('c:settings:team-auth') $e('c:settings:team-auth')

29
packages/nc-gui/components/general/FullScreen.vue

@ -0,0 +1,29 @@
<script setup lang="ts">
import { useSidebar } from '#imports'
const rightSidebar = useSidebar('nc-right-sidebar')
const leftSidebar = useSidebar('nc-left-sidebar')
const isSidebarsOpen = computed({
get: () => rightSidebar.isOpen.value || leftSidebar.isOpen.value,
set: (value) => {
rightSidebar.toggle(value)
leftSidebar.toggle(value)
},
})
</script>
<template>
<a-tooltip>
<!-- todo: i18n -->
<template #title> {{ isSidebarsOpen ? 'Full width' : 'Exit full width' }}</template>
<div
v-e="['c:toolbar:fullscreen']"
class="nc-fullscreen-btn cursor-pointer flex align-center self-center px-2 py-2 mr-2"
@click="isSidebarsOpen = !isSidebarsOpen"
>
<IcTwotoneWidthFull v-if="isSidebarsOpen" class="text-gray-300" />
<IcTwotoneWidthNormal v-else class="text-gray-300" />
</div>
</a-tooltip>
</template>

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

@ -4,7 +4,7 @@ import { computed, useGlobal, useProject, useRoute, useSidebar } from '#imports'
const { signOut, signedIn, user } = useGlobal() const { signOut, signedIn, user } = useGlobal()
const { isOpen } = useSidebar({ isOpen: true }) const { isOpen } = useSidebar('nc-mini-sidebar', { isOpen: true })
const { project } = useProject() const { project } = useProject()

45
packages/nc-gui/components/general/Tooltip.vue

@ -0,0 +1,45 @@
<script lang="ts" setup>
import { onKeyStroke } from '@vueuse/core'
interface Props {
// Key to be pressed on hover to trigger the tooltip
modifierKey?: string
wrapperClass?: string
}
const { modifierKey } = defineProps<Props>()
const showTooltip = ref(false)
const isMouseOver = ref(false)
if (modifierKey) {
onKeyStroke(modifierKey, (e) => {
e.preventDefault()
if (modifierKey && isMouseOver.value) {
showTooltip.value = true
}
})
}
watch(isMouseOver, (val) => {
if (!val) {
showTooltip.value = false
}
// Show tooltip on mouseover if no modifier key is provided
if (val && !modifierKey) {
showTooltip.value = true
}
})
</script>
<template>
<a-tooltip v-model:visible="showTooltip" :trigger="[]">
<template #title>
<slot name="title" />
</template>
<div class="w-full" :class="wrapperClass" @mouseenter="isMouseOver = true" @mouseleave="isMouseOver = false">
<slot />
</div>
</a-tooltip>
</template>

34
packages/nc-gui/components/smartsheet-column/AdvancedOptions.vue

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import { computed } from '#imports' import { computed } from '#imports'
interface Props { interface Props {
@ -10,12 +10,27 @@ const props = defineProps<Props>()
const emit = defineEmits(['update:value']) const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit) const vModel = useVModel(props, 'value', emit)
const { sqlUi } = useProject() const { sqlUi, isPg } = useProject()
const { onAlter, onDataTypeChange, validateInfos } = useColumnCreateStoreOrThrow() const { onAlter, onDataTypeChange, validateInfos } = useColumnCreateStoreOrThrow()
// todo: 2nd argument of `getDataTypeListForUiType` is missing! // todo: 2nd argument of `getDataTypeListForUiType` is missing!
const dataTypes = computed(() => sqlUi?.value?.getDataTypeListForUiType(vModel.value as { uidt: UITypes }, '' as any)) const dataTypes = computed(() => sqlUi.value.getDataTypeListForUiType(vModel.value as { uidt: UITypes }, '' as any))
const sampleValue = computed(() => {
switch (vModel.value.uidt) {
case UITypes.SingleSelect:
return 'eg : a'
case UITypes.MultiSelect:
return 'eg : a,b,c'
default:
return sqlUi.value.getDefaultValueForDatatype(vModel.value.dt)
}
})
const hideLength = computed(() => {
return [UITypes.SingleSelect, UITypes.MultiSelect].includes(vModel.value.uidt)
})
// to avoid type error with checkbox // to avoid type error with checkbox
vModel.value.rqd = !!vModel.value.rqd vModel.value.rqd = !!vModel.value.rqd
@ -23,6 +38,13 @@ vModel.value.pk = !!vModel.value.pk
vModel.value.un = !!vModel.value.un vModel.value.un = !!vModel.value.un
vModel.value.ai = !!vModel.value.ai vModel.value.ai = !!vModel.value.ai
vModel.value.au = !!vModel.value.au vModel.value.au = !!vModel.value.au
onBeforeMount(() => {
// Postgres returns default value wrapped with single quotes & casted with type so we have to get value between single quotes to keep it unified for all databases
if (isPg.value && vModel.value.cdf) {
vModel.value.cdf = vModel.value.cdf.substring(vModel.value.cdf.indexOf(`'`) + 1, vModel.value.cdf.lastIndexOf(`'`))
}
})
</script> </script>
<template> <template>
@ -66,7 +88,7 @@ vModel.value.au = !!vModel.value.au
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item :label="$t('labels.lengthValue')"> <a-form-item v-if="!hideLength" :label="$t('labels.lengthValue')">
<a-input <a-input
v-model:value="vModel.dtxp" v-model:value="vModel.dtxp"
:disabled="sqlUi.getDefaultLengthIsDisabled(vModel.dt) || !sqlUi.columnEditable(vModel)" :disabled="sqlUi.getDefaultLengthIsDisabled(vModel.dt) || !sqlUi.columnEditable(vModel)"
@ -74,11 +96,11 @@ vModel.value.au = !!vModel.value.au
/> />
</a-form-item> </a-form-item>
<a-form-item v-if="sqlUi.showScale(vModel)" label="Scale"> <a-form-item v-if="sqlUi.showScale(vModel)" label="Scale">
<a-input v-model="vModel.dtxs" :disabled="!sqlUi.columnEditable(vModel)" @input="onAlter" /> <a-input v-model:value="vModel.dtxs" :disabled="!sqlUi.columnEditable(vModel)" @input="onAlter" />
</a-form-item> </a-form-item>
<a-form-item :label="$t('placeholder.defaultValue')"> <a-form-item :label="$t('placeholder.defaultValue')">
<a-textarea v-model:value="vModel.cdf" auto-size @input="onAlter(2, true)" /> <a-textarea v-model:value="vModel.cdf" auto-size @input="onAlter(2, true)" />
<span class="text-gray-400 text-xs">{{ sqlUi.getDefaultValueForDatatype(vModel.dt) }}</span> <span class="text-gray-400 text-xs">{{ sampleValue }}</span>
</a-form-item> </a-form-item>
</div> </div>
</template> </template>

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

@ -97,11 +97,11 @@ onMounted(() => {
<template> <template>
<div <div
class="w-[400px] max-h-[95vh] bg-gray-50 shadow-lg p-6 overflow-auto !border" class="w-[400px] bg-gray-50 shadow p-4 overflow-auto border"
:class="{ '!w-[600px]': formState.uidt === UITypes.Formula }" :class="{ '!w-[600px]': formState.uidt === UITypes.Formula }"
@click.stop @click.stop
> >
<a-form v-if="formState" v-model="formState" name="column-create-or-edit" layout="vertical"> <a-form v-if="formState" v-model="formState" no-style name="column-create-or-edit" layout="vertical">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.title"> <a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.title">
<a-input ref="antInput" v-model:value="formState.title" class="nc-column-name-input" @input="onAlter(8)" /> <a-input ref="antInput" v-model:value="formState.title" class="nc-column-name-input" @input="onAlter(8)" />
@ -138,6 +138,7 @@ onMounted(() => {
v-model:value="formState" v-model:value="formState"
/> />
</div> </div>
<div <div
v-if="!isVirtualCol(formState.uidt)" v-if="!isVirtualCol(formState.uidt)"
class="text-xs cursor-pointer text-grey nc-more-options mb-1 mt-4 flex items-center gap-1 justify-end" class="text-xs cursor-pointer text-grey nc-more-options mb-1 mt-4 flex items-center gap-1 justify-end"
@ -147,18 +148,22 @@ onMounted(() => {
<component :is="advancedOptions ? MdiMinusIcon : MdiPlusIcon" /> <component :is="advancedOptions ? MdiMinusIcon : MdiPlusIcon" />
</div> </div>
<div class="overflow-hidden" :class="advancedOptions ? 'h-min mb-2' : 'h-0'"> <Transition name="layout" mode="out-in">
<a-checkbox <div v-if="advancedOptions" class="overflow-hidden">
v-if="formState.meta && columnToValidate.includes(formState.uidt)" <a-checkbox
v-model:checked="formState.meta.validate" v-if="formState.meta && columnToValidate.includes(formState.uidt)"
class="ml-1 mb-1" v-model:checked="formState.meta.validate"
> class="ml-1 mb-1"
<span class="text-[10px] text-gray-600"> >
{{ `Accept only valid ${formState.uidt}` }} <span class="text-[10px] text-gray-600">
</span> {{ `Accept only valid ${formState.uidt}` }}
</a-checkbox> </span>
<SmartsheetColumnAdvancedOptions v-model:value="formState" /> </a-checkbox>
</div>
<SmartsheetColumnAdvancedOptions v-model:value="formState" />
</div>
</Transition>
<a-form-item> <a-form-item>
<div class="flex justify-end gap-1 mt-4"> <div class="flex justify-end gap-1 mt-4">
<a-button html-type="button" @click="emit('cancel')"> <a-button html-type="button" @click="emit('cancel')">

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

@ -21,10 +21,11 @@ interface Props {
parentId?: string parentId?: string
autoSave: boolean autoSave: boolean
hookId?: string hookId?: string
showLoading?: boolean
modelValue?: Filter[] modelValue?: Filter[]
} }
const { nested = false, parentId, autoSave = true, hookId = null, modelValue } = defineProps<Props>() const { nested = false, parentId, autoSave = true, hookId = null, modelValue, showLoading = true } = defineProps<Props>()
const emit = defineEmits(['update:filtersLength']) const emit = defineEmits(['update:filtersLength'])
@ -46,9 +47,7 @@ const { filters, deleteFilter, saveOrUpdate, loadFilters, addFilter, addFilterGr
activeView, activeView,
parentId, parentId,
computed(() => autoSave), computed(() => autoSave),
() => { () => reloadDataHook.trigger(showLoading),
reloadDataHook.trigger()
},
modelValue || nestedFilters.value, modelValue || nestedFilters.value,
!modelValue, !modelValue,
) )
@ -134,8 +133,8 @@ defineExpose({
<template> <template>
<div <div
class="p-6 menu-filter-dropdown bg-gray-50 !border" class="p-4 menu-filter-dropdown bg-gray-50 !border mt-4"
:class="{ 'shadow-xl min-w-[430px] max-w-[630px] max-h-[max(80vh,500px)] overflow-auto': !nested, 'border-1 w-full': nested }" :class="{ 'shadow min-w-[430px] max-w-[630px] max-h-[max(80vh,500px)] overflow-auto': !nested, 'border-1 w-full': nested }"
> >
<div v-if="filters && filters.length" class="nc-filter-grid mb-2" @click.stop> <div v-if="filters && filters.length" class="nc-filter-grid mb-2" @click.stop>
<template v-for="(filter, i) in filters" :key="filter.id || i"> <template v-for="(filter, i) in filters" :key="filter.id || i">

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

@ -4,7 +4,7 @@ import { message } from 'ant-design-vue'
import { import {
computed, computed,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
useClipboard, useCopy,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
useProject, useProject,
@ -17,7 +17,7 @@ const { t } = useI18n()
const { view, $api } = useSmartsheetStoreOrThrow() const { view, $api } = useSmartsheetStoreOrThrow()
const { copy } = useClipboard() const { copy } = useCopy()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()

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

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import { Empty, message } from 'ant-design-vue' import { Empty, message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, onMounted, useClipboard, useI18n, useSmartsheetStoreOrThrow } from '#imports' import { extractSdkResponseErrorMsg, onMounted, useCopy, useI18n, useSmartsheetStoreOrThrow } from '#imports'
import MdiVisibilityOnIcon from '~icons/mdi/visibility' import MdiVisibilityOnIcon from '~icons/mdi/visibility'
import MdiVisibilityOffIcon from '~icons/mdi/visibility-off' import MdiVisibilityOffIcon from '~icons/mdi/visibility-off'
import MdiCopyIcon from '~icons/mdi/content-copy' import MdiCopyIcon from '~icons/mdi/content-copy'
@ -20,7 +20,7 @@ const { t } = useI18n()
const { $api, meta } = useSmartsheetStoreOrThrow() const { $api, meta } = useSmartsheetStoreOrThrow()
const { copy } = useClipboard() const { copy } = useCopy()
const { dashboardUrl } = useDashboard() const { dashboardUrl } = useDashboard()

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

@ -5,7 +5,7 @@ import {
ActiveViewInj, ActiveViewInj,
MetaInj, MetaInj,
inject, inject,
useClipboard, useCopy,
useGlobal, useGlobal,
useI18n, useI18n,
useProject, useProject,
@ -36,7 +36,7 @@ const { xWhere } = useSmartsheetStoreOrThrow()
const { queryParams } = $(useViewData($$(meta), $$(view), xWhere)) const { queryParams } = $(useViewData($$(meta), $$(view), xWhere))
const { copy } = useClipboard() const { copy } = useCopy()
let vModel = $(useVModel(props, 'modelValue', emits)) let vModel = $(useVModel(props, 'modelValue', emits))

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

@ -23,6 +23,7 @@ import {
provide, provide,
reactive, reactive,
ref, ref,
useCopy,
useEventListener, useEventListener,
useGridViewColumnWidth, useGridViewColumnWidth,
useI18n, useI18n,
@ -96,6 +97,8 @@ const {
const { loadGridViewColumns, updateWidth, resizingColWidth, resizingCol } = useGridViewColumnWidth(view) const { loadGridViewColumns, updateWidth, resizingColWidth, resizingCol } = useGridViewColumnWidth(view)
const { copy } = useCopy()
onMounted(loadGridViewColumns) onMounted(loadGridViewColumns)
provide(IsFormInj, ref(false)) provide(IsFormInj, ref(false))
@ -113,8 +116,15 @@ provide(ReadonlyInj, !hasEditPermission)
const disableUrlOverlay = ref(false) const disableUrlOverlay = ref(false)
provide(CellUrlDisableOverlayInj, disableUrlOverlay) provide(CellUrlDisableOverlayInj, disableUrlOverlay)
reloadViewDataHook?.on(async () => { const showLoading = ref(true)
reloadViewDataHook?.on(async (shouldShowLoading) => {
// set value if spinner should be hidden
showLoading.value = !!shouldShowLoading
await loadData() await loadData()
// reset to default (showing spinner on load)
showLoading.value = true
}) })
const skipRowRemovalOnCancel = ref(false) const skipRowRemovalOnCancel = ref(false)
@ -179,8 +189,6 @@ const clearCell = async (ctx: { row: number; col: number }) => {
await updateOrSaveRow(rowObj, columnObj.title) await updateOrSaveRow(rowObj, columnObj.title)
} }
const { copy } = useClipboard()
const makeEditable = (row: Row, col: ColumnType) => { const makeEditable = (row: Row, col: ColumnType) => {
if (!hasEditPermission || editEnabled || isView) { if (!hasEditPermission || editEnabled || isView) {
return return
@ -373,10 +381,12 @@ onBeforeUnmount(async () => {
<template> <template>
<div class="flex flex-col h-full min-h-0 w-full"> <div class="flex flex-col h-full min-h-0 w-full">
<div v-if="isLoading" class="flex items-center justify-center h-full w-full"> <general-overlay :model-value="isLoading" inline transition>
<a-spin size="large" /> <div class="flex items-center justify-center h-full w-full">
</div> <a-spin size="large" />
<div v-else class="nc-grid-wrapper min-h-0 flex-1 scrollbar-thin-dull"> </div>
</general-overlay>
<div class="nc-grid-wrapper min-h-0 flex-1 scrollbar-thin-dull">
<a-dropdown <a-dropdown
v-model:visible="contextMenu" v-model:visible="contextMenu"
:trigger="isSqlView ? [] : ['contextmenu']" :trigger="isSqlView ? [] : ['contextmenu']"

5
packages/nc-gui/components/smartsheet/Toolbar.vue

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { IsPublicInj, useSharedView, useSmartsheetStoreOrThrow } from '#imports' import { IsPublicInj, useSharedView, useSidebar, useSmartsheetStoreOrThrow } from '#imports'
import ToggleDrawer from '~/components/smartsheet/sidebar/toolbar/ToggleDrawer.vue' import ToggleDrawer from '~/components/smartsheet/sidebar/toolbar/ToggleDrawer.vue'
const { isGrid, isForm, isGallery, isSqlView } = useSmartsheetStoreOrThrow() const { isGrid, isForm, isGallery, isSqlView } = useSmartsheetStoreOrThrow()
@ -8,7 +8,7 @@ const isPublic = inject(IsPublicInj, ref(false))
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { isOpen } = useSidebar() const { isOpen } = useSidebar('nc-right-sidebar')
const { allowCSVDownload } = useSharedView() const { allowCSVDownload } = useSharedView()
</script> </script>
@ -42,6 +42,7 @@ const { allowCSVDownload } = useSharedView()
<SmartsheetToolbarAddRow v-if="isUIAllowed('dataInsert') && !isPublic && !isForm && !isSqlView" class="mx-1" /> <SmartsheetToolbarAddRow v-if="isUIAllowed('dataInsert') && !isPublic && !isForm && !isSqlView" class="mx-1" />
<SmartsheetToolbarSearchData v-if="(isGrid || isGallery) && !isPublic" class="shrink mr-2 ml-2" /> <SmartsheetToolbarSearchData v-if="(isGrid || isGallery) && !isPublic" class="shrink mr-2 ml-2" />
<template v-if="!isOpen && !isPublic"> <template v-if="!isOpen && !isPublic">
<div class="border-l-1 pl-3"> <div class="border-l-1 pl-3">
<ToggleDrawer class="mr-2" /> <ToggleDrawer class="mr-2" />

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

@ -55,7 +55,7 @@ const iconColor = '#1890ff'
<template #title> <template #title>
<div class="text-center w-full">{{ $t('general.reload') }}</div> <div class="text-center w-full">{{ $t('general.reload') }}</div>
</template> </template>
<mdi-reload class="cursor-pointer select-none text-gray-500" @click="loadRow" /> <mdi-reload v-if="!isNew" class="cursor-pointer select-none text-gray-500" @click="loadRow" />
</a-tooltip> </a-tooltip>
<a-tooltip v-if="!isSqlView" placement="bottom"> <a-tooltip v-if="!isSqlView" placement="bottom">
<!-- Toggle comments draw --> <!-- Toggle comments draw -->

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

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TableType, ViewType } from 'nocodb-sdk' import type { TableType, ViewType } from 'nocodb-sdk'
import { isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import Cell from '../Cell.vue' import Cell from '../Cell.vue'
import VirtualCell from '../VirtualCell.vue' import VirtualCell from '../VirtualCell.vue'
@ -115,7 +115,13 @@ export default {
<div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]"> <div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]">
<div class="flex-1 overflow-auto scrollbar-thin-dull"> <div class="flex-1 overflow-auto scrollbar-thin-dull">
<div class="w-[500px] mx-auto"> <div class="w-[500px] mx-auto">
<div v-for="col of fields" :key="col.title" class="mt-2 py-2" :class="`nc-expand-col-${col.title}`"> <div
v-for="col of fields"
v-show="!isVirtualCol(col) || !isNew || col.uidt === UITypes.LinkToAnotherRecord"
:key="col.title"
class="mt-2 py-2"
:class="`nc-expand-col-${col.title}`"
>
<SmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" /> <SmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" />
<SmartsheetHeaderCell v-else :column="col" /> <SmartsheetHeaderCell v-else :column="col" />

3
packages/nc-gui/components/smartsheet/sidebar/index.vue

@ -13,6 +13,7 @@ import {
ref, ref,
useRoute, useRoute,
useRouter, useRouter,
useSidebar,
useViews, useViews,
watch, watch,
} from '#imports' } from '#imports'
@ -34,7 +35,7 @@ const { $e } = useNuxtApp()
provide(ViewListInj, views) provide(ViewListInj, views)
/** Sidebar visible */ /** Sidebar visible */
const { isOpen } = useSidebar({ storageKey: 'nc-right-sidebar', isOpen: true }) const { isOpen } = useSidebar('nc-right-sidebar', { isOpen: true })
const sidebarCollapsed = computed(() => !isOpen.value) const sidebarCollapsed = computed(() => !isOpen.value)

2
packages/nc-gui/components/smartsheet/sidebar/toolbar/DeleteTable.vue

@ -5,7 +5,7 @@ const meta = inject(MetaInj, ref())
const { deleteTable } = useTable() const { deleteTable } = useTable()
const { isOpen } = useSidebar({ storageKey: 'nc-right-sidebar' }) const { isOpen } = useSidebar('nc-right-sidebar')
</script> </script>
<template> <template>

8
packages/nc-gui/components/smartsheet/sidebar/toolbar/ToggleDrawer.vue

@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
/** Sidebar visible */ /** Sidebar visible */
const { isOpen, toggle } = useSidebar({ storageKey: 'nc-right-sidebar', isOpen: true }) const { isOpen, toggle } = useSidebar('nc-right-sidebar')
const onClick = () => {
toggle(!isOpen.value)
}
</script> </script>
<template> <template>
<div :class="{ 'nc-active-btn': isOpen }"> <div :class="{ 'nc-active-btn': isOpen }">
<a-button size="small" class="nc-toggle-right-navbar" @click="toggle(!isOpen)"> <a-button size="small" class="nc-toggle-right-navbar" @click="onClick">
<div class="flex items-center gap-1 text-xs" :class="{ 'text-gray-500': !isOpen }"> <div class="flex items-center gap-1 text-xs" :class="{ 'text-gray-500': !isOpen }">
<AntDesignMenuUnfoldOutlined v-if="isOpen" /> <AntDesignMenuUnfoldOutlined v-if="isOpen" />
<AntDesignMenuFoldOutlined v-else /> <AntDesignMenuFoldOutlined v-else />

3
packages/nc-gui/components/smartsheet/sidebar/toolbar/index.vue

@ -20,8 +20,11 @@ const clickCount = $ref(0)
" "
> >
<slot name="start" /> <slot name="start" />
<ToggleDrawer /> <ToggleDrawer />
<span></span> <span></span>
<template v-if="debug"> <template v-if="debug">
<ExportCache /> <ExportCache />

8
packages/nc-gui/components/tabs/Auth.vue

@ -7,7 +7,7 @@ interface Tab {
title: string title: string
label: string label: string
body: any body: any
isUIAllowed: boolean isUIAllowed: () => boolean
} }
const { t } = useI18n() const { t } = useI18n()
@ -19,13 +19,13 @@ const tabsInfo: Tab[] = [
title: 'Users Management', title: 'Users Management',
label: t('title.userMgmt'), label: t('title.userMgmt'),
body: () => UserManagement, body: () => UserManagement,
isUIAllowed: isUIAllowed('userMgmtTab'), isUIAllowed: () => isUIAllowed('userMgmtTab'),
}, },
{ {
title: 'API Token Management', title: 'API Token Management',
label: t('title.apiTokenMgmt'), label: t('title.apiTokenMgmt'),
body: () => ApiTokenManagement, body: () => ApiTokenManagement,
isUIAllowed: isUIAllowed('apiTokenTab'), isUIAllowed: () => isUIAllowed('apiTokenTab'),
}, },
] ]
@ -34,7 +34,7 @@ const selectedTab = $computed(() => tabsInfo[selectedTabKey])
</script> </script>
<template> <template>
<div v-if="selectedTab.isUIAllowed"> <div v-if="selectedTab.isUIAllowed()">
<a-tabs v-model:active-key="selectedTabKey" :open-keys="[]" mode="horizontal" class="nc-auth-tabs !mx-6"> <a-tabs v-model:active-key="selectedTabKey" :open-keys="[]" mode="horizontal" class="nc-auth-tabs !mx-6">
<a-tab-pane v-for="(tab, key) of tabsInfo" :key="key" class="select-none"> <a-tab-pane v-for="(tab, key) of tabsInfo" :key="key" class="select-none">
<template #tab> <template #tab>

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

@ -40,7 +40,7 @@ const openNewRecordFormHook = createEventHook<void>()
const { isGallery, isGrid, isForm, isLocked } = useProvideSmartsheetStore(activeView, meta) const { isGallery, isGrid, isForm, isLocked } = useProvideSmartsheetStore(activeView, meta)
// provide the sidebar injection state // provide the sidebar injection state
provideSidebar({ storageKey: 'nc-right-sidebar', isOpen: true }) provideSidebar('nc-right-sidebar', { useStorage: true, isOpen: true })
// todo: move to store // todo: move to store
provide(MetaInj, meta) provide(MetaInj, meta)

4
packages/nc-gui/components/tabs/auth/ApiTokenManagement.vue

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ApiTokenType } from 'nocodb-sdk' import type { ApiTokenType } from 'nocodb-sdk'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, useClipboard, useI18n } from '#imports' import { extractSdkResponseErrorMsg, useCopy, useI18n } from '#imports'
import KebabIcon from '~icons/ic/baseline-more-vert' import KebabIcon from '~icons/ic/baseline-more-vert'
import MdiPlusIcon from '~icons/mdi/plus' import MdiPlusIcon from '~icons/mdi/plus'
import CloseIcon from '~icons/material-symbols/close-rounded' import CloseIcon from '~icons/material-symbols/close-rounded'
@ -21,7 +21,7 @@ const { $api, $e } = useNuxtApp()
const { project } = $(useProject()) const { project } = $(useProject())
const { copy } = useClipboard() const { copy } = useCopy()
let tokensInfo = $ref<ApiToken[] | undefined>([]) let tokensInfo = $ref<ApiToken[] | undefined>([])

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

@ -8,7 +8,7 @@ import {
onBeforeMount, onBeforeMount,
ref, ref,
useApi, useApi,
useClipboard, useCopy,
useDashboard, useDashboard,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
@ -26,7 +26,7 @@ const { api } = useApi()
const { project } = useProject() const { project } = useProject()
const { copy } = useClipboard() const { copy } = useCopy()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()

4
packages/nc-gui/components/tabs/auth/user-management/ShareBase.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, onMounted, useClipboard, useI18n, useNuxtApp, useProject } from '#imports' import { extractSdkResponseErrorMsg, onMounted, useCopy, useI18n, useNuxtApp, useProject } from '#imports'
interface ShareBase { interface ShareBase {
uuid?: string uuid?: string
@ -25,7 +25,7 @@ const showEditBaseDropdown = $ref(false)
const { project } = useProject() const { project } = useProject()
const { copy } = useClipboard() const { copy } = useCopy()
const url = $computed(() => (base && base.uuid ? `${dashboardUrl}#/base/${base.uuid}` : null)) const url = $computed(() => (base && base.uuid ? `${dashboardUrl}#/base/${base.uuid}` : null))

4
packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue

@ -9,7 +9,7 @@ import {
projectRoleTagColors, projectRoleTagColors,
projectRoles, projectRoles,
ref, ref,
useClipboard, useCopy,
useDashboard, useDashboard,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
@ -37,7 +37,7 @@ const { t } = useI18n()
const { project } = useProject() const { project } = useProject()
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const { copy } = useClipboard() const { copy } = useCopy()
const { dashboardUrl } = $(useDashboard()) const { dashboardUrl } = $(useDashboard())
const usersData = $ref<Users>({ emails: undefined, role: ProjectRole.Viewer, invitationToken: undefined }) const usersData = $ref<Users>({ emails: undefined, role: ProjectRole.Viewer, invitationToken: undefined })

5
packages/nc-gui/components/webhook/Drawer.vue

@ -32,12 +32,13 @@ async function editHook(hook: Record<string, any>) {
class="nc-drawer-webhook" class="nc-drawer-webhook"
@keydown.esc="vModel = false" @keydown.esc="vModel = false"
> >
<a-layout class=""> <a-layout>
<a-layout-content class="px-10 py-5 scrollbar-thin-primary"> <a-layout-content class="px-10 py-5 scrollbar-thin-primary">
<WebhookEditor v-if="editOrAdd" ref="webhookEditorRef" @back-to-list="editOrAdd = false" /> <WebhookEditor v-if="editOrAdd" ref="webhookEditorRef" @back-to-list="editOrAdd = false" />
<WebhookList v-else @edit="editHook" @add="editOrAdd = true" /> <WebhookList v-else @edit="editHook" @add="editOrAdd = true" />
</a-layout-content> </a-layout-content>
<a-layout-footer class="!bg-white flex">
<a-layout-footer class="!bg-white border-t flex">
<a-button v-e="['e:hiring']" class="mx-auto mb-4" href="https://angel.co/company/nocodb" target="_blank" size="large"> <a-button v-e="['e:hiring']" class="mx-auto mb-4" href="https://angel.co/company/nocodb" target="_blank" size="large">
🚀 {{ $t('labels.weAreHiring') }}! 🚀 🚀 {{ $t('labels.weAreHiring') }}! 🚀
</a-button> </a-button>

8
packages/nc-gui/components/webhook/Editor.vue

@ -606,7 +606,13 @@ onMounted(async () => {
<a-col :span="24"> <a-col :span="24">
<a-card> <a-card>
<a-checkbox v-model:checked="hook.condition" class="nc-check-box-hook-condition">On Condition</a-checkbox> <a-checkbox v-model:checked="hook.condition" class="nc-check-box-hook-condition">On Condition</a-checkbox>
<SmartsheetToolbarColumnFilter v-if="hook.condition" ref="filterRef" :auto-save="false" :hook-id="hook.id" /> <SmartsheetToolbarColumnFilter
v-if="hook.condition"
ref="filterRef"
:auto-save="false"
:show-loading="false"
:hook-id="hook.id"
/>
</a-card> </a-card>
</a-col> </a-col>
</a-row> </a-row>

1
packages/nc-gui/composables/index.ts

@ -25,3 +25,4 @@ export * from './useLTARStore'
export * from './useExpandedFormStore' export * from './useExpandedFormStore'
export * from './useSharedFormViewStore' export * from './useSharedFormViewStore'
export * from './useCellUrlConfig' export * from './useCellUrlConfig'
export * from './useCopy'

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

@ -4,7 +4,6 @@ import type { ColumnType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useColumn } from './useColumn'
import { computed, createInjectionState, extractSdkResponseErrorMsg, useNuxtApp } from '#imports' import { computed, createInjectionState, extractSdkResponseErrorMsg, useNuxtApp } from '#imports'
const useForm = Form.useForm const useForm = Form.useForm
@ -82,8 +81,6 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const { resetFields, validate, validateInfos } = useForm(formState, validators) const { resetFields, validate, validateInfos } = useForm(formState, validators)
const onUidtOrIdTypeChange = () => { const onUidtOrIdTypeChange = () => {
const { isCurrency } = useColumn(ref(formState.value as ColumnType))
const colProp = sqlUi.value.getDataTypeForUiType(formState.value as { uidt: UITypes }, idType ?? undefined) const colProp = sqlUi.value.getDataTypeForUiType(formState.value as { uidt: UITypes }, idType ?? undefined)
formState.value = { formState.value = {
...formState.value, ...formState.value,
@ -111,11 +108,13 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
} }
} }
if (isCurrency.value) { // keep length and scale for same datatype
if (column.value?.uidt === UITypes.Currency) { if (column.value && formState.value.uidt === column.value?.uidt) {
formState.value.dtxp = column.value.dtxp formState.value.dtxp = column.value.dtxp
formState.value.dtxs = column.value.dtxs formState.value.dtxs = column.value.dtxs
} else { } else {
// default length and scale for currency
if (formState.value?.uidt === UITypes.Currency) {
formState.value.dtxp = 19 formState.value.dtxp = 19
formState.value.dtxs = 2 formState.value.dtxs = 2
} }
@ -125,8 +124,6 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
} }
const onDataTypeChange = () => { const onDataTypeChange = () => {
const { isCurrency } = useColumn(ref(formState.value as ColumnType))
formState.value.rqd = false formState.value.rqd = false
if (formState.value.uidt !== UITypes.ID) { if (formState.value.uidt !== UITypes.ID) {
formState.value.primaryKey = false formState.value.primaryKey = false
@ -139,16 +136,19 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
formState.value.dtx = 'specificType' formState.value.dtx = 'specificType'
// use enum response as dtxp for select columns
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect] const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect]
if (column.value && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.value.uidt as UITypes)) { if (column.value && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.value.uidt as UITypes)) {
formState.value.dtxp = column.value.dtxp formState.value.dtxp = column.value.dtxp
} }
if (isCurrency.value) { // keep length and scale for same datatype
if (column.value?.uidt === UITypes.Currency) { if (column.value && formState.value.uidt === column.value?.uidt) {
formState.value.dtxp = column.value.dtxp formState.value.dtxp = column.value.dtxp
formState.value.dtxs = column.value.dtxs formState.value.dtxs = column.value.dtxs
} else { } else {
// default length and scale for currency
if (formState.value?.uidt === UITypes.Currency) {
formState.value.dtxp = 19 formState.value.dtxp = 19
formState.value.dtxs = 2 formState.value.dtxs = 2
} }
@ -167,7 +167,6 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const addOrUpdate = async (onSuccess: () => void) => { const addOrUpdate = async (onSuccess: () => void) => {
try { try {
console.log(formState, validators)
if (!(await validate())) return if (!(await validate())) return
} catch (e) { } catch (e) {
console.log(e) console.log(e)

26
packages/nc-gui/composables/useCopy.ts

@ -0,0 +1,26 @@
import { useClipboard } from '#imports'
export const useCopy = () => {
/** fallback for copy if clipboard api is not supported */
const copyFallback = (text: string) => {
const textAreaEl = document.createElement('textarea')
textAreaEl.innerHTML = text
document.body.appendChild(textAreaEl)
textAreaEl.select()
const result = document.execCommand('copy')
document.body.removeChild(textAreaEl)
return result
}
const { copy: _copy, isSupported } = useClipboard()
const copy = async (text: string) => {
if (isSupported) {
await _copy(text)
} else {
copyFallback(text)
}
}
return { copy }
}

87
packages/nc-gui/composables/useProject.ts

@ -1,31 +1,53 @@
import type { MaybeRef } from '@vueuse/core' import type { MaybeRef } from '@vueuse/core'
import { SqlUiFactory } from 'nocodb-sdk'
import type { OracleUi, ProjectType, TableType } from 'nocodb-sdk' import type { OracleUi, ProjectType, TableType } from 'nocodb-sdk'
import { useNuxtApp, useRoute } from '#app' import { SqlUiFactory } from 'nocodb-sdk'
import type { ProjectMetaInfo } from '~/lib' import { isString } from '@vueuse/core'
import {
USER_PROJECT_ROLES,
computed,
createEventHook,
ref,
useApi,
useGlobal,
useInjectionState,
useNuxtApp,
useRoute,
useState,
useTheme,
watch,
} from '#imports'
import type { ProjectMetaInfo, Roles } from '~/lib'
import type { ThemeConfig } from '@/composables/useTheme' import type { ThemeConfig } from '@/composables/useTheme'
import { createEventHook, useInjectionState } from '#imports'
const [setup, use] = useInjectionState((_projectId?: MaybeRef<string>) => { const [setup, use] = useInjectionState((_projectId?: MaybeRef<string>) => {
const { $api, $e } = useNuxtApp() const { $e } = useNuxtApp()
const { api, isLoading } = useApi()
const route = useRoute() const route = useRoute()
const { includeM2M } = useGlobal() const { includeM2M } = useGlobal()
const { setTheme, theme } = useTheme() const { setTheme, theme } = useTheme()
const projectLoadedHook = createEventHook<ProjectType>() const projectLoadedHook = createEventHook<ProjectType>()
const projectId = computed(() => (_projectId ? unref(_projectId) : (route.params.projectId as string)))
const project = ref<ProjectType>({}) const project = ref<ProjectType>({})
const tables = ref<TableType[]>([]) const tables = ref<TableType[]>([])
const projectRoles = useState<Record<string, boolean>>(USER_PROJECT_ROLES, () => ({}))
const projectRoles = useState<Roles>(USER_PROJECT_ROLES, () => ({}))
const projectMetaInfo = ref<ProjectMetaInfo | undefined>() const projectMetaInfo = ref<ProjectMetaInfo | undefined>()
const projectId = computed(() => (_projectId ? unref(_projectId) : (route.params.projectId as string)))
// todo: refactor path param name and variable name // todo: refactor path param name and variable name
const projectType = $computed(() => route.params.projectType as string) const projectType = $computed(() => route.params.projectType as string)
const projectMeta = computed(() => { const projectMeta = computed<Record<string, any>>(() => {
try { try {
return typeof project.value.meta === 'string' ? JSON.parse(project.value.meta) : project.value.meta return isString(project.value.meta) ? JSON.parse(project.value.meta) : project.value.meta
} catch (e) { } catch (e) {
return {} return {}
} }
@ -44,15 +66,15 @@ const [setup, use] = useInjectionState((_projectId?: MaybeRef<string>) => {
async function loadProjectMetaInfo(force?: boolean) { async function loadProjectMetaInfo(force?: boolean) {
if (!projectMetaInfo.value || force) { if (!projectMetaInfo.value || force) {
const data = await $api.project.metaGet(project.value.id!, {}, {}) projectMetaInfo.value = await api.project.metaGet(project.value.id!, {}, {})
projectMetaInfo.value = data
} }
} }
async function loadProjectRoles() { async function loadProjectRoles() {
projectRoles.value = {} projectRoles.value = {}
if (isSharedBase.value) { if (isSharedBase.value) {
const user = await $api.auth.me( const user = await api.auth.me(
{}, {},
{ {
headers: { headers: {
@ -60,33 +82,40 @@ const [setup, use] = useInjectionState((_projectId?: MaybeRef<string>) => {
}, },
}, },
) )
projectRoles.value = user.roles projectRoles.value = user.roles
} else if (project.value.id) { } else if (project.value.id) {
const user = await $api.auth.me({ project_id: project.value.id }) const user = await api.auth.me({ project_id: project.value.id })
projectRoles.value = user.roles projectRoles.value = user.roles
} }
} }
async function loadTables() { async function loadTables() {
if (project.value.id) { if (project.value.id) {
const tablesResponse = await $api.dbTable.list(project.value.id, { const tablesResponse = await api.dbTable.list(project.value.id, {
includeM2M: includeM2M.value, includeM2M: includeM2M.value,
}) })
if (tablesResponse.list) tables.value = tablesResponse.list if (tablesResponse.list) tables.value = tablesResponse.list
} }
} }
async function loadProject() { async function loadProject(id?: string) {
if (projectType === 'base') { if (id) {
const baseData = await $api.public.sharedBaseGet(route.params.projectId as string) project.value = await api.project.read(projectId.value)
project.value = await $api.project.read(baseData.project_id!) } else if (projectType === 'base') {
const baseData = await api.public.sharedBaseGet(route.params.projectId as string)
project.value = await api.project.read(baseData.project_id!)
} else if (projectId.value) { } else if (projectId.value) {
project.value = await $api.project.read(projectId.value) project.value = await api.project.read(projectId.value)
} else { } else {
return return
} }
await loadProjectRoles() await loadProjectRoles()
await loadTables() await loadTables()
setTheme(projectMeta.value?.theme) setTheme(projectMeta.value?.theme)
projectLoadedHook.trigger(project.value) projectLoadedHook.trigger(project.value)
@ -97,15 +126,13 @@ const [setup, use] = useInjectionState((_projectId?: MaybeRef<string>) => {
return return
} }
if (data.meta && typeof data.meta === 'string') { if (data.meta && typeof data.meta === 'string') {
await $api.project.update(projectId.value, data) await api.project.update(projectId.value, data)
} else { } else {
await $api.project.update(projectId.value, { ...data, meta: JSON.stringify(data.meta) }) await api.project.update(projectId.value, { ...data, meta: JSON.stringify(data.meta) })
} }
} }
async function saveTheme(_theme: Partial<ThemeConfig>) { async function saveTheme(_theme: Partial<ThemeConfig>) {
$e('c:themes:change')
const fullTheme = { const fullTheme = {
primaryColor: theme.value.primaryColor, primaryColor: theme.value.primaryColor,
accentColor: theme.value.accentColor, accentColor: theme.value.accentColor,
@ -119,25 +146,27 @@ const [setup, use] = useInjectionState((_projectId?: MaybeRef<string>) => {
theme: fullTheme, theme: fullTheme,
}, },
}) })
setTheme(fullTheme) setTheme(fullTheme)
$e('c:themes:change')
} }
watch( watch(
() => route.params, () => route.params,
(v) => { (next) => {
if (!v?.projectId) { if (!next.projectId) {
setTheme() setTheme()
} }
}, },
) )
// TODO useProject should only called inside a project for now this doesn't work const reset = () => {
onScopeDispose(() => {
project.value = {} project.value = {}
tables.value = [] tables.value = []
projectMetaInfo.value = undefined projectMetaInfo.value = undefined
projectRoles.value = {} projectRoles.value = {}
}) }
return { return {
project, project,
@ -156,6 +185,8 @@ const [setup, use] = useInjectionState((_projectId?: MaybeRef<string>) => {
projectMeta, projectMeta,
saveTheme, saveTheme,
projectLoadedHook: projectLoadedHook.on, projectLoadedHook: projectLoadedHook.on,
reset,
isLoading,
} }
}, 'useProject') }, 'useProject')

74
packages/nc-gui/composables/useSidebar/index.ts

@ -1,10 +1,10 @@
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { useInjectionState, watch } from '#imports' import { MemStorage, onScopeDispose, useInjectionState, watch } from '#imports'
interface UseSidebarProps { interface UseSidebarProps {
hasSidebar?: boolean hasSidebar?: boolean
isOpen?: boolean isOpen?: boolean
storageKey?: string // if a storageKey is passed, use that key for localStorage useStorage?: boolean
} }
/** /**
@ -15,9 +15,11 @@ interface UseSidebarProps {
* *
* If `provideSidebar` is not called explicitly, `useSidebar` will trigger the provider if no injection state can be found * If `provideSidebar` is not called explicitly, `useSidebar` will trigger the provider if no injection state can be found
*/ */
const [setup, use] = useInjectionState((props: UseSidebarProps = {}) => { const [setupSidebarStore, useSidebarStore] = useInjectionState(() => new MemStorage(), 'SidebarStore')
let isOpen = ref(props.isOpen ?? false)
let hasSidebar = ref(props.hasSidebar ?? true) const createSidebar = (id: string, props: UseSidebarProps = {}) => {
const isOpen = ref(props.isOpen ?? false)
const hasSidebar = ref(props.hasSidebar ?? true)
function toggle(state?: boolean) { function toggle(state?: boolean) {
isOpen.value = state ?? !isOpen.value isOpen.value = state ?? !isOpen.value
@ -27,10 +29,10 @@ const [setup, use] = useInjectionState((props: UseSidebarProps = {}) => {
hasSidebar.value = state ?? !hasSidebar.value hasSidebar.value = state ?? !hasSidebar.value
} }
if (props.storageKey) { if (props.useStorage) {
const storage = toRefs(useStorage(props.storageKey, { isOpen, hasSidebar }, localStorage, { mergeDefaults: true }).value) const storage = toRefs(useStorage(id, { isOpen, hasSidebar }, localStorage, { mergeDefaults: true }).value)
isOpen = storage.isOpen syncRef(isOpen, storage.isOpen)
hasSidebar = storage.hasSidebar syncRef(hasSidebar, storage.hasSidebar)
} }
watch( watch(
@ -55,20 +57,54 @@ const [setup, use] = useInjectionState((props: UseSidebarProps = {}) => {
hasSidebar, hasSidebar,
toggleHasSidebar, toggleHasSidebar,
} }
}, 'useSidebar') }
const useSidebarStorage = () => {
let sidebarStorage = useSidebarStore()
if (!sidebarStorage) {
sidebarStorage = setupSidebarStore()
}
return sidebarStorage
}
export const provideSidebar = (id: string, props: UseSidebarProps = {}) => {
const sidebarStorage = useSidebarStorage()
onScopeDispose(() => {
sidebarStorage.remove(id)
})
export const provideSidebar = setup if (!sidebarStorage.has(id)) {
const sidebar = createSidebar(id, props)
export function useSidebar(props: UseSidebarProps = {}) { sidebarStorage.set(id, sidebar)
const state = use()
if (!state) { return sidebar
return setup(props)
} else { } else {
// set state if props were passed const sidebar = sidebarStorage.get(id)
if (typeof props.isOpen !== 'undefined') state.isOpen.value = props.isOpen
if (typeof props.hasSidebar !== 'undefined') state.hasSidebar.value = props.hasSidebar if (props.isOpen !== undefined) sidebar.isOpen.value = props.isOpen
if (props.hasSidebar !== undefined) sidebar.hasSidebar.value = props.hasSidebar
return sidebar
} }
}
export function useSidebar(id: string, props: UseSidebarProps = {}) {
if (!id) throw new Error('useSidebar requires an id')
const sidebarStorage = useSidebarStorage()
return state if (sidebarStorage.has(id)) {
const sidebar = sidebarStorage.get(id)
if (props.isOpen !== undefined) sidebar.isOpen.value = props.isOpen
if (props.hasSidebar !== undefined) sidebar.hasSidebar.value = props.hasSidebar
return sidebar
} else {
return provideSidebar(id, props)
}
} }

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

@ -117,9 +117,9 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
NOCO, NOCO,
project.value?.id as string, project.value?.id as string,
meta.value?.title as string, meta.value?.title as string,
extractPkFromRow(ref(row).value?.row, meta.value?.columns as ColumnType[]), extractPkFromRow(unref(row)?.row, meta.value?.columns as ColumnType[]),
) )
Object.assign(ref(row).value, { Object.assign(unref(row), {
row: record, row: record,
oldRow: { ...record }, oldRow: { ...record },
rowMeta: {}, rowMeta: {},

3
packages/nc-gui/composables/useUIPermission/index.ts

@ -1,3 +1,4 @@
import { isString } from '@vueuse/core'
import type { Permission } from './rolePermissions' import type { Permission } from './rolePermissions'
import rolePermissions from './rolePermissions' import rolePermissions from './rolePermissions'
import { USER_PROJECT_ROLES, computed, useGlobal, useState } from '#imports' import { USER_PROJECT_ROLES, computed, useGlobal, useState } from '#imports'
@ -9,7 +10,7 @@ export function useUIPermission() {
const projectRoles = useState<Record<string, boolean>>(USER_PROJECT_ROLES, () => ({})) const projectRoles = useState<Record<string, boolean>>(USER_PROJECT_ROLES, () => ({}))
const baseRoles = computed(() => { const baseRoles = computed(() => {
let userRoles = typeof user.value?.roles === 'string' ? user.value?.roles : ({ ...(user.value?.roles || {}) } as Roles) let userRoles = isString(user.value?.roles) ? user.value?.roles : ({ ...(user.value?.roles || {}) } as Roles)
// if string populate key-value paired object // if string populate key-value paired object
if (typeof userRoles === 'string') { if (typeof userRoles === 'string') {

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

@ -1,8 +1,6 @@
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 { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import { useNuxtApp } from '#app'
import { import {
IsPublicInj, IsPublicInj,
NOCO, NOCO,
@ -11,6 +9,8 @@ import {
getHTMLEncodedText, getHTMLEncodedText,
useApi, useApi,
useGlobal, useGlobal,
useI18n,
useNuxtApp,
useProject, useProject,
useUIPermission, useUIPermission,
} from '#imports' } from '#imports'

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

@ -150,7 +150,7 @@ export function useViewFilters(
} }
const saveOrUpdate = async (filter: Filter, i: number, force = false) => { const saveOrUpdate = async (filter: Filter, i: number, force = false) => {
if (!view?.value) return if (!view.value) return
try { try {
if (nestedMode.value) { if (nestedMode.value) {

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

@ -21,7 +21,9 @@ export const IsLockedInj: InjectionKey<Ref<boolean>> = Symbol('is-locked-injecti
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<boolean> = Symbol('readonly-injection')
export const ReloadViewDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-view-data-injection')
/** when bool is passed, it indicates if a loading spinner should be visible while reloading */
export const ReloadViewDataHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-data-injection')
export const ReloadRowDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-row-data-injection') export const ReloadRowDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-row-data-injection')
export const OpenNewRecordFormHookInj: InjectionKey<EventHook<void>> = Symbol('open-new-record-form-injection') export const OpenNewRecordFormHookInj: InjectionKey<EventHook<void>> = Symbol('open-new-record-form-injection')
export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection') export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection')

282
packages/nc-gui/lang/ar.json

@ -55,19 +55,19 @@
"notification": "إشعار", "notification": "إشعار",
"reference": "مرجع", "reference": "مرجع",
"function": "وظيفة", "function": "وظيفة",
"confirm": "Confirm", "confirm": "تأكيد",
"generate": "Generate", "generate": "توليد",
"copy": "Copy", "copy": "نسخ",
"misc": "Miscellaneous", "misc": "متفرقات",
"lock": "Lock", "lock": "قفل",
"unlock": "Unlock", "unlock": "فتح",
"credentials": "Credentials", "credentials": "الإعتمادات",
"help": "Help", "help": "المساعدة",
"questions": "Questions", "questions": "الأسئلة",
"reachOut": "Reach out here", "reachOut": "الوصول إلى هنا",
"betaNote": "This feature is currently in beta.", "betaNote": "هذه الميزة حاليا في بيتا.",
"moreInfo": "More information can be found here", "moreInfo": "المزيد من المعلومات تجدها هنا",
"logs": "Logs" "logs": "السجلات"
}, },
"objects": { "objects": {
"project": "مشروع", "project": "مشروع",
@ -120,7 +120,7 @@
"Time": "وقت", "Time": "وقت",
"PhoneNumber": "هاتف", "PhoneNumber": "هاتف",
"Email": "بريد إلكتروني", "Email": "بريد إلكتروني",
"URL": "URL", "URL": "الرابط",
"Number": "عدد", "Number": "عدد",
"Decimal": "عشري", "Decimal": "عشري",
"Currency": "عملة", "Currency": "عملة",
@ -153,8 +153,8 @@
"isNot like": "ليس مثل", "isNot like": "ليس مثل",
"isEmpty": "فارغ", "isEmpty": "فارغ",
"isNotEmpty": "ليس فارغ", "isNotEmpty": "ليس فارغ",
"isNull": "is null", "isNull": "فارغ",
"isNotNull": "is not null" "isNotNull": "ليس فارغاً"
}, },
"title": { "title": {
"newProj": "مشروع جديد", "newProj": "مشروع جديد",
@ -186,14 +186,14 @@
"teamAndSettings": "الفريق والإعدادات", "teamAndSettings": "الفريق والإعدادات",
"apiDocs": "مستندات API", "apiDocs": "مستندات API",
"importFromAirtable": "استيراد من Airtable", "importFromAirtable": "استيراد من Airtable",
"generateToken": "Generate Token", "generateToken": "إنشاء الرمز المميز",
"APIsAndSupport": "APIs & Support", "APIsAndSupport": "APIs & الدعم",
"helpCenter": "Help center", "helpCenter": "مركز المساعدة",
"swaggerDocumentation": "Swagger Documentation", "swaggerDocumentation": "Swagger Documentation",
"quickImportFrom": "Quick Import From", "quickImportFrom": "استيراد سريع من",
"quickImport": "Quick Import", "quickImport": "استيراد سريع",
"advancedSettings": "Advanced Settings", "advancedSettings": "الإعدادات المتقدمة",
"codeSnippet": "Code Snippet" "codeSnippet": "كتلة برمجية"
}, },
"labels": { "labels": {
"notifyVia": "إعلام عبر", "notifyVia": "إعلام عبر",
@ -261,37 +261,37 @@
"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": "طلب مصدر البيانات الذي تحتاجه؟",
"apiKey": "API Key", "apiKey": "مفتاح API",
"sharedBase": "Shared Base", "sharedBase": "قاعدة مشتركة",
"importData": "Import Data", "importData": "استيراد البيانات",
"importSecondaryViews": "Import Secondary Views", "importSecondaryViews": "استيراد المشاهدات الثانوية",
"importRollupColumns": "Import Rollup Columns", "importRollupColumns": "استيراد الأعمدة المتدحرجة",
"importLookupColumns": "Import Lookup Columns", "importLookupColumns": "استيراد أعمدة البحث",
"importAttachmentColumns": "Import Attachment Columns", "importAttachmentColumns": "استيراد أعمدة المرفق",
"importFormulaColumns": "Import Formula Columns", "importFormulaColumns": "استيراد أعمدة الصيغة",
"noData": "No Data", "noData": "لا توجد بيانات",
"goToDashboard": "Go to Dashboard", "goToDashboard": "الذهاب إلى لوحة التحكم",
"importing": "Importing", "importing": "الاستيراد",
"flattenNested": "Flatten Nested", "flattenNested": "متداخلة متقطعة",
"downloadAllowed": "Download allowed", "downloadAllowed": "التحميل المسموح به",
"weAreHiring": "We are Hiring!", "weAreHiring": "نحن نوظف!",
"primaryKey": "Primary key", "primaryKey": "المفتاح الأساسي",
"hasMany": "has many", "hasMany": "لديه العديد",
"belongsTo": "belongs to", "belongsTo": "ينتمي إلى",
"manyToMany": "have many to many relation", "manyToMany": "متعدد لمتعدد",
"extraConnectionParameters": "Extra connection parameters", "extraConnectionParameters": "معلمات اتصال إضافية",
"commentsOnly": "Comments only", "commentsOnly": "التعليقات فقط",
"documentation": "Documentation", "documentation": "الوثائق",
"subscribeNewsletter": "Subscribe to our weekly newsletter", "subscribeNewsletter": "اشترك في نشرتنا الإخبارية الأسبوعية",
"signUpWithGoogle": "Sign up with Google", "signUpWithGoogle": "التسجيل بواسطة Google",
"agreeToTos": "By signing up, you agree to the Terms of Service", "agreeToTos": "بالتسجيل، أنت توافق على شروط الخدمة",
"welcomeToNc": "Welcome to NocoDB!" "welcomeToNc": "مرحبا بكم في NocoDB!"
}, },
"activity": { "activity": {
"createProject": "إنشاء مشروع", "createProject": "إنشاء مشروع",
@ -399,14 +399,14 @@
"editConnJson": "تحرير اتصال جسون", "editConnJson": "تحرير اتصال جسون",
"sponsorUs": "ادعمنا", "sponsorUs": "ادعمنا",
"sendEmail": "ارسل بريد الكتروني", "sendEmail": "ارسل بريد الكتروني",
"addUserToProject": "Add user to project", "addUserToProject": "إضافة مستخدم إلى المشروع",
"getApiSnippet": "Get API Snippet", "getApiSnippet": "احصل على كتلة API",
"clearCell": "Clear cell", "clearCell": "مسح الخلية",
"addFilterGroup": "Add Filter Group", "addFilterGroup": "إضافة مجموعة عوامل التصفية",
"linkRecord": "Link record", "linkRecord": "اربط سجل",
"addNewRecord": "Add new record", "addNewRecord": "إضافة سجل جديد",
"useConnectionUrl": "Use Connection URL", "useConnectionUrl": "استخدام رابط الاتصال",
"toggleCommentsDraw": "Toggle comments draw" "toggleCommentsDraw": "تبديل سحب التعليقات"
}, },
"tooltip": { "tooltip": {
"saveChanges": "حفظ التغييرات", "saveChanges": "حفظ التغييرات",
@ -453,8 +453,8 @@
"noItemsFound": "لم يتم العثور على عناصر", "noItemsFound": "لم يتم العثور على عناصر",
"defaultValue": "القيمة الافتراضية", "defaultValue": "القيمة الافتراضية",
"filterByEmail": "تصفية عن طريق البريد الإلكتروني", "filterByEmail": "تصفية عن طريق البريد الإلكتروني",
"filterQuery": "Filter query", "filterQuery": "إستعلام الفلتر",
"selectField": "Select field" "selectField": "حدد حقل"
}, },
"msg": { "msg": {
"info": { "info": {
@ -548,29 +548,29 @@
"addDefaultColumns": "إضافة الأعمدة الافتراضية", "addDefaultColumns": "إضافة الأعمدة الافتراضية",
"tableNameInDb": "اسم الجدول كما تم حفظه في قاعدة البيانات", "tableNameInDb": "اسم الجدول كما تم حفظه في قاعدة البيانات",
"airtable": { "airtable": {
"credentials": "Where to find this?" "credentials": "أين تجد هذا؟"
}, },
"import": { "import": {
"clickOrDrag": "Click or drag file to this area to upload" "clickOrDrag": "انقر أو اسحب الملف إلى هذه المنطقة لتحميل"
}, },
"metaDataRecreated": "Table metadata recreated successfully", "metaDataRecreated": "تم إعادة إنشاء بيانات تعريف الجدول بنجاح",
"invalidCredentials": "Invalid credentials", "invalidCredentials": "بيانات الاعتماد غير صالحة",
"downloadingMoreFiles": "Downloading more files", "downloadingMoreFiles": "تحميل المزيد من الملفات",
"copiedToClipboard": "Copied to clipboard", "copiedToClipboard": "تم النسخ إلى الحافظة",
"requriedFieldsCantBeMoved": "Required field can't be moved", "requriedFieldsCantBeMoved": "لا يمكن نقل الحقل المطلوب",
"updateNotAllowedWithoutPK": "Update not allowed for table which doesn't have primary key", "updateNotAllowedWithoutPK": "التحديث غير مسموح به للجدول الذي لا يحتوي على مفتاح أساسي",
"autoIncFieldNotEditable": "Auto increment field is not editable", "autoIncFieldNotEditable": "الحقل الإضافي التلقائي غير قابل للتحرير",
"editingPKnotSupported": "Editing primary key not supported", "editingPKnotSupported": "تحرير المفتاح الأساسي غير مدعوم",
"deletedCache": "Deleted cache successfully", "deletedCache": "تم حذف ذاكرة التخزين المؤقت بنجاح",
"cacheEmpty": "Cache is empty", "cacheEmpty": "ذاكرة التخزين المؤقت فارغة",
"exportedCache": "Exported Cache Successfully", "exportedCache": "تم تصدير ذاكرة التخزين المؤقت بنجاح",
"valueAlreadyInList": "This value is already in the list", "valueAlreadyInList": "هذه القيمة موجودة مسبقاً في القائمة",
"noColumnsToUpdate": "No columns to update", "noColumnsToUpdate": "لا توجد أعمدة للتحديث",
"tableDeleted": "Deleted table successfully", "tableDeleted": "تم حذف الجدول بنجاح",
"generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base", "generatePublicShareableReadonlyBase": "إنشاء قاعدة للقراءة فقط قابلة للمشاركة العامة",
"deleteViewConfirmation": "Are you sure you want to delete this view?", "deleteViewConfirmation": "هل أنت متأكد من أنك تريد حذف هذا العرض؟",
"deleteTableConfirmation": "Do you want to delete the table", "deleteTableConfirmation": "هل تريد حذف الجدول",
"showM2mTables": "Show M2M Tables" "showM2mTables": "إظهار جداول M2M"
}, },
"error": { "error": {
"searchProject": "البحث عن {بحث} لم يتم العثور على نتائج", "searchProject": "البحث عن {بحث} لم يتم العثور على نتائج",
@ -586,37 +586,37 @@
"passwdRequired": "كلمة المرور مطلوبة", "passwdRequired": "كلمة المرور مطلوبة",
"passwdLength": "يجب أن تكون كلمة المرور الخاصة بك 8 أحرف على الأقل", "passwdLength": "يجب أن تكون كلمة المرور الخاصة بك 8 أحرف على الأقل",
"passwdMismatch": "كلمات المرور غير متطابقة", "passwdMismatch": "كلمات المرور غير متطابقة",
"completeRuleSet": "At least 8 characters with one Uppercase, one number and one special character", "completeRuleSet": "8 أحرف على الأقل مع حرف كبير واحد ورقم واحد وحرف خاص واحد",
"atLeast8Char": "At least 8 characters", "atLeast8Char": "8 أحرف على الأقل",
"atLeastOneUppercase": "One Uppercase letter", "atLeastOneUppercase": "حرف كبير واحد",
"atLeastOneNumber": "One Number", "atLeastOneNumber": "رقم واحد",
"atLeastOneSpecialChar": "One special character", "atLeastOneSpecialChar": "حرف خاص واحد",
"allowedSpecialCharList": "Allowed special character list" "allowedSpecialCharList": "قائمة الأحرف الخاصة المسموح بها"
}, },
"invalidURL": "Invalid URL", "invalidURL": "رابط غير صالح",
"internalError": "Some internal error occurred", "internalError": "حدث خطأ داخلي",
"templateGeneratorNotFound": "Template Generator cannot be found!", "templateGeneratorNotFound": "لا يمكن العثور على مولد القالب!",
"fileUploadFailed": "Failed to upload file", "fileUploadFailed": "فشل في رفع الملف",
"primaryColumnUpdateFailed": "Failed to update primary column", "primaryColumnUpdateFailed": "فشل تحديث العمود الأساسي",
"formDescriptionTooLong": "Data too long for Form Description", "formDescriptionTooLong": "بيانات طويلة جداً لوصف النموذج",
"columnsRequired": "Following columns are required", "columnsRequired": "الأعمدة التالية مطلوبة",
"selectAtleastOneColumn": "At least one column has to be selected", "selectAtleastOneColumn": "يجب اختيار عمود واحد على الأقل",
"columnDescriptionNotFound": "Cannot find the destination column for", "columnDescriptionNotFound": "لا يمكن العثور على عمود الوجهة لـ",
"duplicateMappingFound": "Duplicate mapping found, please remove one of the mapping", "duplicateMappingFound": "تم العثور على تعيين متكرر، الرجاء إزالة أحد الخرائط",
"nullValueViolatesNotNull": "Null value violates not-null constraint", "nullValueViolatesNotNull": "قيمة الفراغ تنتهك قيودا غير فارغة",
"sourceHasInvalidNumbers": "Source data contains some invalid numbers", "sourceHasInvalidNumbers": "بيانات المصدر تحتوي على بعض الأرقام غير صالحة",
"sourceHasInvalidBoolean": "Source data contains some invalid boolean values", "sourceHasInvalidBoolean": "بيانات المصدر تحتوي على بعض القيم المنطقية غير صالحة",
"invalidForm": "Invalid Form", "invalidForm": "نموذج غير صالح",
"formValidationFailed": "Form validation failed", "formValidationFailed": "فشل التحقق من النموذج",
"youHaveBeenSignedOut": "You have been signed out", "youHaveBeenSignedOut": "لقد قمت بتسجيل الخروج",
"failedToLoadList": "Failed to load list", "failedToLoadList": "فشل تحميل القائمة",
"failedToLoadChildrenList": "Failed to load children list", "failedToLoadChildrenList": "فشل تحميل القائمة الفرعية",
"deleteFailed": "Delete failed", "deleteFailed": "فشل الحذف",
"unlinkFailed": "Unlink failed", "unlinkFailed": "فشل إلغاء الارتباط",
"rowUpdateFailed": "Row update failed", "rowUpdateFailed": "فشل تحديث الصف",
"deleteRowFailed": "Failed to delete row", "deleteRowFailed": "فشل في حذف الصف",
"setFormDataFailed": "Failed to set form data", "setFormDataFailed": "فشل في تعيين بيانات النموذج",
"formViewUpdateFailed": "Failed to update form view" "formViewUpdateFailed": "فشل تحديث عرض النموذج"
}, },
"toast": { "toast": {
"exportMetadata": "تصدير البيانات الوصفية للمشروع بنجاح", "exportMetadata": "تصدير البيانات الوصفية للمشروع بنجاح",
@ -636,33 +636,33 @@
"futureRelease": "قريبا!" "futureRelease": "قريبا!"
}, },
"success": { "success": {
"updatedUIACL": "Updated UI ACL for tables successfully", "updatedUIACL": "تم تحديث ACL واجهة المستخدم للجداول بنجاح",
"pluginUninstalled": "Plugin uninstalled successfully", "pluginUninstalled": "تم إلغاء تثبيت الإضافة بنجاح",
"pluginSettingsSaved": "Plugin settings saved successfully", "pluginSettingsSaved": "تم حفظ إعدادات الإضافة بنجاح",
"pluginTested": "Successfully tested plugin settings", "pluginTested": "تم اختبار إعدادات الإضافات بنجاح",
"tableRenamed": "Table renamed successfully", "tableRenamed": "تمت إعادة تسمية الجدول بنجاح",
"viewDeleted": "View deleted successfully", "viewDeleted": "تم حذف العرض بنجاح",
"primaryColumnUpdated": "Successfully updated as primary column", "primaryColumnUpdated": "تم التحديث بنجاح كعمود أساسي",
"tableDataExported": "Successfully exported all table data", "tableDataExported": "تم تصدير جميع بيانات الجدول بنجاح",
"updated": "Successfully updated", "updated": "تم التحديث بنجاح",
"sharedViewDeleted": "Deleted shared view successfully", "sharedViewDeleted": "تم حذف العرض المشترك بنجاح",
"viewRenamed": "View renamed successfully", "viewRenamed": "تمت إعادة التسمية بنجاح",
"tokenGenerated": "Token generated successfully", "tokenGenerated": "تم إنشاء الرمز بنجاح",
"tokenDeleted": "Token deleted successfully", "tokenDeleted": "تم حذف الرمز بنجاح",
"userAddedToProject": "Successfully added user to project", "userAddedToProject": "تمت إضافة المستخدم إلى المشروع بنجاح",
"userDeletedFromProject": "Successfully deleted user from project", "userDeletedFromProject": "تم حذف المستخدم بنجاح من المشروع",
"inviteEmailSent": "Invite Email sent successfully", "inviteEmailSent": "دعوة البريد الإلكتروني مرسلة بنجاح",
"inviteURLCopied": "Invite URL copied to clipboard", "inviteURLCopied": "تم نسخ عنوان URL الدعوة إلى الحافظة",
"shareableURLCopied": "Copied shareable base URL to clipboard!", "shareableURLCopied": "تم نسخ عنوان URL الأساسي القابل للمشاركة إلى الحافظة!",
"embeddableHTMLCodeCopied": "Copied embeddable HTML code!", "embeddableHTMLCodeCopied": "تم نسخ رمز HTML القابل للدمج!",
"userDetailsUpdated": "Successfully updated the user details", "userDetailsUpdated": "تم تحديث تفاصيل المستخدم بنجاح",
"tableDataImported": "Successfully imported table data", "tableDataImported": "تم استيراد بيانات الجدول بنجاح",
"webhookUpdated": "Webhook details updated successfully", "webhookUpdated": "تم تحديث تفاصيل Webhook بنجاح",
"webhookDeleted": "Hook deleted successfully", "webhookDeleted": "تم حذف هوك بنجاح",
"webhookTested": "Webhook tested successfully", "webhookTested": "تم اختبار Webhook بنجاح",
"columnUpdated": "Column updated", "columnUpdated": "تم تحديث العمود",
"columnCreated": "Column created", "columnCreated": "تم إنشاء العمود",
"passwordChanged": "Password changed successfully. Please login again." "passwordChanged": "تم تغيير كلمة المرور بنجاح. الرجاء تسجيل الدخول مرة أخرى."
} }
} }
} }

4
packages/nc-gui/lang/ru.json

@ -84,8 +84,8 @@
"records": "Записи", "records": "Записи",
"webhook": "Webhook", "webhook": "Webhook",
"webhooks": "Веб-образ", "webhooks": "Веб-образ",
"view": "Отабражение", "view": "Отображение",
"views": "Отабражения", "views": "Отoбражения",
"viewType": { "viewType": {
"grid": "Сетка", "grid": "Сетка",
"gallery": "Галерея", "gallery": "Галерея",

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

@ -1,12 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useTitle } from '@vueuse/core' import { useTitle } from '@vueuse/core'
import { useI18n, useRoute, useSidebar } from '#imports' import { provideSidebar, useI18n, useRoute } from '#imports'
const route = useRoute() const route = useRoute()
const { te, t } = useI18n() const { te, t } = useI18n()
const { hasSidebar } = useSidebar() const { hasSidebar } = provideSidebar('nc-left-sidebar')
useTitle(route.meta?.title && te(route.meta.title) ? `${t(route.meta.title)} | NocoDB` : 'NocoDB') useTitle(route.meta?.title && te(route.meta.title) ? `${t(route.meta.title)} | NocoDB` : 'NocoDB')
</script> </script>

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

@ -31,7 +31,7 @@ export interface Field {
system?: boolean system?: boolean
} }
export type Roles = Record<Role, boolean> | string export type Roles = Record<Role | string, boolean> | string
export type Filter = FilterType & { status?: 'update' | 'delete' | 'create'; parentId?: string; readOnly?: boolean } export type Filter = FilterType & { status?: 'update' | 'delete' | 'create'; parentId?: string; readOnly?: boolean }

12
packages/nc-gui/nuxt-shim.d.ts vendored

@ -1,6 +1,7 @@
import type { Api as BaseAPI } from 'nocodb-sdk' import type { Api as BaseAPI } from 'nocodb-sdk'
import type { UseGlobalReturn } from './composables/useGlobal/types' import type { UseGlobalReturn } from './composables/useGlobal/types'
import type { NocoI18n } from './lib' import type { NocoI18n } from './lib'
import type { TabType } from './composables'
declare module '#app/nuxt' { declare module '#app/nuxt' {
interface NuxtApp { interface NuxtApp {
@ -28,4 +29,15 @@ declare module 'vue-router' {
hideHeader?: boolean hideHeader?: boolean
title?: string title?: string
} }
interface RouteParams {
projectId: string
projectType: 'base' | 'nc' | string
type: TabType
title: string
viewId: string
viewTitle: string
baseId: string
token: string
}
} }

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

@ -49,6 +49,7 @@
"@iconify-json/mi": "^1.1.2", "@iconify-json/mi": "^1.1.2",
"@iconify-json/ph": "^1.1.2", "@iconify-json/ph": "^1.1.2",
"@iconify-json/ri": "^1.1.3", "@iconify-json/ri": "^1.1.3",
"@iconify-json/system-uicons": "^1.1.4",
"@intlify/vite-plugin-vue-i18n": "^6.0.1", "@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@nuxt/image-edge": "^1.0.0-27657146.da85542", "@nuxt/image-edge": "^1.0.0-27657146.da85542",
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
@ -1151,6 +1152,15 @@
"@iconify/types": "*" "@iconify/types": "*"
} }
}, },
"node_modules/@iconify-json/system-uicons": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@iconify-json/system-uicons/-/system-uicons-1.1.4.tgz",
"integrity": "sha512-LL4gc9Fz7ZoGZzBS5fSrFnTNSRB8gXKw+sZAlIq6Msa/rRljyXoTuySIgS66dzX4QnbUC8DqXIFn5BT0/d+Cpg==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": { "node_modules/@iconify/types": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz",
@ -16055,6 +16065,15 @@
"@iconify/types": "*" "@iconify/types": "*"
} }
}, },
"@iconify-json/system-uicons": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@iconify-json/system-uicons/-/system-uicons-1.1.4.tgz",
"integrity": "sha512-LL4gc9Fz7ZoGZzBS5fSrFnTNSRB8gXKw+sZAlIq6Msa/rRljyXoTuySIgS66dzX4QnbUC8DqXIFn5BT0/d+Cpg==",
"dev": true,
"requires": {
"@iconify/types": "*"
}
},
"@iconify/types": { "@iconify/types": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz",

1
packages/nc-gui/package.json

@ -58,6 +58,7 @@
"@iconify-json/mi": "^1.1.2", "@iconify-json/mi": "^1.1.2",
"@iconify-json/ph": "^1.1.2", "@iconify-json/ph": "^1.1.2",
"@iconify-json/ri": "^1.1.3", "@iconify-json/ri": "^1.1.3",
"@iconify-json/system-uicons": "^1.1.4",
"@intlify/vite-plugin-vue-i18n": "^6.0.1", "@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@nuxt/image-edge": "^1.0.0-27657146.da85542", "@nuxt/image-edge": "^1.0.0-27657146.da85542",
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",

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

@ -5,17 +5,20 @@ import {
computed, computed,
definePageMeta, definePageMeta,
navigateTo, navigateTo,
onBeforeMount,
onBeforeUnmount,
onKeyStroke, onKeyStroke,
openLink, openLink,
projectThemeColors, projectThemeColors,
provide, provide,
provideSidebar,
ref, ref,
useClipboard, useCopy,
useGlobal, useGlobal,
useI18n, useI18n,
useProject, useProject,
useRoute, useRoute,
useRouter,
useSidebar,
useTabs, useTabs,
useUIPermission, useUIPermission,
} from '#imports' } from '#imports'
@ -29,22 +32,24 @@ const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter()
const { appInfo, token, signOut, signedIn, user } = useGlobal() const { appInfo, token, signOut, signedIn, user } = useGlobal()
const { projectLoadedHook, project, isSharedBase, loadProjectMetaInfo, projectMetaInfo, saveTheme } = useProject() const { project, isSharedBase, loadProjectMetaInfo, projectMetaInfo, saveTheme, loadProject, reset } = useProject()
const { clearTabs, addTab } = useTabs() const { clearTabs, addTab } = useTabs()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { copy } = useClipboard() const { copy } = useCopy()
const isLocked = ref(false) const isLocked = ref(false)
provide('TreeViewIsLockedInj', isLocked) provide('TreeViewIsLockedInj', isLocked)
// create a new sidebar state // create a new sidebar state
const { isOpen, toggle } = provideSidebar({ isOpen: true }) const { isOpen, toggle } = useSidebar('nc-left-sidebar', { hasSidebar: true, isOpen: true })
const dialogOpen = ref(false) const dialogOpen = ref(false)
@ -114,7 +119,7 @@ const copyProjectInfo = async () => {
// Copied to clipboard // Copied to clipboard
message.info(t('msg.info.copiedToClipboard')) message.info(t('msg.info.copiedToClipboard'))
} catch (e: any) { } catch (e: any) {
console.log(e) console.error(e)
message.error(e.message) message.error(e.message)
} }
} }
@ -125,7 +130,7 @@ const copyAuthToken = async () => {
// Copied to clipboard // Copied to clipboard
message.info(t('msg.info.copiedToClipboard')) message.info(t('msg.info.copiedToClipboard'))
} catch (e: any) { } catch (e: any) {
console.log(e) console.error(e)
message.error(e.message) message.error(e.message)
} }
} }
@ -140,11 +145,21 @@ onKeyStroke(
clearTabs() clearTabs()
projectLoadedHook(() => { onBeforeMount(async () => {
await loadProject()
if (!route.params.type && isUIAllowed('teamAndAuth')) { if (!route.params.type && isUIAllowed('teamAndAuth')) {
addTab({ type: TabType.AUTH, title: t('title.teamAndAuth') }) addTab({ type: TabType.AUTH, title: t('title.teamAndAuth') })
} }
/** If v1 url found navigate to corresponding new url */
const { type, name, view } = route.query
if (type && name) {
await router.replace(`/nc/${route.params.projectId}/${type}/${name}${view ? `/${view}` : ''}`)
}
}) })
onBeforeUnmount(reset)
</script> </script>
<template> <template>

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

@ -1,48 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TabItem } from '~/composables' import type { TabItem } from '~/composables'
import { TabType } from '~/composables' import { TabType } from '~/composables'
import { import { TabMetaInj, provide, useGlobal, useProject, useSidebar, useTabs } from '#imports'
TabMetaInj,
onBeforeMount,
provide,
ref,
useGlobal,
useProject,
useRoute,
useRouter,
useSidebar,
useTabs,
} from '#imports'
import MdiAirTableIcon from '~icons/mdi/table-large' import MdiAirTableIcon from '~icons/mdi/table-large'
import MdiView from '~icons/mdi/eye-circle-outline' import MdiView from '~icons/mdi/eye-circle-outline'
import MdiAccountGroup from '~icons/mdi/account-group' import MdiAccountGroup from '~icons/mdi/account-group'
const { project, loadProject, loadTables } = useProject() const { isLoading: isLoadingProject } = useProject()
const { tabs, activeTabIndex, activeTab, closeTab } = useTabs() const { tabs, activeTabIndex, activeTab, closeTab } = useTabs()
const { isLoading } = useGlobal() const { isLoading } = useGlobal()
const route = useRoute()
const router = useRouter()
const isReady = ref(false)
onBeforeMount(async () => {
if (!Object.keys(project.value).length) await loadProject()
/** If v1 url found navigate to corresponding new url */
const { type, name, view } = route.query
if (type && name) {
await router.replace(`/nc/${route.params.projectId}/${type}/${name}${view ? `/${view}` : ''}`)
}
await loadTables()
isReady.value = true
})
provide(TabMetaInj, activeTab) provide(TabMetaInj, activeTab)
const icon = (tab: TabItem) => { const icon = (tab: TabItem) => {
@ -56,7 +25,7 @@ const icon = (tab: TabItem) => {
} }
} }
const { isOpen, toggle } = useSidebar() const { isOpen, toggle } = useSidebar('nc-left-sidebar')
function onEdit(targetKey: number, action: 'add' | 'remove' | string) { function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
if (action === 'remove') closeTab(targetKey) if (action === 'remove') closeTab(targetKey)
@ -66,7 +35,7 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
<template> <template>
<div class="h-full w-full nc-container"> <div class="h-full w-full nc-container">
<div class="h-full w-full flex flex-col"> <div class="h-full w-full flex flex-col">
<div class="flex items-end !min-h-[var(--header-height)] !bg-primary"> <div class="flex items-end !min-h-[var(--header-height)] !bg-primary nc-tab-bar">
<div <div
v-if="!isOpen" v-if="!isOpen"
class="nc-sidebar-left-toggle-icon hover:after:(bg-primary bg-opacity-75) group nc-sidebar-add-row py-2 px-3" class="nc-sidebar-left-toggle-icon hover:after:(bg-primary bg-opacity-75) group nc-sidebar-add-row py-2 px-3"
@ -107,12 +76,16 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
<MdiLoading class="animate-infinite animate-spin" /> <MdiLoading class="animate-infinite animate-spin" />
</div> </div>
</div> </div>
<GeneralFullScreen class="nc-fullscreen-icon" />
</div> </div>
<div class="w-full min-h-[300px] flex-auto"> <div class="w-full min-h-[300px] flex-auto">
<NuxtPage v-if="isReady" /> <div v-show="!isLoadingProject" class="w-full h-full">
<NuxtPage />
</div>
<div v-else class="w-full h-full flex justify-center items-center"> <div v-show="isLoadingProject" class="w-full h-full flex justify-center items-center">
<a-spin size="large" /> <a-spin size="large" />
</div> </div>
</div> </div>
@ -171,7 +144,18 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
:deep(.ant-menu-submenu::after) { :deep(.ant-menu-submenu::after) {
@apply !border-none; @apply !border-none;
} }
:deep(.ant-tabs-tab-remove) { :deep(.ant-tabs-tab-remove) {
@apply mt-[3px]; @apply mt-[3px];
} }
.nc-tab-bar {
:deep(.nc-fullscreen-icon) {
@apply opacity-0 transition;
}
&:hover :deep(.nc-fullscreen-icon) {
@apply opacity-100;
}
}
</style> </style>

15
packages/nc-gui/pages/[projectType]/[projectId]/index/index/[type]/[title]/[[viewTitle]].vue

@ -3,7 +3,11 @@ import type { TabItem } from '~/composables'
import { TabMetaInj } from '#imports' import { TabMetaInj } from '#imports'
const { getMeta } = useMetas() const { getMeta } = useMetas()
const { project, projectLoadedHook } = useProject()
const route = useRoute() const route = useRoute()
const loading = ref(true) const loading = ref(true)
const activeTab = inject( const activeTab = inject(
@ -11,9 +15,14 @@ const activeTab = inject(
computed(() => ({} as TabItem)), computed(() => ({} as TabItem)),
) )
getMeta(route.params.title as string, true).finally(() => { if (!project.value.id) {
loading.value = false projectLoadedHook(async () => {
}) await getMeta(route.params.title as string, true)
loading.value = false
})
} else {
getMeta(route.params.title as string, true).finally(() => (loading.value = false))
}
</script> </script>
<template> <template>

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

@ -18,7 +18,7 @@ definePageMeta({
public: true, public: true,
}) })
useSidebar({ hasSidebar: false }) useSidebar('nc-left-sidebar', { hasSidebar: false })
const route = useRoute() const route = useRoute()

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

@ -17,7 +17,7 @@ import {
const { isLoading } = useApi() const { isLoading } = useApi()
useSidebar({ hasSidebar: false }) useSidebar('nc-left-sidebar', { hasSidebar: false })
const route = useRoute() const route = useRoute()

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

@ -37,7 +37,7 @@ const { api, isLoading } = useApi()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
useSidebar({ hasSidebar: false }) useSidebar('nc-left-sidebar', { hasSidebar: false })
const { t } = useI18n() const { t } = useI18n()

2
packages/nc-gui/pages/index/index/create.vue

@ -18,7 +18,7 @@ const { $e } = useNuxtApp()
const { api, isLoading } = useApi() const { api, isLoading } = useApi()
useSidebar({ hasSidebar: false }) useSidebar('nc-left-sidebar', { hasSidebar: false })
const nameValidationRules = [ const nameValidationRules = [
{ {

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

@ -9,6 +9,7 @@ import {
navigateTo, navigateTo,
projectThemeColors, projectThemeColors,
ref, ref,
themeV2Colors,
useApi, useApi,
useNuxtApp, useNuxtApp,
useSidebar, useSidebar,
@ -25,7 +26,7 @@ const { api, isLoading } = useApi()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
useSidebar({ hasSidebar: true, isOpen: true }) useSidebar('nc-left-sidebar', { hasSidebar: false, isOpen: true })
const filterQuery = ref('') const filterQuery = ref('')
@ -70,10 +71,14 @@ await loadProjects()
const handleProjectColor = async (projectId: string, color: string) => { const handleProjectColor = async (projectId: string, color: string) => {
const tcolor = tinycolor(color) const tcolor = tinycolor(color)
if (tcolor.isValid()) { if (tcolor.isValid()) {
const complement = tcolor.complement() const complement = tcolor.complement()
const project: ProjectType = await $api.project.read(projectId) const project: ProjectType = await $api.project.read(projectId)
const meta = project?.meta && typeof project.meta === 'string' ? JSON.parse(project.meta) : project.meta || {} const meta = project?.meta && typeof project.meta === 'string' ? JSON.parse(project.meta) : project.meta || {}
await $api.project.update(projectId, { await $api.project.update(projectId, {
color, color,
meta: JSON.stringify({ meta: JSON.stringify({
@ -84,8 +89,10 @@ const handleProjectColor = async (projectId: string, color: string) => {
}, },
}), }),
}) })
// Update local project // Update local project
const localProject = projects.value?.find((p) => p.id === projectId) const localProject = projects.value?.find((p) => p.id === projectId)
if (localProject) { if (localProject) {
localProject.color = color localProject.color = color
localProject.meta = JSON.stringify({ localProject.meta = JSON.stringify({
@ -100,9 +107,21 @@ const handleProjectColor = async (projectId: string, color: string) => {
} }
const getProjectPrimary = (project: ProjectType) => { const getProjectPrimary = (project: ProjectType) => {
const meta = project?.meta && typeof project.meta === 'string' ? JSON.parse(project.meta) : project.meta || {} if (!project) return
return meta?.theme?.primaryColor || themeV2Colors['royal-blue'].DEFAULT
const meta = project.meta && typeof project.meta === 'string' ? JSON.parse(project.meta) : project.meta || {}
return meta.theme?.primaryColor || themeV2Colors['royal-blue'].DEFAULT
} }
const customRow = (record: ProjectType) => ({
onClick: async () => {
await navigateTo(`/nc/${record.id}`)
$e('a:project:open')
},
class: ['group'],
})
</script> </script>
<template> <template>
@ -183,15 +202,7 @@ const getProjectPrimary = (project: ProjectType) => {
<a-table <a-table
v-else v-else
key="table" key="table"
:custom-row=" :custom-row="customRow"
(record) => ({
onClick: () => {
navigateTo(`/nc/${record.id}`)
$e('a:project:open')
},
class: ['group'],
})
"
:data-source="filteredProjects" :data-source="filteredProjects"
:pagination="{ position: ['bottomCenter'] }" :pagination="{ position: ['bottomCenter'] }"
> >

2
packages/nc-gui/pages/signin.vue

@ -19,7 +19,7 @@ const { api, isLoading } = useApi()
const { t } = useI18n() const { t } = useI18n()
useSidebar({ hasSidebar: false }) useSidebar('nc-left-sidebar', { hasSidebar: false })
definePageMeta({ definePageMeta({
requiresAuth: false, requiresAuth: false,

36
packages/nc-gui/utils/memStorage.ts

@ -0,0 +1,36 @@
/**
* Stores all currently created store instances
*/
export class MemStorage<T = any> {
public currentId = 0
public items = new Map<string, T>()
static instance: MemStorage
public static getInstance(): MemStorage {
if (!MemStorage.instance) {
MemStorage.instance = new MemStorage()
}
return MemStorage.instance
}
public set(id: string, item: T) {
return this.items.set(id, item)
}
public get(id: string) {
return this.items.get(id)
}
public has(id: string) {
return this.items.has(id)
}
public remove(id: string) {
return this.items.delete(id)
}
public getId(prefix?: string) {
return `${prefix}${this.currentId++}`
}
}

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

@ -538,6 +538,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/nyc/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/table/node_modules/ansi-styles": { "node_modules/table/node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"dev": true, "dev": true,
@ -1565,6 +1578,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/gh-pages/node_modules/array-union": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
"integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==",
"dev": true,
"dependencies": {
"array-uniq": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/escape-goat": { "node_modules/escape-goat": {
"version": "2.1.1", "version": "2.1.1",
"dev": true, "dev": true,
@ -1786,6 +1811,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/nyc/node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/npm-run-all/node_modules/path-key": { "node_modules/npm-run-all/node_modules/path-key": {
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
@ -2106,6 +2140,15 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/gh-pages/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "3.0.7", "version": "3.0.7",
"dev": true, "dev": true,
@ -2539,6 +2582,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/typedoc/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/trim-repeated": { "node_modules/trim-repeated": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
@ -2970,6 +3022,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/git-semver-tags/node_modules/type-fest": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz",
"integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-indent": { "node_modules/strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"dev": true, "dev": true,
@ -4094,6 +4158,15 @@
"node": ">=0.8.0" "node": ">=0.8.0"
} }
}, },
"node_modules/inquirer/node_modules/is-fullwidth-code-point": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/dotgitignore/node_modules/path-exists": { "node_modules/dotgitignore/node_modules/path-exists": {
"version": "3.0.0", "version": "3.0.0",
"dev": true, "dev": true,
@ -5144,6 +5217,15 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/array-uniq": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
"integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.1.1", "version": "3.1.1",
"dev": true, "dev": true,
@ -5160,6 +5242,21 @@
"node": ">=4.0.0" "node": ">=4.0.0"
} }
}, },
"node_modules/standard-version/node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-module-utils/node_modules/p-limit": { "node_modules/eslint-module-utils/node_modules/p-limit": {
"version": "1.3.0", "version": "1.3.0",
"dev": true, "dev": true,
@ -5596,6 +5693,15 @@
"node": "^10.12.0 || >=12.0.0" "node": "^10.12.0 || >=12.0.0"
} }
}, },
"node_modules/standard-version/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"dev": true, "dev": true,
@ -7101,6 +7207,18 @@
"node": ">=4.8" "node": ">=4.8"
} }
}, },
"node_modules/standard-version/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/spawn-wrap": { "node_modules/spawn-wrap": {
"version": "2.0.0", "version": "2.0.0",
"dev": true, "dev": true,
@ -7133,6 +7251,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
@ -7210,6 +7340,21 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/standard-version/node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@nodelib/fs.walk": { "node_modules/@nodelib/fs.walk": {
"version": "1.2.8", "version": "1.2.8",
"dev": true, "dev": true,
@ -7463,6 +7608,21 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/standard-version/node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/detect-newline": { "node_modules/detect-newline": {
"version": "3.1.0", "version": "3.1.0",
"dev": true, "dev": true,
@ -8451,6 +8611,15 @@
"ini": "^1.3.2" "ini": "^1.3.2"
} }
}, },
"node_modules/standard-version/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/semver-diff": { "node_modules/semver-diff": {
"version": "3.1.1", "version": "3.1.1",
"dev": true, "dev": true,
@ -10468,6 +10637,12 @@
"version": "2.1.0", "version": "2.1.0",
"dev": true "dev": true
}, },
"array-uniq": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
"integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==",
"dev": true
},
"array.prototype.flat": { "array.prototype.flat": {
"version": "1.2.5", "version": "1.2.5",
"dev": true, "dev": true,
@ -12614,6 +12789,15 @@
"globby": "^6.1.0" "globby": "^6.1.0"
}, },
"dependencies": { "dependencies": {
"array-union": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
"integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==",
"dev": true,
"requires": {
"array-uniq": "^1.0.1"
}
},
"commander": { "commander": {
"version": "2.20.3", "version": "2.20.3",
"dev": true "dev": true
@ -12641,6 +12825,12 @@
"pify": { "pify": {
"version": "2.3.0", "version": "2.3.0",
"dev": true "dev": true
},
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true
} }
} }
}, },
@ -12737,6 +12927,12 @@
"semver": { "semver": {
"version": "6.3.0", "version": "6.3.0",
"dev": true "dev": true
},
"type-fest": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz",
"integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==",
"dev": true
} }
} }
}, },
@ -13104,6 +13300,12 @@
"version": "3.0.0", "version": "3.0.0",
"dev": true "dev": true
}, },
"is-fullwidth-code-point": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==",
"dev": true
},
"restore-cursor": { "restore-cursor": {
"version": "2.0.0", "version": "2.0.0",
"dev": true, "dev": true,
@ -14064,6 +14266,12 @@
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
} }
}, },
"camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true
},
"cliui": { "cliui": {
"version": "6.0.0", "version": "6.0.0",
"dev": true, "dev": true,
@ -14105,6 +14313,16 @@
"y18n": "^4.0.0", "y18n": "^4.0.0",
"yargs-parser": "^18.1.2" "yargs-parser": "^18.1.2"
} }
},
"yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"requires": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
}
} }
} }
}, },
@ -15042,6 +15260,12 @@
"supports-color": "^5.3.0" "supports-color": "^5.3.0"
} }
}, },
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true
},
"find-up": { "find-up": {
"version": "5.0.0", "version": "5.0.0",
"dev": true, "dev": true,
@ -15049,6 +15273,48 @@
"locate-path": "^6.0.0", "locate-path": "^6.0.0",
"path-exists": "^4.0.0" "path-exists": "^4.0.0"
} }
},
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true
},
"locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"requires": {
"p-locate": "^5.0.0"
}
},
"p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"requires": {
"yocto-queue": "^0.1.0"
}
},
"p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"requires": {
"p-limit": "^3.0.2"
}
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"requires": {
"has-flag": "^3.0.0"
}
} }
} }
}, },
@ -15528,6 +15794,15 @@
"shiki": "^0.10.1" "shiki": "^0.10.1"
}, },
"dependencies": { "dependencies": {
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"
}
},
"glob": { "glob": {
"version": "8.0.3", "version": "8.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.0.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-8.0.3.tgz",
@ -15804,6 +16079,12 @@
"yn": { "yn": {
"version": "3.1.1", "version": "3.1.1",
"dev": true "dev": true
},
"yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true
} }
} }
} }

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

@ -401,6 +401,7 @@ export interface FormType {
columns?: FormColumnType[]; columns?: FormColumnType[];
fk_model_id?: string; fk_model_id?: string;
lock_type?: 'collaborative' | 'locked' | 'personal'; lock_type?: 'collaborative' | 'locked' | 'personal';
meta?: any;
} }
export interface FormColumnType { export interface FormColumnType {
@ -416,6 +417,7 @@ export interface FormColumnType {
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
description?: string; description?: string;
meta?: any;
} }
export interface PaginatedType { export interface PaginatedType {

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

@ -39,6 +39,11 @@ export enum AuditOperationTypes {
export enum AuditOperationSubTypes { export enum AuditOperationSubTypes {
UPDATE = 'UPDATE', UPDATE = 'UPDATE',
INSERT = 'INSERT', INSERT = 'INSERT',
BULK_INSERT = 'BULK_INSERT',
BULK_UPDATE = 'BULK_UPDATE',
BULK_DELETE = 'BULK_DELETE',
LINK_RECORD = 'LINK_RECORD',
UNLINK_RECORD = 'UNLINK_RECORD',
DELETE = 'DELETE', DELETE = 'DELETE',
CREATED = 'CREATED', CREATED = 'CREATED',
DELETED = 'DELETED', DELETED = 'DELETED',

2
packages/nocodb/.gitignore vendored

@ -16,3 +16,5 @@ xc.db*
noco.db* noco.db*
/nc/ /nc/
/docker/main.js /docker/main.js
test_meta.db
test_sakila.db

5
packages/nocodb/package.json

@ -21,11 +21,10 @@
"local:test:graphql": "cross-env DATABASE_URL=mysql://root:password@localhost:3306/sakila TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/graphql.test.ts --recursive --timeout 10000 --exit", "local:test:graphql": "cross-env DATABASE_URL=mysql://root:password@localhost:3306/sakila TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/graphql.test.ts --recursive --timeout 10000 --exit",
"test:graphql": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/graphql.test.ts --recursive --timeout 10000 --exit", "test:graphql": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/graphql.test.ts --recursive --timeout 10000 --exit",
"test:grpc": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/grpc.test.ts --recursive --timeout 10000 --exit", "test:grpc": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/grpc.test.ts --recursive --timeout 10000 --exit",
"local:test:rest": "cross-env DATABASE_URL=mysql://root:password@localhost:3306/sakila TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/rest.test.ts --recursive --timeout 10000 --exit", "local:test:unit": "cross-env TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/index.test.ts --recursive --timeout 300000 --exit --delay",
"test:rest": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/rest.test.ts --recursive --timeout 10000 --exit", "test:unit": "cross-env TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/index.test.ts --recursive --timeout 300000 --exit --delay",
"test1": "run-s build test:*", "test1": "run-s build test:*",
"test:lint": "tslint --project . && prettier \"src/**/*.ts\" --list-different", "test:lint": "tslint --project . && prettier \"src/**/*.ts\" --list-different",
"test:unit": "nyc --silent ava",
"watch": "run-s clean build:main && run-p \"build:main -- -w\" \"test:unit -- --watch\"", "watch": "run-s clean build:main && run-p \"build:main -- -w\" \"test:unit -- --watch\"",
"cov": "run-s build test:unit cov:html && open-cli coverage/index.html", "cov": "run-s build test:unit cov:html && open-cli coverage/index.html",
"cov:html": "nyc report --reporter=html", "cov:html": "nyc report --reporter=html",

70
packages/nocodb/src/__tests__/tsconfig.json

@ -0,0 +1,70 @@
{
"compilerOptions": {
"skipLibCheck": true,
"composite": true,
"target": "es2017",
"outDir": "build/main",
"rootDir": "src",
"moduleResolution": "node",
"module": "commonjs",
"declaration": true,
"inlineSourceMap": true,
"esModuleInterop": true
/* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
"allowJs": false,
// "strict": true /* Enable all strict type-checking options. */,
/* Strict Type-Checking Options */
// "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
// "strictNullChecks": true /* Enable strict null checks. */,
// "strictFunctionTypes": true /* Enable strict checking of function types. */,
// "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
// "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
// "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
"resolveJsonModule": true,
/* Additional Checks */
"noUnusedLocals": false
/* Report errors on unused locals. */,
"noUnusedParameters": false
/* Report errors on unused parameters. */,
"noImplicitReturns": false
/* Report error when not all code paths in function return a value. */,
"noFallthroughCasesInSwitch": false
/* Report errors for fallthrough cases in switch statement. */,
/* Debugging Options */
"traceResolution": false
/* Report module resolution log messages. */,
"listEmittedFiles": false
/* Print names of generated files part of the compilation. */,
"listFiles": false
/* Print names of files part of the compilation. */,
"pretty": true
/* Stylize errors and messages using color and context. */,
/* Experimental Options */
// "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
// "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
"lib": [
"es2017"
],
"types": [
"mocha", "node"
],
"typeRoots": [
"node_modules/@types",
"src/types"
]
},
"include": [
"src/**/*.ts",
// "src/lib/xgene/migrations/*.js",
"src/**/*.json"
],
"exclude": [
"node_modules/**",
"node_modules",
"../../../xc-lib-private/**",
"../../../xc-lib-private"
],
"compileOnSave": false
}

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

@ -116,6 +116,7 @@ class BaseModelSqlv2 {
return !!(await qb.where(_wherePk(pks, id)).first()); return !!(await qb.where(_wherePk(pks, id)).first());
} }
// todo: add support for sortArrJson
public async findOne( public async findOne(
args: { args: {
where?: string; where?: string;
@ -143,7 +144,6 @@ class BaseModelSqlv2 {
is_group: true, is_group: true,
logical_op: 'and', logical_op: 'and',
}), }),
...(args.filterArr || []),
], ],
qb, qb,
this.dbDriver this.dbDriver
@ -205,7 +205,6 @@ class BaseModelSqlv2 {
is_group: true, is_group: true,
logical_op: 'and', logical_op: 'and',
}), }),
...(args.filterArr || []),
], ],
qb, qb,
this.dbDriver this.dbDriver
@ -230,7 +229,6 @@ class BaseModelSqlv2 {
is_group: true, is_group: true,
logical_op: 'and', logical_op: 'and',
}), }),
...(args.filterArr || []),
], ],
qb, qb,
this.dbDriver this.dbDriver
@ -322,6 +320,7 @@ class BaseModelSqlv2 {
return (this.isPg ? res.rows[0] : res[0][0] ?? res[0]).count; return (this.isPg ? res.rows[0] : res[0][0] ?? res[0]).count;
} }
// todo: add support for sortArrJson and filterArrJson
async groupBy( async groupBy(
args: { args: {
where?: string; where?: string;
@ -1667,8 +1666,10 @@ class BaseModelSqlv2 {
datas: any[], datas: any[],
{ {
chunkSize: _chunkSize = 100, chunkSize: _chunkSize = 100,
cookie,
}: { }: {
chunkSize?: number; chunkSize?: number;
cookie?: any;
} = {} } = {}
) { ) {
try { try {
@ -1699,7 +1700,7 @@ class BaseModelSqlv2 {
.batchInsert(this.model.table_name, insertDatas, chunkSize) .batchInsert(this.model.table_name, insertDatas, chunkSize)
.returning(this.model.primaryKey?.column_name); .returning(this.model.primaryKey?.column_name);
// await this.afterInsertb(insertDatas, null); await this.afterBulkInsert(insertDatas, this.dbDriver, cookie);
return response; return response;
} catch (e) { } catch (e) {
@ -1708,7 +1709,7 @@ class BaseModelSqlv2 {
} }
} }
async bulkUpdate(datas: any[]) { async bulkUpdate(datas: any[], { cookie }: { cookie?: any } = {}) {
let transaction; let transaction;
try { try {
const updateDatas = await Promise.all( const updateDatas = await Promise.all(
@ -1733,7 +1734,7 @@ class BaseModelSqlv2 {
res.push(response); res.push(response);
} }
// await this.afterUpdateb(res, transaction); await this.afterBulkUpdate(updateDatas.length, this.dbDriver, cookie);
transaction.commit(); transaction.commit();
return res; return res;
@ -1747,13 +1748,14 @@ class BaseModelSqlv2 {
async bulkUpdateAll( async bulkUpdateAll(
args: { where?: string; filterArr?: Filter[] } = {}, args: { where?: string; filterArr?: Filter[] } = {},
data data,
{ cookie }: { cookie?: any } = {}
) { ) {
let queryResponse;
try { try {
const updateData = await this.model.mapAliasToColumn(data); const updateData = await this.model.mapAliasToColumn(data);
await this.validate(updateData); await this.validate(updateData);
const pkValues = await this._extractPksValues(updateData); const pkValues = await this._extractPksValues(updateData);
let res = null;
if (pkValues) { if (pkValues) {
// pk is specified - by pass // pk is specified - by pass
} else { } else {
@ -1775,21 +1777,25 @@ class BaseModelSqlv2 {
is_group: true, is_group: true,
logical_op: 'and', logical_op: 'and',
}), }),
...(args.filterArr || []),
], ],
qb, qb,
this.dbDriver this.dbDriver
); );
qb.update(updateData); qb.update(updateData);
res = ((await qb) as any).count; queryResponse = (await qb) as any;
} }
return res;
const count = queryResponse ?? 0;
await this.afterBulkUpdate(count, this.dbDriver, cookie);
return count;
} catch (e) { } catch (e) {
throw e; throw e;
} }
} }
async bulkDelete(ids: any[]) { async bulkDelete(ids: any[], { cookie }: { cookie?: any } = {}) {
let transaction; let transaction;
try { try {
transaction = await this.dbDriver.transaction(); transaction = await this.dbDriver.transaction();
@ -1808,6 +1814,8 @@ class BaseModelSqlv2 {
transaction.commit(); transaction.commit();
await this.afterBulkDelete(ids.length, this.dbDriver, cookie);
return res; return res;
} catch (e) { } catch (e) {
if (transaction) transaction.rollback(); if (transaction) transaction.rollback();
@ -1817,7 +1825,10 @@ class BaseModelSqlv2 {
} }
} }
async bulkDeleteAll(args: { where?: string; filterArr?: Filter[] } = {}) { async bulkDeleteAll(
args: { where?: string; filterArr?: Filter[] } = {},
{ cookie }: { cookie?: any } = {}
) {
try { try {
await this.model.getColumns(); await this.model.getColumns();
const { where } = this._getListArgs(args); const { where } = this._getListArgs(args);
@ -1837,13 +1848,16 @@ class BaseModelSqlv2 {
is_group: true, is_group: true,
logical_op: 'and', logical_op: 'and',
}), }),
...(args.filterArr || []),
], ],
qb, qb,
this.dbDriver this.dbDriver
); );
qb.del(); qb.del();
return ((await qb) as any).count; const count = (await qb) as any;
await this.afterBulkDelete(count, this.dbDriver, cookie);
return count;
} catch (e) { } catch (e) {
throw e; throw e;
} }
@ -1861,7 +1875,7 @@ class BaseModelSqlv2 {
await this.handleHooks('After.insert', data, req); await this.handleHooks('After.insert', data, req);
// if (req?.headers?.['xc-gui']) { // if (req?.headers?.['xc-gui']) {
const id = this._extractPksValues(data); const id = this._extractPksValues(data);
Audit.insert({ await Audit.insert({
fk_model_id: this.model.id, fk_model_id: this.model.id,
row_id: id, row_id: id,
op_type: AuditOperationTypes.DATA, op_type: AuditOperationTypes.DATA,
@ -1876,6 +1890,48 @@ class BaseModelSqlv2 {
// } // }
} }
public async afterBulkUpdate(count: number, _trx: any, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.BULK_UPDATE,
description: DOMPurify.sanitize(
`${count} records bulk updated in ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
}
public async afterBulkDelete(count: number, _trx: any, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.BULK_DELETE,
description: DOMPurify.sanitize(
`${count} records bulk deleted in ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
}
public async afterBulkInsert(data: any[], _trx: any, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.BULK_INSERT,
description: DOMPurify.sanitize(
`${data.length} records bulk inserted into ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
}
public async beforeUpdate(data: any, _trx: any, req): Promise<void> { public async beforeUpdate(data: any, _trx: any, req): Promise<void> {
const ignoreWebhook = req.query?.ignoreWebhook; const ignoreWebhook = req.query?.ignoreWebhook;
if (ignoreWebhook) { if (ignoreWebhook) {
@ -1889,6 +1945,18 @@ class BaseModelSqlv2 {
} }
public async afterUpdate(data: any, _trx: any, req): Promise<void> { public async afterUpdate(data: any, _trx: any, req): Promise<void> {
const id = this._extractPksValues(data);
Audit.insert({
fk_model_id: this.model.id,
row_id: id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.UPDATE,
description: DOMPurify.sanitize(`${id} updated in ${this.model.title}`),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
const ignoreWebhook = req.query?.ignoreWebhook; const ignoreWebhook = req.query?.ignoreWebhook;
if (ignoreWebhook) { if (ignoreWebhook) {
if (ignoreWebhook != 'true' && ignoreWebhook != 'false') { if (ignoreWebhook != 'true' && ignoreWebhook != 'false') {
@ -1907,7 +1975,7 @@ class BaseModelSqlv2 {
public async afterDelete(data: any, _trx: any, req): Promise<void> { public async afterDelete(data: any, _trx: any, req): Promise<void> {
// if (req?.headers?.['xc-gui']) { // if (req?.headers?.['xc-gui']) {
const id = req?.params?.id; const id = req?.params?.id;
Audit.insert({ await Audit.insert({
fk_model_id: this.model.id, fk_model_id: this.model.id,
row_id: id, row_id: id,
op_type: AuditOperationTypes.DATA, op_type: AuditOperationTypes.DATA,
@ -2070,10 +2138,12 @@ class BaseModelSqlv2 {
colId, colId,
rowId, rowId,
childId, childId,
cookie,
}: { }: {
colId: string; colId: string;
rowId: string; rowId: string;
childId: string; childId: string;
cookie?: any;
}) { }) {
const columns = await this.model.getColumns(); const columns = await this.model.getColumns();
const column = columns.find((c) => c.id === colId); const column = columns.find((c) => c.id === colId);
@ -2140,16 +2210,35 @@ class BaseModelSqlv2 {
} }
break; break;
} }
await this.afterAddChild(rowId, childId, cookie);
}
public async afterAddChild(rowId, childId, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.LINK_RECORD,
row_id: rowId,
description: DOMPurify.sanitize(
`Record [id:${childId}] record linked with record [id:${rowId}] record in ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
} }
async removeChild({ async removeChild({
colId, colId,
rowId, rowId,
childId, childId,
cookie,
}: { }: {
colId: string; colId: string;
rowId: string; rowId: string;
childId: string; childId: string;
cookie?: any;
}) { }) {
const columns = await this.model.getColumns(); const columns = await this.model.getColumns();
const column = columns.find((c) => c.id === colId); const column = columns.find((c) => c.id === colId);
@ -2214,6 +2303,23 @@ class BaseModelSqlv2 {
} }
break; break;
} }
await this.afterRemoveChild(rowId, childId, cookie);
}
public async afterRemoveChild(rowId, childId, req): Promise<void> {
await Audit.insert({
fk_model_id: this.model.id,
op_type: AuditOperationTypes.DATA,
op_sub_type: AuditOperationSubTypes.UNLINK_RECORD,
row_id: rowId,
description: DOMPurify.sanitize(
`Record [id:${childId}] record unlinked with record [id:${rowId}] record in ${this.model.title}`
),
// details: JSON.stringify(data),
ip: req?.clientIp,
user: req?.user?.email,
});
} }
private async extractRawQueryAndExec(qb: QueryBuilder) { private async extractRawQueryAndExec(qb: QueryBuilder) {
@ -2225,7 +2331,7 @@ class BaseModelSqlv2 {
} }
return this.isPg return this.isPg
? (await this.dbDriver.raw(query))?.rows ? (await this.dbDriver.raw(query))?.rows
: query.slice(0, 6) === 'select' : query.slice(0, 6) === 'select' && !this.isMssql
? await this.dbDriver.from( ? await this.dbDriver.from(
this.dbDriver.raw(query).wrap('(', ') __nc_alias') this.dbDriver.raw(query).wrap('(', ') __nc_alias')
) )

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

@ -513,7 +513,10 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
if ([UITypes.SingleSelect, UITypes.MultiSelect].includes(colBody.uidt)) { if ([UITypes.SingleSelect, UITypes.MultiSelect].includes(colBody.uidt)) {
const dbDriver = NcConnectionMgrv2.get(base); const dbDriver = NcConnectionMgrv2.get(base);
const driverType = dbDriver.clientType(); const driverType = dbDriver.clientType();
const optionTitles = colBody.colOptions.options.map(el => el.title); const optionTitles = colBody.colOptions.options.map(el => el.title.replace(/'/g, "''"));
// this is not used for select columns and cause issue for MySQL
colBody.dtxs = '';
// Handle default values // Handle default values
if (colBody.cdf) { if (colBody.cdf) {
if (colBody.uidt === UITypes.SingleSelect) { if (colBody.uidt === UITypes.SingleSelect) {
@ -551,13 +554,6 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
NcError.badRequest('Empty options are not allowed!'); NcError.badRequest('Empty options are not allowed!');
} }
// Handle empty enum/set for mysql (we restrict empty user options beforehand)
if (driverType === 'mysql' || driverType === 'mysql2') {
if (!colBody.colOptions.options.length && (!colBody.dtxp || colBody.dtxp === '')) {
colBody.colOptions.options.push({ title: '' });
}
}
// Trim end of enum/set // Trim end of enum/set
if (colBody.dt === 'enum' || colBody.dt === 'set') { if (colBody.dt === 'enum' || colBody.dt === 'set') {
for (const opt of colBody.colOptions.options) { for (const opt of colBody.colOptions.options) {
@ -579,6 +575,13 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
}).join(',')}` }).join(',')}`
: ''; : '';
} }
// Handle empty enum/set for mysql (we restrict empty user options beforehand)
if (driverType === 'mysql' || driverType === 'mysql2') {
if (!colBody.colOptions.options.length && (!colBody.dtxp || colBody.dtxp === '')) {
colBody.dtxp = '\'\'';
}
}
} }
@ -732,7 +735,7 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
dbDriver: NcConnectionMgrv2.get(base) dbDriver: NcConnectionMgrv2.get(base)
}); });
if (column.colOptions?.options) { if (colBody.colOptions?.options) {
const supportedDrivers = ['mysql', 'mysql2', 'pg', 'mssql', 'sqlite3']; const supportedDrivers = ['mysql', 'mysql2', 'pg', 'mssql', 'sqlite3'];
const dbDriver = NcConnectionMgrv2.get(base); const dbDriver = NcConnectionMgrv2.get(base);
const driverType = dbDriver.clientType(); const driverType = dbDriver.clientType();
@ -751,12 +754,14 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
} }
// Handle migrations // Handle migrations
for (const op of column.colOptions.options.filter(el => el.order === null)) { if (column.colOptions?.options) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '') for (const op of column.colOptions.options.filter(el => el.order === null)) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '')
}
} }
// Handle default values // Handle default values
const optionTitles = colBody.colOptions.options.map(el => el.title); const optionTitles = colBody.colOptions.options.map(el => el.title.replace(/'/g, "''"));
if (colBody.cdf) { if (colBody.cdf) {
if (colBody.uidt === UITypes.SingleSelect) { if (colBody.uidt === UITypes.SingleSelect) {
if (!optionTitles.includes(colBody.cdf)) { if (!optionTitles.includes(colBody.cdf)) {
@ -794,13 +799,6 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
NcError.badRequest('Empty options are not allowed!'); NcError.badRequest('Empty options are not allowed!');
} }
// Handle empty enum/set for mysql (we restrict empty user options beforehand)
if (driverType === 'mysql' || driverType === 'mysql2') {
if (!colBody.colOptions.options.length && (!colBody.dtxp || colBody.dtxp === '')) {
colBody.dtxp = '\'\'';
}
}
// Trim end of enum/set // Trim end of enum/set
if (colBody.dt === 'enum' || colBody.dt === 'set') { if (colBody.dt === 'enum' || colBody.dt === 'set') {
for (const opt of colBody.colOptions.options) { for (const opt of colBody.colOptions.options) {
@ -823,26 +821,35 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
: ''; : '';
} }
// Handle option delete // Handle empty enum/set for mysql (we restrict empty user options beforehand)
for (const option of column.colOptions.options.filter(oldOp => colBody.colOptions.options.find(newOp => newOp.id === oldOp.id) ? false : true)) { if (driverType === 'mysql' || driverType === 'mysql2') {
if (!supportedDrivers.includes(driverType) && column.uidt === UITypes.MultiSelect) { if (!colBody.colOptions.options.length && (!colBody.dtxp || colBody.dtxp === '')) {
NcError.badRequest('Your database not yet supported for this operation. Please remove option from records manually before dropping.'); colBody.dtxp = '\'\'';
} }
if (column.uidt === UITypes.SingleSelect) { }
if (driverType === 'mssql') {
await dbDriver.raw(`UPDATE ?? SET ?? = NULL WHERE ?? LIKE ?`, [table.table_name, column.column_name, column.column_name, option.title]); // Handle option delete
} else { if (column.colOptions?.options) {
await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${option.title})` }, { [column.column_name]: null }); for (const option of column.colOptions.options.filter(oldOp => colBody.colOptions.options.find(newOp => newOp.id === oldOp.id) ? false : true)) {
if (!supportedDrivers.includes(driverType) && column.uidt === UITypes.MultiSelect) {
NcError.badRequest('Your database not yet supported for this operation. Please remove option from records manually before dropping.');
} }
} else if (column.uidt === UITypes.MultiSelect) { if (column.uidt === UITypes.SingleSelect) {
if (driverType === 'mysql' || driverType === 'mysql2') { if (driverType === 'mssql') {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', ??, ','), CONCAT(',', ?, ','), ',')) WHERE FIND_IN_SET(?, ??)`, [table.table_name, column.column_name, column.column_name, option.title, option.title, column.column_name]); await dbDriver.raw(`UPDATE ?? SET ?? = NULL WHERE ?? LIKE ?`, [table.table_name, column.column_name, column.column_name, option.title]);
} else if (driverType === 'pg') { } else {
await dbDriver.raw(`UPDATE ?? SET ?? = array_to_string(array_remove(string_to_array(??, ','), ?), ',')`, [table.table_name, column.column_name, column.column_name, option.title]); await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${option.title})` }, { [column.column_name]: null }, { cookie: req});
} else if (driverType === 'mssql') { }
await dbDriver.raw(`UPDATE ?? SET ?? = substring(replace(concat(',', ??, ','), concat(',', ?, ','), ','), 2, len(replace(concat(',', ??, ','), concat(',', ?, ','), ',')) - 2)`, [table.table_name, column.column_name, column.column_name, option.title, column.column_name, option.title]); } else if (column.uidt === UITypes.MultiSelect) {
} else if (driverType === 'sqlite3') { if (driverType === 'mysql' || driverType === 'mysql2') {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(REPLACE(',' || ?? || ',', ',' || ? || ',', ','), ',')`, [table.table_name, column.column_name, column.column_name, option.title]); await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', ??, ','), CONCAT(',', ?, ','), ',')) WHERE FIND_IN_SET(?, ??)`, [table.table_name, column.column_name, column.column_name, option.title, option.title, column.column_name]);
} else if (driverType === 'pg') {
await dbDriver.raw(`UPDATE ?? SET ?? = array_to_string(array_remove(string_to_array(??, ','), ?), ',')`, [table.table_name, column.column_name, column.column_name, option.title]);
} else if (driverType === 'mssql') {
await dbDriver.raw(`UPDATE ?? SET ?? = substring(replace(concat(',', ??, ','), concat(',', ?, ','), ','), 2, len(replace(concat(',', ??, ','), concat(',', ?, ','), ',')) - 2)`, [table.table_name, column.column_name, column.column_name, option.title, column.column_name, option.title]);
} else if (driverType === 'sqlite3') {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(REPLACE(',' || ?? || ',', ',' || ? || ',', ','), ',')`, [table.table_name, column.column_name, column.column_name, option.title]);
}
} }
} }
} }
@ -850,97 +857,99 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
let interchange = []; let interchange = [];
// Handle option update // Handle option update
const old_titles = column.colOptions.options.map(el => el.title); if (column.colOptions?.options) {
for (const option of column.colOptions.options.filter(oldOp => colBody.colOptions.options.find(newOp => newOp.id === oldOp.id && newOp.title !== oldOp.title))) { const old_titles = column.colOptions.options.map(el => el.title);
if (!supportedDrivers.includes(driverType) && column.uidt === UITypes.MultiSelect) { for (const option of column.colOptions.options.filter(oldOp => colBody.colOptions.options.find(newOp => newOp.id === oldOp.id && newOp.title !== oldOp.title))) {
NcError.badRequest('Your database not yet supported for this operation. Please remove option from records manually before updating.'); if (!supportedDrivers.includes(driverType) && column.uidt === UITypes.MultiSelect) {
} NcError.badRequest('Your database not yet supported for this operation. Please remove option from records manually before updating.');
let newOp = { ...colBody.colOptions.options.find(el => option.id === el.id) };
if (old_titles.includes(newOp.title)) {
let def_option = { ...newOp };
let title_counter = 1;
while (old_titles.includes(newOp.title)) {
newOp.title = `${def_option.title}_${title_counter++}`;
} }
interchange.push( {
def_option,
temp_title: newOp.title
} );
}
// Append new option before editing
if ((driverType === 'mysql' || driverType === 'mysql2') && (column.dt === 'enum' || column.dt === 'set')) {
column.colOptions.options.push({ title: newOp.title });
let temp_dtxp = ''; let newOp = { ...colBody.colOptions.options.find(el => option.id === el.id) };
if (column.uidt === UITypes.SingleSelect) { if (old_titles.includes(newOp.title)) {
temp_dtxp = (column.colOptions?.options.length) let def_option = { ...newOp };
? `${column.colOptions.options.map(o => `'${o.title.replace(/'/gi, '\'\'')}'`).join(',')}` let title_counter = 1;
: ''; while (old_titles.includes(newOp.title)) {
} else if (column.uidt === UITypes.MultiSelect){ newOp.title = `${def_option.title}_${title_counter++}`;
temp_dtxp = (column.colOptions?.options.length) }
? `${column.colOptions.options.map((o) => { interchange.push( {
if(o.title.includes(',')) { def_option,
NcError.badRequest('Illegal char(\',\') for MultiSelect'); temp_title: newOp.title
throw new Error(''); } );
}
return `'${o.title.replace(/'/gi, '\'\'')}'`;
}).join(',')}`
: '';
} }
const tableUpdateBody = { // Append new option before editing
...table, if ((driverType === 'mysql' || driverType === 'mysql2') && (column.dt === 'enum' || column.dt === 'set')) {
tn: table.table_name, column.colOptions.options.push({ title: newOp.title });
originalColumns: table.columns.map((c) => ({
...c, let temp_dtxp = '';
cn: c.column_name, if (column.uidt === UITypes.SingleSelect) {
cno: c.column_name, temp_dtxp = (column.colOptions.options.length)
})), ? `${column.colOptions.options.map(o => `'${o.title.replace(/'/gi, '\'\'')}'`).join(',')}`
columns: await Promise.all( : '';
table.columns.map(async (c) => { } else if (column.uidt === UITypes.MultiSelect){
if (c.id === req.params.columnId) { temp_dtxp = (column.colOptions.options.length)
const res = { ? `${column.colOptions.options.map((o) => {
...c, if(o.title.includes(',')) {
...column, NcError.badRequest('Illegal char(\',\') for MultiSelect');
cn: column.column_name, throw new Error('');
cno: c.column_name, }
dtxp: temp_dtxp, return `'${o.title.replace(/'/gi, '\'\'')}'`;
altered: Altered.UPDATE_COLUMN, }).join(',')}`
}; : '';
return Promise.resolve(res); }
} else {
(c as any).cn = c.column_name;
}
return Promise.resolve(c);
})
),
};
const sqlMgr = await ProjectMgrv2.getSqlMgr({ id: base.project_id }); const tableUpdateBody = {
await sqlMgr.sqlOpPlus(base, 'tableUpdate', tableUpdateBody); ...table,
tn: table.table_name,
originalColumns: table.columns.map((c) => ({
...c,
cn: c.column_name,
cno: c.column_name,
})),
columns: await Promise.all(
table.columns.map(async (c) => {
if (c.id === req.params.columnId) {
const res = {
...c,
...column,
cn: column.column_name,
cno: c.column_name,
dtxp: temp_dtxp,
altered: Altered.UPDATE_COLUMN,
};
return Promise.resolve(res);
} else {
(c as any).cn = c.column_name;
}
return Promise.resolve(c);
})
),
};
await Column.update(req.params.columnId, { const sqlMgr = await ProjectMgrv2.getSqlMgr({ id: base.project_id });
...column, await sqlMgr.sqlOpPlus(base, 'tableUpdate', tableUpdateBody);
});
}
if (column.uidt === UITypes.SingleSelect) { await Column.update(req.params.columnId, {
if (driverType === 'mssql') { ...column,
await dbDriver.raw(`UPDATE ?? SET ?? = ? WHERE ?? LIKE ?`, [table.table_name, column.column_name, newOp.title, column.column_name, option.title]); });
} else {
await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${option.title})` }, { [column.column_name]: newOp.title });
} }
} else if (column.uidt === UITypes.MultiSelect) {
if (driverType === 'mysql' || driverType === 'mysql2') { if (column.uidt === UITypes.SingleSelect) {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', ??, ','), CONCAT(',', ?, ','), CONCAT(',', ?, ','))) WHERE FIND_IN_SET(?, ??)`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title, option.title, column.column_name]); if (driverType === 'mssql') {
} else if (driverType === 'pg') { await dbDriver.raw(`UPDATE ?? SET ?? = ? WHERE ?? LIKE ?`, [table.table_name, column.column_name, newOp.title, column.column_name, option.title]);
await dbDriver.raw(`UPDATE ?? SET ?? = array_to_string(array_replace(string_to_array(??, ','), ?, ?), ',')`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title]); } else {
} else if (driverType === 'mssql') { await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${option.title})` }, { [column.column_name]: newOp.title }, { cookie: req});
await dbDriver.raw(`UPDATE ?? SET ?? = substring(replace(concat(',', ??, ','), concat(',', ?, ','), concat(',', ?, ',')), 2, len(replace(concat(',', ??, ','), concat(',', ?, ','), concat(',', ?, ','))) - 2)`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title, column.column_name, option.title, newOp.title]); }
} else if (driverType === 'sqlite3') { } else if (column.uidt === UITypes.MultiSelect) {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(REPLACE(',' || ?? || ',', ',' || ? || ',', ',' || ? || ','), ',')`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title]); if (driverType === 'mysql' || driverType === 'mysql2') {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', ??, ','), CONCAT(',', ?, ','), CONCAT(',', ?, ','))) WHERE FIND_IN_SET(?, ??)`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title, option.title, column.column_name]);
} else if (driverType === 'pg') {
await dbDriver.raw(`UPDATE ?? SET ?? = array_to_string(array_replace(string_to_array(??, ','), ?, ?), ',')`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title]);
} else if (driverType === 'mssql') {
await dbDriver.raw(`UPDATE ?? SET ?? = substring(replace(concat(',', ??, ','), concat(',', ?, ','), concat(',', ?, ',')), 2, len(replace(concat(',', ??, ','), concat(',', ?, ','), concat(',', ?, ','))) - 2)`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title, column.column_name, option.title, newOp.title]);
} else if (driverType === 'sqlite3') {
await dbDriver.raw(`UPDATE ?? SET ?? = TRIM(REPLACE(',' || ?? || ',', ',' || ? || ',', ',' || ? || ','), ',')`, [table.table_name, column.column_name, column.column_name, option.title, newOp.title]);
}
} }
} }
} }
@ -951,7 +960,7 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
if (driverType === 'mssql') { if (driverType === 'mssql') {
await dbDriver.raw(`UPDATE ?? SET ?? = ? WHERE ?? LIKE ?`, [table.table_name, column.column_name, newOp.title, column.column_name, ch.temp_title]); await dbDriver.raw(`UPDATE ?? SET ?? = ? WHERE ?? LIKE ?`, [table.table_name, column.column_name, newOp.title, column.column_name, ch.temp_title]);
} else { } else {
await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${ch.temp_title})` }, { [column.column_name]: newOp.title }); await baseModel.bulkUpdateAll({ where: `(${column.column_name},eq,${ch.temp_title})` }, { [column.column_name]: newOp.title }, { cookie: req});
} }
} else if (column.uidt === UITypes.MultiSelect) { } else if (column.uidt === UITypes.MultiSelect) {
if (driverType === 'mysql' || driverType === 'mysql2') { if (driverType === 'mysql' || driverType === 'mysql2') {

10
packages/nocodb/src/lib/meta/api/dataApis/bulkDataAliasApis.ts

@ -17,7 +17,7 @@ async function bulkDataInsert(req: Request, res: Response) {
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
res.json(await baseModel.bulkInsert(req.body)); res.json(await baseModel.bulkInsert(req.body, { cookie: req }));
} }
async function bulkDataUpdate(req: Request, res: Response) { async function bulkDataUpdate(req: Request, res: Response) {
@ -30,9 +30,10 @@ async function bulkDataUpdate(req: Request, res: Response) {
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
res.json(await baseModel.bulkUpdate(req.body)); res.json(await baseModel.bulkUpdate(req.body, { cookie: req }));
} }
// todo: Integrate with filterArrJson bulkDataUpdateAll
async function bulkDataUpdateAll(req: Request, res: Response) { async function bulkDataUpdateAll(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
const base = await Base.get(model.base_id); const base = await Base.get(model.base_id);
@ -43,7 +44,7 @@ async function bulkDataUpdateAll(req: Request, res: Response) {
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
res.json(await baseModel.bulkUpdateAll(req.query, req.body)); res.json(await baseModel.bulkUpdateAll(req.query, req.body, { cookie: req }));
} }
async function bulkDataDelete(req: Request, res: Response) { async function bulkDataDelete(req: Request, res: Response) {
@ -55,9 +56,10 @@ async function bulkDataDelete(req: Request, res: Response) {
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
res.json(await baseModel.bulkDelete(req.body)); res.json(await baseModel.bulkDelete(req.body, { cookie: req }));
} }
// todo: Integrate with filterArrJson bulkDataDeleteAll
async function bulkDataDeleteAll(req: Request, res: Response) { async function bulkDataDeleteAll(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
const base = await Base.get(model.base_id); const base = await Base.get(model.base_id);

4
packages/nocodb/src/lib/meta/api/dataApis/dataAliasApis.ts

@ -10,6 +10,7 @@ import { getViewAndModelFromRequestByAliasOrId } from './helpers';
import apiMetrics from '../../helpers/apiMetrics'; import apiMetrics from '../../helpers/apiMetrics';
import getAst from '../../../db/sql-data-mapper/lib/sql/helpers/getAst'; import getAst from '../../../db/sql-data-mapper/lib/sql/helpers/getAst';
// todo: Handle the error case where view doesnt belong to model
async function dataList(req: Request, res: Response) { async function dataList(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
res.json(await getDataList(model, view, req)); res.json(await getDataList(model, view, req));
@ -46,6 +47,7 @@ async function dataCount(req: Request, res: Response) {
res.json({ count }); res.json({ count });
} }
// todo: Handle the error case where view doesnt belong to model
async function dataInsert(req: Request, res: Response) { async function dataInsert(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
@ -81,6 +83,8 @@ async function dataDelete(req: Request, res: Response) {
viewId: view?.id, viewId: view?.id,
dbDriver: NcConnectionMgrv2.get(base), dbDriver: NcConnectionMgrv2.get(base),
}); });
// todo: Should have error http status code
const message = await baseModel.hasLTARData(req.params.rowId, model); const message = await baseModel.hasLTARData(req.params.rowId, model);
if (message.length) { if (message.length) {
res.json({ message }); res.json({ message });

11
packages/nocodb/src/lib/meta/api/dataApis/dataAliasNestedApis.ts

@ -4,10 +4,14 @@ import Base from '../../../models/Base';
import NcConnectionMgrv2 from '../../../utils/common/NcConnectionMgrv2'; import NcConnectionMgrv2 from '../../../utils/common/NcConnectionMgrv2';
import { PagedResponseImpl } from '../../helpers/PagedResponse'; import { PagedResponseImpl } from '../../helpers/PagedResponse';
import ncMetaAclMw from '../../helpers/ncMetaAclMw'; import ncMetaAclMw from '../../helpers/ncMetaAclMw';
import { getColumnByIdOrName, getViewAndModelFromRequestByAliasOrId } from './helpers' import {
getColumnByIdOrName,
getViewAndModelFromRequestByAliasOrId,
} from './helpers';
import { NcError } from '../../helpers/catchError'; import { NcError } from '../../helpers/catchError';
import apiMetrics from '../../helpers/apiMetrics'; import apiMetrics from '../../helpers/apiMetrics';
// todo: handle case where the given column is not ltar
export async function mmList(req: Request, res: Response, next) { export async function mmList(req: Request, res: Response, next) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
@ -157,6 +161,7 @@ export async function btExcludedList(req: Request, res: Response, next) {
); );
} }
// todo: handle case where the given column is not ltar
export async function hmList(req: Request, res: Response, next) { export async function hmList(req: Request, res: Response, next) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
if (!model) return next(new Error('Table not found')); if (!model) return next(new Error('Table not found'));
@ -212,12 +217,14 @@ async function relationDataRemove(req, res) {
colId: column.id, colId: column.id,
childId: req.params.refRowId, childId: req.params.refRowId,
rowId: req.params.rowId, rowId: req.params.rowId,
cookie: req,
}); });
res.json({ msg: 'success' }); res.json({ msg: 'success' });
} }
//@ts-ignore //@ts-ignore
// todo: Give proper error message when reference row is already related and handle duplicate ref row id in hm
async function relationDataAdd(req, res) { async function relationDataAdd(req, res) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req); const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
if (!model) NcError.notFound('Table not found'); if (!model) NcError.notFound('Table not found');
@ -235,12 +242,12 @@ async function relationDataAdd(req, res) {
colId: column.id, colId: column.id,
childId: req.params.refRowId, childId: req.params.refRowId,
rowId: req.params.rowId, rowId: req.params.rowId,
cookie: req,
}); });
res.json({ msg: 'success' }); res.json({ msg: 'success' });
} }
const router = Router({ mergeParams: true }); const router = Router({ mergeParams: true });
router.get( router.get(

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

@ -494,6 +494,7 @@ async function relationDataDelete(req, res) {
colId: req.params.colId, colId: req.params.colId,
childId: req.params.childId, childId: req.params.childId,
rowId: req.params.rowId, rowId: req.params.rowId,
cookie: req,
}); });
res.json({ msg: 'success' }); res.json({ msg: 'success' });
@ -521,6 +522,7 @@ async function relationDataAdd(req, res) {
colId: req.params.colId, colId: req.params.colId,
childId: req.params.childId, childId: req.params.childId,
rowId: req.params.rowId, rowId: req.params.rowId,
cookie: req,
}); });
res.json({ msg: 'success' }); res.json({ msg: 'success' });

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

@ -198,7 +198,7 @@ export default async (
multiCollaborator: UITypes.Collaborator, multiCollaborator: UITypes.Collaborator,
date: UITypes.Date, date: UITypes.Date,
phone: UITypes.PhoneNumber, phone: UITypes.PhoneNumber,
number: UITypes.Number, number: UITypes.Decimal,
rating: UITypes.Rating, rating: UITypes.Rating,
formula: UITypes.Formula, formula: UITypes.Formula,
rollup: UITypes.Rollup, rollup: UITypes.Rollup,

4
packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts

@ -6,6 +6,7 @@ import * as nc_015_add_meta_col_in_column_table from './v2/nc_015_add_meta_col_i
import * as nc_016_alter_hooklog_payload_types from './v2/nc_016_alter_hooklog_payload_types'; import * as nc_016_alter_hooklog_payload_types from './v2/nc_016_alter_hooklog_payload_types';
import * as nc_017_add_user_token_version_column from './v2/nc_017_add_user_token_version_column'; import * as nc_017_add_user_token_version_column from './v2/nc_017_add_user_token_version_column';
import * as nc_018_add_meta_in_view from './v2/nc_018_add_meta_in_view'; import * as nc_018_add_meta_in_view from './v2/nc_018_add_meta_in_view';
import * as nc_019_add_meta_in_meta_tables from './v2/nc_019_add_meta_in_meta_tables';
// Create a custom migration source class // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -23,6 +24,7 @@ export default class XcMigrationSourcev2 {
'nc_016_alter_hooklog_payload_types', 'nc_016_alter_hooklog_payload_types',
'nc_017_add_user_token_version_column', 'nc_017_add_user_token_version_column',
'nc_018_add_meta_in_view', 'nc_018_add_meta_in_view',
'nc_019_add_meta_in_meta_tables',
]); ]);
} }
@ -48,6 +50,8 @@ export default class XcMigrationSourcev2 {
return nc_017_add_user_token_version_column; return nc_017_add_user_token_version_column;
case 'nc_018_add_meta_in_view': case 'nc_018_add_meta_in_view':
return nc_018_add_meta_in_view; return nc_018_add_meta_in_view;
case 'nc_019_add_meta_in_meta_tables':
return nc_019_add_meta_in_meta_tables;
} }
} }
} }

37
packages/nocodb/src/lib/migrations/v2/nc_011.ts

@ -1,4 +1,4 @@
import { MetaTable } from '../../utils/globals'; import { MetaTable, orderedMetaTables } from '../../utils/globals';
// import googleAuth from '../plugins/googleAuth'; // import googleAuth from '../plugins/googleAuth';
// import ses from '../plugins/ses'; // import ses from '../plugins/ses';
// import cache from '../plugins/cache'; // import cache from '../plugins/cache';
@ -806,38 +806,9 @@ const up = async (knex) => {
}; };
const down = async (knex) => { const down = async (knex) => {
await knex.schema.dropTable(MetaTable.MODEL_ROLE_VISIBILITY); for (const tableName of orderedMetaTables) {
await knex.schema.dropTable(MetaTable.PLUGIN); await knex.schema.dropTable(tableName);
await knex.schema.dropTable(MetaTable.AUDIT); }
await knex.schema.dropTable(MetaTable.TEAM_USERS);
await knex.schema.dropTable(MetaTable.TEAMS);
await knex.schema.dropTable(MetaTable.ORGS);
await knex.schema.dropTable(MetaTable.PROJECT_USERS);
await knex.schema.dropTable(MetaTable.USERS);
await knex.schema.dropTable(MetaTable.KANBAN_VIEW_COLUMNS);
await knex.schema.dropTable(MetaTable.KANBAN_VIEW);
await knex.schema.dropTable(MetaTable.GRID_VIEW_COLUMNS);
await knex.schema.dropTable(MetaTable.GRID_VIEW);
await knex.schema.dropTable(MetaTable.GALLERY_VIEW_COLUMNS);
await knex.schema.dropTable(MetaTable.GALLERY_VIEW);
await knex.schema.dropTable(MetaTable.FORM_VIEW_COLUMNS);
await knex.schema.dropTable(MetaTable.FORM_VIEW);
await knex.schema.dropTable(MetaTable.SHARED_VIEWS);
await knex.schema.dropTable(MetaTable.SORT);
await knex.schema.dropTable(MetaTable.FILTER_EXP);
await knex.schema.dropTable(MetaTable.HOOK_LOGS);
await knex.schema.dropTable(MetaTable.HOOKS);
await knex.schema.dropTable(MetaTable.VIEWS);
await knex.schema.dropTable(MetaTable.COL_FORMULA);
await knex.schema.dropTable(MetaTable.COL_ROLLUP);
await knex.schema.dropTable(MetaTable.COL_LOOKUP);
await knex.schema.dropTable(MetaTable.COL_SELECT_OPTIONS);
await knex.schema.dropTable(MetaTable.COL_RELATIONS);
await knex.schema.dropTable(MetaTable.COLUMN_VALIDATIONS);
await knex.schema.dropTable(MetaTable.COLUMNS);
await knex.schema.dropTable(MetaTable.MODELS);
await knex.schema.dropTable(MetaTable.BASES);
await knex.schema.dropTable(MetaTable.PROJECT);
}; };
export { up, down }; export { up, down };

34
packages/nocodb/src/lib/migrations/v2/nc_019_add_meta_in_meta_tables.ts

@ -0,0 +1,34 @@
import Knex from 'knex';
import { MetaTable } from '../../utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.FORM_VIEW, (table) => {
table.text('meta');
});
await knex.schema.alterTable(MetaTable.FORM_VIEW_COLUMNS, (table) => {
table.text('meta');
});
await knex.schema.alterTable(MetaTable.GRID_VIEW, (table) => {
table.text('meta');
});
await knex.schema.alterTable(MetaTable.GALLERY_VIEW, (table) => {
table.text('meta');
});
};
const down = async (knex) => {
await knex.schema.alterTable(MetaTable.FORM_VIEW, (table) => {
table.dropColumns('meta');
});
await knex.schema.alterTable(MetaTable.FORM_VIEW_COLUMNS, (table) => {
table.dropColumns('meta');
});
await knex.schema.alterTable(MetaTable.GRID_VIEW, (table) => {
table.dropColumns('meta');
});
await knex.schema.alterTable(MetaTable.GALLERY_VIEW, (table) => {
table.dropColumns('meta');
});
};
export { up, down };

10
packages/nocodb/src/lib/models/FormView.ts

@ -1,6 +1,7 @@
import Noco from '../Noco'; import Noco from '../Noco';
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals'; import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import { FormType } from 'nocodb-sdk'; import { FormType } from 'nocodb-sdk';
import { deserializeJSON, serializeJSON } from '../utils/serialize';
import FormViewColumn from './FormViewColumn'; import FormViewColumn from './FormViewColumn';
import View from './View'; import View from './View';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
@ -26,6 +27,7 @@ export default class FormView implements FormType {
project_id?: string; project_id?: string;
base_id?: string; base_id?: string;
meta?: string | Record<string, any>;
constructor(data: FormView) { constructor(data: FormView) {
Object.assign(this, data); Object.assign(this, data);
@ -42,7 +44,10 @@ export default class FormView implements FormType {
view = await ncMeta.metaGet2(null, null, MetaTable.FORM_VIEW, { view = await ncMeta.metaGet2(null, null, MetaTable.FORM_VIEW, {
fk_view_id: viewId, fk_view_id: viewId,
}); });
await NocoCache.set(`${CacheScope.FORM_VIEW}:${viewId}`, view); if (view) {
view.meta = deserializeJSON(view.meta);
await NocoCache.set(`${CacheScope.FORM_VIEW}:${viewId}`, view);
}
} }
return view && new FormView(view); return view && new FormView(view);
} }
@ -62,6 +67,7 @@ export default class FormView implements FormType {
logo_url: view.logo_url, logo_url: view.logo_url,
submit_another_form: view.submit_another_form, submit_another_form: view.submit_another_form,
show_blank_form: view.show_blank_form, show_blank_form: view.show_blank_form,
meta: serializeJSON(view.meta),
}; };
if (!(view.project_id && view.base_id)) { if (!(view.project_id && view.base_id)) {
const viewRef = await View.get(view.fk_view_id); const viewRef = await View.get(view.fk_view_id);
@ -92,8 +98,10 @@ export default class FormView implements FormType {
o.logo_url = body.logo_url; o.logo_url = body.logo_url;
o.submit_another_form = body.submit_another_form; o.submit_another_form = body.submit_another_form;
o.show_blank_form = body.show_blank_form; o.show_blank_form = body.show_blank_form;
o.meta = body.meta;
// set cache // set cache
await NocoCache.set(key, o); await NocoCache.set(key, o);
o.meta = serializeJSON(body.meta);
} }
// update meta // update meta
return await ncMeta.metaUpdate( return await ncMeta.metaUpdate(

45
packages/nocodb/src/lib/models/FormViewColumn.ts

@ -1,6 +1,7 @@
import Noco from '../Noco'; import Noco from '../Noco';
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals'; import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import { FormColumnType } from 'nocodb-sdk'; import { FormColumnType } from 'nocodb-sdk';
import { deserializeJSON, serializeJSON } from '../utils/serialize';
import View from './View'; import View from './View';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
import { extractProps } from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
@ -18,6 +19,7 @@ export default class FormViewColumn implements FormColumnType {
fk_column_id?: string; fk_column_id?: string;
project_id?: string; project_id?: string;
base_id?: string; base_id?: string;
meta?: string | Record<string, any>;
constructor(data: FormViewColumn) { constructor(data: FormViewColumn) {
Object.assign(this, data); Object.assign(this, data);
@ -25,28 +27,35 @@ export default class FormViewColumn implements FormColumnType {
uuid?: any; uuid?: any;
public static async get(formViewId: string, ncMeta = Noco.ncMeta) { public static async get(formViewColumnId: string, ncMeta = Noco.ncMeta) {
let view = let viewColumn =
formViewId && formViewColumnId &&
(await NocoCache.get( (await NocoCache.get(
`${CacheScope.FORM_VIEW_COLUMN}:${formViewId}`, `${CacheScope.FORM_VIEW_COLUMN}:${formViewColumnId}`,
CacheGetType.TYPE_OBJECT CacheGetType.TYPE_OBJECT
)); ));
if (!view) { if (!viewColumn) {
view = await ncMeta.metaGet2( viewColumn = await ncMeta.metaGet2(
null, null,
null, null,
MetaTable.FORM_VIEW_COLUMNS, MetaTable.FORM_VIEW_COLUMNS,
formViewId formViewColumnId
); );
viewColumn.meta =
viewColumn.meta && typeof viewColumn.meta === 'string'
? JSON.parse(viewColumn.meta)
: viewColumn.meta;
} }
await NocoCache.set(`${CacheScope.FORM_VIEW_COLUMN}:${formViewId}`, view); await NocoCache.set(
`${CacheScope.FORM_VIEW_COLUMN}:${formViewColumnId}`,
viewColumn
);
return view && new FormViewColumn(view); return viewColumn && new FormViewColumn(viewColumn);
} }
static async insert(column: Partial<FormViewColumn>, ncMeta = Noco.ncMeta) { static async insert(column: Partial<FormViewColumn>, ncMeta = Noco.ncMeta) {
const insertObj = { const insertObj: Partial<FormViewColumn> = {
fk_view_id: column.fk_view_id, fk_view_id: column.fk_view_id,
fk_column_id: column.fk_column_id, fk_column_id: column.fk_column_id,
order: await ncMeta.metaGetNextOrder(MetaTable.FORM_VIEW_COLUMNS, { order: await ncMeta.metaGetNextOrder(MetaTable.FORM_VIEW_COLUMNS, {
@ -61,6 +70,10 @@ export default class FormViewColumn implements FormColumnType {
required: column.required, required: column.required,
}; };
if (column.meta) {
insertObj.meta = serializeJSON(column.meta);
}
if (!(column.project_id && column.base_id)) { if (!(column.project_id && column.base_id)) {
const viewRef = await View.get(column.fk_view_id, ncMeta); const viewRef = await View.get(column.fk_view_id, ncMeta);
insertObj.project_id = viewRef.project_id; insertObj.project_id = viewRef.project_id;
@ -113,6 +126,11 @@ export default class FormViewColumn implements FormColumnType {
}, },
} }
); );
for (const viewColumn of viewColumns) {
viewColumn.meta = deserializeJSON(viewColumn.meta);
}
await NocoCache.setList( await NocoCache.setList(
CacheScope.FORM_VIEW_COLUMN, CacheScope.FORM_VIEW_COLUMN,
[viewId], [viewId],
@ -139,7 +157,9 @@ export default class FormViewColumn implements FormColumnType {
'required', 'required',
'show', 'show',
'order', 'order',
'meta',
]); ]);
// get existing cache // get existing cache
const key = `${CacheScope.FORM_VIEW_COLUMN}:${columnId}`; const key = `${CacheScope.FORM_VIEW_COLUMN}:${columnId}`;
const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT); const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
@ -148,6 +168,11 @@ export default class FormViewColumn implements FormColumnType {
// set cache // set cache
await NocoCache.set(key, o); await NocoCache.set(key, o);
} }
if (insertObj.meta) {
insertObj.meta = serializeJSON(insertObj.meta);
}
// update meta // update meta
await ncMeta.metaUpdate( await ncMeta.metaUpdate(
null, null,

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

@ -628,11 +628,13 @@ export default class Model implements TableType {
], ],
} }
); );
await NocoCache.set( if (model) {
`${CacheScope.MODEL}:${project_id}:${aliasOrId}`, await NocoCache.set(
model.id `${CacheScope.MODEL}:${project_id}:${aliasOrId}`,
); model.id
await NocoCache.set(`${CacheScope.MODEL}:${model.id}`, model); );
await NocoCache.set(`${CacheScope.MODEL}:${model.id}`, model);
}
return model && new Model(model); return model && new Model(model);
} }
return modelId && this.get(modelId); return modelId && this.get(modelId);

2
packages/nocodb/src/lib/models/Project.ts

@ -173,6 +173,7 @@ export default class Project implements ProjectType {
return null; return null;
} }
// Todo: Remove the project entry from the connection pool in NcConnectionMgrv2
// @ts-ignore // @ts-ignore
static async softDelete( static async softDelete(
projectId: string, projectId: string,
@ -273,6 +274,7 @@ export default class Project implements ProjectType {
); );
} }
// Todo: Remove the project entry from the connection pool in NcConnectionMgrv2
static async delete(projectId, ncMeta = Noco.ncMeta): Promise<any> { static async delete(projectId, ncMeta = Noco.ncMeta): Promise<any> {
const bases = await Base.list({ projectId }); const bases = await Base.list({ projectId });
for (const base of bases) { for (const base of bases) {

9
packages/nocodb/src/lib/utils/common/NcConnectionMgrv2.ts

@ -22,6 +22,15 @@ export default class NcConnectionMgrv2 {
// this.metaKnex = ncMeta; // this.metaKnex = ncMeta;
// } // }
public static async destroyAll() {
for (const projectId in this.connectionRefs) {
for (const baseId in this.connectionRefs[projectId]) {
await this.connectionRefs[projectId][baseId].destroy();
}
}
}
// Todo: Should await on connection destroy
public static delete(base: Base) { public static delete(base: Base) {
// todo: ignore meta projects // todo: ignore meta projects
if (this.connectionRefs?.[base.project_id]?.[base.id]) { if (this.connectionRefs?.[base.project_id]?.[base.id]) {

35
packages/nocodb/src/lib/utils/globals.ts

@ -39,6 +39,41 @@ export enum MetaTable {
SYNC_LOGS = 'nc_sync_logs_v2', SYNC_LOGS = 'nc_sync_logs_v2',
} }
export const orderedMetaTables = [
MetaTable.MODEL_ROLE_VISIBILITY,
MetaTable.PLUGIN,
MetaTable.AUDIT,
MetaTable.TEAM_USERS,
MetaTable.TEAMS,
MetaTable.ORGS,
MetaTable.PROJECT_USERS,
MetaTable.USERS,
MetaTable.KANBAN_VIEW_COLUMNS,
MetaTable.KANBAN_VIEW,
MetaTable.GRID_VIEW_COLUMNS,
MetaTable.GRID_VIEW,
MetaTable.GALLERY_VIEW_COLUMNS,
MetaTable.GALLERY_VIEW,
MetaTable.FORM_VIEW_COLUMNS,
MetaTable.FORM_VIEW,
MetaTable.SHARED_VIEWS,
MetaTable.SORT,
MetaTable.FILTER_EXP,
MetaTable.HOOK_LOGS,
MetaTable.HOOKS,
MetaTable.VIEWS,
MetaTable.COL_FORMULA,
MetaTable.COL_ROLLUP,
MetaTable.COL_LOOKUP,
MetaTable.COL_SELECT_OPTIONS,
MetaTable.COL_RELATIONS,
MetaTable.COLUMN_VALIDATIONS,
MetaTable.COLUMNS,
MetaTable.MODELS,
MetaTable.BASES,
MetaTable.PROJECT,
];
export enum CacheScope { export enum CacheScope {
PROJECT = 'project', PROJECT = 'project',
BASE = 'base', BASE = 'base',

19
packages/nocodb/src/lib/utils/serialize.ts

@ -0,0 +1,19 @@
export const serializeJSON = (data: string | Record<string, any>) => {
// if already in string format ignore stringify
if (typeof data === 'string') {
return data;
}
return JSON.stringify(data);
};
export const deserializeJSON = (data: string | Record<string, any>) => {
// if already in object format ignore parse
if (typeof data === 'object') {
return data ?? {};
}
try {
return JSON.parse(data) ?? {};
} catch (e) {
return {};
}
};

658
packages/nocodb/tests/mysql-sakila-db/03-test-sakila-schema.sql

@ -0,0 +1,658 @@
-- Sakila Sample Database Schema
-- Version 1.2
-- Copyright (c) 2006, 2019, Oracle and/or its affiliates.
-- Redistribution and use in source and binary forms, with or without
-- modification, are permitted provided that the following conditions are
-- met:
-- * Redistributions of source code must retain the above copyright notice,
-- this list of conditions and the following disclaimer.
-- * Redistributions in binary form must reproduce the above copyright
-- notice, this list of conditions and the following disclaimer in the
-- documentation and/or other materials provided with the distribution.
-- * Neither the name of Oracle nor the names of its contributors may be used
-- to endorse or promote products derived from this software without
-- specific prior written permission.
-- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
-- IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
-- THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-- PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
-- CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
-- EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
-- PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
-- PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
-- LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
-- NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-- SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
SET NAMES utf8mb4;
SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0;
SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;
SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='TRADITIONAL';
DROP SCHEMA IF EXISTS test_sakila;
CREATE SCHEMA test_sakila;
USE test_sakila;
--
-- Table structure for table `actor`
--
CREATE TABLE actor (
actor_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
first_name VARCHAR(45) NOT NULL,
last_name VARCHAR(45) NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (actor_id),
KEY idx_actor_last_name (last_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `address`
--
CREATE TABLE address (
address_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
address VARCHAR(50) NOT NULL,
address2 VARCHAR(50) DEFAULT NULL,
district VARCHAR(20) NOT NULL,
city_id SMALLINT UNSIGNED NOT NULL,
postal_code VARCHAR(10) DEFAULT NULL,
phone VARCHAR(20) NOT NULL,
-- Add GEOMETRY column for MySQL 5.7.5 and higher
-- Also include SRID attribute for MySQL 8.0.3 and higher
/*!50705 location GEOMETRY */ /*!80003 SRID 0 */ /*!50705 NOT NULL,*/
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (address_id),
KEY idx_fk_city_id (city_id),
/*!50705 SPATIAL KEY `idx_location` (location),*/
CONSTRAINT `fk_address_city` FOREIGN KEY (city_id) REFERENCES city (city_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `category`
--
CREATE TABLE category (
category_id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(25) NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `city`
--
CREATE TABLE city (
city_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
city VARCHAR(50) NOT NULL,
country_id SMALLINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (city_id),
KEY idx_fk_country_id (country_id),
CONSTRAINT `fk_city_country` FOREIGN KEY (country_id) REFERENCES country (country_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `country`
--
CREATE TABLE country (
country_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
country VARCHAR(50) NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (country_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `customer`
--
CREATE TABLE customer (
customer_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
store_id TINYINT UNSIGNED NOT NULL,
first_name VARCHAR(45) NOT NULL,
last_name VARCHAR(45) NOT NULL,
email VARCHAR(50) DEFAULT NULL,
address_id SMALLINT UNSIGNED NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
create_date DATETIME NOT NULL,
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (customer_id),
KEY idx_fk_store_id (store_id),
KEY idx_fk_address_id (address_id),
KEY idx_last_name (last_name),
CONSTRAINT fk_customer_address FOREIGN KEY (address_id) REFERENCES address (address_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_customer_store FOREIGN KEY (store_id) REFERENCES store (store_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `film`
--
CREATE TABLE film (
film_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(128) NOT NULL,
description TEXT DEFAULT NULL,
release_year YEAR DEFAULT NULL,
language_id TINYINT UNSIGNED NOT NULL,
original_language_id TINYINT UNSIGNED DEFAULT NULL,
rental_duration TINYINT UNSIGNED NOT NULL DEFAULT 3,
rental_rate DECIMAL(4,2) NOT NULL DEFAULT 4.99,
length SMALLINT UNSIGNED DEFAULT NULL,
replacement_cost DECIMAL(5,2) NOT NULL DEFAULT 19.99,
rating ENUM('G','PG','PG-13','R','NC-17') DEFAULT 'G',
special_features SET('Trailers','Commentaries','Deleted Scenes','Behind the Scenes') DEFAULT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (film_id),
KEY idx_title (title),
KEY idx_fk_language_id (language_id),
KEY idx_fk_original_language_id (original_language_id),
CONSTRAINT fk_film_language FOREIGN KEY (language_id) REFERENCES language (language_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_film_language_original FOREIGN KEY (original_language_id) REFERENCES language (language_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `film_actor`
--
CREATE TABLE film_actor (
actor_id SMALLINT UNSIGNED NOT NULL,
film_id SMALLINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (actor_id,film_id),
KEY idx_fk_film_id (`film_id`),
CONSTRAINT fk_film_actor_actor FOREIGN KEY (actor_id) REFERENCES actor (actor_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_film_actor_film FOREIGN KEY (film_id) REFERENCES film (film_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `film_category`
--
CREATE TABLE film_category (
film_id SMALLINT UNSIGNED NOT NULL,
category_id TINYINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (film_id, category_id),
CONSTRAINT fk_film_category_film FOREIGN KEY (film_id) REFERENCES film (film_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_film_category_category FOREIGN KEY (category_id) REFERENCES category (category_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `film_text`
--
-- InnoDB added FULLTEXT support in 5.6.10. If you use an
-- earlier version, then consider upgrading (recommended) or
-- changing InnoDB to MyISAM as the film_text engine
--
-- Use InnoDB for film_text as of 5.6.10, MyISAM prior to 5.6.10.
SET @old_default_storage_engine = @@default_storage_engine;
SET @@default_storage_engine = 'MyISAM';
/*!50610 SET @@default_storage_engine = 'InnoDB'*/;
CREATE TABLE film_text (
film_id SMALLINT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
PRIMARY KEY (film_id),
FULLTEXT KEY idx_title_description (title,description)
) DEFAULT CHARSET=utf8mb4;
SET @@default_storage_engine = @old_default_storage_engine;
--
-- Triggers for loading film_text from film
--
CREATE TRIGGER `ins_film` AFTER INSERT ON `film` FOR EACH ROW BEGIN
INSERT INTO film_text (film_id, title, description)
VALUES (new.film_id, new.title, new.description);
END;
CREATE TRIGGER `upd_film` AFTER UPDATE ON `film` FOR EACH ROW BEGIN
IF (old.title != new.title) OR (old.description != new.description) OR (old.film_id != new.film_id)
THEN
UPDATE film_text
SET title=new.title,
description=new.description,
film_id=new.film_id
WHERE film_id=old.film_id;
END IF;
END;
CREATE TRIGGER `del_film` AFTER DELETE ON `film` FOR EACH ROW BEGIN
DELETE FROM film_text WHERE film_id = old.film_id;
END;
--
-- Table structure for table `inventory`
--
CREATE TABLE inventory (
inventory_id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT,
film_id SMALLINT UNSIGNED NOT NULL,
store_id TINYINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (inventory_id),
KEY idx_fk_film_id (film_id),
KEY idx_store_id_film_id (store_id,film_id),
CONSTRAINT fk_inventory_store FOREIGN KEY (store_id) REFERENCES store (store_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_inventory_film FOREIGN KEY (film_id) REFERENCES film (film_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `language`
--
CREATE TABLE language (
language_id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
name CHAR(20) NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (language_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `payment`
--
CREATE TABLE payment (
payment_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
customer_id SMALLINT UNSIGNED NOT NULL,
staff_id TINYINT UNSIGNED NOT NULL,
rental_id INT DEFAULT NULL,
amount DECIMAL(5,2) NOT NULL,
payment_date DATETIME NOT NULL,
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (payment_id),
KEY idx_fk_staff_id (staff_id),
KEY idx_fk_customer_id (customer_id),
CONSTRAINT fk_payment_rental FOREIGN KEY (rental_id) REFERENCES rental (rental_id) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT fk_payment_customer FOREIGN KEY (customer_id) REFERENCES customer (customer_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_payment_staff FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `rental`
--
CREATE TABLE rental (
rental_id INT NOT NULL AUTO_INCREMENT,
rental_date DATETIME NOT NULL,
inventory_id MEDIUMINT UNSIGNED NOT NULL,
customer_id SMALLINT UNSIGNED NOT NULL,
return_date DATETIME DEFAULT NULL,
staff_id TINYINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (rental_id),
UNIQUE KEY (rental_date,inventory_id,customer_id),
KEY idx_fk_inventory_id (inventory_id),
KEY idx_fk_customer_id (customer_id),
KEY idx_fk_staff_id (staff_id),
CONSTRAINT fk_rental_staff FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_rental_inventory FOREIGN KEY (inventory_id) REFERENCES inventory (inventory_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_rental_customer FOREIGN KEY (customer_id) REFERENCES customer (customer_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `staff`
--
CREATE TABLE staff (
staff_id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
first_name VARCHAR(45) NOT NULL,
last_name VARCHAR(45) NOT NULL,
address_id SMALLINT UNSIGNED NOT NULL,
picture BLOB DEFAULT NULL,
email VARCHAR(50) DEFAULT NULL,
store_id TINYINT UNSIGNED NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
username VARCHAR(16) NOT NULL,
password VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (staff_id),
KEY idx_fk_store_id (store_id),
KEY idx_fk_address_id (address_id),
CONSTRAINT fk_staff_store FOREIGN KEY (store_id) REFERENCES store (store_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_staff_address FOREIGN KEY (address_id) REFERENCES address (address_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- Table structure for table `store`
--
CREATE TABLE store (
store_id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
manager_staff_id TINYINT UNSIGNED NOT NULL,
address_id SMALLINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (store_id),
UNIQUE KEY idx_unique_manager (manager_staff_id),
KEY idx_fk_address_id (address_id),
CONSTRAINT fk_store_staff FOREIGN KEY (manager_staff_id) REFERENCES staff (staff_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_store_address FOREIGN KEY (address_id) REFERENCES address (address_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- View structure for view `customer_list`
--
CREATE VIEW customer_list
AS
SELECT cu.customer_id AS ID, CONCAT(cu.first_name, _utf8mb4' ', cu.last_name) AS name, a.address AS address, a.postal_code AS `zip code`,
a.phone AS phone, city.city AS city, country.country AS country, IF(cu.active, _utf8mb4'active',_utf8mb4'') AS notes, cu.store_id AS SID
FROM customer AS cu JOIN address AS a ON cu.address_id = a.address_id JOIN city ON a.city_id = city.city_id
JOIN country ON city.country_id = country.country_id;
--
-- View structure for view `film_list`
--
CREATE VIEW film_list
AS
SELECT film.film_id AS FID, film.title AS title, film.description AS description, category.name AS category, film.rental_rate AS price,
film.length AS length, film.rating AS rating, GROUP_CONCAT(CONCAT(actor.first_name, _utf8mb4' ', actor.last_name) SEPARATOR ', ') AS actors
FROM category LEFT JOIN film_category ON category.category_id = film_category.category_id LEFT JOIN film ON film_category.film_id = film.film_id
JOIN film_actor ON film.film_id = film_actor.film_id
JOIN actor ON film_actor.actor_id = actor.actor_id
GROUP BY film.film_id, category.name;
--
-- View structure for view `nicer_but_slower_film_list`
--
CREATE VIEW nicer_but_slower_film_list
AS
SELECT film.film_id AS FID, film.title AS title, film.description AS description, category.name AS category, film.rental_rate AS price,
film.length AS length, film.rating AS rating, GROUP_CONCAT(CONCAT(CONCAT(UCASE(SUBSTR(actor.first_name,1,1)),
LCASE(SUBSTR(actor.first_name,2,LENGTH(actor.first_name))),_utf8mb4' ',CONCAT(UCASE(SUBSTR(actor.last_name,1,1)),
LCASE(SUBSTR(actor.last_name,2,LENGTH(actor.last_name)))))) SEPARATOR ', ') AS actors
FROM category LEFT JOIN film_category ON category.category_id = film_category.category_id LEFT JOIN film ON film_category.film_id = film.film_id
JOIN film_actor ON film.film_id = film_actor.film_id
JOIN actor ON film_actor.actor_id = actor.actor_id
GROUP BY film.film_id, category.name;
--
-- View structure for view `staff_list`
--
CREATE VIEW staff_list
AS
SELECT s.staff_id AS ID, CONCAT(s.first_name, _utf8mb4' ', s.last_name) AS name, a.address AS address, a.postal_code AS `zip code`, a.phone AS phone,
city.city AS city, country.country AS country, s.store_id AS SID
FROM staff AS s JOIN address AS a ON s.address_id = a.address_id JOIN city ON a.city_id = city.city_id
JOIN country ON city.country_id = country.country_id;
--
-- View structure for view `sales_by_store`
--
CREATE VIEW sales_by_store
AS
SELECT
CONCAT(c.city, _utf8mb4',', cy.country) AS store
, CONCAT(m.first_name, _utf8mb4' ', m.last_name) AS manager
, SUM(p.amount) AS total_sales
FROM payment AS p
INNER JOIN rental AS r ON p.rental_id = r.rental_id
INNER JOIN inventory AS i ON r.inventory_id = i.inventory_id
INNER JOIN store AS s ON i.store_id = s.store_id
INNER JOIN address AS a ON s.address_id = a.address_id
INNER JOIN city AS c ON a.city_id = c.city_id
INNER JOIN country AS cy ON c.country_id = cy.country_id
INNER JOIN staff AS m ON s.manager_staff_id = m.staff_id
GROUP BY s.store_id
ORDER BY cy.country, c.city;
--
-- View structure for view `sales_by_film_category`
--
-- Note that total sales will add up to >100% because
-- some titles belong to more than 1 category
--
CREATE VIEW sales_by_film_category
AS
SELECT
c.name AS category
, SUM(p.amount) AS total_sales
FROM payment AS p
INNER JOIN rental AS r ON p.rental_id = r.rental_id
INNER JOIN inventory AS i ON r.inventory_id = i.inventory_id
INNER JOIN film AS f ON i.film_id = f.film_id
INNER JOIN film_category AS fc ON f.film_id = fc.film_id
INNER JOIN category AS c ON fc.category_id = c.category_id
GROUP BY c.name
ORDER BY total_sales DESC;
--
-- View structure for view `actor_info`
--
CREATE DEFINER=CURRENT_USER SQL SECURITY INVOKER VIEW actor_info
AS
SELECT
a.actor_id,
a.first_name,
a.last_name,
GROUP_CONCAT(DISTINCT CONCAT(c.name, ': ',
(SELECT GROUP_CONCAT(f.title ORDER BY f.title SEPARATOR ', ')
FROM test_sakila.film f
INNER JOIN test_sakila.film_category fc
ON f.film_id = fc.film_id
INNER JOIN test_sakila.film_actor fa
ON f.film_id = fa.film_id
WHERE fc.category_id = c.category_id
AND fa.actor_id = a.actor_id
)
)
ORDER BY c.name SEPARATOR '; ')
AS film_info
FROM test_sakila.actor a
LEFT JOIN test_sakila.film_actor fa
ON a.actor_id = fa.actor_id
LEFT JOIN test_sakila.film_category fc
ON fa.film_id = fc.film_id
LEFT JOIN test_sakila.category c
ON fc.category_id = c.category_id
GROUP BY a.actor_id, a.first_name, a.last_name;
--
-- Procedure structure for procedure `rewards_report`
--
CREATE PROCEDURE rewards_report (
IN min_monthly_purchases TINYINT UNSIGNED
, IN min_dollar_amount_purchased DECIMAL(10,2)
, OUT count_rewardees INT
)
LANGUAGE SQL
NOT DETERMINISTIC
READS SQL DATA
SQL SECURITY DEFINER
COMMENT 'Provides a customizable report on best customers'
proc: BEGIN
DECLARE last_month_start DATE;
DECLARE last_month_end DATE;
/* Some sanity checks... */
IF min_monthly_purchases = 0 THEN
SELECT 'Minimum monthly purchases parameter must be > 0';
LEAVE proc;
END IF;
IF min_dollar_amount_purchased = 0.00 THEN
SELECT 'Minimum monthly dollar amount purchased parameter must be > $0.00';
LEAVE proc;
END IF;
/* Determine start and end time periods */
SET last_month_start = DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH);
SET last_month_start = STR_TO_DATE(CONCAT(YEAR(last_month_start),'-',MONTH(last_month_start),'-01'),'%Y-%m-%d');
SET last_month_end = LAST_DAY(last_month_start);
/*
Create a temporary storage area for
Customer IDs.
*/
CREATE TEMPORARY TABLE tmpCustomer (customer_id SMALLINT UNSIGNED NOT NULL PRIMARY KEY);
/*
Find all customers meeting the
monthly purchase requirements
*/
INSERT INTO tmpCustomer (customer_id)
SELECT p.customer_id
FROM payment AS p
WHERE DATE(p.payment_date) BETWEEN last_month_start AND last_month_end
GROUP BY customer_id
HAVING SUM(p.amount) > min_dollar_amount_purchased
AND COUNT(customer_id) > min_monthly_purchases;
/* Populate OUT parameter with count of found customers */
SELECT COUNT(*) FROM tmpCustomer INTO count_rewardees;
/*
Output ALL customer information of matching rewardees.
Customize output as needed.
*/
SELECT c.*
FROM tmpCustomer AS t
INNER JOIN customer AS c ON t.customer_id = c.customer_id;
/* Clean up */
DROP TABLE tmpCustomer;
END;
CREATE FUNCTION IF NOT EXISTS get_customer_balance(p_customer_id INT, p_effective_date DATETIME) RETURNS DECIMAL(5,2)
DETERMINISTIC
READS SQL DATA
BEGIN
#OK, WE NEED TO CALCULATE THE CURRENT BALANCE GIVEN A CUSTOMER_ID AND A DATE
#THAT WE WANT THE BALANCE TO BE EFFECTIVE FOR. THE BALANCE IS:
# 1) RENTAL FEES FOR ALL PREVIOUS RENTALS
# 2) ONE DOLLAR FOR EVERY DAY THE PREVIOUS RENTALS ARE OVERDUE
# 3) IF A FILM IS MORE THAN RENTAL_DURATION * 2 OVERDUE, CHARGE THE REPLACEMENT_COST
# 4) SUBTRACT ALL PAYMENTS MADE BEFORE THE DATE SPECIFIED
DECLARE v_rentfees DECIMAL(5,2); #FEES PAID TO RENT THE VIDEOS INITIALLY
DECLARE v_overfees INTEGER; #LATE FEES FOR PRIOR RENTALS
DECLARE v_payments DECIMAL(5,2); #SUM OF PAYMENTS MADE PREVIOUSLY
SELECT IFNULL(SUM(film.rental_rate),0) INTO v_rentfees
FROM film, inventory, rental
WHERE film.film_id = inventory.film_id
AND inventory.inventory_id = rental.inventory_id
AND rental.rental_date <= p_effective_date
AND rental.customer_id = p_customer_id;
SELECT IFNULL(SUM(IF((TO_DAYS(rental.return_date) - TO_DAYS(rental.rental_date)) > film.rental_duration,
((TO_DAYS(rental.return_date) - TO_DAYS(rental.rental_date)) - film.rental_duration),0)),0) INTO v_overfees
FROM rental, inventory, film
WHERE film.film_id = inventory.film_id
AND inventory.inventory_id = rental.inventory_id
AND rental.rental_date <= p_effective_date
AND rental.customer_id = p_customer_id;
SELECT IFNULL(SUM(payment.amount),0) INTO v_payments
FROM payment
WHERE payment.payment_date <= p_effective_date
AND payment.customer_id = p_customer_id;
RETURN v_rentfees + v_overfees - v_payments;
END;
CREATE PROCEDURE film_in_stock(IN p_film_id INT, IN p_store_id INT, OUT p_film_count INT)
READS SQL DATA
BEGIN
SELECT inventory_id
FROM inventory
WHERE film_id = p_film_id
AND store_id = p_store_id
AND inventory_in_stock(inventory_id);
SELECT COUNT(*)
FROM inventory
WHERE film_id = p_film_id
AND store_id = p_store_id
AND inventory_in_stock(inventory_id)
INTO p_film_count;
END;
CREATE PROCEDURE film_not_in_stock(IN p_film_id INT, IN p_store_id INT, OUT p_film_count INT)
READS SQL DATA
BEGIN
SELECT inventory_id
FROM inventory
WHERE film_id = p_film_id
AND store_id = p_store_id
AND NOT inventory_in_stock(inventory_id);
SELECT COUNT(*)
FROM inventory
WHERE film_id = p_film_id
AND store_id = p_store_id
AND NOT inventory_in_stock(inventory_id)
INTO p_film_count;
END;
CREATE FUNCTION IF NOT EXISTS inventory_held_by_customer(p_inventory_id INT) RETURNS INT
READS SQL DATA
BEGIN
DECLARE v_customer_id INT;
DECLARE EXIT HANDLER FOR NOT FOUND RETURN NULL;
SELECT customer_id INTO v_customer_id
FROM rental
WHERE return_date IS NULL
AND inventory_id = p_inventory_id;
RETURN v_customer_id;
END;
CREATE FUNCTION IF NOT EXISTS inventory_in_stock(p_inventory_id INT) RETURNS BOOLEAN
READS SQL DATA
BEGIN
DECLARE v_rentals INT;
DECLARE v_out INT;
#AN ITEM IS IN-STOCK IF THERE ARE EITHER NO ROWS IN THE rental TABLE
#FOR THE ITEM OR ALL ROWS HAVE return_date POPULATED
SELECT COUNT(*) INTO v_rentals
FROM rental
WHERE inventory_id = p_inventory_id;
IF v_rentals = 0 THEN
RETURN TRUE;
END IF;
SELECT COUNT(rental_id) INTO v_out
FROM inventory LEFT JOIN rental USING(inventory_id)
WHERE inventory.inventory_id = p_inventory_id
AND rental.return_date IS NULL;
IF v_out > 0 THEN
RETURN FALSE;
ELSE
RETURN TRUE;
END IF;
END;
SET SQL_MODE=@OLD_SQL_MODE;
SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;
SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS;

46449
packages/nocodb/tests/mysql-sakila-db/04-test-sakila-data.sql

File diff suppressed because one or more lines are too long

243
packages/nocodb/tests/unit/TestDbMngr.ts

@ -0,0 +1,243 @@
import { DbConfig } from "../../src/interface/config";
import { NcConfigFactory } from "../../src/lib";
import SqlMgrv2 from "../../src/lib/db/sql-mgr/v2/SqlMgrv2";
import fs from 'fs';
import knex from "knex";
import process from "process";
export default class TestDbMngr {
public static readonly dbName = 'test_meta';
public static readonly sakilaDbName = 'test_sakila';
public static metaKnex: knex;
public static sakilaKnex: knex;
public static defaultConnection = {
user: process.env['DB_USER'] || 'root',
password: process.env['DB_PASSWORD'] || 'password',
host: process.env['DB_HOST'] || 'localhost',
port: Number(process.env['DB_PORT']) || 3306,
client: 'mysql2',
}
public static dbConfig: DbConfig;
static async testConnection(config: DbConfig) {
try {
return await SqlMgrv2.testConnection(config);
} catch (e) {
console.log(e);
return { code: -1, message: 'Connection invalid' };
}
}
static async init() {
if(await TestDbMngr.isMysqlConfigured()){
await TestDbMngr.connectMysql();
} else {
await TestDbMngr.switchToSqlite();
}
}
static async isMysqlConfigured() {
const { user, password, host, port, client } = TestDbMngr.defaultConnection;
const config = NcConfigFactory.urlToDbConfig(`${client}://${user}:${password}@${host}:${port}`);
config.connection = {
user,
password,
host,
port,
}
const result = await TestDbMngr.testConnection(config);
return result.code !== -1;
}
static async connectMysql() {
const { user, password, host, port, client } = TestDbMngr.defaultConnection;
if(!process.env[`DATABASE_URL`]){
process.env[`DATABASE_URL`] = `${client}://${user}:${password}@${host}:${port}/${TestDbMngr.dbName}`;
}
TestDbMngr.dbConfig = NcConfigFactory.urlToDbConfig(
NcConfigFactory.extractXcUrlFromJdbc(process.env[`DATABASE_URL`])
);
this.dbConfig.meta = {
tn: 'nc_evolutions',
dbAlias: 'db',
api: {
type: 'rest',
prefix: '',
graphqlDepthLimit: 10,
},
inflection: {
tn: 'camelize',
cn: 'camelize',
},
}
await TestDbMngr.setupMeta();
await TestDbMngr.setupSakila();
}
static async setupMeta() {
if(TestDbMngr.metaKnex){
await TestDbMngr.metaKnex.destroy();
}
if(TestDbMngr.isSqlite()){
await TestDbMngr.resetMetaSqlite();
TestDbMngr.metaKnex = knex(TestDbMngr.getMetaDbConfig());
return
}
TestDbMngr.metaKnex = knex(TestDbMngr.getDbConfigWithNoDb());
await TestDbMngr.resetDatabase(TestDbMngr.metaKnex, TestDbMngr.dbName);
await TestDbMngr.metaKnex.destroy();
TestDbMngr.metaKnex = knex(TestDbMngr.getMetaDbConfig());
await TestDbMngr.useDatabase(TestDbMngr.metaKnex, TestDbMngr.dbName);
}
static async setupSakila () {
if(TestDbMngr.sakilaKnex) {
await TestDbMngr.sakilaKnex.destroy();
}
if(TestDbMngr.isSqlite()){
await TestDbMngr.seedSakila();
TestDbMngr.sakilaKnex = knex(TestDbMngr.getSakilaDbConfig());
return
}
TestDbMngr.sakilaKnex = knex(TestDbMngr.getDbConfigWithNoDb());
await TestDbMngr.resetDatabase(TestDbMngr.sakilaKnex, TestDbMngr.sakilaDbName);
await TestDbMngr.sakilaKnex.destroy();
TestDbMngr.sakilaKnex = knex(TestDbMngr.getSakilaDbConfig());
await TestDbMngr.useDatabase(TestDbMngr.sakilaKnex, TestDbMngr.sakilaDbName);
}
static async switchToSqlite() {
// process.env[`DATABASE_URL`] = `sqlite3:///?database=${__dirname}/${TestDbMngr.dbName}.sqlite`;
TestDbMngr.dbConfig = {
client: 'sqlite3',
connection: {
filename: `${__dirname}/${TestDbMngr.dbName}.db`,
database: TestDbMngr.dbName,
},
useNullAsDefault: true,
meta: {
tn: 'nc_evolutions',
dbAlias: 'db',
api: {
type: 'rest',
prefix: '',
graphqlDepthLimit: 10,
},
inflection: {
tn: 'camelize',
cn: 'camelize',
},
},
}
process.env[`NC_DB`] = `sqlite3:///?database=${__dirname}/${TestDbMngr.dbName}.db`;
await TestDbMngr.setupMeta();
await TestDbMngr.setupSakila();
}
private static async resetDatabase(knexClient, dbName) {
if(TestDbMngr.isSqlite()){
// return knexClient.raw(`DELETE FROM sqlite_sequence`);
} else {
try {
await knexClient.raw(`DROP DATABASE ${dbName}`);
} catch(e) {}
await knexClient.raw(`CREATE DATABASE ${dbName}`);
console.log(`Database ${dbName} created`);
await knexClient.raw(`USE ${dbName}`);
}
}
static isSqlite() {
return TestDbMngr.dbConfig.client === 'sqlite3';
}
private static async useDatabase(knexClient, dbName) {
if(!TestDbMngr.isSqlite()){
await knexClient.raw(`USE ${dbName}`);
}
}
static getDbConfigWithNoDb() {
const dbConfig =JSON.parse(JSON.stringify(TestDbMngr.dbConfig));
delete dbConfig.connection.database;
return dbConfig;
}
static getMetaDbConfig() {
return TestDbMngr.dbConfig;
}
private static resetMetaSqlite() {
if(fs.existsSync(`${__dirname}/test_meta.db`)){
fs.unlinkSync(`${__dirname}/test_meta.db`);
}
}
static getSakilaDbConfig() {
const sakilaDbConfig = JSON.parse(JSON.stringify(TestDbMngr.dbConfig));
sakilaDbConfig.connection.database = TestDbMngr.sakilaDbName;
sakilaDbConfig.connection.multipleStatements = true
if(TestDbMngr.isSqlite()){
sakilaDbConfig.connection.filename = `${__dirname}/test_sakila.db`;
}
return sakilaDbConfig;
}
static async seedSakila() {
const testsDir = __dirname.replace('tests/unit', 'tests');
if(TestDbMngr.isSqlite()){
if(fs.existsSync(`${__dirname}/test_sakila.db`)){
fs.unlinkSync(`${__dirname}/test_sakila.db`);
}
fs.copyFileSync(`${testsDir}/sqlite-sakila-db/sakila.db`, `${__dirname}/test_sakila.db`);
} else {
const schemaFile = fs.readFileSync(`${testsDir}/mysql-sakila-db/03-test-sakila-schema.sql`).toString();
const dataFile = fs.readFileSync(`${testsDir}/mysql-sakila-db/04-test-sakila-data.sql`).toString();
await TestDbMngr.sakilaKnex.raw(schemaFile);
await TestDbMngr.sakilaKnex.raw(dataFile);
}
}
static async disableForeignKeyChecks(knexClient) {
if(TestDbMngr.isSqlite()){
await knexClient.raw("PRAGMA foreign_keys = OFF");
}
else {
await knexClient.raw(`SET FOREIGN_KEY_CHECKS = 0`);
}
}
static async enableForeignKeyChecks(knexClient) {
if(TestDbMngr.isSqlite()){
await knexClient.raw(`PRAGMA foreign_keys = ON;`);
}
else {
await knexClient.raw(`SET FOREIGN_KEY_CHECKS = 1`);
}
}
static async showAllTables(knexClient) {
if(TestDbMngr.isSqlite()){
const tables = await knexClient.raw(`SELECT name FROM sqlite_master WHERE type='table'`);
return tables.filter(t => t.name !== 'sqlite_sequence' && t.name !== '_evolutions').map(t => t.name);
}
else {
const response = await knexClient.raw(`SHOW TABLES`);
return response[0].map(
(table) => Object.values(table)[0]
);
}
}
}

203
packages/nocodb/tests/unit/factory/column.ts

@ -0,0 +1,203 @@
import { UITypes } from 'nocodb-sdk';
import request from 'supertest';
import Column from '../../../src/lib/models/Column';
import FormViewColumn from '../../../src/lib/models/FormViewColumn';
import GalleryViewColumn from '../../../src/lib/models/GalleryViewColumn';
import GridViewColumn from '../../../src/lib/models/GridViewColumn';
import Model from '../../../src/lib/models/Model';
import Project from '../../../src/lib/models/Project';
import View from '../../../src/lib/models/View';
import { isSqlite } from '../init/db';
const defaultColumns = function(context) {
return [
{
column_name: 'id',
title: 'Id',
uidt: 'ID',
},
{
column_name: 'title',
title: 'Title',
uidt: 'SingleLineText',
},
{
cdf: 'CURRENT_TIMESTAMP',
column_name: 'created_at',
title: 'CreatedAt',
dtxp: '',
dtxs: '',
uidt: 'DateTime',
},
{
cdf: isSqlite(context) ? 'CURRENT_TIMESTAMP': 'CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP',
column_name: 'updated_at',
title: 'UpdatedAt',
dtxp: '',
dtxs: '',
uidt: 'DateTime',
},
]
};
const createColumn = async (context, table, columnAttr) => {
await request(context.app)
.post(`/api/v1/db/meta/tables/${table.id}/columns`)
.set('xc-auth', context.token)
.send({
...columnAttr,
});
const column: Column = (await table.getColumns()).find(
(column) => column.title === columnAttr.title
);
return column;
};
const createRollupColumn = async (
context,
{
project,
title,
rollupFunction,
table,
relatedTableName,
relatedTableColumnTitle,
}: {
project: Project;
title: string;
rollupFunction: string;
table: Model;
relatedTableName: string;
relatedTableColumnTitle: string;
}
) => {
const childBases = await project.getBases();
const childTable = await Model.getByIdOrName({
project_id: project.id,
base_id: childBases[0].id!,
table_name: relatedTableName,
});
const childTableColumns = await childTable.getColumns();
const childTableColumn = await childTableColumns.find(
(column) => column.title === relatedTableColumnTitle
);
const ltarColumn = (await table.getColumns()).find(
(column) =>
column.uidt === UITypes.LinkToAnotherRecord &&
column.colOptions?.fk_related_model_id === childTable.id
);
const rollupColumn = await createColumn(context, table, {
title: title,
uidt: UITypes.Rollup,
fk_relation_column_id: ltarColumn?.id,
fk_rollup_column_id: childTableColumn?.id,
rollup_function: rollupFunction,
table_name: table.table_name,
column_name: title,
});
return rollupColumn;
};
const createLookupColumn = async (
context,
{
project,
title,
table,
relatedTableName,
relatedTableColumnTitle,
}: {
project: Project;
title: string;
table: Model;
relatedTableName: string;
relatedTableColumnTitle: string;
}
) => {
const childBases = await project.getBases();
const childTable = await Model.getByIdOrName({
project_id: project.id,
base_id: childBases[0].id!,
table_name: relatedTableName,
});
const childTableColumns = await childTable.getColumns();
const childTableColumn = await childTableColumns.find(
(column) => column.title === relatedTableColumnTitle
);
if (!childTableColumn) {
throw new Error(
`Could not find column ${relatedTableColumnTitle} in ${relatedTableName}`
);
}
const ltarColumn = (await table.getColumns()).find(
(column) =>
column.uidt === UITypes.LinkToAnotherRecord &&
column.colOptions?.fk_related_model_id === childTable.id
);
const lookupColumn = await createColumn(context, table, {
title: title,
uidt: UITypes.Lookup,
fk_relation_column_id: ltarColumn?.id,
fk_lookup_column_id: childTableColumn?.id,
table_name: table.table_name,
column_name: title,
});
return lookupColumn;
};
const createLtarColumn = async (
context,
{
title,
parentTable,
childTable,
type,
}: {
title: string;
parentTable: Model;
childTable: Model;
type: string;
}
) => {
const ltarColumn = await createColumn(context, parentTable, {
title: title,
column_name: title,
uidt: UITypes.LinkToAnotherRecord,
parentId: parentTable.id,
childId: childTable.id,
type: type,
});
return ltarColumn;
};
const updateViewColumn = async (context, {view, column, attr}: {column: Column, view: View, attr: any}) => {
const res = await request(context.app)
.patch(`/api/v1/db/meta/views/${view.id}/columns/${column.id}`)
.set('xc-auth', context.token)
.send({
...attr,
});
const updatedColumn: FormViewColumn | GridViewColumn | GalleryViewColumn = (await view.getColumns()).find(
(column) => column.id === column.id
)!;
return updatedColumn;
}
export {
defaultColumns,
createColumn,
createRollupColumn,
createLookupColumn,
createLtarColumn,
updateViewColumn
};

64
packages/nocodb/tests/unit/factory/project.ts

@ -0,0 +1,64 @@
import request from 'supertest';
import Project from '../../../src/lib/models/Project';
import TestDbMngr from '../TestDbMngr';
const externalProjectConfig = {
title: 'sakila',
bases: [
{
type: 'mysql2',
config: {
client: 'mysql2',
connection: {
host: 'localhost',
port: '3306',
user: 'root',
password: 'password',
database: TestDbMngr.sakilaDbName,
},
},
inflection_column: 'camelize',
inflection_table: 'camelize',
},
],
external: true,
};
const defaultProjectValue = {
title: 'Title',
};
const defaultSharedBaseValue = {
roles: 'viewer',
password: 'test',
};
const createSharedBase = async (app, token, project, sharedBaseArgs = {}) => {
await request(app)
.post(`/api/v1/db/meta/projects/${project.id}/shared`)
.set('xc-auth', token)
.send({
...defaultSharedBaseValue,
...sharedBaseArgs,
});
};
const createSakilaProject = async (context) => {
const response = await request(context.app)
.post('/api/v1/db/meta/projects/')
.set('xc-auth', context.token)
.send(externalProjectConfig);
return (await Project.getByTitleOrId(response.body.id)) as Project;
};
const createProject = async (context, projectArgs = defaultProjectValue) => {
const response = await request(context.app)
.post('/api/v1/db/meta/projects/')
.set('xc-auth', context.token)
.send(projectArgs);
return (await Project.getByTitleOrId(response.body.id)) as Project;
};
export { createProject, createSharedBase, createSakilaProject };

181
packages/nocodb/tests/unit/factory/row.ts

@ -0,0 +1,181 @@
import { ColumnType, UITypes } from 'nocodb-sdk';
import request from 'supertest';
import Column from '../../../src/lib/models/Column';
import Filter from '../../../src/lib/models/Filter';
import Model from '../../../src/lib/models/Model';
import Project from '../../../src/lib/models/Project';
import Sort from '../../../src/lib/models/Sort';
import NcConnectionMgrv2 from '../../../src/lib/utils/common/NcConnectionMgrv2';
const rowValue = (column: ColumnType, index: number) => {
switch (column.uidt) {
case UITypes.Number:
return index;
case UITypes.SingleLineText:
return `test-${index}`;
case UITypes.Date:
return '2020-01-01';
case UITypes.DateTime:
return '2020-01-01 00:00:00';
case UITypes.Email:
return `test-${index}@example.com`;
default:
return `test-${index}`;
}
};
const getRow = async (context, {project, table, id}) => {
const response = await request(context.app)
.get(`/api/v1/db/data/noco/${project.id}/${table.id}/${id}`)
.set('xc-auth', context.token);
return response.body;
};
const listRow = async ({
project,
table,
options,
}: {
project: Project;
table: Model;
options?: {
limit?: any;
offset?: any;
filterArr?: Filter[];
sortArr?: Sort[];
};
}) => {
const bases = await project.getBases();
const baseModel = await Model.getBaseModelSQL({
id: table.id,
dbDriver: NcConnectionMgrv2.get(bases[0]!),
});
const ignorePagination = !options;
return await baseModel.list(options, ignorePagination);
};
const getOneRow = async (
context,
{ project, table }: { project: Project; table: Model }
) => {
const response = await request(context.app)
.get(`/api/v1/db/data/noco/${project.id}/${table.id}/find-one`)
.set('xc-auth', context.token);
return response.body;
};
const generateDefaultRowAttributes = ({
columns,
index = 0,
}: {
columns: ColumnType[];
index?: number;
}) =>
columns.reduce((acc, column) => {
if (
column.uidt === UITypes.LinkToAnotherRecord ||
column.uidt === UITypes.ForeignKey ||
column.uidt === UITypes.ID
) {
return acc;
}
acc[column.title!] = rowValue(column, index);
return acc;
}, {});
const createRow = async (
context,
{
project,
table,
index = 0,
}: {
project: Project;
table: Model;
index?: number;
}
) => {
const columns = await table.getColumns();
const rowData = generateDefaultRowAttributes({ columns, index });
const response = await request(context.app)
.post(`/api/v1/db/data/noco/${project.id}/${table.id}`)
.set('xc-auth', context.token)
.send(rowData);
return response.body;
};
const createBulkRows = async (
context,
{
project,
table,
values
}: {
project: Project;
table: Model;
values: any[];
}) => {
await request(context.app)
.post(`/api/v1/db/data/bulk/noco/${project.id}/${table.id}`)
.set('xc-auth', context.token)
.send(values)
.expect(200);
}
// Links 2 table rows together. Will create rows if ids are not provided
const createChildRow = async (
context,
{
project,
table,
childTable,
column,
rowId,
childRowId,
type,
}: {
project: Project;
table: Model;
childTable: Model;
column: Column;
rowId?: string;
childRowId?: string;
type: string;
}
) => {
if (!rowId) {
const row = await createRow(context, { project, table });
rowId = row['Id'];
}
if (!childRowId) {
const row = await createRow(context, { table: childTable, project });
childRowId = row['Id'];
}
await request(context.app)
.post(
`/api/v1/db/data/noco/${project.id}/${table.id}/${rowId}/${type}/${column.title}/${childRowId}`
)
.set('xc-auth', context.token);
const row = await getRow(context, { project, table, id: rowId });
return row;
};
export {
createRow,
getRow,
createChildRow,
getOneRow,
listRow,
generateDefaultRowAttributes,
createBulkRows
};

42
packages/nocodb/tests/unit/factory/table.ts

@ -0,0 +1,42 @@
import request from 'supertest';
import Model from '../../../src/lib/models/Model';
import Project from '../../../src/lib/models/Project';
import { defaultColumns } from './column';
const defaultTableValue = (context) => ({
table_name: 'Table1',
title: 'Table1_Title',
columns: defaultColumns(context),
});
const createTable = async (context, project, args = {}) => {
const defaultValue = defaultTableValue(context);
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({ ...defaultValue, ...args });
const table: Model = await Model.get(response.body.id);
return table;
};
const getTable = async ({project, name}: {project: Project, name: string}) => {
const bases = await project.getBases();
return await Model.getByIdOrName({
project_id: project.id,
base_id: bases[0].id!,
table_name: name,
});
}
const getAllTables = async ({project}: {project: Project}) => {
const bases = await project.getBases();
const tables = await Model.list({
project_id: project.id,
base_id: bases[0].id!,
});
return tables;
}
export { createTable, getTable, getAllTables };

18
packages/nocodb/tests/unit/factory/user.ts

@ -0,0 +1,18 @@
import request from 'supertest';
import User from '../../../src/lib/models/User';
const defaultUserArgs = {
email: 'test@example.com',
password: 'A1234abh2@dsad',
};
const createUser = async (context, userArgs = {}) => {
const args = { ...defaultUserArgs, ...userArgs };
const response = await request(context.app)
.post('/api/v1/auth/user/signup')
.send(args);
const user = User.getByEmail(args.email);
return { token: response.body.token, user };
};
export { createUser, defaultUserArgs };

35
packages/nocodb/tests/unit/factory/view.ts

@ -0,0 +1,35 @@
import { ViewTypes } from 'nocodb-sdk';
import request from 'supertest';
import Model from '../../../src/lib/models/Model';
import View from '../../../src/lib/models/View';
const createView = async (context, {title, table, type}: {title: string, table: Model, type: ViewTypes}) => {
const viewTypeStr = (type) => {
switch (type) {
case ViewTypes.GALLERY:
return 'galleries';
case ViewTypes.FORM:
return 'forms';
case ViewTypes.GRID:
return 'grids';
case ViewTypes.KANBAN:
return 'kanbans';
default:
throw new Error('Invalid view type');
}
};
await request(context.app)
.post(`/api/v1/db/meta/tables/${table.id}/${viewTypeStr(type)}`)
.set('xc-auth', context.token)
.send({
title,
type,
});
const view = await View.getByTitleOrId({fk_model_id: table.id, titleOrId:title}) as View;
return view
}
export {createView}

20
packages/nocodb/tests/unit/index.test.ts

@ -0,0 +1,20 @@
import 'mocha';
import restTests from './rest/index.test';
import modelTests from './model/index.test';
import TestDbMngr from './TestDbMngr'
process.env.NODE_ENV = 'test';
process.env.TEST = 'test';
process.env.NC_DISABLE_CACHE = 'true';
process.env.NC_DISABLE_TELE = 'true';
(async function() {
await TestDbMngr.init();
modelTests();
restTests();
run();
})();

56
packages/nocodb/tests/unit/init/cleanupMeta.ts

@ -0,0 +1,56 @@
import Model from "../../../src/lib/models/Model";
import Project from "../../../src/lib/models/Project";
import NcConnectionMgrv2 from "../../../src/lib/utils/common/NcConnectionMgrv2";
import { orderedMetaTables } from "../../../src/lib/utils/globals";
import TestDbMngr from "../TestDbMngr";
const dropTablesAllNonExternalProjects = async () => {
const projects = await Project.list({});
const userCreatedTableNames: string[] = [];
await Promise.all(
projects
.filter((project) => project.is_meta)
.map(async (project) => {
await project.getBases();
const base = project.bases && project.bases[0];
if (!base) return;
const models = await Model.list({
project_id: project.id,
base_id: base.id!,
});
models.forEach((model) => {
userCreatedTableNames.push(model.table_name);
});
})
);
await TestDbMngr.disableForeignKeyChecks(TestDbMngr.metaKnex);
for (const tableName of userCreatedTableNames) {
await TestDbMngr.metaKnex.raw(`DROP TABLE ${tableName}`);
}
await TestDbMngr.enableForeignKeyChecks(TestDbMngr.metaKnex);
};
const cleanupMetaTables = async () => {
await TestDbMngr.disableForeignKeyChecks(TestDbMngr.metaKnex);
for (const tableName of orderedMetaTables) {
try {
await TestDbMngr.metaKnex.raw(`DELETE FROM ${tableName}`);
} catch (e) {}
}
await TestDbMngr.enableForeignKeyChecks(TestDbMngr.metaKnex);
};
export default async function () {
try {
await NcConnectionMgrv2.destroyAll();
await dropTablesAllNonExternalProjects();
await cleanupMetaTables();
} catch (e) {
console.error('cleanupMeta', e);
}
}

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

Loading…
Cancel
Save