Browse Source

Merge branch 'develop' into feat/pnpm

pull/5903/head
Wing-Kam Wong 10 months ago
parent
commit
fe008165c7
  1. 20
      .github/workflows/dispatch-oss.yml
  2. 18
      .github/workflows/playwright-test-workflow.yml
  3. 11
      packages/nc-gui/components.d.ts
  4. 6
      packages/nc-gui/components/api-client/Params.vue
  5. 9
      packages/nc-gui/components/dlg/AirtableImport.vue
  6. 5
      packages/nc-gui/components/smartsheet/Gallery.vue
  7. 13
      packages/nc-gui/components/smartsheet/column/AdvancedOptions.vue
  8. 2
      packages/nc-gui/components/smartsheet/grid/Table.vue
  9. 8
      packages/nc-gui/components/smartsheet/header/Cell.vue
  10. 32
      packages/nc-gui/layouts/shared-view.vue
  11. 19
      packages/nc-gui/pages/index-old/apps.vue
  12. 11
      packages/nc-gui/pages/index-old/index.vue
  13. 110
      packages/nc-gui/pages/index-old/index/[projectId].vue
  14. 643
      packages/nc-gui/pages/index-old/index/create-external.vue
  15. 171
      packages/nc-gui/pages/index-old/index/create.vue
  16. 354
      packages/nc-gui/pages/index-old/index/index-old.vue
  17. 306
      packages/nc-gui/pages/index-old/index/index.vue
  18. 162
      packages/nc-gui/pages/index-old/index/user.vue
  19. 11
      packages/nc-gui/pages/index.vue
  20. 2
      packages/nc-gui/pages/index/[typeOrId].vue
  21. 4
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId].vue
  22. 2
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  23. 3
      packages/nc-gui/pages/signin.vue
  24. 3
      packages/nc-gui/pages/signup/[[token]].vue
  25. 21
      packages/nc-gui/pages/ws/[typeOrId].vue
  26. 44
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]].vue
  27. 155
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index.vue
  28. 157
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index.vue
  29. 38
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/[type]/[viewId]/[[viewTitle]].vue
  30. 3
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/auth.vue
  31. 17
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/erd/[baseId].vue
  32. 133
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/index.vue
  33. 3
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/sql/[baseId].vue
  34. 16
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/base/[baseId].vue
  35. 3
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/cowriter/[projectId]/[...slugs].vue
  36. 126
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId].vue
  37. 148
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index.vue
  38. 141
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index/index.vue
  39. 472
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index/survey.vue
  40. 35
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/gallery/[viewId]/index.vue
  41. 22
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/index.vue
  42. 35
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/kanban/[viewId]/index.vue
  43. 35
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/map/[viewId]/index.vue
  44. 59
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/shared/[erdUuid]/index.vue
  45. 36
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/view/[viewId].vue
  46. 2
      packages/nc-lib-gui/package.json
  47. 17
      packages/noco-docs/docs/020.getting-started/010.installation.md
  48. 2
      packages/noco-docs/docs/020.getting-started/020.environment-variables.md
  49. 2
      packages/noco-docs/docs/030.setup-and-usages/061.links.md
  50. 295
      packages/noco-docs/docs/040.developer-resources/040.webhooks.md
  51. 4
      packages/noco-docs/docusaurus.config.js
  52. 4
      packages/noco-docs/src/css/header.scss
  53. BIN
      packages/noco-docs/static/img/nocodb-full-color.png
  54. 26
      packages/noco-docs/versioned_docs/version-0.109.7/020.getting-started/010.installation.md
  55. 2
      packages/noco-docs/versioned_docs/version-0.109.7/030.setup-and-usages/220.links.md
  56. 2
      packages/nocodb-sdk/package.json
  57. 135
      packages/nocodb-sdk/src/lib/Api.ts
  58. 6
      packages/nocodb/package.json
  59. 36
      packages/nocodb/src/db/BaseModelSqlv2.ts
  60. 3
      packages/nocodb/src/modules/jobs/jobs/at-import/at-import.processor.ts
  61. 9
      packages/nocodb/src/modules/jobs/jobs/at-import/helpers/fetchAT.ts
  62. 241
      packages/nocodb/src/schema/swagger.json
  63. 12
      packages/nocodb/src/services/project-users/project-users.service.ts
  64. 6
      packages/nocodb/src/services/telemetry.service.ts
  65. 1
      packages/nocodb/src/utils/projectAcl.ts
  66. 3
      tests/playwright/pages/Account/AppStore.ts
  67. 2
      tests/playwright/pages/Dashboard/BarcodeOverlay/index.ts
  68. 29
      tests/playwright/pages/Dashboard/BulkUpdate/index.ts
  69. 107
      tests/playwright/pages/Dashboard/Details/ErdPage.ts
  70. 3
      tests/playwright/pages/Dashboard/Details/index.ts
  71. 14
      tests/playwright/pages/Dashboard/ExpandedForm/index.ts
  72. 44
      tests/playwright/pages/Dashboard/Form/index.ts
  73. 10
      tests/playwright/pages/Dashboard/Grid/Column/Attachment.ts
  74. 16
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/ChildList.ts
  75. 12
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts
  76. 8
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  77. 46
      tests/playwright/pages/Dashboard/Grid/columnHeader.ts
  78. 55
      tests/playwright/pages/Dashboard/Grid/index.ts
  79. 8
      tests/playwright/pages/Dashboard/Import/ImportTemplate.ts
  80. 32
      tests/playwright/pages/Dashboard/Kanban/index.ts
  81. 6
      tests/playwright/pages/Dashboard/Map/index.ts
  82. 4
      tests/playwright/pages/Dashboard/ProjectView/AccessSettingsPage.ts
  83. 76
      tests/playwright/pages/Dashboard/ProjectView/Audit.ts
  84. 33
      tests/playwright/pages/Dashboard/ProjectView/DataSourcePage.ts
  85. 22
      tests/playwright/pages/Dashboard/ProjectView/Metadata.ts
  86. 2
      tests/playwright/pages/Dashboard/QrCodeOverlay/index.ts
  87. 12
      tests/playwright/pages/Dashboard/Settings/Audit.ts
  88. 2
      tests/playwright/pages/Dashboard/Settings/DataSources.ts
  89. 8
      tests/playwright/pages/Dashboard/Settings/Teams.ts
  90. 2
      tests/playwright/pages/Dashboard/ShareProjectButton/index.ts
  91. 2
      tests/playwright/pages/Dashboard/Sidebar/DocsSidebar.ts
  92. 4
      tests/playwright/pages/Dashboard/SurveyForm/index.ts
  93. 43
      tests/playwright/pages/Dashboard/TreeView.ts
  94. 2
      tests/playwright/pages/Dashboard/ViewSidebar/index.ts
  95. 8
      tests/playwright/pages/Dashboard/WebhookForm/index.ts
  96. 4
      tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts
  97. 2
      tests/playwright/pages/Dashboard/common/Cell/DateCell.ts
  98. 2
      tests/playwright/pages/Dashboard/common/Cell/DateTimeCell.ts
  99. 2
      tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts
  100. 2
      tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts
  101. Some files were not shown because too many files have changed in this diff Show More

20
.github/workflows/dispatch-oss.yml

@ -0,0 +1,20 @@
name: "Dispatch OSS"
on:
workflow_dispatch:
push:
branches: [ develop ]
jobs:
push:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
github-token: ${{ secrets.OSS_DISPATCH }}
script: |
const result = await github.rest.repos.createDispatchEvent({
owner: 'nocodb',
repo: 'nocohub',
event_type: 'OSS'
})

18
.github/workflows/playwright-test-workflow.yml

@ -181,4 +181,20 @@ jobs:
working-directory: ./packages/nocodb
run: |
service postgresql stop
service mysql stop
service mysql stop
- name: Copy Artifacts to Local Artifacts Dir
if: always()
working-directory: ./
run: |
# expects the variables to be available in runner context.
path="gh-artifacts/runs/${GITHUB_RUN_ID}/run-attempt/${GITHUB_RUN_ATTEMPT}/job_name/"${GITHUB_JOB}"-${{ inputs.shard }}/${RANDOM}-$(date +%s)"
target_dir="/mnt/${path}"
mkdir -p ${target_dir}
mkdir -p ${target_dir}/playwright-report
# start : add any artifacts to be copied here
cp -r ./tests/playwright/playwright-report ${target_dir}/ || echo "playwright reports directory does not exists" >> ${target_dir}/playwright-report/index.html
cp ./packages/nocodb/*_test_backend.log ${target_dir}/ || echo "backend logs file does not exists" >> ${target_dir}/index.html
# end: artifacts copy
SUMMARY='[Artifacts](http://135.181.48.96/'${path}')
[playwright-report](http://135.181.48.96/'${path}'/playwright-report)'
echo "$SUMMARY" >> $GITHUB_STEP_SUMMARY

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

@ -80,12 +80,10 @@ declare module '@vue/runtime-core' {
ClarityColorPickerSolid: typeof import('~icons/clarity/color-picker-solid')['default']
ClaritySuccessLine: typeof import('~icons/clarity/success-line')['default']
IcBaselineMoreVert: typeof import('~icons/ic/baseline-more-vert')['default']
IcOutlineAccessTime: typeof import('~icons/ic/outline-access-time')['default']
IcOutlineInsertDriveFile: typeof import('~icons/ic/outline-insert-drive-file')['default']
IcRoundEdit: typeof import('~icons/ic/round-edit')['default']
IcRoundKeyboardArrowDown: typeof import('~icons/ic/round-keyboard-arrow-down')['default']
IcRoundSearch: typeof import('~icons/ic/round-search')['default']
IcRoundStarBorder: typeof import('~icons/ic/round-star-border')['default']
LogosGoogleGmail: typeof import('~icons/logos/google-gmail')['default']
MaterialSymbolsArrowCircleLeftRounded: typeof import('~icons/material-symbols/arrow-circle-left-rounded')['default']
MaterialSymbolsArrowCircleRightRounded: typeof import('~icons/material-symbols/arrow-circle-right-rounded')['default']
@ -95,8 +93,6 @@ declare module '@vue/runtime-core' {
MaterialSymbolsDarkModeOutline: typeof import('~icons/material-symbols/dark-mode-outline')['default']
MaterialSymbolsDeleteOutlineRounded: typeof import('~icons/material-symbols/delete-outline-rounded')['default']
MaterialSymbolsFileCopyOutline: typeof import('~icons/material-symbols/file-copy-outline')['default']
MaterialSymbolsGroupOutlineRounded: typeof import('~icons/material-symbols/group-outline-rounded')['default']
MaterialSymbolsInboxOutlineRounded: typeof import('~icons/material-symbols/inbox-outline-rounded')['default']
MaterialSymbolsKeyboardArrowDownRounded: typeof import('~icons/material-symbols/keyboard-arrow-down-rounded')['default']
MaterialSymbolsKeyboardReturn: typeof import('~icons/material-symbols/keyboard-return')['default']
MaterialSymbolsLightModeOutline: typeof import('~icons/material-symbols/light-mode-outline')['default']
@ -125,20 +121,15 @@ declare module '@vue/runtime-core' {
MdiChatProcessingOutline: typeof import('~icons/mdi/chat-processing-outline')['default']
MdiCheck: typeof import('~icons/mdi/check')['default']
MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default']
MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
MdiCircleMedium: typeof import('~icons/mdi/circle-medium')['default']
MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseCircleOutline: typeof import('~icons/mdi/close-circle-outline')['default']
MdiCodeTags: typeof import('~icons/mdi/code-tags')['default']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDatabaseOutline: typeof import('~icons/mdi/database-outline')['default']
MdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
MdiDiscord: typeof import('~icons/mdi/discord')['default']
MdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default']
MdiEditOutline: typeof import('~icons/mdi/edit-outline')['default']
MdiEye: typeof import('~icons/mdi/eye')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiFolder: typeof import('~icons/mdi/folder')['default']
@ -152,8 +143,6 @@ declare module '@vue/runtime-core' {
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default']
MdiMoonFull: typeof import('~icons/mdi/moon-full')['default']
MdiPlus: typeof import('~icons/mdi/plus')['default']
MdiPlusOutline: typeof import('~icons/mdi/plus-outline')['default']
MdiRefresh: typeof import('~icons/mdi/refresh')['default']
MdiReload: typeof import('~icons/mdi/reload')['default']
MdiRocketLaunchOutline: typeof import('~icons/mdi/rocket-launch-outline')['default']
MdiScriptTextOutline: typeof import('~icons/mdi/script-text-outline')['default']

6
packages/nc-gui/components/api-client/Params.vue

@ -23,6 +23,7 @@ const deleteParamRow = (i: number) => {
<table class="w-full nc-webhooks-params">
<thead class="h-8">
<tr>
<th></th>
<th>
<div class="text-left font-normal ml-2">Parameter Name</div>
</th>
@ -39,6 +40,11 @@ const deleteParamRow = (i: number) => {
<tbody>
<tr v-for="(paramRow, idx) in vModel" :key="idx" class="!h-2 overflow-hidden">
<td class="px-2 nc-hook-params-tab-checkbox">
<a-form-item class="form-item">
<a-checkbox v-model:checked="paramRow.enabled" />
</a-form-item>
</td>
<td class="px-2">
<a-form-item class="form-item">
<a-input v-model:value="paramRow.name" placeholder="Key" class="!rounded-lg" />

9
packages/nc-gui/components/dlg/AirtableImport.vue

@ -57,6 +57,7 @@ const syncSource = ref({
syncDirection: 'Airtable to NocoDB',
syncRetryCount: 1,
apiKey: '',
appId: '',
shareId: '',
syncSourceUrlOrId: '',
options: {
@ -158,7 +159,8 @@ async function loadSyncSrc() {
if (srcs && srcs[0]) {
srcs[0].details = srcs[0].details || {}
syncSource.value = migrateSync(srcs[0])
syncSource.value.details.syncSourceUrlOrId = srcs[0].details.shareId
syncSource.value.details.syncSourceUrlOrId =
srcs[0].details.appId && srcs[0].details.appId.length > 0 ? srcs[0].details.syncSourceUrlOrId : srcs[0].details.shareId
$jobs.subscribe({ syncId: syncSource.value.id }, onSubscribe, onStatus, onLog)
} else {
syncSource.value = {
@ -169,6 +171,7 @@ async function loadSyncSrc() {
syncDirection: 'Airtable to NocoDB',
syncRetryCount: 1,
apiKey: '',
appId: '',
shareId: '',
syncSourceUrlOrId: '',
options: {
@ -242,6 +245,8 @@ watch(
if (syncSource.value.details) {
const m = v && v.match(/(exp|shr).{14}/g)
syncSource.value.details.shareId = m ? m[0] : ''
const m2 = v && v.match(/(app).{14}/g)
syncSource.value.details.appId = m2 ? m2[0] : ''
}
},
)
@ -296,7 +301,7 @@ onMounted(async () => {
<a-input
v-model:value="syncSource.details.syncSourceUrlOrId"
class="nc-input-shared-base"
:placeholder="`${$t('labels.sharedBase')} ID / URL`"
:placeholder="`${$t('labels.sharedBase')} URL`"
size="large"
/>
</a-form-item>

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

@ -39,6 +39,7 @@ const reloadViewDataHook = inject(ReloadViewDataHookInj)
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj, createEventHook())
const { isViewDataLoading } = storeToRefs(useViewsStore())
const { isSqlView, xWhere } = useSmartsheetStoreOrThrow()
const expandedFormDlg = ref(false)
const expandedFormRow = ref<RowType>()
@ -54,7 +55,7 @@ const {
addEmptyRow,
deleteRow,
navigateToSiblingRow,
} = useViewData(meta, view)
} = useViewData(meta, view, xWhere)
provide(IsFormInj, ref(false))
provide(IsGalleryInj, ref(true))
@ -85,8 +86,6 @@ const isRowEmpty = (record: any, col: any) => {
return Array.isArray(val) && val.length === 0
}
const { isSqlView } = useSmartsheetStoreOrThrow()
const { isUIAllowed } = useUIPermission()
const hasEditPermission = computed(() => isUIAllowed('xcDatatableEditable'))
// TODO: extract this code (which is duplicated in grid and gallery) into a separate component

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

@ -50,7 +50,7 @@ vModel.value.au = !!vModel.value.au */
<a-form-item label="NN">
<a-checkbox
v-model:checked="vModel.rqd"
:disabled="vModel.pk || !sqlUi.columnEditable(vModel)"
:disabled="vModel.pk"
class="nc-column-checkbox-NN"
@change="onAlter"
/>
@ -59,7 +59,6 @@ vModel.value.au = !!vModel.value.au */
<a-form-item label="PK">
<a-checkbox
v-model:checked="vModel.pk"
:disabled="!sqlUi.columnEditable(vModel)"
class="nc-column-checkbox-PK"
@change="onAlter"
/>
@ -68,17 +67,17 @@ vModel.value.au = !!vModel.value.au */
<a-form-item label="AI">
<a-checkbox
v-model:checked="vModel.ai"
:disabled="sqlUi.colPropUNDisabled(vModel) || !sqlUi.columnEditable(vModel)"
:disabled="sqlUi.colPropUNDisabled(vModel)"
class="nc-column-checkbox-AI"
@change="onAlter"
/>
</a-form-item>
<a-form-item label="UN" :disabled="sqlUi.colPropUNDisabled(vModel) || !sqlUi.columnEditable(vModel)" @change="onAlter">
<a-form-item label="UN" :disabled="sqlUi.colPropUNDisabled(vModel)" @change="onAlter">
<a-checkbox v-model:checked="vModel.un" class="nc-column-checkbox-UN" />
</a-form-item>
<a-form-item label="AU" :disabled="sqlUi.colPropAuDisabled(vModel) || !sqlUi.columnEditable(vModel)" @change="onAlter">
<a-form-item label="AU" :disabled="sqlUi.colPropAuDisabled(vModel)" @change="onAlter">
<a-checkbox v-model:checked="vModel.au" class="nc-column-checkbox-AU" />
</a-form-item>
</div>
@ -96,13 +95,13 @@ vModel.value.au = !!vModel.value.au */
<a-form-item v-if="!hideLength" :label="$t('labels.lengthValue')">
<a-input
v-model:value="vModel.dtxp"
:disabled="sqlUi.getDefaultLengthIsDisabled(vModel.dt) || !sqlUi.columnEditable(vModel)"
:disabled="sqlUi.getDefaultLengthIsDisabled(vModel.dt)"
@input="onAlter"
/>
</a-form-item>
<a-form-item v-if="sqlUi.showScale(vModel)" label="Scale">
<a-input v-model:value="vModel.dtxs" :disabled="!sqlUi.columnEditable(vModel)" @input="onAlter" />
<a-input v-model:value="vModel.dtxs" @input="onAlter" />
</a-form-item>
</template>

2
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -1124,7 +1124,7 @@ defineExpose({
})
// when expand is clicked the drawer should open
// and cell should loose focs
// and cell should loose focus
const expandAndLooseFocus = (row: Row, col: Record<string, any>) => {
if (expandForm) {
expandForm(row, col)

8
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { ColumnInj, IsFormInj, IsKanbanInj, inject, provide, ref, toRef, useUIPermission } from '#imports'
import { ColumnInj, IsFormInj, IsKanbanInj, IsLockedInj, inject, provide, ref, toRef, useUIPermission } from '#imports'
interface Props {
column: ColumnType
@ -18,6 +18,8 @@ const isDropDownOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const isLocked = inject(IsLockedInj, ref(false))
const column = toRef(props, 'column')
const { isUIAllowed } = useUIPermission()
@ -39,7 +41,7 @@ const closeAddColumnDropdown = () => {
}
const openHeaderMenu = () => {
if (!isForm.value && isUIAllowed('edit-column')) {
if (!isLocked.value && !isForm.value && isUIAllowed('edit-column')) {
editColumnDropdown.value = true
}
}
@ -57,7 +59,7 @@ const openHeaderMenu = () => {
<div
v-if="column"
class="name pl-1 !truncate"
:class="{ 'cursor-pointer pt-0.25': !isForm && isUIAllowed('edit-column') && !hideMenu }"
:class="{ 'cursor-pointer pt-0.25': !isForm && isUIAllowed('edit-column') && !hideMenu && !isLocked }"
style="white-space: pre-line"
:title="column.title"
>

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

@ -76,12 +76,40 @@ export default {
</div>
</div>
<div class="flex-1" />
<a-tooltip placement="bottom">
<template #title> Switch language</template>
<LazyGeneralLanguage class="nc-lang-btn" />
</a-tooltip>
</a-layout-header>
<div class="w-full overflow-hidden" style="height: calc(100vh)">
<div class="w-full overflow-scroll" style="height: calc(100vh)">
<slot />
</div>
</a-layout>
</a-layout>
</template>
<style lang="scss">
.nc-lang-btn {
@apply color-transition flex items-center justify-center fixed bottom-10 right-10 z-99 w-12 h-12 rounded-full shadow-md shadow-gray-500 p-2 !bg-primary text-white ring-opacity-100 active:(ring ring-accent) hover:(ring ring-accent);
&::after {
@apply rounded-full absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary;
content: '';
z-index: -1;
}
&:hover::after {
@apply transform scale-110 ring ring-accent ring-opacity-100;
}
&:active::after {
@apply ring ring-accent ring-opacity-100;
}
}
.nc-navbar {
@apply flex !bg-white items-center !pl-2 !pr-5;
}
</style>

19
packages/nc-gui/pages/index-old/apps.vue

@ -1,19 +0,0 @@
<script lang="ts" setup>
import { Role, definePageMeta } from '#imports'
definePageMeta({
requiresAuth: true,
allowedRoles: [Role.Super],
title: 'title.appStore',
})
useSidebar('nc-left-sidebar', { hasSidebar: false })
</script>
<template>
<div class="p-10 h-full overflow-auto">
<h1 class="text-3xl text-center mb-11 nc-app-store-title">{{ $t('title.appStore') }}</h1>
<LazyDashboardSettingsAppStore />
</div>
</template>

11
packages/nc-gui/pages/index-old/index.vue

@ -1,11 +0,0 @@
<script lang="ts" setup>
import { useSidebar } from '#imports'
definePageMeta({
hideHeader: true,
})
useSidebar('nc-left-sidebar', { hasSidebar: false })
</script>
<template>
<NuxtPage :transition="false" />
</template>

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

@ -1,110 +0,0 @@
<script lang="ts" setup>
import type { Form } from 'ant-design-vue'
import type { ProjectType } from 'nocodb-sdk'
import type { VNodeRef } from '@vue/runtime-core'
import type { RuleObject } from 'ant-design-vue/es/form'
import {
extractSdkResponseErrorMsg,
iconMap,
message,
navigateTo,
projectTitleValidator,
reactive,
ref,
storeToRefs,
useProject,
useRoute,
} from '#imports'
const route = useRoute()
const projectStore = useProject()
const { loadProject, updateProject } = projectStore
const { project, isLoading } = storeToRefs(projectStore)
const nameValidationRules = [
{
required: true,
message: 'Project name is required',
},
projectTitleValidator,
] as RuleObject[]
const form = ref<typeof Form>()
const formState = reactive<Partial<ProjectType>>({
title: '',
})
const renameProject = async () => {
try {
await updateProject(formState)
navigateTo(`/nc/${route.params.projectId}`)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
onBeforeMount(async () => {
await loadProject(false)
formState.title = project.value?.title
})
const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</script>
<template>
<div
class="update-project relative flex-auto flex flex-col justify-center gap-2 p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"
>
<LazyGeneralNocoIcon class="color-transition hover:(ring ring-accent)" :animate="isLoading" />
<div
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full cursor-pointer"
@click="navigateTo('/')"
>
<component :is="iconMap.chevronLeft" class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.editProject') }}</h1>
<a-skeleton v-if="isLoading" />
<a-form
v-else
ref="form"
:model="formState"
name="basic"
layout="vertical"
class="lg:max-w-3/4 w-full !mx-auto"
no-style
autocomplete="off"
@finish="renameProject"
>
<a-form-item :label="$t('labels.projName')" name="title" :rules="nameValidationRules">
<a-input :ref="focus" v-model:value="formState.title" name="title" class="nc-metadb-project-name" />
</a-form-item>
<div class="text-center">
<button v-e="['a:project:edit:rename']" type="submit" class="scaling-btn bg-opacity-100">
<span class="flex items-center gap-2">
<MaterialSymbolsRocketLaunchOutline />
{{ $t('general.edit') }}
</span>
</button>
</div>
</a-form>
</div>
</template>
<style lang="scss" scoped>
.update-project {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
}
</style>

643
packages/nc-gui/pages/index-old/index/create-external.vue

@ -1,643 +0,0 @@
<script lang="ts" setup>
import type { ProjectType } from 'nocodb-sdk'
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select'
import type { DefaultConnection, ProjectCreateForm } from '#imports'
import {
CertTypes,
ClientType,
Form,
Modal,
SSLUsage,
clientTypes as _clientTypes,
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
generateUniqueName,
getDefaultConnectionConfig,
getTestDatabaseName,
iconMap,
message,
navigateTo,
nextTick,
onMounted,
projectTitleValidator,
readFile,
ref,
useApi,
useI18n,
useNuxtApp,
useSidebar,
watch,
} from '#imports'
const useForm = Form.useForm
const testSuccess = ref(false)
const form = ref<typeof Form>()
const { api, isLoading } = useApi({ useGlobalInstance: true })
const { $e } = useNuxtApp()
useSidebar('nc-left-sidebar', { hasSidebar: false })
const { t } = useI18n()
const formState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'none',
inflectionTable: 'none',
},
sslUse: SSLUsage.No,
extraParameters: [],
})
const customFormState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'none',
inflectionTable: 'none',
},
sslUse: SSLUsage.No,
extraParameters: [],
})
const clientTypes = computed(() => {
return _clientTypes.filter((type) => {
// return appInfo.value?.ee || type.value !== ClientType.SNOWFLAKE
return type.value !== ClientType.SNOWFLAKE
})
})
const validators = computed(() => {
let clientValidations: Record<string, any[]> = {
'dataSource.connection.host': [fieldRequiredValidator()],
'dataSource.connection.port': [fieldRequiredValidator()],
'dataSource.connection.user': [fieldRequiredValidator()],
'dataSource.connection.password': [fieldRequiredValidator()],
'dataSource.connection.database': [fieldRequiredValidator()],
}
switch (formState.value.dataSource.client) {
case ClientType.SQLITE:
clientValidations = {
'dataSource.connection.connection.filename': [fieldRequiredValidator()],
}
break
case ClientType.SNOWFLAKE:
clientValidations = {
'dataSource.connection.account': [fieldRequiredValidator()],
'dataSource.connection.username': [fieldRequiredValidator()],
'dataSource.connection.password': [fieldRequiredValidator()],
'dataSource.connection.warehouse': [fieldRequiredValidator()],
'dataSource.connection.database': [fieldRequiredValidator()],
'dataSource.connection.schema': [fieldRequiredValidator()],
}
break
case ClientType.PG:
case ClientType.MSSQL:
clientValidations['dataSource.searchPath.0'] = [fieldRequiredValidator()]
break
}
return {
'title': [
{
required: true,
message: 'Project name is required',
},
projectTitleValidator,
],
'extraParameters': [extraParameterValidator],
'dataSource.client': [fieldRequiredValidator()],
...clientValidations,
}
})
const { validate, validateInfos } = useForm(formState.value, validators)
const populateName = (v: string) => {
formState.value.dataSource.connection.database = `${v.trim()}_noco`
}
const onClientChange = () => {
formState.value.dataSource = { ...getDefaultConnectionConfig(formState.value.dataSource.client) }
populateName(formState.value.title)
}
const onSSLModeChange = ((mode: SSLUsage) => {
if (formState.value.dataSource.client !== ClientType.SQLITE) {
const connection = formState.value.dataSource.connection as DefaultConnection
switch (mode) {
case SSLUsage.No:
delete connection.ssl
break
case SSLUsage.Allowed:
connection.ssl = 'no-verify'
break
case SSLUsage.Preferred:
connection.ssl = 'true'
break
default:
connection.ssl = {
ca: '',
cert: '',
key: '',
}
break
}
}
}) as SelectHandler
const updateSSLUse = () => {
if (formState.value.dataSource.client !== ClientType.SQLITE) {
const connection = formState.value.dataSource.connection as DefaultConnection
if (connection.ssl) {
if (typeof connection.ssl === 'string') {
formState.value.sslUse = SSLUsage.Allowed
} else {
formState.value.sslUse = SSLUsage.Preferred
}
} else {
formState.value.sslUse = SSLUsage.No
}
}
}
const addNewParam = () => {
formState.value.extraParameters.push({ key: '', value: '' })
}
const removeParam = (index: number) => {
formState.value.extraParameters.splice(index, 1)
}
const inflectionTypes = ['camelize', 'none']
const importURL = ref('')
const configEditDlg = ref(false)
const importURLDlg = ref(false)
const caFileInput = ref<HTMLInputElement>()
const keyFileInput = ref<HTMLInputElement>()
const certFileInput = ref<HTMLInputElement>()
const onFileSelect = (key: CertTypes, el?: HTMLInputElement) => {
if (!el) return
readFile(el, (content) => {
if ('ssl' in formState.value.dataSource.connection && typeof formState.value.dataSource.connection.ssl === 'object')
formState.value.dataSource.connection.ssl[key] = content ?? ''
})
}
const sslFilesRequired = computed(
() => !!formState.value.sslUse && formState.value.sslUse !== SSLUsage.No && formState.value.sslUse !== SSLUsage.Allowed,
)
function getConnectionConfig() {
const extraParameters = Object.fromEntries(new Map(formState.value.extraParameters.map((object) => [object.key, object.value])))
const connection = {
...formState.value.dataSource.connection,
...extraParameters,
}
if ('ssl' in connection && connection.ssl) {
if (
formState.value.sslUse === SSLUsage.No ||
(typeof connection.ssl === 'object' && Object.values(connection.ssl).every((v) => v === null || v === undefined))
) {
delete connection.ssl
}
}
return connection
}
const focusInvalidInput = () => {
form.value?.$el.querySelector('.ant-form-item-explain-error')?.parentNode?.parentNode?.querySelector('input')?.focus()
}
const createProject = async () => {
try {
await validate()
} catch (e) {
focusInvalidInput()
return
}
try {
const connection = getConnectionConfig()
const config = { ...formState.value.dataSource, connection }
const result = (await api.project.create({
title: formState.value.title,
bases: [
{
type: formState.value.dataSource.client,
config,
inflection_column: formState.value.inflection.inflectionColumn,
inflection_table: formState.value.inflection.inflectionTable,
},
],
external: true,
})) as Partial<ProjectType>
$e('a:project:create:extdb')
await navigateTo(`/nc/${result.id}`)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const testConnection = async () => {
try {
await validate()
} catch (e) {
focusInvalidInput()
return
}
$e('a:project:create:extdb:test-connection', [])
try {
if (formState.value.dataSource.client === ClientType.SQLITE) {
testSuccess.value = true
} else {
const connection = getConnectionConfig()
connection.database = getTestDatabaseName(formState.value.dataSource)!
const testConnectionConfig = {
...formState.value.dataSource,
connection,
}
const result = await api.utils.testConnection(testConnectionConfig)
if (result.code === 0) {
testSuccess.value = true
Modal.confirm({
title: t('msg.info.dbConnected'),
icon: null,
type: 'success',
okText: t('activity.OkSaveProject'),
okType: 'primary',
cancelText: t('general.cancel'),
onOk: createProject,
})
} else {
testSuccess.value = false
message.error(`${t('msg.error.dbConnectionFailed')} ${result.message}`)
}
}
} catch (e: any) {
testSuccess.value = false
message.error(await extractSdkResponseErrorMsg(e))
}
}
const handleImportURL = async () => {
if (!importURL.value || importURL.value === '') return
const connectionConfig = await api.utils.urlToConfig({ url: importURL.value })
if (connectionConfig) {
formState.value.dataSource.client = connectionConfig.client
formState.value.dataSource.connection = { ...connectionConfig.connection }
} else {
message.error(t('msg.error.invalidURL'))
}
importURLDlg.value = false
updateSSLUse()
}
const handleEditJSON = () => {
customFormState.value = { ...formState.value }
configEditDlg.value = true
}
const handleOk = () => {
formState.value = { ...customFormState.value }
configEditDlg.value = false
updateSSLUse()
}
// reset test status on config change
watch(
() => formState.value.dataSource,
() => (testSuccess.value = false),
{ deep: true },
)
// populate database name based on title
watch(
() => formState.value.title,
(v) => populateName(v),
)
// select and focus title field on load
onMounted(async () => {
formState.value.title = await generateUniqueName()
await nextTick(() => {
// todo: replace setTimeout and follow better approach
setTimeout(() => {
const input = form.value?.$el?.querySelector('input[type=text]')
input.setSelectionRange(0, formState.value.title.length)
input.focus()
}, 500)
})
})
</script>
<template>
<div
class="create-external relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"
>
<LazyGeneralNocoIcon class="color-transition hover:(ring ring-accent)" :animate="isLoading" />
<div
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full cursor-pointer"
@click="navigateTo('/')"
>
<component :is="iconMap.chevronLeft" class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.createProject') }}</h1>
<a-form
ref="form"
:model="formState"
name="external-project-create-form"
layout="horizontal"
no-style
:label-col="{ span: 8 }"
>
<a-form-item :label="$t('placeholder.projName')" v-bind="validateInfos.title">
<a-input v-model:value="formState.title" class="nc-extdb-proj-name" />
</a-form-item>
<a-form-item :label="$t('labels.dbType')" v-bind="validateInfos['dataSource.client']">
<a-select
v-model:value="formState.dataSource.client"
class="nc-extdb-db-type"
dropdown-class-name="nc-dropdown-ext-db-type"
@change="onClientChange"
>
<a-select-option v-for="client in clientTypes" :key="client.value" :value="client.value">
{{ client.text }}
</a-select-option>
</a-select>
</a-form-item>
<!-- SQLite File -->
<a-form-item
v-if="formState.dataSource.client === ClientType.SQLITE"
:label="$t('labels.sqliteFile')"
v-bind="validateInfos['dataSource.connection.connection.filename']"
>
<a-input v-model:value="formState.dataSource.connection.connection.filename" />
</a-form-item>
<template v-else>
<!-- Host Address -->
<a-form-item :label="$t('labels.hostAddress')" v-bind="validateInfos['dataSource.connection.host']">
<a-input v-model:value="formState.dataSource.connection.host" class="nc-extdb-host-address" />
</a-form-item>
<!-- Port Number -->
<a-form-item :label="$t('labels.port')" v-bind="validateInfos['dataSource.connection.port']">
<a-input-number v-model:value="formState.dataSource.connection.port" class="!w-full nc-extdb-host-port" />
</a-form-item>
<!-- Username -->
<a-form-item :label="$t('labels.username')" v-bind="validateInfos['dataSource.connection.user']">
<a-input v-model:value="formState.dataSource.connection.user" class="nc-extdb-host-user" />
</a-form-item>
<!-- Password -->
<a-form-item :label="$t('labels.password')">
<a-input-password v-model:value="formState.dataSource.connection.password" class="nc-extdb-host-password" />
</a-form-item>
<!-- Database -->
<a-form-item :label="$t('labels.database')" v-bind="validateInfos['dataSource.connection.database']">
<!-- Database : create if not exists -->
<a-input
v-model:value="formState.dataSource.connection.database"
:placeholder="$t('labels.dbCreateIfNotExists')"
class="nc-extdb-host-database"
/>
</a-form-item>
<!-- Schema name -->
<a-form-item
v-if="[ClientType.MSSQL, ClientType.PG].includes(formState.dataSource.client) && formState.dataSource.searchPath"
:label="$t('labels.schemaName')"
v-bind="validateInfos['dataSource.searchPath.0']"
>
<a-input v-model:value="formState.dataSource.searchPath[0]" />
</a-form-item>
<a-collapse ghost expand-icon-position="right" class="!mt-6">
<a-collapse-panel key="1">
<template #header>
<div class="flex items-center gap-2">
<!-- Use Connection URL -->
<a-button type="default" class="nc-extdb-btn-import-url" @click.stop="importURLDlg = true">
{{ $t('activity.useConnectionUrl') }}
</a-button>
<span>{{ $t('title.advancedParameters') }}</span>
</div>
</template>
<a-form-item label="SSL mode">
<a-select v-model:value="formState.sslUse" dropdown-class-name="nc-dropdown-ssl-mode" @select="onSSLModeChange">
<a-select-option v-for="opt in Object.values(SSLUsage)" :key="opt" :value="opt">{{ opt }} </a-select-option>
</a-select>
</a-form-item>
<a-form-item label="SSL keys">
<div class="flex gap-2">
<a-tooltip placement="top">
<!-- Select .cert file -->
<template #title>
<span>{{ $t('tooltip.clientCert') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="certFileInput?.click()">
{{ $t('labels.clientCert') }}
</a-button>
</a-tooltip>
<a-tooltip placement="top">
<!-- Select .key file -->
<template #title>
<span>{{ $t('tooltip.clientKey') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="keyFileInput?.click()">
{{ $t('labels.clientKey') }}
</a-button>
</a-tooltip>
<a-tooltip placement="top">
<!-- Select CA file -->
<template #title>
<span>{{ $t('tooltip.clientCA') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="caFileInput?.click()">
{{ $t('labels.serverCA') }}
</a-button>
</a-tooltip>
</div>
</a-form-item>
<input ref="caFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.ca, caFileInput)" />
<input ref="certFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.cert, certFileInput)" />
<input ref="keyFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.key, keyFileInput)" />
<a-divider />
<!-- Extra connection parameters -->
<a-form-item class="mb-2" :label="$t('labels.extraConnectionParameters')" v-bind="validateInfos.extraParameters">
<a-card>
<div v-for="(item, index) of formState.extraParameters" :key="index">
<div class="flex py-1 items-center gap-1">
<a-input v-model:value="item.key" />
<span>:</span>
<a-input v-model:value="item.value" />
<component
:is="iconMap.close"
:style="{ 'font-size': '1.5em', 'color': 'red' }"
@click="removeParam(index)"
/>
</div>
</div>
<a-button type="dashed" class="w-full caption mt-2" @click="addNewParam">
<div class="flex items-center justify-center">
<component :is="iconMap.plus" />
</div>
</a-button>
</a-card>
</a-form-item>
<a-divider />
<a-form-item :label="$t('labels.inflection.tableName')">
<a-select
v-model:value="formState.inflection.inflectionTable"
dropdown-class-name="nc-dropdown-inflection-table-name"
>
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('labels.inflection.columnName')">
<a-select
v-model:value="formState.inflection.inflectionColumn"
dropdown-class-name="nc-dropdown-inflection-column-name"
>
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<div class="flex justify-end">
<a-button class="!shadow-md" @click="handleEditJSON()">
<!-- Edit connection JSON -->
{{ $t('activity.editConnJson') }}
</a-button>
</div>
</a-collapse-panel>
</a-collapse>
</template>
<a-form-item class="flex justify-center !mt-5">
<div class="flex justify-center gap-2">
<a-button type="primary" ghost class="nc-extdb-btn-test-connection" @click="testConnection">
{{ $t('activity.testDbConn') }}
</a-button>
<a-button type="primary" :disabled="!testSuccess" class="nc-extdb-btn-submit !shadow" @click="createProject">
{{ $t('general.submit') }}
</a-button>
</div>
</a-form-item>
</a-form>
<a-modal
v-model:visible="configEditDlg"
:class="{ active: configEditDlg }"
:title="$t('activity.editConnJson')"
width="600px"
wrap-class-name="nc-modal-edit-connection-json"
@ok="handleOk"
>
<LazyMonacoEditor v-if="configEditDlg" v-model="customFormState" class="h-[400px] w-full" />
</a-modal>
<!-- Use Connection URL -->
<a-modal
v-model:visible="importURLDlg"
:class="{ active: importURLDlg }"
:title="$t('activity.useConnectionUrl')"
width="600px"
:ok-text="$t('general.ok')"
:cancel-text="$t('general.cancel')"
wrap-class-name="nc-modal-connection-url"
@ok="handleImportURL"
>
<a-input v-model:value="importURL" />
</a-modal>
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-collapse-header) {
@apply !pr-10 !-mt-4 text-right justify-end;
}
:deep(.ant-collapse-content-box) {
@apply !px-0;
}
:deep(.ant-form-item-explain-error) {
@apply !text-xs;
}
:deep(.ant-form-item) {
@apply mb-2;
}
:deep(.ant-form-item-with-help .ant-form-item-explain) {
@apply !min-h-0;
}
.create-external {
:deep(.ant-input-affix-wrapper),
:deep(.ant-input),
:deep(.ant-select) {
@apply !appearance-none border-1 border-solid rounded;
}
:deep(.ant-input-password) {
input {
@apply !border-none my-0;
}
}
}
</style>

171
packages/nc-gui/pages/index-old/index/create.vue

@ -1,171 +0,0 @@
<script lang="ts" setup>
import type { Form, Input } from 'ant-design-vue'
import type { RuleObject } from 'ant-design-vue/es/form'
import type { VNodeRef } from '@vue/runtime-core'
import type { ProjectType } from 'nocodb-sdk'
import tinycolor from 'tinycolor2'
import {
NcProjectType,
extractSdkResponseErrorMsg,
generateUniqueName,
iconMap,
message,
navigateTo,
nextTick,
onMounted,
projectTitleValidator,
reactive,
ref,
useApi,
useCommandPalette,
useGlobal,
useNuxtApp,
useProject,
useRoute,
useSidebar,
useTable,
} from '#imports'
const { $e } = useNuxtApp()
useProjects()
const { loadTables } = useProject()
useTable(async (_) => {
await loadTables()
})
const { navigateToProject } = useGlobal()
const { refreshCommandPalette } = useCommandPalette()
const { api, isLoading } = useApi({ useGlobalInstance: true })
useSidebar('nc-left-sidebar', { hasSidebar: false })
const nameValidationRules = [
{
required: true,
message: 'Project name is required',
},
projectTitleValidator,
] as RuleObject[]
const form = ref<typeof Form>()
const formState = reactive({
title: '',
})
const route = useRoute()
const creating = ref(false)
const createProject = async () => {
$e('a:project:create:xcdb')
try {
// pick a random color from array and assign to project
const color = projectThemeColors[Math.floor(Math.random() * 1000) % projectThemeColors.length]
const tcolor = tinycolor(color)
const complement = tcolor.complement()
const { getBaseUrl } = useGlobal()
// todo: provide proper project type
creating.value = true
const result = (await api.project.create(
{
title: formState.title,
color,
meta: JSON.stringify({
theme: {
primaryColor: color,
accentColor: complement.toHex8String(),
},
}),
},
{
baseURL: getBaseUrl(route.query.workspaceId as string),
},
)) as Partial<ProjectType>
refreshCommandPalette()
navigateToProject({
projectId: result.id!,
workspaceId: route.query.workspaceId as string,
type: NcProjectType.DB,
})
} catch (e: any) {
console.error(e)
message.error(await extractSdkResponseErrorMsg(e))
} finally {
creating.value = false
}
}
const input: VNodeRef = ref<typeof Input>()
onMounted(async () => {
formState.title = await generateUniqueName()
await nextTick()
input.value?.$el?.focus()
input.value?.$el?.select()
})
</script>
<template>
<NuxtLayout name="new">
<div class="mt-20">
<div
class="min-w-2/4 xl:max-w-2/4 w-full mx-auto create relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"
>
<LazyGeneralNocoIcon class="color-transition hover:(ring ring-accent)" :animate="isLoading" />
<div
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full cursor-pointer"
@click="navigateTo('/')"
>
<component :is="iconMap.chevronLeft" class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.createProject') }}</h1>
<a-form
ref="form"
:model="formState"
name="basic"
layout="vertical"
class="lg:max-w-3/4 w-full !mx-auto"
no-style
autocomplete="off"
@finish="createProject"
>
<a-form-item :label="$t('labels.projName')" name="title" :rules="nameValidationRules" class="m-10">
<a-input ref="input" v-model:value="formState.title" name="title" class="nc-metadb-project-name" />
</a-form-item>
<div class="text-center">
<a-spin v-if="creating" spinning />
<button v-else class="scaling-btn bg-opacity-100" type="submit">
<span class="flex items-center gap-2">
<MaterialSymbolsRocketLaunchOutline />
{{ $t('general.create') }}
</span>
</button>
</div>
</a-form>
</div>
</div>
</NuxtLayout>
</template>
<style lang="scss">
.create {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
}
</style>

354
packages/nc-gui/pages/index-old/index/index-old.vue

@ -1,354 +0,0 @@
<script lang="ts" setup>
import type { ProjectType } from 'nocodb-sdk'
import tinycolor from 'tinycolor2'
import { breakpointsTailwind } from '@vueuse/core'
import {
Empty,
Modal,
computed,
definePageMeta,
extractSdkResponseErrorMsg,
message,
navigateTo,
onBeforeMount,
projectThemeColors,
ref,
themeV2Colors,
useApi,
useBreakpoints,
useCopy,
useGlobal,
useNuxtApp,
useUIPermission,
} from '#imports'
definePageMeta({
title: 'title.myProject',
})
const { $api, $e } = useNuxtApp()
const { api, isLoading } = useApi()
const { isUIAllowed } = useUIPermission()
const { md } = useBreakpoints(breakpointsTailwind)
const filterQuery = ref('')
const projects = ref<ProjectType[]>()
const { appInfo } = useGlobal()
const loadProjects = async () => {
const response = await api.project.list({})
projects.value = response.list
}
const filteredProjects = computed(
() =>
projects.value?.filter(
(project) => !filterQuery.value || project.title?.toLowerCase?.().includes(filterQuery.value.toLowerCase()),
) ?? [],
)
const deleteProject = (project: ProjectType) => {
$e('c:project:delete')
Modal.confirm({
title: `Do you want to delete '${project.title}' project?`,
wrapClassName: 'nc-modal-project-delete',
okText: 'Yes',
okType: 'danger',
cancelText: 'No',
async onOk() {
try {
await api.project.delete(project.id as string)
$e('a:project:delete')
projects.value?.splice(projects.value?.indexOf(project), 1)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
const handleProjectColor = async (projectId: string, color: string) => {
const tcolor = tinycolor(color)
if (tcolor.isValid()) {
const complement = tcolor.complement()
const project: ProjectType = await $api.project.read(projectId)
const meta = project?.meta && typeof project.meta === 'string' ? JSON.parse(project.meta) : project.meta || {}
await $api.project.update(projectId, {
color,
meta: JSON.stringify({
...meta,
theme: {
primaryColor: color,
accentColor: complement.toHex8String(),
},
}),
})
// Update local project
const localProject = projects.value?.find((p) => p.id === projectId)
if (localProject) {
localProject.color = color
localProject.meta = JSON.stringify({
...meta,
theme: {
primaryColor: color,
accentColor: complement.toHex8String(),
},
})
}
}
}
const getProjectPrimary = (project: ProjectType) => {
if (!project) return
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 () => {
if (record.type === 'docs') {
await navigateTo(`/nc/doc/${record.id}`)
} else {
await navigateTo(`/nc/${record.id}`)
}
$e('a:project:open')
},
class: ['group'],
})
onBeforeMount(loadProjects)
const { copy } = useCopy()
const copyProjectMeta = async () => {
const aggregatedMetaInfo = await $api.utils.aggregatedMetaInfo()
copy(JSON.stringify(aggregatedMetaInfo))
message.info('Copied aggregated project meta to clipboard')
}
</script>
<template>
<div
class="relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"
data-testid="projects-container"
>
<h1 class="flex items-center justify-center gap-2 leading-8 mb-8 mt-4">
<span class="text-4xl nc-project-page-title" @dblclick="copyProjectMeta">{{ $t('title.myProject') }}</span>
</h1>
<div class="flex flex-wrap gap-2 mb-6">
<a-input-search
v-model:value="filterQuery"
class="max-w-[250px] nc-project-page-search rounded"
:placeholder="$t('activity.searchProject')"
/>
<a-tooltip title="Reload projects">
<div
class="transition-all duration-200 h-full flex-0 flex items-center group hover:ring active:(ring ring-accent) rounded-full mt-1"
:class="isLoading ? 'animate-spin ring ring-gray-200' : ''"
>
<MdiRefresh
v-e="['a:project:refresh']"
class="text-xl text-gray-500 group-hover:text-accent cursor-pointer"
:class="isLoading ? '!text-primary' : ''"
data-testid="projects-reload-button"
@click="loadProjects"
/>
</div>
</a-tooltip>
<div class="flex-1" />
<a-dropdown v-if="isUIAllowed('projectCreate', true)" :trigger="['click']" overlay-class-name="nc-dropdown-create-project">
<button class="nc-new-project-menu mt-4 md:mt-0">
<span class="flex items-center w-full">
{{ $t('title.newProj') }}
<MdiMenuDown class="menu-icon" />
</span>
</button>
<template #overlay>
<a-menu class="!py-0 rounded">
<a-menu-item>
<div
v-e="['c:project:create:xcdb']"
class="nc-project-menu-item group nc-create-xc-db-project"
@click="navigateTo('/create')"
>
<MdiPlusOutline class="group-hover:text-accent" />
<div>{{ $t('activity.createProject') }}</div>
</div>
</a-menu-item>
<a-menu-item v-if="appInfo.connectToExternalDB">
<div
v-e="['c:project:create:extdb']"
class="nc-project-menu-item group nc-create-external-db-project"
@click="navigateTo('/create-external')"
>
<MdiDatabaseOutline class="group-hover:text-accent" />
<div v-html="$t('activity.createProjectExtended.extDB')" />
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<!--
TODO: bring back transition after fixing the bug with navigation
<Transition name="layout" mode="out-in"> -->
<div v-if="isLoading">
<a-skeleton />
</div>
<a-table
v-else
:custom-row="customRow"
:data-source="filteredProjects"
:pagination="{ position: ['bottomCenter'] }"
:table-layout="md ? 'auto' : 'fixed'"
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<!-- Title -->
<a-table-column key="title" :title="$t('general.title')" data-index="title">
<template #default="{ text, record }">
<div class="flex items-center">
<div @click.stop>
<a-menu class="!border-0 !m-0 !p-0" trigger-sub-menu-action="click">
<template v-if="isUIAllowed('projectTheme')">
<a-sub-menu key="theme" popup-class-name="custom-color">
<template #title>
<div
class="color-selector"
:style="{
'background-color': getProjectPrimary(record),
'width': '8px',
'height': '100%',
}"
/>
</template>
<template #expandIcon></template>
<LazyGeneralColorPicker
:model-value="getProjectPrimary(record)"
:colors="projectThemeColors"
:row-size="9"
:advanced="false"
@input="handleProjectColor(record.id, $event)"
/>
<a-sub-menu key="pick-primary">
<template #title>
<div class="nc-project-menu-item group !py-0">
<ClarityColorPickerSolid class="group-hover:text-accent" />
Custom Color
</div>
</template>
<template #expandIcon></template>
<LazyGeneralChromeWrapper @input="handleProjectColor(record.id, $event)" />
</a-sub-menu>
</a-sub-menu>
</template>
</a-menu>
</div>
<div
class="capitalize color-transition group-hover:text-primary !w-[400px] h-full overflow-hidden overflow-ellipsis whitespace-nowrap pl-2"
>
{{ text }}
</div>
</div>
</template>
</a-table-column>
<!-- Actions -->
<a-table-column key="id" :title="$t('labels.actions')" data-index="id">
<template #default="{ text, record }">
<div class="flex items-center gap-2">
<MdiEditOutline v-e="['c:project:edit:rename']" class="nc-action-btn" @click.stop="navigateTo(`/${text}`)" />
<MdiDeleteOutline
class="nc-action-btn"
:data-testid="`delete-project-${record.title}`"
@click.stop="deleteProject(record)"
/>
</div>
</template>
</a-table-column>
</a-table>
<!-- </Transition> -->
</div>
</template>
<style scoped>
.nc-action-btn {
@apply text-gray-500 group-hover:text-accent active:(ring ring-accent ring-opacity-100) cursor-pointer p-2 w-[30px] h-[30px] hover:bg-gray-300/50 rounded-full;
}
.nc-new-project-menu {
@apply cursor-pointer z-1 relative color-transition rounded-md px-3 py-2 text-white;
&::after {
@apply ring-opacity-100 rounded-md absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary bg-opacity-100;
content: '';
z-index: -1;
}
&:hover::after {
@apply transform scale-110;
}
}
:deep(.ant-table-cell) {
@apply py-1;
}
:deep(.ant-table-row) {
@apply cursor-pointer;
}
:deep(.ant-table) {
@apply min-h-[428px];
}
:deep(.ant-menu-submenu-title) {
@apply !p-0 !mr-1 !my-0 !h-5;
}
.color-selector:hover {
filter: brightness(1.5);
}
</style>
<style>
.custom-color .ant-menu-submenu-title {
height: auto !important;
}
</style>

306
packages/nc-gui/pages/index-old/index/index.vue

@ -1,306 +0,0 @@
<script setup lang="ts">
import type { Menu } from 'ant-design-vue'
import { nextTick } from '@vue/runtime-core'
import { WorkspaceStatus } from 'nocodb-sdk'
import { computed, onMounted, storeToRefs, useRouter, useSidebar, useWorkspace } from '#imports'
const router = useRouter()
const { isUIAllowed } = useUIPermission()
const workspaceStore = useWorkspace()
const { deleteWorkspace: _deleteWorkspace, loadWorkspaces, populateWorkspace } = workspaceStore
const { workspacesList, activeWorkspace, activePage, collaborators, activeWorkspaceId } = storeToRefs(workspaceStore)
const projectsStore = useProjects()
const { loadProjects } = projectsStore
const route = router.currentRoute
const selectedWorkspaceIndex = computed<number[]>({
get() {
const index = workspacesList?.value?.findIndex((workspace) => workspace.id === (route.value.query?.workspaceId as string))
return activePage?.value === 'workspace' ? [index === -1 ? 0 : index] : []
},
set(index: number[]) {
if (index?.length) {
router.push({ query: { workspaceId: workspacesList.value?.[index[0]]?.id, page: 'workspace' } })
} else {
router.push({ query: {} })
}
},
})
// create a new sidebar state
const { toggle, toggleHasSidebar } = useSidebar('nc-left-sidebar', { hasSidebar: true, isOpen: true })
const isCreateDlgOpen = ref(false)
const isCreateProjectOpen = ref(false)
const menuEl = ref<typeof Menu | null>(null)
onMounted(async () => {
toggle(true)
toggleHasSidebar(true)
loadProjects('recent')
})
watch(
() => route.value.query.workspaceId,
async (newId, oldId) => {
if (!newId || (oldId !== newId && oldId)) {
projectsStore.clearProjects()
collaborators.value = []
}
if (newId) {
populateWorkspace()
}
},
{
immediate: true,
},
)
const tab = computed({
get() {
return route.value.query?.tab ?? 'projects'
},
set(tab: string) {
router.push({ query: { ...route.value.query, tab } })
},
})
const projectListType = computed(() => {
switch (activePage.value) {
case 'recent':
return 'Recent'
case 'shared':
return 'Shared With Me'
case 'starred':
return 'Starred'
default:
return '='
}
})
watch(activeWorkspaceId, async () => {
if (activeWorkspace.value?.status !== WorkspaceStatus.CREATED) return
await loadProjects(activePage.value)
})
watch(
() => activeWorkspace.value?.status,
async (status) => {
if (status === WorkspaceStatus.CREATED) {
await loadProjects()
}
},
)
</script>
<template>
<NuxtLayout name="new">
<template #sidebar>
<div class="h-full flex flex-col min-h-[400px] overflow-auto">
<div class="nc-workspace-group overflow-auto mt-8.5">
<div class="flex text-sm font-medium text-gray-400 mx-4.5 mb-2">All Projects</div>
<div
class="nc-workspace-group-item"
:class="{ active: activePage === 'recent' }"
@click="
navigateTo({
query: {
page: 'recent',
},
})
"
>
<IcOutlineAccessTime class="nc-icon" />
<span>Recent</span>
</div>
<div
class="nc-workspace-group-item"
:class="{ active: activePage === 'shared' }"
@click="
navigateTo({
query: {
page: 'shared',
},
})
"
>
<MaterialSymbolsGroupOutlineRounded class="nc-icon" />
<span>Shared with me</span>
</div>
<div
v-if="false"
class="nc-workspace-group-item"
:class="{ active: activePage === 'starred' }"
@click="
navigateTo({
query: {
page: 'starred',
},
})
"
>
<IcRoundStarBorder class="nc-icon !h-5" />
<span>Starred</span>
</div>
</div>
</div>
</template>
<div class="h-full nc-workspace-container overflow-x-hidden" style="width: calc(100vw - 250px)">
<div class="h-full flex flex-col px-6 mt-3">
<div class="flex items-center gap-2 mb-5.5 mt-4 text-xl ml-5.5">
<h2 class="text-3xl font-weight-bold tracking-[0.5px] mb-0">
{{ projectListType }}
</h2>
<div class="flex-grow min-w-10"></div>
<WorkspaceCreateProjectBtn
v-if="isUIAllowed('projectCreate', false) && tab === 'projects'"
v-model:is-open="isCreateProjectOpen"
class="mt-0.75"
type="primary"
:active-workspace-id="activeWorkspace?.id"
modal
>
<div
class="gap-x-2 flex flex-row w-full items-center rounded py-1.5 pl-2 pr-2.75"
:class="{
'!bg-opacity-10': isCreateProjectOpen,
}"
>
<MdiPlus class="!h-4.2" />
<div class="flex">{{ $t('title.newProj') }}</div>
</div>
</WorkspaceCreateProjectBtn>
</div>
<WorkspaceProjectList class="min-h-20 grow" />
</div>
</div>
</NuxtLayout>
</template>
<style scoped lang="scss">
.nc-workspace-avatar {
@apply min-w-6 h-6 rounded-[6px] flex items-center justify-center text-white font-weight-bold uppercase;
font-size: 0.7rem;
}
.nc-workspace-list {
.nc-workspace-list-item {
@apply flex gap-2 items-center;
}
:deep(.ant-menu-item) {
@apply relative;
& .color-band {
@apply opacity-0 absolute w-2 h-7 -left-1 top-[6px] bg-[#4351E8] rounded-[99px] trasition-opacity;
}
}
:deep(.ant-menu-item-selected, .ant-menu-item-active) .color-band {
@apply opacity-100;
}
.nc-workspace-menu,
.nc-workspace-drag-icon {
@apply opacity-0 transition-opactity min-w-4 text-gray-500;
}
.nc-workspace-drag-icon {
@apply cursor-move;
}
:deep(.ant-menu-item:hover) {
.nc-workspace-menu,
.nc-workspace-drag-icon {
@apply opacity-100;
}
}
}
:deep(.nc-workspace-list .ant-menu-item) {
@apply !my-0;
}
.nc-workspace-group {
.nc-workspace-group-item {
&:hover {
@apply bg-primary bg-opacity-3 text-primary;
}
&.active {
@apply bg-primary bg-opacity-8 text-primary font-weight-bold;
}
@apply h-[40px] p-4 pl-3 flex items-center gap-2 cursor-pointer;
.nc-icon {
@apply w-6;
}
}
}
// todo: apply globally at windicss level
.nc-root {
@apply text-[#4B5563];
}
.nc-collab-list {
.nc-collab-list-item {
@apply flex gap-2 py-2 px-4 items-center;
.nc-collab-avatar {
@apply w-6 h-6 rounded-full flex items-center justify-center text-white font-weight-bold uppercase;
font-size: 0.7rem;
}
}
}
:deep(.ant-tabs-nav-list) {
@apply !ml-6;
}
.ant-layout-header {
@apply !h-20 bg-transparent;
border-bottom: 1px solid #f5f5f5;
}
.nc-quick-action-wrapper {
@apply relative;
input {
@apply h-10 w-60 bg-gray-100 rounded-md pl-9 pr-5 mr-2;
}
.nc-quick-action-icon {
@apply absolute left-2 top-6;
}
.nc-quick-action-shortcut {
@apply text-gray-400 absolute right-4 top-0;
}
}
:deep(.ant-tabs-tab:not(ant-tabs-tab-active)) {
@apply !text-gray-500;
}
:deep(.ant-tabs-content) {
@apply !min-h-25 !h-full;
}
:deep(.ant-tabs-nav) {
@apply !mb-0;
}
</style>

162
packages/nc-gui/pages/index-old/index/user.vue

@ -1,162 +0,0 @@
<script lang="ts" setup>
import type { RuleObject } from 'ant-design-vue/es/form'
import { iconMap, message, navigateTo, reactive, ref, useApi, useGlobal, useI18n, useRouter } from '#imports'
const router = useRouter()
const { api, error, isLoading } = useApi({ useGlobalInstance: true })
const { t } = useI18n()
const { signOut } = useGlobal()
const formValidator = ref()
const form = reactive({
currentPassword: '',
password: '',
passwordRepeat: '',
})
const formRules: Record<string, RuleObject[]> = {
currentPassword: [
// Current password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
],
password: [
// Password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
{ min: 8, message: t('msg.error.signUpRules.passwdLength') },
],
passwordRepeat: [
// PasswordRepeat is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
// Passwords match
{
validator: (_: unknown, _v: string) => {
return new Promise((resolve, reject) => {
if (form.password === form.passwordRepeat) return resolve()
reject(new Error(t('msg.error.signUpRules.passwdMismatch')))
})
},
message: t('msg.error.signUpRules.passwdMismatch'),
},
],
}
const passwordChange = async () => {
const valid = formValidator.value.validate()
if (!valid) return
error.value = null
await api.auth.passwordChange({
currentPassword: form.currentPassword,
newPassword: form.password,
})
message.success(t('msg.success.passwordChanged'))
await signOut()
await navigateTo('/signin')
}
const resetError = () => {
if (error.value) error.value = null
}
</script>
<template>
<div
class="relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"
data-testid="user-change-password"
>
<LazyGeneralNocoIcon class="color-transition hover:(ring ring-accent)" :animate="isLoading" />
<div
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full cursor-pointer"
@click="() => router.back()"
>
<component :is="iconMap.chevronLeft" class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.changePwd') }}</h1>
<a-form
ref="formValidator"
data-testid="nc-user-settings-form"
layout="vertical"
class="change-password lg:max-w-3/4 w-full !mx-auto"
no-style
:model="form"
@finish="passwordChange"
>
<Transition name="layout">
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning />
{{ error }}
</div>
</div>
</Transition>
<a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword">
<a-input-password
v-model:value="form.currentPassword"
data-testid="nc-user-settings-form__current-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.current')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.new')" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
data-testid="nc-user-settings-form__new-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.new')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.confirm')" name="passwordRepeat" :rules="formRules.passwordRepeat">
<a-input-password
v-model:value="form.passwordRepeat"
data-testid="nc-user-settings-form__new-password-repeat"
size="large"
class="password"
:placeholder="$t('placeholder.password.confirm')"
@focus="resetError"
/>
</a-form-item>
<div class="text-center">
<button data-testid="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit">
<span class="flex items-center gap-2">
<component :is="iconMap.passwordChange" />
{{ $t('activity.changePwd') }}
</span>
</button>
</div>
</a-form>
</div>
</template>
<style lang="scss">
.change-password {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
.password {
input {
@apply !border-none !m-0;
}
}
}
</style>

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

@ -35,6 +35,12 @@ const isSharedView = computed(() => {
// check route is not project page by route name
return !routeName.startsWith('index-typeOrId-projectId-') && !['index', 'index-typeOrId'].includes(routeName)
})
const isSharedFormView = computed(() => {
const routeName = (route.value.name as string) || ''
// check route is shared form view route
return routeName.startsWith('index-typeOrId-form-viewId')
})
watch(
() => route.value.params.typeOrId,
@ -67,7 +73,10 @@ provide(ToggleDialogInj, toggleDialog)
<template>
<div>
<NuxtLayout v-if="isSharedView" name="shared-view">
<NuxtLayout v-if="isSharedFormView">
<NuxtPage />
</NuxtLayout>
<NuxtLayout v-else-if="isSharedView" name="shared-view">
<NuxtPage />
</NuxtLayout>
<NuxtLayout v-else name="dashboard">

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

@ -5,7 +5,7 @@ const route = router.currentRoute
</script>
<template>
<div>
<div class="h-full w-full">
<NuxtPage :transition="false" :page-key="route.params.typeOrId" />
</div>
</template>

4
packages/nc-gui/pages/index/[typeOrId]/form/[viewId].vue

@ -59,7 +59,7 @@ watch(
</script>
<template>
<NuxtLayout>
<div class="h-[100vh]">
<NuxtPage v-if="!passwordDlg" />
<a-modal
@ -91,7 +91,7 @@ watch(
</a-form>
</div>
</a-modal>
</NuxtLayout>
</div>
</template>
<style lang="scss" scoped>

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

@ -70,7 +70,7 @@ const onDecode = async (scannedCodeValue: string) => {
</script>
<template>
<div class="h-full flex flex-col items-center">
<div class="h-full flex flex-col items-center py-4">
<div
class="color-transition relative flex flex-col justify-center gap-2 w-full max-w-[max(33%,600px)] m-auto py-4 pb-8 px-16 md:(bg-white dark:bg-slate-700 rounded-lg border-1 border-gray-200 shadow-xl)"
>

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

@ -104,6 +104,8 @@ function resetError() {
<a-form-item :label="$t('labels.email')" name="email" :rules="formRules.email">
<a-input
v-model:value="form.email"
type="email"
autocomplete="email"
data-testid="nc-form-signin__email"
size="large"
:placeholder="$t('msg.info.signUp.workEmail')"
@ -114,6 +116,7 @@ function resetError() {
<a-form-item :label="$t('labels.password')" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
autocomplete="current-password"
data-testid="nc-form-signin__password"
size="large"
class="password"

3
packages/nc-gui/pages/signup/[[token]].vue

@ -149,6 +149,8 @@ function resetError() {
<a-form-item :label="$t('labels.email')" name="email" :rules="formRules.email">
<a-input
v-model:value="form.email"
autocomplete="email"
type="email"
size="large"
:placeholder="$t('msg.info.signUp.workEmail')"
@focus="resetError"
@ -158,6 +160,7 @@ function resetError() {
<a-form-item :label="$t('labels.password')" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
autocomplete="new-password"
size="large"
class="password"
:placeholder="$t('msg.info.signUp.enterPassword')"

21
packages/nc-gui/pages/ws/[typeOrId].vue

@ -1,21 +0,0 @@
<script lang="ts" setup>
const router = useRouter()
const route = router.currentRoute
const projectsStore = useProjects()
watch(
() => route.value.params.typeOrId,
async () => {
await projectsStore.loadProjects('recent')
},
{
immediate: true,
},
)
</script>
<template>
<NuxtPage :page-key="route.params.typeOrId" />
</template>

44
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]].vue

@ -1,44 +0,0 @@
<script lang="ts" setup>
definePageMeta({
hideHeader: true,
hasSidebar: true,
})
const dialogOpen = ref(false)
const openDialogKey = ref<string>('')
const dataSourcesState = ref<string>('')
const projectId = ref<string>()
function toggleDialog(value?: boolean, key?: string, dsState?: string, pId?: string) {
dialogOpen.value = value ?? !dialogOpen.value
openDialogKey.value = key || ''
dataSourcesState.value = dsState || ''
projectId.value = pId || ''
}
provide(ToggleDialogInj, toggleDialog)
</script>
<template>
<div>
<NuxtLayout name="dashboard">
<template #sidebar>
<DashboardSidebar />
</template>
<template #content>
<NuxtPage />
</template>
</NuxtLayout>
<LazyDashboardSettingsModal
v-model:model-value="dialogOpen"
v-model:open-key="openDialogKey"
v-model:data-sources-state="dataSourcesState"
:project-id="projectId"
/>
</div>
</template>
<style scoped></style>

155
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index.vue

@ -1,155 +0,0 @@
<script setup lang="ts">
import {
definePageMeta,
extractSdkResponseErrorMsg,
isDrawerOrModalExist,
isMac,
message,
onBeforeMount,
onBeforeUnmount,
onKeyStroke,
onMounted,
ref,
resolveComponent,
useDialog,
useI18n,
useProject,
useRoute,
useRouter,
useSidebar,
useTheme,
} from '#imports'
definePageMeta({
hideHeader: true,
hasSidebar: true,
})
useTheme()
const { t } = useI18n()
const { $e } = useNuxtApp()
const route = useRoute()
const router = useRouter()
const projectStore = useProject()
const { loadProject } = projectStore
// create a new sidebar state
const { toggle, toggleHasSidebar } = useSidebar('nc-left-sidebar', { hasSidebar: true, isOpen: true })
const dropdownOpen = ref(false)
onKeyStroke(
'Escape',
() => {
dropdownOpen.value = false
},
{ eventName: 'keydown' },
)
onBeforeMount(async () => {
try {
await loadProject()
} catch (e: any) {
if (e.response?.status === 403) {
// Project is not accessible
message.error(t('msg.error.projectNotAccessible'))
router.replace('/')
return
}
message.error(await extractSdkResponseErrorMsg(e))
}
// if (route.name.toString().includes('projectType-projectId-index-index') && isUIAllowed('teamAndAuth')) {
// addTab({ id: TabType.AUTH, 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}` : ''}`)
}
})
onMounted(() => {
toggle(true)
toggleHasSidebar(true)
})
onBeforeUnmount(() => {
// clearTabs()
// reset()
})
function openKeyboardShortcutDialog() {
$e('a:actions:keyboard-shortcut')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgKeyboardShortcuts'), {
'modelValue': isOpen,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (cmdOrCtrl) {
switch (e.key) {
case '/':
if (!isDrawerOrModalExist()) {
$e('c:shortcut', { key: 'CTRL + /' })
openKeyboardShortcutDialog()
}
break
}
}
})
</script>
<template>
<div>
<div>
<NuxtPage />
<LazyGeneralPreviewAs float />
</div>
</div>
</template>
<style lang="scss" scoped>
:global(#nc-sidebar-left .ant-layout-sider-collapsed) {
@apply !w-0 !max-w-0 !min-w-0 overflow-x-hidden;
}
.nc-left-sidebar {
.nc-sidebar-left-toggle-icon {
@apply opacity-0 transition-opactity duration-200 transition-color text-gray-500/80 hover:text-gray-500/100;
.nc-left-sidebar {
@apply !border-r-0;
}
}
&:hover .nc-sidebar-left-toggle-icon {
@apply opacity-100;
}
}
:deep(.ant-dropdown-menu-submenu-title) {
@apply py-0;
}
.nc-sidebar-header {
@apply border-[var(--navbar-border)] !bg-[var(--navbar-bg)];
}
</style>

157
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index.vue

@ -1,157 +0,0 @@
<script setup lang="ts">
import { TabMetaInj, provide, storeToRefs, useSidebar, useTabs } from '#imports'
const tabStore = useTabs()
const { activeTab } = storeToRefs(tabStore)
useProjectsShortcuts()
provide(TabMetaInj, activeTab)
useSidebar('nc-left-sidebar')
</script>
<template>
<div class="h-full w-full nc-container">
<div class="h-full w-full flex flex-col">
<!-- <div class="flex items-end !min-h-[var(--sidebar-top-height)] !bg-white-500 nc-tab-bar">
<div
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 mb-1"
>
<GeneralIcon
v-e="['c:grid:toggle-navdraw']"
icon="sidebarMinimise"
class="cursor-pointer transform transition-transform duration-500 text-gray-500/80 hover:text-gray-500"
:class="{ 'rotate-180': !isOpen }"
@click="toggle(!isOpen)"
/>
</div>
<a-tabs v-model:activeKey="activeTabIndex" class="nc-root-tabs min-w-[500px]" type="editable-card" @edit="onEdit">
<a-tab-pane v-for="(tab, i) of tabs" :key="i">
<template #tab>
<div class="flex items-center gap-2" data-testid="nc-tab-title">
<div class="flex items-center">
<Icon
v-if="tab.meta?.icon"
:icon="tab.meta?.icon"
class="text-xl"
:data-testid="`nc-tab-icon-${tab.meta?.icon}`"
/>
<component :is="icon(tab)" v-else class="text-sm" />
</div>
<div :data-testid="`nc-root-tabs-${tab.title}`">
<GeneralTruncateText :key="tab.title" :length="12">
{{ tab.title }}
</GeneralTruncateText>
</div>
</div>
</template>
</a-tab-pane>
</a-tabs>
<span class="flex-1" />
<div class="flex justify-center self-center mr-2 min-w-[115px]">
<div v-if="isLoading" class="flex items-center gap-2 ml-3 text-gray-200" data-testid="nc-loading">
{{ $t('general.loading') }}
<MdiLoading class="animate-infinite animate-spin" />
</div>
</div>
<LazyGeneralShareBaseButton class="mb-1px" />
<LazyGeneralFullScreen class="nc-fullscreen-icon mb-1px" />
</div>
-->
<div class="w-full min-h-[300px] flex-auto">
<NuxtPage />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.nc-container {
height: 100vh;
flex: 1 1 100%;
}
:deep(.nc-root-tabs) {
& > .ant-tabs-nav {
@apply !mb-0 before:(!border-b-0);
.ant-tabs-extra-content {
@apply !bg-white/0;
}
.ant-tabs-nav-add {
@apply !hidden;
}
.ant-tabs-nav-more {
@apply py-1.5;
}
& > .ant-tabs-nav-wrap > .ant-tabs-nav-list {
& > .ant-tabs-tab {
@apply border-0 !text-sm py-2 font-weight-medium z-2;
border-top-right-radius: 8px;
border-top-left-radius: 8px;
& + .ant-tabs-tab {
@apply ml-1;
}
}
& > .ant-tabs-tab-active {
@apply relative bg-white w-full h-full overflow-y-visible;
border-top: 1px solid white;
border-left: 1px solid white;
border-right: 1px solid white;
@apply !border-[var(--navbar-border)];
&:after {
@apply absolute content-[''] left-0 -bottom-[1px] w-full h-[1px] bg-inherit z-100;
}
}
& > .ant-tabs-tab:not(.ant-tabs-tab-active) {
@apply bg-gray-50 text-gray-500;
.ant-tabs-tab-remove {
@apply !text-default;
}
}
}
}
}
:deep(.ant-menu-item-selected) {
@apply text-inherit !bg-inherit;
}
:deep(.ant-menu-horizontal),
:deep(.ant-menu-item::after),
:deep(.ant-menu-submenu::after) {
@apply !border-none;
}
.nc-tab-bar {
@apply border-gray-150 !bg-gray-50 relative z-1;
:deep(.ant-tabs-tab-remove) {
@apply flex mt-[2px];
}
background: linear-gradient(0deg, var(--navbar-border) 1px, var(--navbar-bg) 1px) !important;
:deep(.ant-tabs-tab:not(.ant-tabs-tab-active)) {
background: linear-gradient(0deg, var(--navbar-border) 1px, #f2f2f2 1px) !important;
}
}
</style>

38
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/[type]/[viewId]/[[viewTitle]].vue

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { TabItem } from '#imports'
import { TabMetaInj, computed, inject, storeToRefs, until, useMetas, useProject, useRoute } from '#imports'
const { getMeta } = useMetas()
const projectStore = useProject()
const { tables } = storeToRefs(projectStore)
const route = useRoute()
const activeTab = inject(
TabMetaInj,
computed(() => ({} as TabItem)),
)
const viewType = computed(() => {
return route.params.type as string
})
watch(
() => route.params.viewId,
(viewId) => {
/** wait until table list loads since meta load requires table list **/
until(tables)
.toMatch((tables) => tables.length > 0)
.then(() => {
getMeta(viewId as string, true)
})
},
{ immediate: true },
)
</script>
<template>
<div class="w-full h-full relative">
<LazyTabsSmartsheet :key="viewType" :active-tab="activeTab" />
</div>
</template>

3
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/auth.vue

@ -1,3 +0,0 @@
<template>
<LazyTabsAuth />
</template>

17
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/erd/[baseId].vue

@ -1,17 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
const route = useRoute()
const { bases } = storeToRefs(useProject())
const base = computed(() => bases.value.find((el) => el.id === route.params?.baseId) || bases.value.filter((el) => el.enabled)[0])
useMetas()
</script>
<template>
<div class="w-full h-full !p-0">
<LazyErdView :base-id="base?.id" />
</div>
</template>

133
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/index.vue

@ -1,133 +0,0 @@
<script lang="ts" setup>
import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import type { BaseType } from 'nocodb-sdk'
import { message, ref, resolveComponent, storeToRefs, useDialog, useFileDialog, useNuxtApp, useProject, watch } from '#imports'
const projectStore = useProject()
const { project } = storeToRefs(projectStore)
const { files, reset } = useFileDialog()
const { bases } = storeToRefs(projectStore)
const { $e } = useNuxtApp()
type QuickImportTypes = 'excel' | 'json' | 'csv'
const allowedQuickImportTypes = [
// Excel
'application/vnd.ms-excel', // .xls
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel.sheet.macroenabled.12', // .xlsm
'application/vnd.oasis.opendocument.spreadsheet', // .ods
'application/vnd.oasis.opendocument.spreadsheet-template', // .ots
// CSV
'text/csv',
// JSON
'application/json',
'text/json',
]
watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles), { flush: 'post' })
function onFileSelect(fileList: FileList | null) {
if (!fileList) return
const files = Array.from(fileList).map((file) => file)
onDrop(files)
}
function onDrop(droppedFiles: File[] | null) {
if (!droppedFiles) return
/** we can only handle one file per drop */
if (droppedFiles.length > 1) {
return message.error({
content: `Only one file can be imported at a time.`,
duration: 2,
})
}
let fileType: QuickImportTypes | null = null
const isValid = allowedQuickImportTypes.some((type) => {
const isAllowed = droppedFiles[0].type === type
if (isAllowed) {
const ext = droppedFiles[0].name.split('.').pop()
fileType = ext === 'csv' || ext === 'json' ? ext : ('excel' as QuickImportTypes)
}
return isAllowed
})
/** Invalid file type was dropped */
if (!isValid) {
return message.error({
content: 'Invalid file type',
duration: 2,
})
}
if (fileType && isValid) {
openQuickImportDialog(fileType, droppedFiles[0])
}
}
function openQuickImportDialog(type: QuickImportTypes, file: File) {
$e(`a:actions:import-${type}`)
const isOpen = ref(true)
const { close, vNode } = useDialog(resolveComponent('DlgQuickImport'), {
'modelValue': isOpen,
'importType': type,
'onUpdate:modelValue': closeDialog,
'baseId': bases.value?.filter((base: BaseType) => base.enabled)[0].id,
})
vNode.value?.component?.exposed?.handleChange({
file: {
uid: `${type}-${file.name}-${Math.random().toString(36).substring(2)}`,
name: file.name,
type: file.type,
status: 'done',
fileName: file.name,
lastModified: file.lastModified,
size: file.size,
originFileObj: file,
},
event: { percent: 100 },
} as UploadChangeParam<UploadFile<File>>)
function closeDialog() {
isOpen.value = false
close(1000)
reset()
}
}
watch(
() => project.value.id,
() => {
if (project.value?.id && project.value.type === 'database') {
const { addTab } = useTabs()
addTab({
id: project.value.id,
title: project.value.title!,
type: TabType.DB,
projectId: project.value.id,
})
}
},
)
</script>
<template>
<ProjectView />
</template>

3
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/sql/[baseId].vue

@ -1,3 +0,0 @@
<template>
<span />
</template>

16
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/base/[baseId].vue

@ -1,16 +0,0 @@
<script setup lang="ts">
/** A dummy page to redirect old shared base url from v1 to latest */
import { useRoute, useRouter } from '#imports'
const route = useRoute()
const router = useRouter()
const { type, name, view } = route.query
if (type && name) {
router.replace(`/base/${route.params.baseId}/${type}/${name}${view ? `/${view}` : ''}`)
} else {
router.replace(`/base/${route.params.baseId}`)
}
</script>

3
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/cowriter/[projectId]/[...slugs].vue

@ -1,3 +0,0 @@
<template>
<span />
</template>

126
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId].vue

@ -1,126 +0,0 @@
<script setup lang="ts">
import {
IsFormInj,
IsPublicInj,
MetaInj,
ReloadViewDataHookInj,
applyLanguageDirection,
createError,
createEventHook,
definePageMeta,
navigateTo,
provide,
reactive,
ref,
useProvideSharedFormStore,
useProvideSmartsheetStore,
useRoute,
useSidebar,
watch,
} from '#imports'
definePageMeta({
public: true,
})
useSidebar('nc-left-sidebar', { hasSidebar: false })
const route = useRoute()
const { loadSharedView, sharedView, sharedViewMeta, meta, notFound, password, passwordDlg, passwordError } =
useProvideSharedFormStore(route.params.viewId as string)
await loadSharedView()
if (!notFound.value) {
provide(ReloadViewDataHookInj, createEventHook())
provide(MetaInj, meta)
provide(IsPublicInj, ref(true))
provide(IsFormInj, ref(true))
useProvideSmartsheetStore(sharedView, meta, true)
applyLanguageDirection(sharedViewMeta.value.rtl ? 'rtl' : 'ltr')
} else {
navigateTo('/error/404')
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' })
}
const form = reactive({
password: '',
})
watch(
() => form.password,
() => {
password.value = form.password
},
)
</script>
<template>
<NuxtLayout>
<NuxtPage v-if="!passwordDlg" />
<a-modal
v-model:visible="passwordDlg"
:class="{ active: passwordDlg }"
:closable="false"
width="min(100%, 450px)"
centered
:footer="null"
:mask-closable="false"
wrap-class-name="nc-modal-shared-form-password-dlg"
@close="passwordDlg = false"
>
<div class="w-full flex flex-col gap-4">
<!-- todo: i18n -->
<h2 class="text-xl font-semibold">This shared view is protected</h2>
<a-form layout="vertical" no-style :model="form" @finish="loadSharedView">
<a-form-item name="password" :rules="[{ required: true, message: $t('msg.error.signUpRules.passwdRequired') }]">
<a-input-password v-model:value="form.password" size="large" :placeholder="$t('msg.info.signUp.enterPassword')" />
</a-form-item>
<Transition name="layout">
<div v-if="passwordError" class="mb-2 text-sm text-red-500">{{ passwordError }}</div>
</Transition>
<!-- Unlock -->
<button type="submit" class="mt-4 scaling-btn bg-opacity-100">{{ $t('general.unlock') }}</button>
</a-form>
</div>
</a-modal>
</NuxtLayout>
</template>
<style lang="scss" scoped>
:deep(.nc-cell-attachment) {
@apply p-0;
.nc-attachment-cell {
@apply px-4 min-h-[75px] w-full h-full;
.nc-attachment {
@apply md:(w-[50px] h-[50px]) lg:(w-[75px] h-[75px]) min-h-[50px] min-w-[50px];
}
.nc-attachment-cell-dropzone {
@apply rounded bg-gray-400/75;
}
}
}
.nc-modal-shared-form-password-dlg {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
.password {
input {
@apply !border-none !m-0;
}
}
}
</style>

148
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index.vue

@ -1,148 +0,0 @@
<script setup lang="ts">
import { navigateTo, useDark, useRoute, useRouter, useSharedFormStoreOrThrow, useTheme, watch } from '#imports'
const { sharedViewMeta } = useSharedFormStoreOrThrow()
const isDark = useDark()
const { setTheme } = useTheme()
const route = useRoute()
const router = useRouter()
watch(
() => sharedViewMeta.value.withTheme,
(hasTheme) => {
if (hasTheme && sharedViewMeta.value.theme) setTheme(sharedViewMeta.value.theme)
},
{ immediate: true },
)
const onClick = () => {
isDark.value = !isDark.value
}
const shouldRedirect = (to: string) => {
if (sharedViewMeta.value.surveyMode) {
if (!to.includes('survey')) navigateTo(`/nc/form/${route.params.viewId}/survey`)
} else {
if (to.includes('survey')) navigateTo(`/nc/form/${route.params.viewId}`)
}
}
shouldRedirect(route.name as string)
router.afterEach((to) => shouldRedirect(to.name as string))
</script>
<template>
<div
class="scrollbar-thin-dull overflow-y-auto overflow-x-hidden flex flex-col color-transition nc-form-view relative bg-primary bg-opacity-10 dark:(bg-slate-900) h-full min-h-[600px]"
>
<NuxtPage />
<div
class="color-transition flex items-center justify-center cursor-pointer absolute top-4 md:top-15 right-4 md:right-15 rounded-full p-2 bg-white dark:(bg-slate-600) shadow hover:(ring-1 ring-accent ring-opacity-100)"
@click="onClick"
>
<Transition name="slide-left" duration="250" mode="out-in">
<MaterialSymbolsDarkModeOutline v-if="isDark" />
<MaterialSymbolsLightModeOutline v-else />
</Transition>
</div>
</div>
</template>
<style lang="scss">
html,
body,
h1,
h2,
h3,
h4,
h5,
h6,
p {
@apply dark:text-white color-transition;
}
.nc-form-view {
.nc-cell {
@apply bg-white dark:bg-slate-500;
&.nc-cell-checkbox {
@apply color-transition !border-0;
.nc-icon {
@apply !text-2xl;
}
.nc-cell-hover-show {
opacity: 100 !important;
div {
background-color: transparent !important;
}
}
}
&:not(.nc-cell-checkbox) {
@apply bg-white dark:bg-slate-500;
&.nc-input {
@apply w-full rounded p-2 min-h-[40px] flex items-center border-solid border-1 border-gray-300 dark:border-slate-200;
.duration-cell-wrapper {
@apply w-full;
input {
@apply !outline-none;
&::placeholder {
@apply text-gray-400 dark:text-slate-300;
}
}
}
input,
textarea,
&.nc-virtual-cell,
> div {
@apply bg-white dark:(bg-slate-500 text-white);
.ant-btn {
@apply dark:(bg-slate-300);
}
.chip {
@apply dark:(bg-slate-700 text-white);
}
}
&.nc-cell-longtext {
@apply !p-0 pb-2px pr-2px;
}
textarea {
@apply px-4 py-2 rounded;
&:focus {
box-shadow: none !important;
}
}
}
}
.nc-attachment-cell > div {
@apply dark:(bg-slate-100);
}
}
}
.nc-form-column-label {
> * {
@apply dark:text-slate-300;
}
}
</style>

141
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index/index.vue

@ -1,141 +0,0 @@
<script lang="ts" setup>
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { useSharedFormStoreOrThrow } from '#imports'
const { sharedFormView, submitForm, v$, formState, notFound, formColumns, submitted, secondsRemain, isLoading } =
useSharedFormStoreOrThrow()
function isRequired(_columnObj: Record<string, any>, required = false) {
let columnObj = _columnObj
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
columnObj.colOptions &&
columnObj.colOptions.type === RelationTypes.BELONGS_TO
) {
columnObj = formColumns.value?.find((c) => c.id === columnObj.colOptions.fk_child_column_id) as Record<string, any>
}
return !!(required || (columnObj && columnObj.rqd && !columnObj.cdf))
}
</script>
<template>
<div class="h-full flex flex-col items-center">
<div
class="color-transition relative flex flex-col justify-center gap-2 w-full max-w-[max(33%,600px)] m-auto py-4 pb-8 px-16 md:(bg-white dark:bg-slate-700 rounded-lg border-1 border-gray-200 shadow-xl)"
>
<template v-if="sharedFormView">
<h1 class="prose-2xl font-bold self-center my-4">{{ sharedFormView.heading }}</h1>
<h2 v-if="sharedFormView.subheading" class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6">
{{ sharedFormView.subheading }}
</h2>
<a-alert v-if="notFound" type="warning" class="my-4 text-center" message="Not found" />
<template v-else-if="submitted">
<div class="flex justify-center">
<div v-if="sharedFormView" class="min-w-350px mt-3">
<a-alert
type="success"
class="my-4 text-center"
outlined
:message="sharedFormView.success_msg || 'Successfully submitted form data'"
/>
<p v-if="sharedFormView.show_blank_form" class="text-xs text-slate-500 dark:text-slate-300 text-center my-4">
New form will be loaded after {{ secondsRemain }} seconds
</p>
<div v-if="sharedFormView.submit_another_form" class="text-center">
<a-button type="primary" @click="submitted = false"> Submit Another Form</a-button>
</div>
</div>
</div>
</template>
<template v-else>
<GeneralOverlay class="bg-gray-400/75" :model-value="isLoading" inline transition>
<div class="w-full h-full flex items-center justify-center">
<a-spin size="large" />
</div>
</GeneralOverlay>
<div class="nc-form-wrapper">
<div class="nc-form h-full">
<div class="flex flex-col gap-6">
<div v-for="(field, index) in formColumns" :key="index" class="flex flex-col gap-2">
<div class="flex nc-form-column-label">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell
v-else
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
</div>
<div>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="null"
class="mt-0 nc-input nc-cell"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="true"
/>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1">
<div v-for="error of v$.localState[field.title]?.$errors" :key="error" class="text-red-500">
{{ error.$message }}
</div>
{{ field.description }}
</div>
</div>
</div>
</div>
<div class="text-center mt-4">
<button
type="submit"
class="uppercase scaling-btn prose-sm"
data-testid="shared-form-submit-button"
@click="submitForm"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
</div>
</template>
</template>
</div>
<div class="flex items-end">
<GeneralPoweredBy />
</div>
</div>
</template>
<style lang="scss" scoped>
:deep(.nc-cell .nc-action-icon) {
@apply !text-white-500 !bg-white/50 !rounded-full !p-1 !text-xs !w-7 !h-7 !flex !items-center !justify-center !cursor-pointer !hover:!bg-white-600 !hover:!text-white-600 !transition;
}
</style>

472
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index/survey.vue

@ -1,472 +0,0 @@
<script lang="ts" setup>
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { breakpointsTailwind } from '@vueuse/core'
import {
DropZoneRef,
computed,
onKeyStroke,
onMounted,
provide,
ref,
useBreakpoints,
usePointerSwipe,
useSharedFormStoreOrThrow,
useStepper,
} from '#imports'
enum TransitionDirection {
Left = 'left',
Right = 'right',
}
enum AnimationTarget {
ArrowLeft = 'arrow-left',
ArrowRight = 'arrow-right',
OkButton = 'ok-button',
SubmitButton = 'submit-button',
}
const { md } = useBreakpoints(breakpointsTailwind)
const { v$, formState, formColumns, submitForm, submitted, secondsRemain, sharedFormView, sharedViewMeta, onReset } =
useSharedFormStoreOrThrow()
const isTransitioning = ref(false)
const transitionName = ref<TransitionDirection>(TransitionDirection.Left)
const animationTarget = ref<AnimationTarget>(AnimationTarget.ArrowRight)
const isAnimating = ref(false)
const el = ref<HTMLDivElement>()
provide(DropZoneRef, el)
const transitionDuration = computed(() => sharedViewMeta.value.transitionDuration || 50)
const steps = computed(() => {
if (!formColumns.value) return []
return formColumns.value.reduce<string[]>((acc, column) => {
const title = column.label || column.title
if (!title) return acc
acc.push(title)
return acc
}, [])
})
const { index, goToPrevious, goToNext, isFirst, isLast, goTo } = useStepper(steps)
const field = computed(() => formColumns.value?.[index.value])
function isRequired(column: ColumnType, required = false) {
let columnObj = column
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
columnObj.colOptions &&
(columnObj.colOptions as { type: RelationTypes }).type === RelationTypes.BELONGS_TO
) {
columnObj = formColumns.value?.find(
(c) => c.id === (columnObj.colOptions as LinkToAnotherRecordType).fk_child_column_id,
) as ColumnType
}
return required || (columnObj && columnObj.rqd && !columnObj.cdf)
}
function transition(direction: TransitionDirection) {
isTransitioning.value = true
transitionName.value = direction
setTimeout(() => {
transitionName.value =
transitionName.value === TransitionDirection.Left ? TransitionDirection.Right : TransitionDirection.Left
}, transitionDuration.value / 2)
setTimeout(() => {
isTransitioning.value = false
setTimeout(focusInput, 100)
}, transitionDuration.value)
}
function animate(target: AnimationTarget) {
animationTarget.value = target
isAnimating.value = true
setTimeout(() => {
isAnimating.value = false
}, transitionDuration.value / 2)
}
async function goNext(animationTarget?: AnimationTarget) {
if (isLast.value || submitted.value) return
if (!field.value || !field.value.title) return
const validationField = v$.value.localState[field.value.title]
if (validationField) {
const isValid = await validationField.$validate()
if (!isValid) return
}
animate(animationTarget || AnimationTarget.ArrowRight)
setTimeout(
() => {
transition(TransitionDirection.Left)
goToNext()
},
animationTarget === AnimationTarget.OkButton ? 300 : 0,
)
}
async function goPrevious(animationTarget?: AnimationTarget) {
if (isFirst.value || submitted.value) return
animate(animationTarget || AnimationTarget.ArrowLeft)
transition(TransitionDirection.Right)
goToPrevious()
}
function focusInput() {
if (document && typeof document !== 'undefined') {
const inputEl =
(document.querySelector('.nc-cell input') as HTMLInputElement) ||
(document.querySelector('.nc-cell textarea') as HTMLTextAreaElement)
if (inputEl) {
inputEl.select()
inputEl.focus()
}
}
}
function resetForm() {
v$.value.$reset()
submitted.value = false
transition(TransitionDirection.Right)
goTo(steps.value[0])
}
function submit() {
if (submitted.value) return
submitForm()
}
onReset(resetForm)
onKeyStroke(['ArrowLeft', 'ArrowDown'], () => {
goPrevious(AnimationTarget.ArrowLeft)
})
onKeyStroke(['ArrowRight', 'ArrowUp'], () => {
goNext(AnimationTarget.ArrowRight)
})
onKeyStroke(['Enter', 'Space'], () => {
if (isLast.value) {
submit()
} else {
goNext(AnimationTarget.OkButton)
}
})
onMounted(() => {
focusInput()
if (!md.value) {
const { direction } = usePointerSwipe(el, {
onSwipe: () => {
if (isTransitioning.value) return
if (direction.value === 'left') {
goNext()
} else if (direction.value === 'right') {
goPrevious()
}
},
})
}
})
</script>
<template>
<div ref="el" class="survey pt-8 md:p-0 w-full h-full flex flex-col">
<div
v-if="sharedFormView"
style="height: max(40vh, 225px); min-height: 225px"
class="max-w-[max(33%,600px)] mx-auto flex flex-col justify-end"
>
<div class="px-4 md:px-0 flex flex-col justify-end">
<h1 class="prose-2xl font-bold self-center my-4" data-testid="nc-survey-form__heading">
{{ sharedFormView.heading }}
</h1>
<h2
v-if="sharedFormView.subheading && sharedFormView.subheading !== ''"
class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6"
data-testid="nc-survey-form__sub-heading"
>
{{ sharedFormView?.subheading }}
</h2>
</div>
</div>
<div class="h-full w-full flex items-center px-4 md:px-0">
<Transition :name="`slide-${transitionName}`" :duration="transitionDuration" mode="out-in">
<div
ref="el"
:key="field?.title"
class="color-transition h-full flex flex-col mt-6 gap-4 w-full max-w-[max(33%,600px)] m-auto"
>
<div v-if="field && !submitted" class="flex flex-col gap-2">
<div class="flex nc-form-column-label" data-testid="nc-form-column-label">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell
v-else
:class="field.uidt === UITypes.Checkbox ? 'nc-form-column-label__checkbox' : ''"
:column="{ meta: {}, ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
</div>
<div v-if="field.title">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
v-model="formState[field.title]"
class="mt-0 nc-input"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="true"
/>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1">
<div v-for="error of v$.localState[field.title]?.$errors" :key="error" class="text-red-500">
{{ error.$message }}
</div>
<div
class="block text-[14px]"
:class="field.uidt === UITypes.Checkbox ? 'text-center' : ''"
data-testid="nc-survey-form__field-description"
>
{{ field.description }}
</div>
<div v-if="field.uidt === UITypes.LongText" class="text-sm text-gray-500 flex flex-wrap items-center">
Shift <MdiAppleKeyboardShift class="mx-1 text-primary" /> + Enter
<MaterialSymbolsKeyboardReturn class="mx-1 text-primary" /> to make a line break
</div>
</div>
</div>
</div>
<div class="ml-1 mt-4 flex w-full text-lg">
<div class="flex-1 flex justify-center">
<div v-if="isLast && !submitted && !v$.$invalid" class="text-center my-4">
<button
:class="
animationTarget === AnimationTarget.SubmitButton && isAnimating
? 'transform translate-y-[1px] translate-x-[1px] ring ring-accent ring-opacity-100'
: ''
"
type="submit"
class="uppercase scaling-btn prose-sm"
data-testid="nc-survey-form__btn-submit"
@click="submit"
>
{{ $t('general.submit') }}
</button>
</div>
<div v-else-if="!submitted" class="flex items-center gap-3 flex-col">
<a-tooltip
:title="v$.localState[field.title]?.$error ? v$.localState[field.title].$errors[0].$message : 'Go to next'"
:mouse-enter-delay="0.25"
:mouse-leave-delay="0"
>
<button
class="bg-opacity-100 scaling-btn flex items-center gap-1"
data-testid="nc-survey-form__btn-next"
:class="[
v$.localState[field.title]?.$error ? 'after:!bg-gray-100 after:!ring-red-500' : '',
animationTarget === AnimationTarget.OkButton && isAnimating
? 'transform translate-y-[2px] translate-x-[2px] after:(!ring !ring-accent !ring-opacity-100)'
: '',
]"
@click="goNext()"
>
<Transition name="fade">
<span v-if="!v$.localState[field.title]?.$error" class="uppercase text-white">Ok</span>
</Transition>
<Transition name="slide-right" mode="out-in">
<MdiCloseCircleOutline v-if="v$.localState[field.title]?.$error" class="text-red-500 md:text-md" />
<MdiCheck v-else class="text-white md:text-md" />
</Transition>
</button>
</a-tooltip>
<!-- todo: i18n -->
<div class="hidden md:flex text-sm text-gray-500 items-center gap-1">
Press Enter <MaterialSymbolsKeyboardReturn class="text-primary" />
</div>
</div>
</div>
</div>
<Transition name="slide-left">
<div v-if="submitted" class="flex flex-col justify-center items-center text-center">
<div class="text-lg px-6 py-3 bg-green-300 text-gray-700 rounded" data-testid="nc-survey-form__success-msg">
<template v-if="sharedFormView?.success_msg">
{{ sharedFormView?.success_msg }}
</template>
<template v-else>
<div class="flex flex-col gap-1">
<div>Thank you!</div>
<div>You have successfully submitted the form data.</div>
</div>
</template>
</div>
<div v-if="sharedFormView" class="mt-3">
<p v-if="sharedFormView?.show_blank_form" class="text-xs text-slate-500 dark:text-slate-300 text-center my-4">
New form will be loaded after {{ secondsRemain }} seconds
</p>
<div v-if="sharedFormView?.submit_another_form" class="text-center">
<button
type="button"
class="scaling-btn bg-opacity-100"
data-testid="nc-survey-form__btn-submit-another-form"
@click="resetForm"
>
Submit Another Form
</button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</div>
<template v-if="!submitted">
<div class="mb-24 md:my-4 select-none text-center text-gray-500 dark:text-slate-200" data-testid="nc-survey-form__footer">
{{ index + 1 }} / {{ formColumns?.length }}
</div>
</template>
<div class="relative flex w-full items-end">
<Transition name="fade">
<div
v-if="!submitted"
class="color-transition shadow-sm absolute bottom-18 right-1/2 transform translate-x-[50%] md:bottom-4 md:(right-12 transform-none) flex items-center bg-white border dark:bg-slate-500 rounded divide-x-1"
>
<a-tooltip :title="isFirst ? '' : 'Go to previous'" :mouse-enter-delay="0.25" :mouse-leave-delay="0">
<button
:class="
animationTarget === AnimationTarget.ArrowLeft && isAnimating
? 'transform translate-y-[1px] translate-x-[1px] text-primary'
: ''
"
class="p-0.5 flex items-center group color-transition"
data-testid="nc-survey-form__icon-prev"
@click="goPrevious()"
>
<MdiChevronLeft :class="isFirst ? 'text-gray-300' : 'group-hover:text-accent'" class="text-2xl md:text-md" />
</button>
</a-tooltip>
<a-tooltip
:title="v$.localState[field.title]?.$error ? '' : 'Go to next'"
:mouse-enter-delay="0.25"
:mouse-leave-delay="0"
>
<button
:class="
animationTarget === AnimationTarget.ArrowRight && isAnimating
? 'transform translate-y-[1px] translate-x-[-1px] text-primary'
: ''
"
class="p-0.5 flex items-center group color-transition"
data-testid="nc-survey-form__icon-next"
@click="goNext()"
>
<MdiChevronRight
:class="[isLast || v$.localState[field.title]?.$error ? 'text-gray-300' : 'group-hover:text-accent']"
class="text-2xl md:text-md"
/>
</button>
</a-tooltip>
</div>
</Transition>
<GeneralPoweredBy />
</div>
</div>
</template>
<style lang="scss">
:global(html, body) {
@apply overscroll-x-none;
}
.survey {
.nc-form-column-label {
> * {
@apply !prose-lg;
}
.nc-icon {
@apply mr-2;
}
}
.nc-form-column-label__checkbox {
@apply flex items-center justify-center gap-2 text-left;
}
.nc-input {
@apply appearance-none w-full rounded px-2 py-2 my-2 border-solid border-1 border-primary border-opacity-50;
&.nc-cell-checkbox {
> * {
@apply justify-center flex items-center;
}
}
input {
@apply !py-1 !px-1;
}
}
}
</style>

35
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/gallery/[viewId]/index.vue

@ -1,35 +0,0 @@
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { definePageMeta } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
layout: 'shared-view',
})
const route = useRoute()
const { loadSharedView } = useSharedView()
const showPassword = ref(false)
try {
await loadSharedView(route.params.viewId as string)
} catch (e: any) {
if (e?.response?.status === 403) {
showPassword.value = true
} else {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>
<LazySharedViewGallery v-else />
</NuxtLayout>
</template>

22
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/index.vue

@ -1,22 +0,0 @@
<script lang="ts" setup>
definePageMeta({
hideHeader: true,
})
</script>
<template>
<div>
<div>
<div class="w-full h-full nc-container">
<WorkspaceEmptyPlaceholder buttons />
</div>
</div>
</div>
</template>
<style scoped>
.nc-container {
height: 100vh;
flex: 1 1 100%;
}
</style>

35
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/kanban/[viewId]/index.vue

@ -1,35 +0,0 @@
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { definePageMeta } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
layout: 'shared-view',
})
const route = useRoute()
const { loadSharedView } = useSharedView()
const showPassword = ref(false)
try {
await loadSharedView(route.params.viewId as string)
} catch (e: any) {
if (e?.response?.status === 403) {
showPassword.value = true
} else {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>
<LazySharedViewKanban v-else />
</NuxtLayout>
</template>

35
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/map/[viewId]/index.vue

@ -1,35 +0,0 @@
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { definePageMeta } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
layout: 'shared-view',
})
const route = useRoute()
const { loadSharedView } = useSharedView()
const showPassword = ref(false)
try {
await loadSharedView(route.params.viewId as string)
} catch (e: any) {
if (e?.response?.status === 403) {
showPassword.value = true
} else {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>
<LazySharedViewMap v-else />
</NuxtLayout>
</template>

59
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/shared/[erdUuid]/index.vue

@ -1,59 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { definePageMeta, navigateTo, onMounted, ref, useGlobal, useMetas, useNuxtApp, useProject, useRoute } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
})
const route = useRoute()
const { appInfo } = useGlobal()
const projectStore = useProject()
const { loadProject } = projectStore
const { project } = storeToRefs(projectStore)
useMetas()
const baseData = ref({} as any)
const { $api } = useNuxtApp()
onMounted(async () => {
try {
baseData.value = await $api.public.sharedErdMetaGet(route.params.erdUuid as string)
} catch (e: any) {
console.error(e)
navigateTo('/')
return
}
await loadProject(false, baseData.value.project_id)
})
</script>
<template>
<div
class="absolute z-60 transition-all duration-200 m-6 cursor-pointer transform hover:scale-105 flex text-xl items-center"
@click="navigateTo('/')"
>
<a-tooltip placement="bottom">
<template #title>
{{ appInfo.version }}
</template>
<img width="50" alt="NocoDB" src="~/assets/img/icons/256x256.png" />
</a-tooltip>
<div class="ml-2 font-bold text-gray-500 uppercase">{{ project.title }}</div>
</div>
<div class="w-full h-full !p-0">
<ErdView :base-id="baseData.id" />
</div>
</template>
<style lang="scss" scoped>
:deep(.nc-erd-histogram.top) {
display: none;
}
</style>

36
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/view/[viewId].vue

@ -1,36 +0,0 @@
<script setup lang="ts">
import { definePageMeta, extractSdkResponseErrorMsg, message, ref, useRoute, useSharedView } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
layout: 'shared-view',
})
const route = useRoute()
const { loadSharedView } = useSharedView()
const showPassword = ref(false)
try {
await loadSharedView(route.params.viewId as string)
} catch (e: any) {
if (e?.response?.status === 403) {
showPassword.value = true
} else {
console.error(e)
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>
<LazySharedViewGrid v-else />
</NuxtLayout>
</template>

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

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

17
packages/noco-docs/docs/020.getting-started/010.installation.md

@ -82,15 +82,6 @@ If you plan to input some special characters, you may need to change the charact
We provide different docker-compose.yml files under [this directory](https://github.com/nocodb/nocodb/tree/master/docker-compose). Here are some examples.
<Tabs>
<TabItem value="js" label="JavaScript">
```js
function helloWorld() {
console.log('Hello, world!');
}
```
</TabItem>
<TabItem value="mysql" label="MySQL">
```bash
@ -319,7 +310,7 @@ If your service fails to start, you may check the logs in ECS console or in Clou
<details>
<summary>Click to Expand</summary>
#### Pull NocoDB Image on Cloud Shell
Since Cloud Run only supports images from Google Container Registry (GCR) or Artifact Registry, we need to pull NocoDB image, tag it and push it in GCP using Cloud Shell. Here are some sample commands which you can execute in Cloud Shell.
@ -350,7 +341,7 @@ If your service fails to start, you may check the logs in ECS console or in Clou
<details>
<summary>Click to Expand</summary>
#### Create Apps
On Home page, Click on Create icon & Select Apps (Deploy your code).
@ -392,7 +383,7 @@ If your service fails to start, you may check the logs in ECS console or in Clou
<details>
<summary>Click to Expand</summary>
#### Navigate to App Store
Log into Cloudron and select App Store
@ -440,7 +431,7 @@ If your service fails to start, you may check the logs in ECS console or in Clou
<details>
<summary>Click to Expand</summary>
#### Navigate to Templates
Go to [Templates](https://railway.app/templates), Search NocoDB and click Deploy

2
packages/noco-docs/docs/020.getting-started/020.environment-variables.md

@ -12,7 +12,7 @@ For production usecases, it is **recommended** to configure
| Variable | Comments | If absent |
|---|---|---|
| NC_DB | See our database URLs | A local SQLite will be created in root folder if `NC_DB` is not provided |
| NC_DB | See our example database URLs [here](https://github.com/nocodb/nocodb#docker). | A local SQLite will be created in root folder if `NC_DB` is not provided |
| NC_DB_JSON | Can be used instead of `NC_DB` and value should be valid knex connection JSON | |
| NC_DB_JSON_FILE | Can be used instead of `NC_DB` and value should be a valid path to knex connection JSON | |
| DATABASE_URL | JDBC URL Format. Can be used instead of NC_DB. | |

2
packages/noco-docs/docs/030.setup-and-usages/061.links.md

@ -18,7 +18,7 @@ description: "Understanding Link Columns!"
Further details of relationship types can be found [here](https://afteracademy.com/blog/what-are-the-different-types-of-relationships-in-dbms)
From Release v0.110.0, table records can be connected through relationships using the **Links** column type.
From Release v0.111.0, table records can be connected through relationships using the **Links** column type.
It is important to note that, earlier supported column type **LinkToAnotherRecord** for creating relations is considered deprecated. While the old datatype is still supported for backward compatibility, it is no longer possible to create new fields of that type.
The main distinction between these two column types lies in how the contents are displayed within the cell when links are established between two tables. With the **LinkToAnotherRecord** column type, the cell displays the **Primary value** of the related records. On the other hand, the **Links** column type only shows the **count** of related records.

295
packages/noco-docs/docs/040.developer-resources/040.webhooks.md

@ -5,27 +5,298 @@ description: "Webhooks allows user to trigger on certain operations on following
## Overview
Some types of notifications can be triggered by a webhook after a particular event.
You can employ webhooks to notify external systems whenever there are additions, updates, or removals of rows within NocoDB. This feature allows you to receive instantaneous notifications for any changes made to your database.
NocoDB also offers webhooks for bulk endpoints for creating, updating, or deleting multiple records simultaneously.
Note that, Webhooks currently are specific for associated table.
- Open `Details` tab in topbar, click on `Webhooks`
- Click `Add New Webhook`
![webhook](https://github.com/nocodb/nocodb/assets/86527202/dfd9e8ff-e175-4a9d-8a95-75e3c2f96873)
### Configure Webhook
- General configurations
- Webhook Name
- Webhook Trigger
- Webhook Type
- Webhook Type specific configuration : additional configuration details depending on webhook type selected
- Webhook Conditional Trigger
- Only records meeting the criteria will trigger webhook
To setup a new Webhook
1. Open `Details` tab in topbar,
2. Click on `Webhooks` tab
3. Click `Add New Webhook`
![webhook](https://github.com/nocodb/nocodb/assets/86527202/07f375af-f2c3-4d7c-9500-976f38b15c12)
Webhook configuration
![webhook config](https://github.com/nocodb/nocodb/assets/86527202/338c8f23-237c-4a00-870d-5221e00a1d34)
1. Name
2. Event
1. After Insert
2. After Update
3. After Delete
4. After Bulk Insert
5. After Bulk Update
6. After Bulk Delete
3. Type
| Trigger | Details |
| --------------- | ---------------------------------------------- |
| Email | Send email to certain email addresses |
| Slack | Notify via Slack channel |
| Microsoft Teams | Notify via Microsoft Teams channel |
| Discord | Notify via Discord channel |
| Mattermost | Notify via Mattermost channel |
| Twilio | Send SMS to certain mobile numbers |
| Whatsapp Twilio | Send Whatsapp messages to numbers using Twilio |
| URL | Invoke an HTTP API |
4. Action
1. GET
2. POST
3. DELETE
4. PUT
5. HEAD
6. PATCH
5. Type specific configuration : additional configuration details depending on webhook type selected
Example: `Link` for type `URL`
6. [Optional] Headers & Parameters :
Configure Request headers & parameters (if any)
7. [Optional] Condition :
Only records meeting the criteria will trigger webhook
8. [Optional] Test :
Test webhook (with sample payload) to verify if parameter are configured appropriately
9. Save
![webhook config](https://github.com/nocodb/nocodb/assets/86527202/3c95ed48-b763-44ad-b73e-2a28e049ec23)
<!-- ![image](https://user-images.githubusercontent.com/35857179/166660248-a3c81a34-4334-48c2-846a-65759d761559.png) -->
### Enable/Disable Webhook
To disable a Webhook
- Open `Webhook` tab to find list of webhooks created
- Toggle `Activate` button to enable/disable
![Screenshot 2023-09-01 at 3 59 28 PM](https://github.com/nocodb/nocodb/assets/86527202/c62cca12-6164-46a8-87e5-179d28c989b6)
### Delete Webhook
To delete a Webhook
- Open `Webhook` tab to find list of webhooks created
- Click on `...` actions button associated with the webhook to be deleted
- Select `Delete`
![Screenshot 2023-09-01 at 4 01 46 PM](https://github.com/nocodb/nocodb/assets/86527202/23a8aec1-ba29-4be4-8143-f3c94198a88c)
### Duplicate Webhook
To duplicate a Webhook
- Open `Webhook` tab to find list of webhooks created
- Click on `...` actions button associated with the webhook to be duplicate
- Select `Duplicate`
![Screenshot 2023-09-01 at 4 01 46 PM](https://github.com/nocodb/nocodb/assets/86527202/23a8aec1-ba29-4be4-8143-f3c94198a88c)
A copy of the webhook will be created (disabled by default) with a suffix ` - Copy`
### Webhook Response Sample
#### Insert
```
{
"type": "records.after.insert",
"id": "9dac1c54-b3be-49a1-a676-af388145fa8c",
"data": {
"table_id": "md_xzru7dcqrecc60",
"table_name": "Film",
"view_id": "vw_736wrpoas7tr0c",
"view_name": "Film",
"rows": [
{
"FilmId": 1011,
"Title": "FOO",
"Language": {
"LanguageId": 1,
"Name": "English"
},
}
]
}
}
```
#### Update
```
{
"type": "records.after.update",
"id": "6a6ebfe4-b0b5-434e-b5d6-5212adbf82fa",
"data": {
"table_id": "md_xzru7dcqrecc60",
"table_name": "Film",
"view_id": "vw_736wrpoas7tr0c",
"view_name": "Film",
"previous_rows": [
{
"FilmId": 1,
"Title": "ACADEMY DINOSAUR",
"Description": "A Epic Drama of a Feminist in The Canadian Rockies",
"Actor List": [
{
"ActorId": 10,
"FirstName": "CHRISTIAN"
}
],
}
],
"rows": [
{
"FilmId": 1,
"Title": "ACADEMY DINOSAUR (Edited)",
"Actor List": [
{
"ActorId": 10,
"FirstName": "CHRISTIAN"
}
],
}
]
}
}
```
#### Delete
```
{
"type": "records.after.delete",
"id": "e593079f-70e5-4965-8944-5ff7aeed005c",
"data": {
"table_id": "md_xzru7dcqrecc60",
"table_name": "Film",
"view_id": "vw_736wrpoas7tr0c",
"view_name": "Film",
"rows": [
{
"FilmId": 1010,
"Title": "ALL-EDITED",
"Language": {
"LanguageId": 1,
"Name": "English"
},
}
]
}
}
```
#### Bulk Insert
```
{
"type": "records.after.bulkInsert",
"id": "f8397b06-a399-4a3a-b6b0-6d1c0c2f7578",
"data": {
"table_id": "md_xzru7dcqrecc60",
"table_name": "Film",
"view_id": "vw_3fq2e9q8drkblw",
"view_name": "GridView",
"rows_inserted": 2
}
}
```
#### Bulk Update
```
{
"type": "records.after.bulkUpdate",
"id": "e983cea5-8e38-438e-96a0-048751f6830b",
"data": {
"table_id": "md_xzru7dcqrecc60",
"table_name": "Film",
"view_id": "vw_3fq2e9q8drkblw",
"view_name": "Sheet-1",
"previous_rows": [
[
{
"FilmId": 1005,
"Title": "Q",
"Language": {
"LanguageId": 1,
"Name": "English"
},
},
{
"FilmId": 1004,
"Title": "P",
"Language": {
"LanguageId": 1,
"Name": "English"
}
}
]
],
"rows": [
[
{
"FilmId": 1005,
"Title": "Q-EDITED",
"Language": {
"LanguageId": 1,
"Name": "English"
}
},
{
"FilmId": 1004,
"Title": "P-EDITED",
"Language": {
"LanguageId": 1,
"Name": "English"
},
}
]
]
}
}
```
#### Bulk Delete
```
{
"type": "records.after.bulkDelete",
"id": "e7f1f4e5-7052-4ca2-9355-241ceb836f43",
"data": {
"table_id": "md_xzru7dcqrecc60",
"table_name": "Film",
"view_id": "vw_3fq2e9q8drkblw",
"view_name": "Sheet-1",
"rows": [
[
{
"FilmId": 1022,
"Title": "x",
"Language": {
"LanguageId": 1,
"Name": "English"
},
},
{
"FilmId": 1023,
"Title": "x",
"Language": {
"LanguageId": 1,
"Name": "English"
},
}
]
]
}
}
```
## Call Log
Call Log allows user to check the call history of the hook. By default, it has been disabled. However, it can be configured by using environment variable `NC_AUTOMATION_LOG_LEVEL`.

4
packages/noco-docs/docusaurus.config.js

@ -67,10 +67,10 @@ const config = {
// Replace with your project's social card
image: 'img/docusaurus-social-card.jpg',
navbar: {
title: 'NocoDB',
title: '',
logo: {
alt: 'NocoDB',
src: 'img/icon.png',
src: 'img/nocodb-full-color.png',
},
items: [
{

4
packages/noco-docs/src/css/header.scss

@ -13,6 +13,10 @@
}
}
.navbar__logo {
height: 3rem;
}
/* Dark Mode Styles */
html[data-theme='dark'] {

BIN
packages/noco-docs/static/img/nocodb-full-color.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

26
packages/noco-docs/versioned_docs/version-0.109.7/020.getting-started/010.installation.md vendored

@ -17,15 +17,6 @@ Simple installation - takes about three minutes!
If you are a Docker user, you may try this way!
<Tabs>
<TabItem value="js" label="JavaScript">
```js
function helloWorld() {
console.log('Hello, world!');
}
```
</TabItem>
<TabItem value="sqlite" label="SQLite">
```bash
@ -91,15 +82,6 @@ If you plan to input some special characters, you may need to change the charact
We provide different docker-compose.yml files under [this directory](https://github.com/nocodb/nocodb/tree/master/docker-compose). Here are some examples.
<Tabs>
<TabItem value="js" label="JavaScript">
```js
function helloWorld() {
console.log('Hello, world!');
}
```
</TabItem>
<TabItem value="mysql" label="MySQL">
```bash
@ -328,7 +310,7 @@ If your service fails to start, you may check the logs in ECS console or in Clou
<details>
<summary>Click to Expand</summary>
#### Pull NocoDB Image on Cloud Shell
Since Cloud Run only supports images from Google Container Registry (GCR) or Artifact Registry, we need to pull NocoDB image, tag it and push it in GCP using Cloud Shell. Here are some sample commands which you can execute in Cloud Shell.
@ -359,7 +341,7 @@ If your service fails to start, you may check the logs in ECS console or in Clou
<details>
<summary>Click to Expand</summary>
#### Create Apps
On Home page, Click on Create icon & Select Apps (Deploy your code).
@ -401,7 +383,7 @@ If your service fails to start, you may check the logs in ECS console or in Clou
<details>
<summary>Click to Expand</summary>
#### Navigate to App Store
Log into Cloudron and select App Store
@ -449,7 +431,7 @@ If your service fails to start, you may check the logs in ECS console or in Clou
<details>
<summary>Click to Expand</summary>
#### Navigate to Templates
Go to [Templates](https://railway.app/templates), Search NocoDB and click Deploy

2
packages/noco-docs/versioned_docs/version-0.109.7/030.setup-and-usages/220.links.md vendored

@ -18,7 +18,7 @@ description: "Understanding Link Columns!"
Further details of relationship types can be found [here](https://afteracademy.com/blog/what-are-the-different-types-of-relationships-in-dbms)
From Release v0.110.0, table records can be connected through relationships using the **Links** column type.
From Release v0.111.0, table records can be connected through relationships using the **Links** column type.
It is important to note that, earlier supported column type **LinkToAnotherRecord** for creating relations is considered deprecated. While the old datatype is still supported for backward compatibility, it is no longer possible to create new fields of that type.
The main distinction between these two column types lies in how the contents are displayed within the cell when links are established between two tables. With the **LinkToAnotherRecord** column type, the cell displays the **Primary value** of the related records. On the other hand, the **Links** column type only shows the **count** of related records.

2
packages/nocodb-sdk/package.json

@ -1,6 +1,6 @@
{
"name": "nocodb-sdk",
"version": "0.111.0",
"version": "0.111.1",
"description": "NocoDB SDK",
"main": "build/main/index.js",
"typings": "build/main/index.d.ts",

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

@ -5906,13 +5906,7 @@ export class Api<
* @summary Bulk create-update-delete columns
* @request POST:/api/v1/db/meta/tables/{tableId}/columns/bulk
* @response `200` `{
failedOps?: ({
op: "add" | "update" | "delete",
\** Model for Column *\
column: ColumnType,
error?: any,
})[],
failedOps?: (any)[],
}` OK
* @response `400` `{
@ -5932,12 +5926,7 @@ export class Api<
) =>
this.request<
{
failedOps?: {
op: 'add' | 'update' | 'delete';
/** Model for Column */
column: ColumnType;
error?: any;
}[];
failedOps?: any[];
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
@ -9976,126 +9965,6 @@ export class Api<
...params,
}),
};
cowriterTable = {
/**
* No description
*
* @tags Cowriter Table
* @name Create
* @summary Cowriter Create
* @request POST:/api/v1/cowriter/meta/tables/{tableId}
* @response `200` `any` OK
*/
create: (tableId: string, data: object, params: RequestParams = {}) =>
this.request<any, any>({
path: `/api/v1/cowriter/meta/tables/${tableId}`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* No description
*
* @tags Cowriter Table
* @name List
* @summary Cowriter List
* @request GET:/api/v1/cowriter/meta/tables/{tableId}
* @response `200` `any` OK
*/
list: (tableId: string, params: RequestParams = {}) =>
this.request<any, any>({
path: `/api/v1/cowriter/meta/tables/${tableId}`,
method: 'GET',
format: 'json',
...params,
}),
/**
* No description
*
* @tags Cowriter Table
* @name Get
* @summary Cowriter Get
* @request GET:/api/v1/cowriter/meta/tables/{tableId}/{cowriterId}
* @response `200` `any` OK
* @response `0` `any`
*/
get: (tableId: string, cowriterId: string, params: RequestParams = {}) =>
this.request<any, any>({
path: `/api/v1/cowriter/meta/tables/${tableId}/${cowriterId}`,
method: 'GET',
format: 'json',
...params,
}),
/**
* No description
*
* @tags Cowriter Table
* @name Patch
* @summary Cowriter Patch
* @request PATCH:/api/v1/cowriter/meta/tables/{tableId}/{cowriterId}
* @response `200` `void` OK
*/
patch: (
tableId: string,
cowriterId: string,
data: any,
params: RequestParams = {}
) =>
this.request<void, any>({
path: `/api/v1/cowriter/meta/tables/${tableId}/${cowriterId}`,
method: 'PATCH',
body: data,
type: ContentType.Json,
...params,
}),
/**
* @description Generate Columns using AI
*
* @tags Cowriter Table
* @name GenerateColumns
* @summary Cowriter Generate Columns
* @request POST:/api/v1/cowriter/meta/tables/{tableId}/generate-columns
* @response `200` `void` OK
*/
generateColumns: (
tableId: string,
data: {
title?: string;
},
params: RequestParams = {}
) =>
this.request<void, any>({
path: `/api/v1/cowriter/meta/tables/${tableId}/generate-columns`,
method: 'POST',
body: data,
type: ContentType.Json,
...params,
}),
/**
* No description
*
* @tags Cowriter Table
* @name CreateBulk
* @summary Cowriter Create Bulk
* @request POST:/api/v1/cowriter/meta/tables/{tableId}/bulk
* @response `200` `void` OK
*/
createBulk: (tableId: string, data: any[], params: RequestParams = {}) =>
this.request<void, any>({
path: `/api/v1/cowriter/meta/tables/${tableId}/bulk`,
method: 'POST',
body: data,
type: ContentType.Json,
...params,
}),
};
plugin = {
/**
* @description List all plugins

6
packages/nocodb/package.json

@ -1,6 +1,6 @@
{
"name": "nocodb",
"version": "0.111.0",
"version": "0.111.1",
"description": "NocoDB Backend",
"main": "dist/bundle.js",
"author": {
@ -129,7 +129,7 @@
"mysql2": "^3.2.0",
"nanoid": "^3.1.20",
"nc-help": "^0.2.88",
"nc-lib-gui": "0.111.0",
"nc-lib-gui": "0.111.1",
"nc-plugin": "^0.1.3",
"ncp": "^2.0.0",
"nestjs-kafka": "^1.0.6",
@ -226,4 +226,4 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
}

36
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -2176,7 +2176,7 @@ class BaseModelSqlv2 {
return res;
}
async updateByPk(id, data, trx?, cookie?, disableOptimization = false) {
async updateByPk(id, data, trx?, cookie?, _disableOptimization = false) {
try {
const updateObj = await this.model.mapAliasToColumn(
data,
@ -2679,7 +2679,15 @@ class BaseModelSqlv2 {
// pk not specified - bypass
continue;
}
if (!raw) prevData.push(await this.readByPk(pkValues));
if (!raw)
prevData.push(
await this.readByPk(
pkValues,
false,
{},
{ ignoreView: true, getHiddenColumn: true },
),
);
const wherePk = await this._wherePk(pkValues);
res.push(wherePk);
toBeUpdated.push({ d, wherePk });
@ -2696,7 +2704,12 @@ class BaseModelSqlv2 {
if (!raw) {
for (const pkValues of updatePkValues) {
const oldRecord = await this.readByPk(pkValues);
const oldRecord = await this.readByPk(
pkValues,
false,
{},
{ ignoreView: true, getHiddenColumn: true },
);
if (!oldRecord && throwExceptionIfNotExist)
NcError.unprocessableEntity(
`Record with pk ${JSON.stringify(pkValues)} not found`,
@ -2800,7 +2813,12 @@ class BaseModelSqlv2 {
continue;
}
const oldRecord = await this.readByPk(pkValues);
const oldRecord = await this.readByPk(
pkValues,
false,
{},
{ ignoreView: true, getHiddenColumn: true },
);
if (!oldRecord && throwExceptionIfNotExist)
NcError.unprocessableEntity(
`Record with pk ${JSON.stringify(pkValues)} not found`,
@ -3650,9 +3668,10 @@ class BaseModelSqlv2 {
const proto = await this.getProto();
const data = await groupedQb;
const result = data?.map((d) => {
d.__proto__ = proto;
return d;
return this.convertDateFormat(d);
});
const groupedResult = result.reduce<Map<string | number | null, any[]>>(
@ -3660,9 +3679,7 @@ class BaseModelSqlv2 {
if (!aggObj.has(row[column.title])) {
aggObj.set(row[column.title], []);
}
aggObj.get(row[column.title]).push(row);
return aggObj;
},
new Map(),
@ -3915,10 +3932,7 @@ class BaseModelSqlv2 {
}
}
if (
this.isPg &&
(col.dt === 'timestamp with time zone' || col.dt === 'timestamptz')
) {
if (this.isPg) {
// postgres - timezone already attached to input
// e.g. 2023-05-11 16:16:51+08:00
keepLocalTime = false;

3
packages/nocodb/src/modules/jobs/jobs/at-import/at-import.processor.ts

@ -231,7 +231,7 @@ export class AtImportProcessor {
const template = await FetchAT.readTemplate(sDB.shareId);
await FetchAT.initialize(template.template.exploreApplication.shareId);
} else {
await FetchAT.initialize(sDB.shareId);
await FetchAT.initialize(sDB.shareId, sDB.appId);
}
const ft = await FetchAT.read();
const duration = Date.now() - start;
@ -2450,6 +2450,7 @@ export interface AirtableSyncConfig {
projectId?: string;
baseId?: string;
apiKey: string;
appId?: string;
shareId: string;
user: UserType;
options: {

9
packages/nocodb/src/modules/jobs/jobs/at-import/helpers/fetchAT.ts

@ -4,9 +4,14 @@ const info: any = {
initialized: false,
};
async function initialize(shareId) {
async function initialize(shareId, appId?: string) {
info.cookie = '';
const url = `https://airtable.com/${shareId}`;
if (!appId || appId === '') {
appId = null;
}
const url = `https://airtable.com/${appId ? `${appId}/` : ''}${shareId}`;
try {
const hreq = await axios

241
packages/nocodb/src/schema/swagger.json

@ -13570,210 +13570,6 @@
]
}
},
"/api/v1/cowriter/meta/tables/{tableId}": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true
}
],
"post": {
"summary": "Cowriter Create",
"operationId": "cowriter-table-create",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Cowriter"
}
}
}
}
},
"tags": [
"Cowriter Table"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"get": {
"summary": "Cowriter List",
"operationId": "cowriter-table-list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CowriterList"
}
}
}
}
},
"tags": [
"Cowriter Table"
]
}
},
"/api/v1/cowriter/meta/tables/{tableId}/{cowriterId}": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"name": "cowriterId",
"in": "path",
"required": true
}
],
"get": {
"summary": "Cowriter Get",
"operationId": "cowriter-table-get",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CowriterList"
}
}
}
},
"": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Cowriter"
}
}
}
}
},
"tags": [
"Cowriter Table"
]
},
"patch": {
"summary": "Cowriter Patch",
"operationId": "cowriter-table-patch",
"responses": {
"200": {
"description": "OK"
}
},
"tags": [
"Cowriter Table"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Cowriter"
}
}
}
}
}
},
"/api/v1/cowriter/meta/tables/{tableId}/generate-columns": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true
}
],
"post": {
"summary": "Cowriter Generate Columns",
"operationId": "cowriter-table-generate-columns",
"responses": {
"200": {
"description": "OK"
}
},
"description": "Generate Columns using AI",
"tags": [
"Cowriter Table"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": {
"type": "string"
}
}
}
}
}
}
}
},
"/api/v1/cowriter/meta/tables/{tableId}/bulk": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true
}
],
"post": {
"summary": "Cowriter Create Bulk",
"operationId": "cowriter-table-create-bulk",
"responses": {
"200": {
"description": "OK"
}
},
"description": "",
"tags": [
"Cowriter Table"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Cowriter"
}
}
}
}
}
}
},
"/api/v1/db/meta/plugins": {
"parameters": [
{
@ -15444,22 +15240,24 @@
"failedOps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": [
"add",
"update",
"delete"
],
"required": true
},
"column": {
"$ref": "#/components/schemas/Column",
"required": true
},
"error": {}
"schema": {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": [
"add",
"update",
"delete"
],
"required": true
},
"column": {
"$ref": "#/components/schemas/Column",
"required": true
},
"error": {}
}
}
}
}
@ -22835,9 +22633,6 @@
{
"$ref": "#/components/schemas/ProjectEvent"
},
{
"$ref": "#/components/schemas/WorkspaceInviteEvent"
},
{
"$ref": "#/components/schemas/TableEvent"
},

12
packages/nocodb/src/services/project-users/project-users.service.ts

@ -42,16 +42,18 @@ export class ProjectUsersService {
projectUser: ProjectUserReqType;
req: any;
}): Promise<any> {
validatePayload(
'swagger.json#/components/schemas/ProjectUserReq',
param.projectUser,
);
const emails = (param.projectUser.email || '')
.toLowerCase()
.split(/\s*,\s*/)
.map((v) => v.trim());
emails.forEach((email) => {
validatePayload('swagger.json#/components/schemas/ProjectUserReq', {
...param.projectUser,
email,
});
});
// check for invalid emails
const invalidEmails = emails.filter((v) => !validator.isEmail(v));
if (!emails.length) {

6
packages/nocodb/src/services/telemetry.service.ts

@ -19,7 +19,9 @@ export class TelemetryService {
evt_type: string;
[key: string]: any;
}) {
if (event === '$pageview') T.page({ ...payload, event_name: event });
else T.event({ ...payload, event_name: event });
if (event === '$pageview') T.page({ ...payload, event });
else {
T.event({ ...payload, event });
}
}
}

1
packages/nocodb/src/utils/projectAcl.ts

@ -135,6 +135,7 @@ const rolePermissions = {
filterDelete: true,
filterGet: true,
filterChildrenRead: true,
filterChildrenList: true,
mmList: true,
hmList: true,

3
tests/playwright/pages/Account/AppStore.ts

@ -1,4 +1,3 @@
import { expect } from '@playwright/test';
import BasePage from '../Base';
import { AccountPage } from './index';
@ -23,7 +22,7 @@ export class AccountAppStorePage extends BasePage {
}
async install({ name }: { name: string }) {
const card = await this.accountPage.get().locator(`.nc-app-store-card-${name}`);
const card = this.accountPage.get().locator(`.nc-app-store-card-${name}`);
await card.click();
// todo: Hack to solve the issue when if the test installing a plugin fails, the next test will fail because the plugin is already installed

2
tests/playwright/pages/Dashboard/BarcodeOverlay/index.ts

@ -16,7 +16,7 @@ export class BarcodeOverlay extends BasePage {
async verifyBarcodeSvgValue(expectedValue: string) {
const foundBarcodeSvg = await this.get().getByTestId('barcode').innerHTML();
await expect(foundBarcodeSvg).toContain(expectedValue);
expect(foundBarcodeSvg).toContain(expectedValue);
}
async clickCloseButton() {

29
tests/playwright/pages/Dashboard/BulkUpdate/index.ts

@ -1,7 +1,6 @@
import { expect, Locator } from '@playwright/test';
import BasePage from '../../Base';
import { DashboardPage } from '..';
import { DateTimeCellPageObject } from '../common/Cell/DateTimeCell';
import { getTextExcludeIconText } from '../../../tests/utils/general';
export class BulkUpdatePage extends BasePage {
@ -29,17 +28,17 @@ export class BulkUpdatePage extends BasePage {
}
async getInactiveColumn(index: number) {
const inactiveColumns = await this.columnsDrawer.locator('.ant-card');
const inactiveColumns = this.columnsDrawer.locator('.ant-card');
return inactiveColumns.nth(index);
}
async getActiveColumn(index: number) {
const activeColumns = await this.form.locator('[data-testid="nc-bulk-update-fields"]');
const activeColumns = this.form.locator('[data-testid="nc-bulk-update-fields"]');
return activeColumns.nth(index);
}
async getInactiveColumns() {
const inactiveColumns = await this.columnsDrawer.locator('.ant-card');
const inactiveColumns = this.columnsDrawer.locator('.ant-card');
const inactiveColumnsCount = await inactiveColumns.count();
const inactiveColumnsTitles = [];
// get title for each inactive column
@ -52,7 +51,7 @@ export class BulkUpdatePage extends BasePage {
}
async getActiveColumns() {
const activeColumns = await this.form.locator('[data-testid="nc-bulk-update-fields"]');
const activeColumns = this.form.locator('[data-testid="nc-bulk-update-fields"]');
const activeColumnsCount = await activeColumns.count();
const activeColumnsTitles = [];
// get title for each active column
@ -67,7 +66,7 @@ export class BulkUpdatePage extends BasePage {
}
async removeField(index: number) {
const removeFieldButton = await this.form.locator('[data-testid="nc-bulk-update-fields"]');
const removeFieldButton = this.form.locator('[data-testid="nc-bulk-update-fields"]');
const removeFieldButtonCount = await removeFieldButton.count();
await removeFieldButton.nth(index).locator('[data-testid="nc-bulk-update-fields-remove-icon"]').click();
const newRemoveFieldButtonCount = await removeFieldButton.count();
@ -75,7 +74,7 @@ export class BulkUpdatePage extends BasePage {
}
async addField(index: number) {
const addFieldButton = await this.columnsDrawer.locator('.ant-card');
const addFieldButton = this.columnsDrawer.locator('.ant-card');
const addFieldButtonCount = await addFieldButton.count();
await addFieldButton.nth(index).click();
const newAddFieldButtonCount = await addFieldButton.count();
@ -119,16 +118,8 @@ export class BulkUpdatePage extends BasePage {
const time = value.split(':');
// eslint-disable-next-line no-case-declarations
const timePanel = picker.locator('.ant-picker-time-panel-column');
await timePanel
.nth(0)
.locator('li')
.nth(+time[0])
.click();
await timePanel
.nth(1)
.locator('li')
.nth(+time[1])
.click();
await timePanel.nth(0).locator('li').nth(+time[0]).click();
await timePanel.nth(1).locator('li').nth(+time[1]).click();
await picker.locator('.ant-picker-ok').click();
break;
case 'singleSelect':
@ -151,7 +142,7 @@ export class BulkUpdatePage extends BasePage {
case 'attachment':
// eslint-disable-next-line no-case-declarations
const attachFileAction = field.locator('[data-testid="attachment-cell-file-picker-button"]').click();
await this.attachFile({ filePickUIAction: attachFileAction, filePath: value });
await this.attachFile({ filePickUIAction: attachFileAction, filePath: [value] });
break;
case 'date':
{
@ -182,7 +173,7 @@ export class BulkUpdatePage extends BasePage {
awaitResponse?: boolean;
} = {}) {
await this.bulkUpdateButton.click();
const confirmModal = await this.rootPage.locator('.ant-modal-confirm');
const confirmModal = this.rootPage.locator('.ant-modal-confirm');
const saveRowAction = () => confirmModal.locator('.ant-btn-primary').click();
if (!awaitResponse) {

107
tests/playwright/pages/Dashboard/Details/ErdPage.ts

@ -0,0 +1,107 @@
import BasePage from '../../Base';
import { expect, Locator } from '@playwright/test';
import { DetailsPage } from './index';
export class ErdPage extends BasePage {
readonly detailsPage: DetailsPage;
readonly contextMenuBase: Locator;
readonly contextMenu = {};
readonly btn_fullScreen: Locator;
readonly btn_zoomIn: Locator;
readonly btn_zoomOut: Locator;
constructor(details: DetailsPage) {
super(details.rootPage);
this.detailsPage = details;
this.btn_fullScreen = this.get().locator('.nc-erd-histogram > .nc-icon');
this.btn_zoomIn = this.get().locator('.nc-erd-zoom-btn').nth(0);
this.btn_zoomOut = this.get().locator('.nc-erd-zoom-btn').nth(1);
this.contextMenuBase = this.get().locator('.nc-erd-context-menu');
this.contextMenu['Show Columns'] = this.contextMenuBase.locator('.ant-checkbox-wrapper').nth(0);
this.contextMenu['Show Primary and Foreign Keys'] = this.contextMenuBase.locator('.ant-checkbox-wrapper').nth(1);
this.contextMenu['Show SQL Views'] = this.contextMenuBase.locator('.ant-checkbox-wrapper').nth(2);
}
get() {
// pop up when triggered from data sources page
return this.rootPage.locator('.vue-flow');
}
async verifyNode({
tableName,
columnName,
columnNameShouldNotExist,
}: {
tableName: string;
columnName?: string;
columnNameShouldNotExist?: string;
}) {
await this.get().locator(`.nc-erd-table-node-${tableName}`).waitFor({ state: 'visible' });
if (columnName) {
await this.get().locator(`.nc-erd-table-node-${tableName}-column-${columnName}`).waitFor({ state: 'visible' });
}
if (columnNameShouldNotExist) {
await this.get()
.locator(`.nc-erd-table-node-${tableName}-column-${columnNameShouldNotExist}`)
.waitFor({ state: 'hidden' });
}
}
async verifyNodeDoesNotExist({ tableName }: { tableName: string }) {
await this.get().locator(`.nc-erd-table-node-${tableName}`).waitFor({ state: 'hidden' });
}
async verifyColumns({ tableName, columns }: { tableName: string; columns: string[] }) {
for (const column of columns) {
await this.verifyNode({ tableName, columnName: column });
}
}
async verifyNodesCount(count: number) {
await expect(this.get().locator('.nc-erd-table-node')).toHaveCount(count);
}
async verifyEdgesCount({
count,
circleCount,
rectangleCount,
}: {
count: number;
circleCount: number;
rectangleCount: number;
}) {
await expect(this.get().locator('.vue-flow__edge')).toHaveCount(count);
await expect(this.get().locator('.nc-erd-edge-circle')).toHaveCount(circleCount);
await expect(this.get().locator('.nc-erd-edge-rect')).toHaveCount(rectangleCount);
}
async verifyJunctionTableLabel({ tableTitle, tableName }: { tableName: string; tableTitle: string }) {
await this.get().locator(`.nc-erd-table-label-${tableTitle}-${tableName}`).waitFor({
state: 'visible',
});
}
async clickShowColumnNames() {
await this.contextMenu['Show Columns'].click();
await (await this.get().elementHandle())?.waitForElementState('stable');
}
async clickShowPkAndFk() {
await this.contextMenu['Show Primary and Foreign Keys'].click();
await (await this.get().elementHandle())?.waitForElementState('stable');
}
async clickShowSqlViews() {
await this.contextMenu['Show SQL Views'].click();
await (await this.get().elementHandle())?.waitForElementState('stable');
}
async close() {
await this.get().click();
await this.rootPage.keyboard.press('Escape');
await this.get().waitFor({ state: 'hidden' });
}
}

3
tests/playwright/pages/Dashboard/Details/index.ts

@ -3,11 +3,13 @@ import BasePage from '../../Base';
import { TopbarPage } from '../common/Topbar';
import { Locator } from '@playwright/test';
import { WebhookPage } from './WebhookPage';
import { ErdPage } from './ErdPage';
export class DetailsPage extends BasePage {
readonly dashboard: DashboardPage;
readonly topbar: TopbarPage;
readonly webhook: WebhookPage;
readonly relations: ErdPage;
readonly tab_webhooks: Locator;
readonly tab_apiSnippet: Locator;
@ -21,6 +23,7 @@ export class DetailsPage extends BasePage {
this.dashboard = dashboard;
this.topbar = dashboard.grid.topbar;
this.webhook = new WebhookPage(this);
this.relations = new ErdPage(this);
this.tab_webhooks = this.get().locator(`[data-testid="nc-webhooks-tab"]`);
this.tab_apiSnippet = this.get().locator(`[data-testid="nc-apis-tab"]`);

14
tests/playwright/pages/Dashboard/ExpandedForm/index.ts

@ -38,7 +38,7 @@ export class ExpandedFormPage extends BasePage {
// add delay; wait for the menu to appear
await this.rootPage.waitForTimeout(500);
const popUpMenu = await this.rootPage.locator('.ant-dropdown');
const popUpMenu = this.rootPage.locator('.ant-dropdown');
await popUpMenu.locator(`.ant-dropdown-menu-item:has-text("${menuItem}")`).click();
}
@ -55,12 +55,12 @@ export class ExpandedFormPage extends BasePage {
}
async isDisabledDuplicateRow() {
const isDisabled = await this.duplicateRowButton;
const isDisabled = this.duplicateRowButton;
return await isDisabled.count();
}
async isDisabledDeleteRow() {
const isDisabled = await this.deleteRowButton;
const isDisabled = this.deleteRowButton;
return await isDisabled.count();
}
@ -71,7 +71,7 @@ export class ExpandedFormPage extends BasePage {
}
async gotoUsingUrlAndRowId({ rowId }: { rowId: string }) {
const url = await this.dashboard.rootPage.url();
const url = this.dashboard.rootPage.url();
const expandedFormUrl = '/' + url.split('/').slice(3).join('/').split('?')[0] + `?rowId=${rowId}`;
await this.rootPage.goto(expandedFormUrl);
await this.dashboard.waitForLoaderToDisappear();
@ -157,7 +157,7 @@ export class ExpandedFormPage extends BasePage {
}
async openChildCard(param: { column: string; title: string }) {
const childList = await this.get().locator(`[data-testid="nc-expand-col-${param.column}"]`);
const childList = this.get().locator(`[data-testid="nc-expand-col-${param.column}"]`);
await childList.locator(`.ant-card:has-text("${param.title}")`).click();
}
@ -172,9 +172,9 @@ export class ExpandedFormPage extends BasePage {
expect(await this.btn_moreActions.count()).toBe(1);
await this.btn_moreActions.click();
const menu = await this.rootPage.locator('.ant-dropdown:visible');
const menu = this.rootPage.locator('.ant-dropdown:visible');
await menu.waitFor({ state: 'visible' });
const menuItems = await menu.locator('.ant-dropdown-menu-item');
const menuItems = menu.locator('.ant-dropdown-menu-item');
for (let i = 0; i < (await menuItems.count()); i++) {
if (role === 'owner' || role === 'editor' || role === 'creator') {
const menuText = ['Reload', 'Duplicate row', 'Delete row', 'Close'];

44
tests/playwright/pages/Dashboard/Form/index.ts

@ -79,62 +79,60 @@ export class FormPage extends BasePage {
}
async verifyFormFieldLabel({ index, label }: { index: number; label: string }) {
await expect(await this.getFormFields().nth(index).locator('[data-testid="nc-form-input-label"]')).toContainText(
label
);
await expect(this.getFormFields().nth(index).locator('[data-testid="nc-form-input-label"]')).toContainText(label);
}
async verifyFormFieldHelpText({ index, helpText }: { index: number; helpText: string }) {
await expect(
await this.getFormFields().nth(index).locator('[data-testid="nc-form-input-help-text-label"]')
this.getFormFields().nth(index).locator('[data-testid="nc-form-input-help-text-label"]')
).toContainText(helpText);
}
async verifyFieldsIsEditable({ index }: { index: number }) {
await expect(await this.getFormFields().nth(index)).toHaveClass(/nc-editable/);
await expect(this.getFormFields().nth(index)).toHaveClass(/nc-editable/);
}
async verifyAfterSubmitMsg({ msg }: { msg: string }) {
await expect((await this.afterSubmitMsg.inputValue()).includes(msg)).toBeTruthy();
expect((await this.afterSubmitMsg.inputValue()).includes(msg)).toBeTruthy();
}
async verifyFormViewFieldsOrder({ fields }: { fields: string[] }) {
const fieldLabels = await this.get().locator('[data-testid="nc-form-input-label"]');
await expect(await fieldLabels).toHaveCount(fields.length);
const fieldLabels = this.get().locator('[data-testid="nc-form-input-label"]');
await expect(fieldLabels).toHaveCount(fields.length);
for (let i = 0; i < fields.length; i++) {
await expect(await fieldLabels.nth(i)).toContainText(fields[i]);
await expect(fieldLabels.nth(i)).toContainText(fields[i]);
}
}
async reorderFields({ sourceField, destinationField }: { sourceField: string; destinationField: string }) {
await expect(await this.get().locator(`.nc-form-drag-${sourceField}`)).toBeVisible();
await expect(await this.get().locator(`.nc-form-drag-${destinationField}`)).toBeVisible();
const src = await this.get().locator(`.nc-form-drag-${sourceField.replace(' ', '')}`);
const dst = await this.get().locator(`.nc-form-drag-${destinationField.replace(' ', '')}`);
await expect(this.get().locator(`.nc-form-drag-${sourceField}`)).toBeVisible();
await expect(this.get().locator(`.nc-form-drag-${destinationField}`)).toBeVisible();
const src = this.get().locator(`.nc-form-drag-${sourceField.replace(' ', '')}`);
const dst = this.get().locator(`.nc-form-drag-${destinationField.replace(' ', '')}`);
await src.dragTo(dst);
}
async removeField({ field, mode }: { mode: string; field: string }) {
if (mode === 'dragDrop') {
const src = await this.get().locator(`.nc-form-drag-${field.replace(' ', '')}`);
const dst = await this.get().locator(`[data-testid="nc-drag-n-drop-to-hide"]`);
const src = this.get().locator(`.nc-form-drag-${field.replace(' ', '')}`);
const dst = this.get().locator(`[data-testid="nc-drag-n-drop-to-hide"]`);
await src.dragTo(dst);
} else if (mode === 'hideField') {
const src = await this.get().locator(`.nc-form-drag-${field.replace(' ', '')}`);
const src = this.get().locator(`.nc-form-drag-${field.replace(' ', '')}`);
await src.locator(`[data-testid="nc-field-remove-icon"]`).click();
}
}
async addField({ field, mode }: { mode: string; field: string }) {
if (mode === 'dragDrop') {
const src = await this.get().locator(`[data-testid="nc-form-hidden-column-${field}"] > div.ant-card-body`);
const dst = await this.get().locator(`[data-testid="nc-form-input-Country"]`);
const src = this.get().locator(`[data-testid="nc-form-hidden-column-${field}"] > div.ant-card-body`);
const dst = this.get().locator(`[data-testid="nc-form-input-Country"]`);
await src.waitFor({ state: 'visible' });
await dst.waitFor({ state: 'visible' });
await src.dragTo(dst, { trial: true });
await src.dragTo(dst);
} else if (mode === 'clickField') {
const src = await this.get().locator(`[data-testid="nc-form-hidden-column-${field}"]`);
const src = this.get().locator(`[data-testid="nc-form-hidden-column-${field}"]`);
await src.click();
}
}
@ -206,12 +204,12 @@ export class FormPage extends BasePage {
if (required) expectText = label + ' *';
else expectText = label;
const fieldLabel = await this.get()
const fieldLabel = this.get()
.locator(`.nc-form-drag-${field.replace(' ', '')}`)
.locator('div[data-testid="nc-form-input-label"]');
await expect(fieldLabel).toHaveText(expectText);
const fieldHelpText = await this.get()
const fieldHelpText = this.get()
.locator(`.nc-form-drag-${field.replace(' ', '')}`)
.locator('div[data-testid="nc-form-input-help-text-label"]');
await expect(fieldHelpText).toHaveText(helpText);
@ -232,10 +230,10 @@ export class FormPage extends BasePage {
await this.rootPage.waitForTimeout(100 * retryCounter);
retryCounter++;
}
await expect(await this.getFormAfterSubmit()).toContainText(param.message);
await expect(this.getFormAfterSubmit()).toContainText(param.message);
}
if (true === param.submitAnotherForm) {
await expect(await this.getFormAfterSubmit().locator('button:has-text("Submit Another Form")')).toBeVisible();
await expect(this.getFormAfterSubmit().locator('button:has-text("Submit Another Form")')).toBeVisible();
}
if (true === param.showBlankForm) {
await this.get().waitFor();

10
tests/playwright/pages/Dashboard/Grid/Column/Attachment.ts

@ -34,22 +34,22 @@ export class AttachmentColumnPageObject extends BasePage {
// Checkbox order: Application, Audio, Image, Video, Misc
if (fileCount) {
const inputMaxCount = await this.column.get().locator(`.nc-attachment-max-count`);
const inputMaxCount = this.column.get().locator(`.nc-attachment-max-count`);
await inputMaxCount.locator(`input`).fill(fileCount.toString());
}
if (fileSize) {
const inputMaxSize = await this.column.get().locator(`.nc-attachment-max-size`);
const inputMaxSize = this.column.get().locator(`.nc-attachment-max-size`);
await inputMaxSize.locator(`input`).fill(fileSize.toString());
}
if (fileTypesExcludeList) {
// click on nc-allow-all-mime-type-checkbox
const allowAllMimeCheckbox = await this.column.get().locator(`.nc-allow-all-mime-type-checkbox`);
const allowAllMimeCheckbox = this.column.get().locator(`.nc-allow-all-mime-type-checkbox`);
await allowAllMimeCheckbox.click();
const treeList = await this.column.get().locator(`.ant-tree-list`);
const checkboxList = await treeList.locator(`.ant-tree-treenode`);
const treeList = this.column.get().locator(`.ant-tree-list`);
const checkboxList = treeList.locator(`.ant-tree-treenode`);
for (let i = 0; i < fileTypesExcludeList.length; i++) {
const fileType = fileTypesExcludeList[i];

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

@ -20,8 +20,8 @@ export class ChildList extends BasePage {
// button: Link to 'City'
// icon: reload
await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Child list`);
await expect(await this.get().locator(`text=/Link to '.*${linkField}'/i`).isVisible()).toBeTruthy();
await expect(await this.get().locator(`[data-testid="nc-child-list-reload"]`).isVisible()).toBeTruthy();
expect(await this.get().locator(`text=/Link to '.*${linkField}'/i`).isVisible()).toBeTruthy();
expect(await this.get().locator(`[data-testid="nc-child-list-reload"]`).isVisible()).toBeTruthy();
// child list body validation (card count, card title)
const cardCount = cardTitle.length;
@ -29,20 +29,16 @@ export class ChildList extends BasePage {
{
const childList = this.get().locator(`.ant-card`);
const childCards = await childList.count();
await expect(childCards).toEqual(cardCount);
expect(childCards).toEqual(cardCount);
for (let i = 0; i < cardCount; i++) {
await childList.nth(i).locator('.name').waitFor({ state: 'visible' });
await childList.nth(i).locator('.name').scrollIntoViewIfNeeded();
await this.rootPage.waitForTimeout(100);
await expect(await childList.nth(i).locator('.name').textContent()).toContain(cardTitle[i]);
expect(await childList.nth(i).locator('.name').textContent()).toContain(cardTitle[i]);
// icon: unlink
// icon: delete
await expect(
await childList.nth(i).locator(`[data-testid="nc-child-list-icon-unlink"]`).isVisible()
).toBeTruthy();
await expect(
await childList.nth(i).locator(`[data-testid="nc-child-list-icon-delete"]`).isVisible()
).toBeTruthy();
expect(await childList.nth(i).locator(`[data-testid="nc-child-list-icon-unlink"]`).isVisible()).toBeTruthy();
expect(await childList.nth(i).locator(`[data-testid="nc-child-list-icon-delete"]`).isVisible()).toBeTruthy();
}
}
}

12
tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts

@ -12,26 +12,26 @@ export class LinkRecord extends BasePage {
async verify(cardTitle?: string[]) {
await this.dashboard.get().locator('.nc-modal-link-record').waitFor();
const linkRecord = await this.get();
const linkRecord = this.get();
// DOM element validation
// title: Link Record
// button: Add new record
// icon: reload
await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Link record`);
await expect(await linkRecord.locator(`button:has-text("Add new record")`).isVisible()).toBeTruthy();
await expect(await linkRecord.locator(`.nc-reload`).isVisible()).toBeTruthy();
expect(await linkRecord.locator(`button:has-text("Add new record")`).isVisible()).toBeTruthy();
expect(await linkRecord.locator(`.nc-reload`).isVisible()).toBeTruthy();
// placeholder: Filter query
await expect(await linkRecord.locator(`[placeholder="Filter query"]`).isVisible()).toBeTruthy();
expect(await linkRecord.locator(`[placeholder="Filter query"]`).isVisible()).toBeTruthy();
{
const childList = linkRecord.locator(`.ant-card`);
const childCards = await childList.count();
await expect(childCards).toEqual(cardTitle.length);
expect(childCards).toEqual(cardTitle.length);
for (let i = 0; i < cardTitle.length; i++) {
await childList.nth(i).locator('.name').scrollIntoViewIfNeeded();
await childList.nth(i).locator('.name').waitFor({ state: 'visible' });
await expect(await childList.nth(i).locator('.name').textContent()).toContain(cardTitle[i]);
expect(await childList.nth(i).locator('.name').textContent()).toContain(cardTitle[i]);
}
}
}

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

@ -336,7 +336,7 @@ export class ColumnPageObject extends BasePage {
}
await this.waitForResponse({
uiAction: async() => await this.rootPage.locator('li[role="menuitem"]:has-text("Hide Field"):visible').click(),
uiAction: async () => await this.rootPage.locator('li[role="menuitem"]:has-text("Hide Field"):visible').click(),
requestUrlPathToMatch: 'api/v1/db/meta/views',
httpMethodsToMatch: ['PATCH'],
});
@ -346,7 +346,7 @@ export class ColumnPageObject extends BasePage {
async save({ isUpdated }: { isUpdated?: boolean } = {}) {
await this.waitForResponse({
uiAction: async() => await this.get().locator('button:has-text("Save")').click(),
uiAction: async () => await this.get().locator('button:has-text("Save")').click(),
requestUrlPathToMatch: 'api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
responseJsonMatcher: json => json['pageInfo'],
@ -401,7 +401,7 @@ export class ColumnPageObject extends BasePage {
async sortColumn({ title, direction = 'asc' }: { title: string; direction: 'asc' | 'desc' }) {
await this.grid.get().locator(`th[data-title="${title}"] .nc-ui-dt-dropdown`).click();
let menuOption;
let menuOption: { (): Promise<void>; (): Promise<void> };
if (direction === 'desc') {
menuOption = () => this.rootPage.locator('li[role="menuitem"]:has-text("Sort Descending"):visible').click();
} else {
@ -444,7 +444,7 @@ export class ColumnPageObject extends BasePage {
async getWidth(param: { title: string }) {
const { title } = param;
const cell = await this.rootPage.locator(`th[data-title="${title}"]`);
const cell = this.rootPage.locator(`th[data-title="${title}"]`);
return await cell.evaluate(el => el.getBoundingClientRect().width);
}
}

46
tests/playwright/pages/Dashboard/Grid/columnHeader.ts

@ -0,0 +1,46 @@
import { GridPage } from '../Grid';
import BasePage from '../../Base';
import { expect, Locator } from '@playwright/test';
export class ColumnHeaderPageObject extends BasePage {
readonly grid: GridPage;
readonly btn_addColumn: Locator;
readonly btn_selectAll: Locator;
constructor(grid: GridPage) {
super(grid.rootPage);
this.grid = grid;
this.btn_addColumn = this.get().locator(`.nc-grid-add-edit-column`);
this.btn_selectAll = this.get().locator(`[data-testid="nc-check-all"]`);
}
get() {
return this.rootPage.locator('.nc-grid-header');
}
async getColumnHeader({ title }: { title: string }) {
return this.get().locator(`th[data-title="${title}"]`);
}
async getColumnHeaderContextMenu({ title }: { title: string }) {
return (await this.getColumnHeader({ title })).locator(`.nc-ui-dt-dropdown`);
}
async verifyLockMode() {
// add column button
await expect(this.btn_addColumn).toBeVisible({ visible: false });
// column header context menu
expect(await this.get().locator('.nc-ui-dt-dropdown').count()).toBe(0);
}
async verifyCollaborativeMode() {
// add column button
await expect(this.btn_addColumn).toBeVisible({ visible: true });
// column header context menu
expect(await this.get().locator('.nc-ui-dt-dropdown').count()).toBeGreaterThan(1);
}
}

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

@ -12,6 +12,7 @@ import { BarcodeOverlay } from '../BarcodeOverlay';
import { RowPageObject } from './Row';
import { WorkspaceMenuObject } from '../common/WorkspaceMenu';
import { GroupPageObject } from './Group';
import { ColumnHeaderPageObject } from './columnHeader';
export class GridPage extends BasePage {
readonly dashboard: DashboardPage;
@ -19,6 +20,7 @@ export class GridPage extends BasePage {
readonly dashboardPage: DashboardPage;
readonly qrCodeOverlay: QrCodeOverlay;
readonly barcodeOverlay: BarcodeOverlay;
readonly columnHeader: ColumnHeaderPageObject;
readonly column: ColumnPageObject;
readonly cell: CellPageObject;
readonly topbar: TopbarPage;
@ -29,6 +31,8 @@ export class GridPage extends BasePage {
readonly rowPage: RowPageObject;
readonly groupPage: GroupPageObject;
readonly btn_addNewRow: Locator;
constructor(dashboardPage: DashboardPage) {
super(dashboardPage.rootPage);
this.dashboard = dashboardPage;
@ -36,6 +40,7 @@ export class GridPage extends BasePage {
this.qrCodeOverlay = new QrCodeOverlay(this);
this.barcodeOverlay = new BarcodeOverlay(this);
this.column = new ColumnPageObject(this);
this.columnHeader = new ColumnHeaderPageObject(this);
this.cell = new CellPageObject(this);
this.topbar = new TopbarPage(this);
this.toolbar = new ToolbarPage(this);
@ -44,6 +49,26 @@ export class GridPage extends BasePage {
this.workspaceMenu = new WorkspaceMenuObject(this);
this.rowPage = new RowPageObject(this);
this.groupPage = new GroupPageObject(this);
this.btn_addNewRow = this.get().locator('.nc-grid-add-new-cell');
}
async verifyLockMode() {
// add new row button
expect(await this.btn_addNewRow.count()).toBe(0);
await this.toolbar.verifyLockMode();
await this.footbar.verifyLockMode();
await this.columnHeader.verifyLockMode();
}
async verifyCollaborativeMode() {
// add new row button
expect(await this.btn_addNewRow.count()).toBe(1);
await this.toolbar.verifyCollaborativeMode();
await this.footbar.verifyCollaborativeMode();
await this.columnHeader.verifyCollaborativeMode();
}
get() {
@ -98,7 +123,7 @@ export class GridPage extends BasePage {
// add delay for UI to render (can wait for count to stabilize by reading it multiple times)
await this.rootPage.waitForTimeout(100);
await expect(await this.get().locator('.nc-grid-row').count()).toBe(rowCount + 1);
expect(await this.get().locator('.nc-grid-row').count()).toBe(rowCount + 1);
await this._fillRow({ index, columnHeader, value: rowValue });
@ -187,13 +212,13 @@ export class GridPage extends BasePage {
async addRowRightClickMenu(index: number, columnHeader = 'Title') {
const rowCount = await this.get().locator('.nc-grid-row').count();
const cell = await this.get().locator(`td[data-testid="cell-${columnHeader}-${index}"]`).last();
const cell = this.get().locator(`td[data-testid="cell-${columnHeader}-${index}"]`).last();
await cell.click();
await cell.click({ button: 'right' });
// Click text=Insert New Row
await this.rootPage.locator('text=Insert New Row').click();
await expect(await this.get().locator('.nc-grid-row')).toHaveCount(rowCount + 1);
await expect(this.get().locator('.nc-grid-row')).toHaveCount(rowCount + 1);
}
async openExpandedRow({ index }: { index: number }) {
@ -203,7 +228,7 @@ export class GridPage extends BasePage {
}
async selectRow(index: number) {
const cell: Locator = await this.get().locator(`td[data-testid="cell-Id-${index}"]`);
const cell: Locator = this.get().locator(`td[data-testid="cell-Id-${index}"]`);
await cell.hover();
await cell.locator('input[type="checkbox"]').check({ force: true });
}
@ -333,24 +358,24 @@ export class GridPage extends BasePage {
index: 0,
columnHeader: columnHeader,
});
await expect(await cell.locator('input')).not.toBeVisible();
await expect(cell.locator('input')).not.toBeVisible();
// right click menu
await this.get().locator(`td[data-testid="cell-${columnHeader}-0"]`).click({
button: 'right',
});
await expect(await this.rootPage.locator('text=Insert New Row')).not.toBeVisible();
await expect(this.rootPage.locator('text=Insert New Row')).not.toBeVisible();
// in cell-add
await this.cell.get({ index: 0, columnHeader: 'Cities' }).hover();
await expect(
await this.cell.get({ index: 0, columnHeader: 'Cities' }).locator('.nc-action-icon.nc-plus')
this.cell.get({ index: 0, columnHeader: 'Cities' }).locator('.nc-action-icon.nc-plus')
).not.toBeVisible();
// expand row
await this.cell.get({ index: 0, columnHeader: 'Cities' }).hover();
await expect(
await this.cell.get({ index: 0, columnHeader: 'Cities' }).locator('.nc-action-icon >> nth=0')
this.cell.get({ index: 0, columnHeader: 'Cities' }).locator('.nc-action-icon >> nth=0')
).not.toBeVisible();
}
@ -361,7 +386,7 @@ export class GridPage extends BasePage {
index: 0,
columnHeader: columnHeader,
});
await expect(await cell.locator('input')).toBeVisible();
await expect(cell.locator('input')).toBeVisible();
// press escape to exit edit mode
await cell.press('Escape');
@ -370,13 +395,11 @@ export class GridPage extends BasePage {
await this.get().locator(`td[data-testid="cell-${columnHeader}-0"]`).click({
button: 'right',
});
await expect(await this.rootPage.locator('text=Insert New Row')).toBeVisible();
await expect(this.rootPage.locator('text=Insert New Row')).toBeVisible();
// in cell-add
await this.cell.get({ index: 0, columnHeader: 'Cities' }).hover();
await expect(
await this.cell.get({ index: 0, columnHeader: 'Cities' }).locator('.nc-action-icon.nc-plus')
).toBeVisible();
await expect(this.cell.get({ index: 0, columnHeader: 'Cities' }).locator('.nc-action-icon.nc-plus')).toBeVisible();
}
async verifyRoleAccess(param: { role: string }) {
@ -387,9 +410,9 @@ export class GridPage extends BasePage {
}
async selectRange({ start, end }: { start: CellProps; end: CellProps }) {
const startCell = await this.cell.get({ index: start.index, columnHeader: start.columnHeader });
const endCell = await this.cell.get({ index: end.index, columnHeader: end.columnHeader });
const page = await this.dashboard.get().page();
const startCell = this.cell.get({ index: start.index, columnHeader: start.columnHeader });
const endCell = this.cell.get({ index: end.index, columnHeader: end.columnHeader });
const page = this.dashboard.get().page();
await startCell.hover();
await page.mouse.down();
await endCell.hover();

8
tests/playwright/pages/Dashboard/Import/ImportTemplate.ts

@ -19,7 +19,7 @@ export class ImportTemplatePage extends BasePage {
async getImportTableList() {
await this.get().locator(`.ant-collapse-header`).nth(0).waitFor();
const tr = await this.get().locator(`.ant-collapse-header`);
const tr = this.get().locator(`.ant-collapse-header`);
const rowCount = await tr.count();
const tableList: string[] = [];
for (let i = 0; i < rowCount; i++) {
@ -32,7 +32,7 @@ export class ImportTemplatePage extends BasePage {
async getImportColumnList() {
// return an array
const columnList: { type: string; name: string }[] = [];
const tr = await this.get().locator(`tr.ant-table-row-level-0:visible`);
const tr = this.get().locator(`tr.ant-table-row-level-0:visible`);
const rowCount = await tr.count();
for (let i = 0; i < rowCount; i++) {
// replace \n and \t from innerText
@ -51,9 +51,9 @@ export class ImportTemplatePage extends BasePage {
const tblList = await this.getImportTableList();
for (let i = 0; i < result.length; i++) {
await expect(tblList[i]).toBe(result[i].name);
expect(tblList[i]).toBe(result[i].name);
const columnList = await this.getImportColumnList();
await expect(columnList).toEqual(result[i].columns);
expect(columnList).toEqual(result[i].columns);
if (i < result.length - 1) {
await this.expandTableList({ index: i + 1 });
}

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

@ -34,10 +34,10 @@ export class KanbanPage extends BasePage {
async dragDropCard(param: { from: { stack: number; card: number }; to: { stack: number; card: number } }) {
const { from, to } = param;
const srcStack = await this.get().locator(`.nc-kanban-stack`).nth(from.stack);
const dstStack = await this.get().locator(`.nc-kanban-stack`).nth(to.stack);
const fromCard = await srcStack.locator(`.nc-kanban-item`).nth(from.card);
const toCard = await dstStack.locator(`.nc-kanban-item`).nth(to.card);
const srcStack = this.get().locator(`.nc-kanban-stack`).nth(from.stack);
const dstStack = this.get().locator(`.nc-kanban-stack`).nth(to.stack);
const fromCard = srcStack.locator(`.nc-kanban-item`).nth(from.card);
const toCard = dstStack.locator(`.nc-kanban-item`).nth(to.card);
console.log(await fromCard.allTextContents());
console.log(await toCard.allTextContents());
@ -68,10 +68,10 @@ export class KanbanPage extends BasePage {
const { order } = param;
const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
const stack = this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded();
// Since otherwise stack title will be repeated as title is in two divs, with one having hidden class
const stackTitle = await stack.locator(`.nc-kanban-stack-head >> [data-testid="truncate-label"]`);
const stackTitle = stack.locator(`.nc-kanban-stack-head >> [data-testid="truncate-label"]`);
await expect(stackTitle).toHaveText(order[i], { ignoreCase: true });
}
}
@ -80,10 +80,10 @@ export class KanbanPage extends BasePage {
const { count } = param;
const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
const stack = this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded();
const stackFooter = await stack.locator(`.nc-kanban-data-count`).innerText();
await expect(stackFooter).toContain(`${count[i]} record${count[i] !== 1 ? 's' : ''}`);
expect(stackFooter).toContain(`${count[i]} record${count[i] !== 1 ? 's' : ''}`);
}
}
@ -91,7 +91,7 @@ export class KanbanPage extends BasePage {
const { count } = param;
const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
const stack = this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded();
const stackCards = stack.locator(`.nc-kanban-item`);
await expect(stackCards).toHaveCount(count[i]);
@ -100,11 +100,11 @@ export class KanbanPage extends BasePage {
async verifyCardOrder(param: { order: string[]; stackIndex: number }) {
const { order, stackIndex } = param;
const stack = await this.get().locator(`.nc-kanban-stack`).nth(stackIndex);
const stack = this.get().locator(`.nc-kanban-stack`).nth(stackIndex);
for (let i = 0; i < order.length; i++) {
const card = await stack.locator(`.nc-kanban-item`).nth(i);
const card = stack.locator(`.nc-kanban-item`).nth(i);
await card.scrollIntoViewIfNeeded();
const cardTitle = await card.locator(`.nc-cell`);
const cardTitle = card.locator(`.nc-cell`);
await expect(cardTitle).toHaveText(order[i]);
}
}
@ -121,7 +121,7 @@ export class KanbanPage extends BasePage {
async collapseStack(param: { index: number }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.index).click();
const modal = await this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
const modal = this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Collapse Stack")').click();
}
@ -135,15 +135,15 @@ export class KanbanPage extends BasePage {
async addCard(param: { stackIndex: number }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.stackIndex).click();
const modal = await this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
const modal = this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Add new record")').click();
}
async deleteStack(param: { index: number }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.index).click();
const modal = await this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
const modal = this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Delete Stack")').click();
const confirmationModal = await this.rootPage.locator(`div.ant-modal-content`);
const confirmationModal = this.rootPage.locator(`div.ant-modal-content`);
await confirmationModal.locator(`button:has-text("Delete Stack")`).click();
}
}

6
tests/playwright/pages/Dashboard/Map/index.ts

@ -22,7 +22,7 @@ export class MapPage extends BasePage {
async marker(lat: string, long: string) {
const latLongStr = `${lat}, ${long}`;
const marker = await this.get().locator(`.leaflet-marker-pane img[alt="${latLongStr}"]`);
const marker = this.get().locator(`.leaflet-marker-pane img[alt="${latLongStr}"]`);
return marker;
}
@ -35,12 +35,12 @@ export class MapPage extends BasePage {
}
async verifyMarkerCount(count: number) {
const markers = await this.get().locator('.leaflet-marker-pane img');
const markers = this.get().locator('.leaflet-marker-pane img');
await expect(markers).toHaveCount(count);
}
async zoomOut(times = 10) {
const zoomOutButton = await this.get().locator('.leaflet-control-zoom-out');
const zoomOutButton = this.get().locator('.leaflet-control-zoom-out');
for (let i = 0; i < times; i++) {
await zoomOutButton.click();
await this.rootPage.waitForTimeout(400);

4
tests/playwright/pages/Dashboard/ProjectView/AccessSettingsPage.ts

@ -17,10 +17,10 @@ export class AccessSettingsPage extends BasePage {
await this.get().locator('.nc-collaborators-list-row').nth(0).waitFor({ state: 'visible' });
const userCount = await this.get().locator('.nc-collaborators-list-row').count();
for (let i = 0; i < userCount; i++) {
const user = await this.get().locator('.nc-collaborators-list-row').nth(i);
const user = this.get().locator('.nc-collaborators-list-row').nth(i);
const userEmail = (await user.locator('.email').innerText()).split('\n')[1];
if (userEmail === email) {
const roleDropdown = await user.locator('.nc-collaborator-role-select');
const roleDropdown = user.locator('.nc-collaborator-role-select');
const selectedRole = await user.locator('.nc-collaborator-role-select').innerText();

76
tests/playwright/pages/Dashboard/ProjectView/Audit.ts

@ -0,0 +1,76 @@
import { expect } from '@playwright/test';
import BasePage from '../../Base';
import { DataSourcePage } from './DataSourcePage';
export class AuditPage extends BasePage {
constructor(dataSource: DataSourcePage) {
super(dataSource.rootPage);
}
get() {
return this.rootPage.locator('div.ant-modal-content');
}
async verifyRow({
index,
opType,
opSubtype,
description,
user,
created,
}: {
index: number;
opType?: string;
opSubtype?: string;
description?: string;
user?: string;
created?: string;
}) {
const table = this.get().locator('[data-testid="audit-tab-table"]');
const row = table.locator(`tr.ant-table-row`).nth(index);
if (opType) {
await row
.locator(`td.ant-table-cell`)
.nth(0)
.textContent()
.then(async text => expect(text).toContain(opType));
}
if (opSubtype) {
await row
.locator(`td.ant-table-cell`)
.nth(1)
.textContent()
.then(async text => expect(text).toContain(opSubtype));
}
if (description) {
await row
.locator(`td.ant-table-cell`)
.nth(2)
.textContent()
.then(async text => expect(text).toContain(description));
}
if (user) {
await row
.locator(`td.ant-table-cell`)
.nth(3)
.textContent()
.then(async text => expect(text).toContain(user));
}
if (created) {
await row
.locator(`td.ant-table-cell`)
.nth(4)
.textContent()
.then(async text => expect(text).toContain(created));
}
}
async close() {
await this.get().click();
await this.rootPage.keyboard.press('Escape');
}
}

33
tests/playwright/pages/Dashboard/ProjectView/DataSourcePage.ts

@ -1,15 +1,21 @@
import BasePage from '../../Base';
import { ProjectViewPage } from './index';
import { Locator } from '@playwright/test';
import { MetaDataPage } from './Metadata';
import { AuditPage } from './Audit';
export class DataSourcePage extends BasePage {
readonly projectView: ProjectViewPage;
readonly databaseType: Locator;
readonly metaData: MetaDataPage;
readonly audit: AuditPage;
constructor(projectView: ProjectViewPage) {
super(projectView.rootPage);
this.projectView = projectView;
this.databaseType = this.get().locator('.nc-extdb-db-type');
this.metaData = new MetaDataPage(this);
this.audit = new AuditPage(this);
}
get() {
@ -18,7 +24,7 @@ export class DataSourcePage extends BasePage {
async getDatabaseTypeList() {
await this.databaseType.click();
const nodes = await this.rootPage.locator('.nc-dropdown-ext-db-type').locator('.ant-select-item');
const nodes = this.rootPage.locator('.nc-dropdown-ext-db-type').locator('.ant-select-item');
const list = [];
for (let i = 0; i < (await nodes.count()); i++) {
const node = nodes.nth(i);
@ -28,12 +34,27 @@ export class DataSourcePage extends BasePage {
return list;
}
async openMetaSync({ rowIndex }: { rowIndex: number }) {
// 0th offset for header
const row = this.get()
.locator('.ds-table-row')
.nth(rowIndex + 1);
await row.locator('button.nc-action-btn:has-text("Sync Metadata")').click();
}
async openERD({ rowIndex }: { rowIndex: number }) {
// hardwired
await this.rootPage.locator('button.nc-action-btn').nth(1).click();
// 0th offset for header
const row = this.get()
.locator('.ds-table-row')
.nth(rowIndex + 1);
await row.locator('button.nc-action-btn:has-text("Relations")').click();
}
// const row = this.get().locator('.ds-table-row').nth(rowIndex);
// await row.locator('.ds-table-actions').locator('button.nc-action-btn').waitFor();
// await row.locator('.ds-table-actions').locator('button.nc-action-btn').nth(1).click();
async openAudit({ rowIndex }: { rowIndex: number }) {
// 0th offset for header
const row = this.get()
.locator('.ds-table-row')
.nth(rowIndex + 1);
await row.locator('button.nc-action-btn:has-text("Audit")').click();
}
}

22
tests/playwright/pages/Dashboard/Settings/Metadata.ts → tests/playwright/pages/Dashboard/ProjectView/Metadata.ts

@ -1,18 +1,15 @@
import { expect } from '@playwright/test';
import BasePage from '../../Base';
import { DataSourcesPage } from './DataSources';
import { getTextExcludeIconText } from '../../../tests/utils/general';
import { DataSourcePage } from './DataSourcePage';
export class MetaDataPage extends BasePage {
private readonly dataSources: DataSourcesPage;
constructor(dataSources: DataSourcesPage) {
super(dataSources.rootPage);
this.dataSources = dataSources;
constructor(dataSource: DataSourcePage) {
super(dataSource.rootPage);
}
get() {
return this.dataSources.get();
return this.rootPage.locator('div.ant-modal-content');
}
async clickReload() {
@ -24,6 +21,13 @@ export class MetaDataPage extends BasePage {
await this.get().locator(`.animate-spin`).waitFor({ state: 'detached' });
}
async close() {
await this.get().click();
await this.rootPage.keyboard.press('Escape');
await this.rootPage.keyboard.press('Escape');
await this.get().waitFor({ state: 'detached' });
}
async sync() {
await this.get().locator(`button:has-text("Sync Now")`).click();
await this.verifyToast({ message: 'Table metadata recreated successfully' });
@ -32,9 +36,9 @@ export class MetaDataPage extends BasePage {
}
async verifyRow({ index, model, state }: { index: number; model: string; state: string }) {
const fieldLocator = await this.get().locator(`tr.ant-table-row`).nth(index).locator(`td.ant-table-cell`).nth(0);
const fieldLocator = this.get().locator(`tr.ant-table-row`).nth(index).locator(`td.ant-table-cell`).nth(0);
const fieldText = await getTextExcludeIconText(fieldLocator);
await expect(fieldText).toBe(model);
expect(fieldText).toBe(model);
await expect(this.get().locator(`tr.ant-table-row`).nth(index).locator(`td.ant-table-cell`).nth(1)).toHaveText(
state,

2
tests/playwright/pages/Dashboard/QrCodeOverlay/index.ts

@ -18,7 +18,7 @@ export class QrCodeOverlay extends BasePage {
const foundQrValueLabelText = await this.get()
.locator('[data-testid="nc-qr-code-large-value-label"]')
.textContent();
await expect(foundQrValueLabelText).toContain(expectedValue);
expect(foundQrValueLabelText).toContain(expectedValue);
}
async clickCloseButton() {

12
tests/playwright/pages/Dashboard/Settings/Audit.ts

@ -29,7 +29,7 @@ export class AuditSettingsPage extends BasePage {
user?: string;
created?: string;
}) {
const table = await this.get();
const table = this.get();
const row = table.locator(`tr.ant-table-row`).nth(index);
if (opType) {
@ -37,7 +37,7 @@ export class AuditSettingsPage extends BasePage {
.locator(`td.ant-table-cell`)
.nth(0)
.textContent()
.then(async text => await expect(text).toContain(opType));
.then(async text => expect(text).toContain(opType));
}
if (opSubtype) {
@ -45,7 +45,7 @@ export class AuditSettingsPage extends BasePage {
.locator(`td.ant-table-cell`)
.nth(1)
.textContent()
.then(async text => await expect(text).toContain(opSubtype));
.then(async text => expect(text).toContain(opSubtype));
}
if (description) {
@ -53,7 +53,7 @@ export class AuditSettingsPage extends BasePage {
.locator(`td.ant-table-cell`)
.nth(2)
.textContent()
.then(async text => await expect(text).toContain(description));
.then(async text => expect(text).toContain(description));
}
if (user) {
@ -61,7 +61,7 @@ export class AuditSettingsPage extends BasePage {
.locator(`td.ant-table-cell`)
.nth(3)
.textContent()
.then(async text => await expect(text).toContain(user));
.then(async text => expect(text).toContain(user));
}
if (created) {
@ -69,7 +69,7 @@ export class AuditSettingsPage extends BasePage {
.locator(`td.ant-table-cell`)
.nth(4)
.textContent()
.then(async text => await expect(text).toContain(created));
.then(async text => expect(text).toContain(created));
}
}
}

2
tests/playwright/pages/Dashboard/Settings/DataSources.ts

@ -3,7 +3,7 @@ import { defaultBaseName } from '../../../constants';
import BasePage from '../../Base';
import { AclPage } from './Acl';
import { SettingsErdPage } from './Erd';
import { MetaDataPage } from './Metadata';
import { MetaDataPage } from '../ProjectView/Metadata';
export class DataSourcesPage extends BasePage {
private readonly settings: SettingsPage;

8
tests/playwright/pages/Dashboard/Settings/Teams.ts

@ -38,7 +38,7 @@ export class TeamsPage extends BasePage {
await this.inviteTeamModal.getByTestId('docs-share-dlg-share-project-collaborate-emails').fill(email);
await this.inviteTeamModal.getByTestId('nc-share-invite-user-role-option-viewer').click();
const dropdown = await this.rootPage.locator('.nc-dropdown-user-role');
const dropdown = this.rootPage.locator('.nc-dropdown-user-role');
await dropdown.locator(`.nc-role-option:has-text("${role}")`).click();
await this.inviteTeamModal.getByTestId('docs-share-btn').click();
await this.inviteTeamModal.getByTestId('docs-share-invitation-copy').waitFor({ state: 'visible', timeout: 2000 });
@ -72,14 +72,14 @@ export class TeamsPage extends BasePage {
if (toggle) {
// if share base was disabled && request was to enable
await toggleBtn.click();
const modal = await this.rootPage.locator(`.nc-dropdown-shared-base-toggle`);
const modal = this.rootPage.locator(`.nc-dropdown-shared-base-toggle`);
await modal.locator(`.ant-dropdown-menu-title-content`).click();
}
} else {
if (!toggle) {
// if share base was enabled && request was to disable
await toggleBtn.click();
const modal = await this.rootPage.locator(`.nc-dropdown-shared-base-toggle`);
const modal = this.rootPage.locator(`.nc-dropdown-shared-base-toggle`);
await modal.locator(`.ant-dropdown-menu-title-content`).click();
}
}
@ -106,7 +106,7 @@ export class TeamsPage extends BasePage {
// .locator(`.nc-shared-base-role`)
// .waitFor();
await this.getSharedBaseSubModal().locator(`.nc-shared-base-role:visible`).click();
const userRoleModal = await this.rootPage.locator(`.nc-dropdown-share-base-role:visible`);
const userRoleModal = this.rootPage.locator(`.nc-dropdown-share-base-role:visible`);
await userRoleModal.locator(`.ant-select-item-option-content:has-text("${role}"):visible`).click();
}
}

2
tests/playwright/pages/Dashboard/ShareProjectButton/index.ts

@ -1,4 +1,4 @@
import { expect, Locator } from '@playwright/test';
import { expect } from '@playwright/test';
import { DashboardPage } from '..';
import BasePage from '../../Base';

2
tests/playwright/pages/Dashboard/Sidebar/DocsSidebar.ts

@ -1,4 +1,4 @@
import { expect, Locator } from '@playwright/test';
import { expect } from '@playwright/test';
import { SidebarPage } from '.';
import BasePage from '../../Base';

4
tests/playwright/pages/Dashboard/SurveyForm/index.ts

@ -51,7 +51,7 @@ export class SurveyFormPage extends BasePage {
fieldLabel = fieldLabel.replace(/\u00A0/g, ' ');
fieldText = fieldText.replace(/\u00A0/g, ' ');
await expect(fieldText).toBe(fieldLabel);
expect(fieldText).toBe(fieldLabel);
// parse footer text ("1 / 3") to identify if last slide
let isLastSlide = false;
@ -76,7 +76,7 @@ export class SurveyFormPage extends BasePage {
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).press('Enter');
} else if (param.type === 'DateTime') {
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).click();
const modal = await this.rootPage.locator('.nc-picker-datetime');
const modal = this.rootPage.locator('.nc-picker-datetime');
await expect(modal).toBeVisible();
await modal.locator('.ant-picker-now-btn').click();
await modal.locator('.ant-picker-ok').click();

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

@ -63,7 +63,7 @@ export class TreeViewPage extends BasePage {
}
async openBase({ title }: { title: string }) {
const nodes = await this.get().locator(`[data-testid="nc-sidebar-project-${title.toLowerCase()}"]`);
const nodes = this.get().locator(`[data-testid="nc-sidebar-project-${title.toLowerCase()}"]`);
await nodes.click();
return;
}
@ -164,11 +164,12 @@ export class TreeViewPage extends BasePage {
await this.dashboard.get().locator('div.nc-project-menu-item:has-text("Delete"):visible').click();
await this.waitForResponse({
uiAction: () => {
uiAction: async () => {
// Create a promise that resolves after 1 second
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// Returning a promise that resolves with the result after the 1-second delay
return delay(100).then(() => this.dashboard.get().locator('button:has-text("Delete Table")').click());
await delay(100);
return await this.dashboard.get().locator('button:has-text("Delete Table")').click();
},
httpMethodsToMatch: ['DELETE'],
requestUrlPathToMatch: `/api/v1/db/meta/tables/`,
@ -210,7 +211,7 @@ export class TreeViewPage extends BasePage {
await settingsMenu.locator(`[data-menu-id="teamAndSettings"]`).click();
}
async quickImport({ title, projectTitle }: { title: string; projectTitle }) {
async quickImport({ title, projectTitle }: { title: string; projectTitle: string }) {
await this.getProjectContextMenu({ projectTitle }).hover();
await this.getProjectContextMenu({ projectTitle }).click();
const importMenu = this.dashboard.get().locator('.ant-dropdown-menu.nc-scrollbar-md');
@ -223,7 +224,7 @@ export class TreeViewPage extends BasePage {
await this.get().locator(`.nc-project-tree-tbl-${title} .nc-table-icon`).click();
await this.rootPage.locator('.emoji-mart-search').type(icon);
const emojiList = await this.rootPage.locator('[id="emoji-mart-list"]');
const emojiList = this.rootPage.locator('[id="emoji-mart-list"]');
await emojiList.locator('button').first().click();
await expect(
this.get().locator(`.nc-project-tree-tbl-${title}`).locator(`.nc-table-icon:has-text("${iconDisplay}")`)
@ -235,14 +236,14 @@ export class TreeViewPage extends BasePage {
await this.dashboard.get().locator('div.nc-project-menu-item:has-text("Duplicate")').click();
// Find the checkbox element with the label "Include data"
const includeDataCheckbox = await this.dashboard.get().getByText('Include data', { exact: true });
const includeDataCheckbox = this.dashboard.get().getByText('Include data', { exact: true });
// Check the checkbox if it is not already checked
if ((await includeDataCheckbox.isChecked()) && !includeData) {
await includeDataCheckbox.click(); // click the checkbox to check it
}
// Find the checkbox element with the label "Include data"
const includeViewsCheckbox = await this.dashboard.get().getByText('Include views', { exact: true });
const includeViewsCheckbox = this.dashboard.get().getByText('Include views', { exact: true });
// Check the checkbox if it is not already checked
if ((await includeViewsCheckbox.isChecked()) && !includeViews) {
await includeViewsCheckbox.click(); // click the checkbox to check it
@ -319,12 +320,36 @@ export class TreeViewPage extends BasePage {
return this.get().locator(`.project-title-node`).nth(param.index);
}
async renameProject(param: { newTitle: string; title: string }) {
await this.getProjectContextMenu({ projectTitle: param.title }).hover();
await this.getProjectContextMenu({ projectTitle: param.title }).click();
const contextMenu = this.dashboard.get().locator('.ant-dropdown-menu.nc-scrollbar-md:visible').last();
await contextMenu.waitFor();
await contextMenu.locator(`.ant-dropdown-menu-item:has-text("Edit")`).click();
const projectNodeInput = (await this.getProject({ index: 0, title: param.title })).locator('input');
await projectNodeInput.clear();
await projectNodeInput.fill(param.newTitle);
await projectNodeInput.press('Enter');
}
async deleteProject(param: { title: string }) {
await this.getProjectContextMenu({ projectTitle: param.title }).hover();
await this.getProjectContextMenu({ projectTitle: param.title }).click();
const contextMenu = this.dashboard.get().locator('.ant-dropdown-menu.nc-scrollbar-md');
const contextMenu = this.dashboard.get().locator('.ant-dropdown-menu.nc-scrollbar-md:visible').last();
await contextMenu.waitFor();
await contextMenu.locator(`.ant-dropdown-menu-item:has-text("Delete")`).click();
await this.rootPage.locator('div.ant-modal-content').locator(`button.ant-btn:has-text("Delete Project")`).click();
}
async duplicateProject(param: { title: string }) {
await this.getProjectContextMenu({ projectTitle: param.title }).hover();
await this.getProjectContextMenu({ projectTitle: param.title }).click();
const contextMenu = this.dashboard.get().locator('.ant-dropdown-menu.nc-scrollbar-md:visible');
await contextMenu.waitFor();
await contextMenu.locator(`.ant-dropdown-menu-item:has-text("Duplicate Project")`).click();
await this.rootPage.locator('div.ant-modal-content').locator(`button.ant-btn:has-text("Confirm")`).click();
}
}

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

@ -177,7 +177,7 @@ export class ViewSidebarPage extends BasePage {
await this.get().locator(`[data-testid="view-sidebar-view-${title}"] .nc-view-icon`).click();
await this.rootPage.locator('.emoji-mart-search').type(icon);
const emojiList = await this.rootPage.locator('[id="emoji-mart-list"]');
const emojiList = this.rootPage.locator('[id="emoji-mart-list"]');
await emojiList.locator('button').first().click();
await expect(
this.get()

8
tests/playwright/pages/Dashboard/WebhookForm/index.ts

@ -68,17 +68,17 @@ export class WebhookFormPage extends BasePage {
save: boolean;
}) {
await this.get().locator(`.nc-check-box-hook-condition`).click();
const modal = await this.get().locator(`.menu-filter-dropdown`).last();
const modal = this.get().locator(`.menu-filter-dropdown`).last();
await modal.locator(`button:has-text("Add Filter")`).click();
await modal.locator('.nc-filter-field-select').waitFor({ state: 'visible', timeout: 4000 });
await modal.locator('.nc-filter-field-select').click();
const modalField = await this.dashboard.rootPage.locator('.nc-dropdown-toolbar-field-list:visible');
const modalField = this.dashboard.rootPage.locator('.nc-dropdown-toolbar-field-list:visible');
await modalField.locator(`.ant-select-item:has-text("${column}")`).click();
await modal.locator('.nc-filter-operation-select').click();
const modalOp = await this.dashboard.rootPage.locator('.nc-dropdown-filter-comp-op:visible');
const modalOp = this.dashboard.rootPage.locator('.nc-dropdown-filter-comp-op:visible');
await modalOp.locator(`.ant-select-item:has-text("${operator}")`).click();
if (operator != 'is null' && operator != 'is not null') {
@ -203,7 +203,7 @@ export class WebhookFormPage extends BasePage {
const locator = this.get().locator('.nc-select-hook-notification-type >> .ant-select-selection-item');
const text = await getTextExcludeIconText(locator);
await expect(text).toBe(notificationType);
expect(text).toBe(notificationType);
await expect(this.get().locator('.nc-select-hook-url-method >> .ant-select-selection-item')).toHaveText(urlMethod);
await expect.poll(async () => await this.get().locator('input.nc-text-field-hook-url-path').inputValue()).toBe(url);

4
tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts

@ -46,14 +46,14 @@ export class AttachmentCellPageObject extends BasePage {
}
async verifyFile({ index, columnHeader }: { index: number; columnHeader: string }) {
await expect(await this.get({ index, columnHeader }).locator('.nc-attachment')).toBeVisible();
await expect(this.get({ index, columnHeader }).locator('.nc-attachment')).toBeVisible();
}
async verifyFileCount({ index, columnHeader, count }: { index: number; columnHeader: string; count: number }) {
// retry below logic for 5 times, with 1 second delay
let retryCount = 0;
while (retryCount < 5) {
const attachments = await this.get({ index, columnHeader }).locator('.nc-attachment');
const attachments = this.get({ index, columnHeader }).locator('.nc-attachment');
// console.log(await attachments.count());
if ((await attachments.count()) === count) {
break;

2
tests/playwright/pages/Dashboard/common/Cell/DateCell.ts

@ -22,7 +22,7 @@ export class DateCellPageObject extends BasePage {
}
async verify({ index, columnHeader, date }: { index: number; columnHeader: string; date: string }) {
const cell = await this.get({ index, columnHeader });
const cell = this.get({ index, columnHeader });
await cell.scrollIntoViewIfNeeded();
await expect(cell.locator(`[title="${date}"]`)).toBeVisible();
}

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

@ -85,7 +85,7 @@ export class DateTimeCellPageObject extends BasePage {
async setDateTime({ index, columnHeader, dateTime }: { index: number; columnHeader: string; dateTime: string }) {
const [date, time] = dateTime.split(' ');
const [hour, minute, second] = time.split(':');
const [hour, minute, _second] = time.split(':');
await this.open({ index, columnHeader });
await this.selectDate({ date });
await this.selectTime({ hour: +hour, minute: +minute });

2
tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts

@ -24,7 +24,7 @@ export class RatingCellPageObject extends BasePage {
}
async verify({ index, columnHeader, rating }: { index: number; columnHeader: string; rating: number }) {
const cell = await this.get({ index, columnHeader });
const cell = this.get({ index, columnHeader });
await cell.scrollIntoViewIfNeeded();
await expect(cell.locator(`li.ant-rate-star.ant-rate-star-full`)).toHaveCount(rating);
}

2
tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts

@ -91,7 +91,7 @@ export class SelectOptionCellPageObject extends BasePage {
return await expect(this.cell.get({ index, columnHeader })).toContainText(option, { useInnerText: true });
}
const locator = await this.cell.get({ index, columnHeader }).locator('.ant-tag');
const locator = this.cell.get({ index, columnHeader }).locator('.ant-tag');
await locator.waitFor({ state: 'visible' });
const text = await locator.allInnerTexts();
return expect(text).toContain(option);

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

Loading…
Cancel
Save