Browse Source

Merge branch 'nocodb:develop' into fix/ssl-connection-config-with-file-path

pull/5364/head
Kamal Mahmudi 2 years ago committed by GitHub
parent
commit
5dcdd40c2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .github/workflows/publish-api-docs.yml
  2. 151
      packages/nc-gui/components.d.ts
  3. 2
      packages/nc-gui/components/cell/ClampedText.vue
  4. 2
      packages/nc-gui/components/cell/MultiSelect.vue
  5. 2
      packages/nc-gui/components/cell/SingleSelect.vue
  6. 5
      packages/nc-gui/components/cell/Text.vue
  7. 5
      packages/nc-gui/components/cell/TextArea.vue
  8. 68
      packages/nc-gui/components/dashboard/TreeView.vue
  9. 2
      packages/nc-gui/components/dashboard/settings/Modal.vue
  10. 29
      packages/nc-gui/components/dlg/TableRename.vue
  11. 47
      packages/nc-gui/components/general/EmojiIcons.vue
  12. 2
      packages/nc-gui/components/general/SocialCard.vue
  13. 62
      packages/nc-gui/components/smartsheet/Grid.vue
  14. 44
      packages/nc-gui/components/smartsheet/Kanban.vue
  15. 3
      packages/nc-gui/components/smartsheet/Row.vue
  16. 7
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  17. 82
      packages/nc-gui/components/smartsheet/header/Menu.vue
  18. 8
      packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts
  19. 67
      packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue
  20. 10
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  21. 2
      packages/nc-gui/components/smartsheet/toolbar/AddRow.vue
  22. 4
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  23. 177
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  24. 2
      packages/nc-gui/components/smartsheet/toolbar/Reload.vue
  25. 21
      packages/nc-gui/components/smartsheet/toolbar/RowHeight.vue
  26. 9
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  27. 29
      packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue
  28. 6
      packages/nc-gui/components/virtual-cell/QrCode.vue
  29. 5
      packages/nc-gui/components/virtual-cell/barcode/Barcode.vue
  30. 19
      packages/nc-gui/components/webhook/Editor.vue
  31. 4
      packages/nc-gui/composables/useColumnCreateStore.ts
  32. 83
      packages/nc-gui/composables/useExpandedFormStore.ts
  33. 37
      packages/nc-gui/composables/useGridViewColumnWidth.ts
  34. 147
      packages/nc-gui/composables/useKanbanViewStore.ts
  35. 41
      packages/nc-gui/composables/useLTARStore.ts
  36. 9
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  37. 180
      packages/nc-gui/composables/useUndoRedo.ts
  38. 19
      packages/nc-gui/composables/useViewColumns.ts
  39. 252
      packages/nc-gui/composables/useViewData.ts
  40. 159
      packages/nc-gui/composables/useViewFilters.ts
  41. 117
      packages/nc-gui/composables/useViewSorts.ts
  42. 1
      packages/nc-gui/just-clone-shims.d.ts
  43. 16
      packages/nc-gui/lang/fr.json
  44. 242
      packages/nc-gui/lang/uk.json
  45. 6
      packages/nc-gui/lib/types.ts
  46. 24
      packages/nc-gui/package-lock.json
  47. 2
      packages/nc-gui/package.json
  48. 10
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  49. 15
      packages/nc-gui/utils/dataUtils.ts
  50. 1670
      packages/nc-gui/utils/iconUtils.ts
  51. 2
      packages/nc-lib-gui/package.json
  52. 4
      packages/nocodb-sdk/package-lock.json
  53. 4
      packages/nocodb-sdk/package.json
  54. 6
      packages/nocodb-sdk/src/lib/Api.ts
  55. 20
      packages/nocodb/package-lock.json
  56. 4
      packages/nocodb/package.json
  57. 21
      packages/nocodb/src/lib/controllers/dbData/oldData.ctl.ts
  58. 4
      packages/nocodb/src/lib/controllers/publicControllers/publicDataExport.ctl.ts
  59. 204
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  60. 69
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulav2/formulaQueryBuilderv2.ts
  61. 155
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/helpers/getAst.ts
  62. 2
      packages/nocodb/src/lib/models/Column.ts
  63. 22
      packages/nocodb/src/lib/services/column.svc.ts
  64. 15
      packages/nocodb/src/lib/services/dbData/helpers.ts
  65. 46
      packages/nocodb/src/lib/services/dbData/index.ts
  66. 33
      packages/nocodb/src/lib/services/public/publicData.svc.ts
  67. 4
      packages/nocodb/src/lib/services/public/publicDataExport.svc.ts
  68. 8
      packages/nocodb/src/schema/swagger.json
  69. 11
      tests/playwright/package-lock.json
  70. 1
      tests/playwright/package.json
  71. 1
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts
  72. 16
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  73. 10
      tests/playwright/pages/Dashboard/Grid/index.ts
  74. 29
      tests/playwright/pages/Dashboard/Kanban/index.ts
  75. 20
      tests/playwright/pages/Dashboard/TreeView.ts
  76. 2
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  77. 46
      tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts
  78. 36
      tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts
  79. 23
      tests/playwright/pages/Dashboard/common/Toolbar/Sort.ts
  80. 18
      tests/playwright/tests/columnCheckbox.spec.ts
  81. 18
      tests/playwright/tests/columnMultiSelect.spec.ts
  82. 18
      tests/playwright/tests/columnRating.spec.ts
  83. 18
      tests/playwright/tests/columnSingleSelect.spec.ts
  84. 6
      tests/playwright/tests/expandedFormUrl.spec.ts
  85. 58
      tests/playwright/tests/filters.spec.ts
  86. 12
      tests/playwright/tests/metaSync.spec.ts
  87. 8
      tests/playwright/tests/toolbarOperations.spec.ts
  88. 684
      tests/playwright/tests/undo-redo.spec.ts
  89. 30
      tests/playwright/tests/viewGridShare.spec.ts
  90. 24
      tests/playwright/tests/viewKanban.spec.ts

1
.github/workflows/publish-api-docs.yml

@ -1,6 +1,7 @@
name: "Publish : Api Docs"
on:
workflow_dispatch:
push:
branches: [ master ]
paths:

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

@ -77,9 +77,7 @@ declare module '@vue/runtime-core' {
CilFullscreen: typeof import('~icons/cil/fullscreen')['default']
CilFullscreenExit: typeof import('~icons/cil/fullscreen-exit')['default']
ClarityColorPickerSolid: typeof import('~icons/clarity/color-picker-solid')['default']
ClarityImageLine: typeof import('~icons/clarity/image-line')['default']
ClaritySuccessLine: typeof import('~icons/clarity/success-line')['default']
EvaEmailOutline: typeof import('~icons/eva/email-outline')['default']
IcBaselineMoreVert: typeof import('~icons/ic/baseline-more-vert')['default']
IcOutlineInsertDriveFile: typeof import('~icons/ic/outline-insert-drive-file')['default']
IcRoundEdit: typeof import('~icons/ic/round-edit')['default']
@ -89,12 +87,9 @@ declare module '@vue/runtime-core' {
IcTwotoneWidthNormal: typeof import('~icons/ic/twotone-width-normal')['default']
LogosGoogleGmail: typeof import('~icons/logos/google-gmail')['default']
LogosMysqlIcon: typeof import('~icons/logos/mysql-icon')['default']
LogosOracle: typeof import('~icons/logos/oracle')['default']
LogosPostgresql: typeof import('~icons/logos/postgresql')['default']
LogosRedditIcon: typeof import('~icons/logos/reddit-icon')['default']
LogosSnowflakeIcon: typeof import('~icons/logos/snowflake-icon')['default']
LogosSwagger: typeof import('~icons/logos/swagger')['default']
MaterialSymbolsAccountTreeRounded: typeof import('~icons/material-symbols/account-tree-rounded')['default']
MaterialSymbolsArrowCircleLeftRounded: typeof import('~icons/material-symbols/arrow-circle-left-rounded')['default']
MaterialSymbolsArrowCircleRightRounded: typeof import('~icons/material-symbols/arrow-circle-right-rounded')['default']
MaterialSymbolsAttachFile: typeof import('~icons/material-symbols/attach-file')['default']
@ -111,184 +106,40 @@ declare module '@vue/runtime-core' {
MaterialSymbolsVisibility: typeof import('~icons/material-symbols/visibility')['default']
MaterialSymbolsVisibilityOff: typeof import('~icons/material-symbols/visibility-off')['default']
MaterialSymbolsWarning: typeof import('~icons/material-symbols/warning')['default']
MdiAccount: typeof import('~icons/mdi/account')['default']
MdiAccountCircle: typeof import('~icons/mdi/account-circle')['default']
MdiAccountCircleOutline: typeof import('~icons/mdi/account-circle-outline')['default']
MdiAccountOutline: typeof import('~icons/mdi/account-outline')['default']
MdiAccountPlusOutline: typeof import('~icons/mdi/account-plus-outline')['default']
MdiAccountSupervisorOutline: typeof import('~icons/mdi/account-supervisor-outline')['default']
MdiAdd: typeof import('~icons/mdi/add')['default']
MdiAlpha: typeof import('~icons/mdi/alpha')['default']
MdiAlphaA: typeof import('~icons/mdi/alpha-a')['default']
MdiApi: typeof import('~icons/mdi/api')['default']
MdiAppleKeyboardShift: typeof import('~icons/mdi/apple-keyboard-shift')['default']
MdiArrowCollapse: typeof import('~icons/mdi/arrow-collapse')['default']
MdiArrowDownDropCircle: typeof import('~icons/mdi/arrow-down-drop-circle')['default']
MdiArrowDownDropCircleOutline: typeof import('~icons/mdi/arrow-down-drop-circle-outline')['default']
MdiArrowExpand: typeof import('~icons/mdi/arrow-expand')['default']
MdiArrowLeftBold: typeof import('~icons/mdi/arrow-left-bold')['default']
MdiArrowULeftBottom: typeof import('~icons/mdi/arrow-u-left-bottom')['default']
MdiAt: typeof import('~icons/mdi/at')['default']
MdiBackburger: typeof import('~icons/mdi/backburger')['default']
MdiBookOpenOutline: typeof import('~icons/mdi/book-open-outline')['default']
MdiBugOutline: typeof import('~icons/mdi/bug-outline')['default']
MdiCalculator: typeof import('~icons/mdi/calculator')['default']
MdiCalendarMonth: typeof import('~icons/mdi/calendar-month')['default']
MdiCardsHeart: typeof import('~icons/mdi/cards-heart')['default']
MdiCellphoneMessage: typeof import('~icons/mdi/cellphone-message')['default']
MdiChat: typeof import('~icons/mdi/chat')['default']
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']
MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseBox: typeof import('~icons/mdi/close-box')['default']
MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
MdiCloseCircleOutline: typeof import('~icons/mdi/close-circle-outline')['default']
MdiCloseThick: typeof import('~icons/mdi/close-thick')['default']
MdiCodeJson: typeof import('~icons/mdi/code-json')['default']
MdiCodeScan: typeof import('~icons/mdi/code-scan')['default']
MdiCodeTags: typeof import('~icons/mdi/code-tags')['default']
MdiCog: typeof import('~icons/mdi/cog')['default']
MdiCommentTextOutline: typeof import('~icons/mdi/comment-text-outline')['default']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
MdiContentSave: typeof import('~icons/mdi/content-save')['default']
MdiContentSaveEdit: typeof import('~icons/mdi/content-save-edit')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDatabaseAlert: typeof import('~icons/mdi/database-alert')['default']
MdiDatabaseLockOutline: typeof import('~icons/mdi/database-lock-outline')['default']
MdiDatabasePlusOutline: typeof import('~icons/mdi/database-plus-outline')['default']
MdiDatabaseSync: typeof import('~icons/mdi/database-sync')['default']
MdiDelete: typeof import('~icons/mdi/delete')['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']
MdiDownload: typeof import('~icons/mdi/download')['default']
MdiDownloadOutline: typeof import('~icons/mdi/download-outline')['default']
MdiDrag: typeof import('~icons/mdi/drag')['default']
MdiDragVertical: typeof import('~icons/mdi/drag-vertical')['default']
MdiDramaMasks: typeof import('~icons/mdi/drama-masks')['default']
MdiEditOutline: typeof import('~icons/mdi/edit-outline')['default']
MdiEmail: typeof import('~icons/mdi/email')['default']
MdiEmailArrowRightOutline: typeof import('~icons/mdi/email-arrow-right-outline')['default']
MdiExitToApp: typeof import('~icons/mdi/exit-to-app')['default']
MdiExport: typeof import('~icons/mdi/export')['default']
MdiEyeCircleOutline: typeof import('~icons/mdi/eye-circle-outline')['default']
MdiEyeOffOutline: typeof import('~icons/mdi/eye-off-outline')['default']
MdiFileDocumentOutline: typeof import('~icons/mdi/file-document-outline')['default']
MdiFileExcel: typeof import('~icons/mdi/file-excel')['default']
MdiFileEyeOutline: typeof import('~icons/mdi/file-eye-outline')['default']
MdiFileImageBox: typeof import('~icons/mdi/file-image-box')['default']
MdiFilePlusOutline: typeof import('~icons/mdi/file-plus-outline')['default']
MdiFileReplaceOutline: typeof import('~icons/mdi/file-replace-outline')['default']
MdiFileUploadOutline: typeof import('~icons/mdi/file-upload-outline')['default']
MdiFilterOutline: typeof import('~icons/mdi/filter-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiFolder: typeof import('~icons/mdi/folder')['default']
MdiFunction: typeof import('~icons/mdi/function')['default']
MdiGestureDoubleTap: typeof import('~icons/mdi/gesture-double-tap')['default']
MdiGithub: typeof import('~icons/mdi/github')['default']
MdiGpsFixed: typeof import('~icons/mdi/gps-fixed')['default']
MdiGraphOutline: typeof import('~icons/mdi/graph-outline')['default']
MdiHeart: typeof import('~icons/mdi/heart')['default']
MdiHook: typeof import('~icons/mdi/hook')['default']
MdiInformation: typeof import('~icons/mdi/information')['default']
MdiJson: typeof import('~icons/mdi/json')['default']
MdiKey: typeof import('~icons/mdi/key')['default']
MdiKeyboard: typeof import('~icons/mdi/keyboard')['default']
MdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
MdiKeyChange: typeof import('~icons/mdi/key-change')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
MdiLink: typeof import('~icons/mdi/link')['default']
MdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
MdiLinkVariantRemove: typeof import('~icons/mdi/link-variant-remove')['default']
MdiLoading: typeof import('~icons/mdi/loading')['default']
MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['default']
MdiMapMarker: typeof import('~icons/mdi/map-marker')['default']
MdiMapMarkerAlert: typeof import('~icons/mdi/map-marker-alert')['default']
MdiMenu: typeof import('~icons/mdi/menu')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default']
MdiMinusCircleOutline: typeof import('~icons/mdi/minus-circle-outline')['default']
MdiMoonFull: typeof import('~icons/mdi/moon-full')['default']
MdiNumeric: typeof import('~icons/mdi/numeric')['default']
MdiOpenInNew: typeof import('~icons/mdi/open-in-new')['default']
MdiPencil: typeof import('~icons/mdi/pencil')['default']
MdiPlus: typeof import('~icons/mdi/plus')['default']
MdiPlusCircleOutline: typeof import('~icons/mdi/plus-circle-outline')['default']
MdiPlusOutline: typeof import('~icons/mdi/plus-outline')['default']
MdiPlusThick: typeof import('~icons/mdi/plus-thick')['default']
MdiQrcodeScan: typeof import('~icons/mdi/qrcode-scan')['default']
MdiReddit: typeof import('~icons/mdi/reddit')['default']
MdiRefresh: typeof import('~icons/mdi/refresh')['default']
MdiReload: typeof import('~icons/mdi/reload')['default']
MdiRocketLaunchOutline: typeof import('~icons/mdi/rocket-launch-outline')['default']
MdiScriptTextKeyOutline: typeof import('~icons/mdi/script-text-key-outline')['default']
MdiScriptTextOutline: typeof import('~icons/mdi/script-text-outline')['default']
MdiShieldKeyOutline: typeof import('~icons/mdi/shield-key-outline')['default']
MdiSlack: typeof import('~icons/mdi/slack')['default']
MdiSort: typeof import('~icons/mdi/sort')['default']
MdiSortAscending: typeof import('~icons/mdi/sort-ascending')['default']
MdiSortDescending: typeof import('~icons/mdi/sort-descending')['default']
MdiStar: typeof import('~icons/mdi/star')['default']
MdiStarOutline: typeof import('~icons/mdi/star-outline')['default']
MdiStorefrontOutline: typeof import('~icons/mdi/storefront-outline')['default']
MdiTable: typeof import('~icons/mdi/table')['default']
MdiTableColumnPlusAfter: typeof import('~icons/mdi/table-column-plus-after')['default']
MdiTableColumnPlusBefore: typeof import('~icons/mdi/table-column-plus-before')['default']
MdiTableKey: typeof import('~icons/mdi/table-key')['default']
MdiTableLarge: typeof import('~icons/mdi/table-large')['default']
MdiTestTube: typeof import('~icons/mdi/test-tube')['default']
MdiText: typeof import('~icons/mdi/text')['default']
MdiThumbUp: typeof import('~icons/mdi/thumb-up')['default']
MdiTrashCan: typeof import('~icons/mdi/trash-can')['default']
MdiTwitter: typeof import('~icons/mdi/twitter')['default']
MdiUpload: typeof import('~icons/mdi/upload')['default']
MdiUploadOutline: typeof import('~icons/mdi/upload-outline')['default']
MdiViewListOutline: typeof import('~icons/mdi/view-list-outline')['default']
MdiWarning: typeof import('~icons/mdi/warning')['default']
MdiWhatsapp: typeof import('~icons/mdi/whatsapp')['default']
MdiXml: typeof import('~icons/mdi/xml')['default']
MiCircleWarning: typeof import('~icons/mi/circle-warning')['default']
NcIconsRowHeightExtraTall: typeof import('~icons/nc-icons/row-height-extra-tall')['default']
NcIconsRowHeightMedium: typeof import('~icons/nc-icons/row-height-medium')['default']
NcIconsRowHeightShort: typeof import('~icons/nc-icons/row-height-short')['default']
NcIconsRowHeightTall: typeof import('~icons/nc-icons/row-height-tall')['default']
PhArrowClockwiseThin: typeof import('~icons/ph/arrow-clockwise-thin')['default']
PhAtThin: typeof import('~icons/ph/at-thin')['default']
PhBracketsAngleThin: typeof import('~icons/ph/brackets-angle-thin')['default']
PhBracketsCurlyThin: typeof import('~icons/ph/brackets-curly-thin')['default']
PhCaretDoubleLeftThin: typeof import('~icons/ph/caret-double-left-thin')['default']
PhCaretDoubleRightThin: typeof import('~icons/ph/caret-double-right-thin')['default']
PhCaretDoubleThin: typeof import('~icons/ph/caret-double-thin')['default']
PhCaretDownThin: typeof import('~icons/ph/caret-down-thin')['default']
PhChatTextThin: typeof import('~icons/ph/chat-text-thin')['default']
PhClockClockwiseThin: typeof import('~icons/ph/clock-clockwise-thin')['default']
PhCloudLightningDuotone: typeof import('~icons/ph/cloud-lightning-duotone')['default']
PhCloudLightningThin: typeof import('~icons/ph/cloud-lightning-thin')['default']
PhEyeThin: typeof import('~icons/ph/eye-thin')['default']
PhFileCsv: typeof import('~icons/ph/file-csv')['default']
PhFolderSimpleThin: typeof import('~icons/ph/folder-simple-thin')['default']
PhFolderThin: typeof import('~icons/ph/folder-thin')['default']
PhFunnelThin: typeof import('~icons/ph/funnel-thin')['default']
PhlistBulletsThin: typeof import('~icons/ph/list-bullets-thin')['default']
PhListBulletsThin: typeof import('~icons/ph/list-bullets-thin')['default']
PhMagnifyingGlassThin: typeof import('~icons/ph/magnifying-glass-thin')['default']
PhPlusThin: typeof import('~icons/ph/plus-thin')['default']
PhPresentationThin: typeof import('~icons/ph/presentation-thin')['default']
PhShareThin: typeof import('~icons/ph/share-thin')['default']
PhSignOutThin: typeof import('~icons/ph/sign-out-thin')['default']
PhSortAscendingThin: typeof import('~icons/ph/sort-ascending-thin')['default']
PhSplitVerticalThin: typeof import('~icons/ph/split-vertical-thin')['default']
PhTranslateThin: typeof import('~icons/ph/translate-thin')['default']
PhUserPlusThin: typeof import('~icons/ph/user-plus-thin')['default']
PhUsersThreeThin: typeof import('~icons/ph/users-three-thin')['default']
RiLineHeight: typeof import('~icons/ri/line-height')['default']
RiTeamFill: typeof import('~icons/ri/team-fill')['default']
PhXCircleLight: typeof import('~icons/ph/x-circle-light')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SimpleIconsMicrosoftsqlserver: typeof import('~icons/simple-icons/microsoftsqlserver')['default']

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

@ -31,7 +31,7 @@ onMounted(() => {
:key="`clamp-${key}-${props.value?.toString().length || 0}`"
class="w-full h-full break-word"
:text="`${props.value || ' '}`"
:max-lines="props.lines"
:max-lines="props.lines || 1"
/>
</div>
</template>

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

@ -283,7 +283,7 @@ const onTagClick = (e: Event, onClose: Function) => {
}
}
const cellClickHook = inject(CellClickHookInj)
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = () => {
if (cellClickHook) return

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

@ -209,7 +209,7 @@ const onSelect = () => {
isOpen.value = false
}
const cellClickHook = inject(CellClickHookInj)
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = (e: Event) => {
// todo: refactor

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

@ -14,7 +14,10 @@ const { showNull } = useGlobal()
const editEnabled = inject(EditModeInj)
const rowHeight = inject(RowHeightInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const readonly = inject(ReadonlyInj, ref(false))

5
packages/nc-gui/components/cell/TextArea.vue

@ -10,7 +10,10 @@ const emits = defineEmits(['update:modelValue'])
const editEnabled = inject(EditModeInj)
const rowHeight = inject(RowHeightInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const { showNull } = useGlobal()

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

@ -29,6 +29,7 @@ import {
useTabs,
useToggle,
useUIPermission,
useUndoRedo,
watchEffect,
} from '#imports'
@ -55,6 +56,8 @@ const [searchActive, toggleSearchActive] = useToggle()
const { appInfo } = useGlobal()
const { addUndo, defineProjectScope } = useUndoRedo()
const toggleDialog = inject(ToggleDialogInj, () => {})
const keys = $ref<Record<string, number>>({})
@ -90,13 +93,14 @@ const initSortable = (el: Element) => {
if (sortables[base_id]) sortables[base_id].destroy()
Sortable.create(el as HTMLLIElement, {
onEnd: async (evt) => {
const offset = tables.value.findIndex((table) => table.base_id === base_id)
const { newIndex = 0, oldIndex = 0 } = evt
const itemEl = evt.item as HTMLLIElement
const item = tablesById[itemEl.dataset.id as string]
// store the old order for undo
const oldOrder = item.order
// get the html collection of all list items
const children: HTMLCollection = evt.to.children
@ -120,8 +124,19 @@ const initSortable = (el: Element) => {
item.order = ((itemBefore.order as number) + (itemAfter.order as number)) / 2
}
// update the order of the moved item
tables.value?.splice(newIndex + offset, 0, ...tables.value?.splice(oldIndex + offset, 1))
// find the index of the moved item
const itemIndex = tables.value?.findIndex((table) => table.id === item.id)
// move the item to the new position
if (itemBefore) {
// find the index of the item before the moved item
const itemBeforeIndex = tables.value?.findIndex((table) => table.id === itemBefore.id)
tables.value?.splice(itemBeforeIndex + (newIndex > oldIndex ? 0 : 1), 0, ...tables.value?.splice(itemIndex, 1))
} else {
// if the item before is undefined (moving item to first slot), then find the index of the item after the moved item
const itemAfterIndex = tables.value?.findIndex((table) => table.id === itemAfter.id)
tables.value?.splice(itemAfterIndex, 0, ...tables.value?.splice(itemIndex, 1))
}
// force re-render the list
if (keys[base_id]) {
@ -134,6 +149,38 @@ const initSortable = (el: Element) => {
await $api.dbTable.reorder(item.id as string, {
order: item.order,
})
const nextIndex = tables.value?.findIndex((table) => table.id === item.id)
addUndo({
undo: {
fn: async (id: string, order: number, index: number) => {
const itemIndex = tables.value.findIndex((table) => table.id === id)
if (itemIndex < 0) return
const item = tables.value[itemIndex]
item.order = order
tables.value?.splice(index, 0, ...tables.value?.splice(itemIndex, 1))
await $api.dbTable.reorder(item.id as string, {
order: item.order,
})
},
args: [item.id, oldOrder, itemIndex],
},
redo: {
fn: async (id: string, order: number, index: number) => {
const itemIndex = tables.value.findIndex((table) => table.id === id)
if (itemIndex < 0) return
const item = tables.value[itemIndex]
item.order = order
tables.value?.splice(index, 0, ...tables.value?.splice(itemIndex, 1))
await $api.dbTable.reorder(item.id as string, {
order: item.order,
})
},
args: [item.id, item.order, nextIndex],
},
scope: defineProjectScope({ project: project.value }),
})
},
animation: 150,
})
@ -654,7 +701,11 @@ const setIcon = async (icon: string, table: TableType) => {
</component>
</div>
<template v-if="isUIAllowed('tableIconCustomisation')" #overlay>
<GeneralEmojiIcons class="shadow bg-white p-2" @select-icon="setIcon($event, table)" />
<GeneralEmojiIcons
class="shadow bg-white p-2"
:show-reset="!!table.meta?.icon"
@select-icon="setIcon($event, table)"
/>
</template>
</component>
</div>
@ -964,7 +1015,11 @@ const setIcon = async (icon: string, table: TableType) => {
</component>
</div>
<template v-if="isUIAllowed('tableIconCustomisation')" #overlay>
<GeneralEmojiIcons class="shadow bg-white p-2" @select-icon="setIcon($event, table)" />
<GeneralEmojiIcons
class="shadow bg-white p-2"
:show-reset="!!table.meta?.icon"
@select-icon="setIcon($event, table)"
/>
</template>
</component>
</div>
@ -1077,6 +1132,7 @@ const setIcon = async (icon: string, table: TableType) => {
<style scoped lang="scss">
.nc-treeview-container {
@apply h-[calc(100vh_-_var(--header-height))];
border-right: 1px solid var(--navbar-border) !important;
}
.nc-treeview-footer-item {

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

@ -227,7 +227,7 @@ watch(
@click="vDataState = DataSourcesSubTab.New"
>
<div v-if="vDataState === ''" class="flex items-center gap-2 text-primary font-light">
<component :is="iconMap.plusCircle" class="text-lg group-hover:text-accent" />
<component :is="iconMap.plusCircle" class="group-hover:text-accent" />
New
</div>
</a-button>

29
packages/nc-gui/components/dlg/TableRename.vue

@ -14,6 +14,7 @@ import {
useNuxtApp,
useProject,
useTabs,
useUndoRedo,
useVModel,
validateTableName,
watchEffect,
@ -43,6 +44,8 @@ const projectStore = useProject()
const { loadTables, isMysql, isMssql, isPg } = projectStore
const { tables, project } = storeToRefs(projectStore)
const { addUndo, defineProjectScope } = useUndoRedo()
const inputEl = $ref<ComponentPublicInstance>()
let loading = $ref(false)
@ -113,7 +116,7 @@ watchEffect(
{ flush: 'post' },
)
const renameTable = async () => {
const renameTable = async (undo = false) => {
if (!tableMeta) return
loading = true
@ -126,6 +129,26 @@ const renameTable = async () => {
dialogShow.value = false
if (!undo) {
addUndo({
redo: {
fn: (t: string) => {
formState.title = t
renameTable(true)
},
args: [formState.title],
},
undo: {
fn: (t: string) => {
formState.title = t
renameTable(true)
},
args: [tableMeta.title],
},
scope: defineProjectScope({ model: tableMeta }),
})
}
await loadTables()
// update metas
@ -161,7 +184,7 @@ const renameTable = async () => {
<template #footer>
<a-button key="back" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" type="primary" :loading="loading" @click="renameTable">{{ $t('general.submit') }}</a-button>
<a-button key="submit" type="primary" :loading="loading" @click="renameTable()">{{ $t('general.submit') }}</a-button>
</template>
<div class="pl-10 pr-10 pt-5">
@ -175,7 +198,7 @@ const renameTable = async () => {
v-model:value="formState.title"
hide-details
:placeholder="$t('msg.info.enterTableName')"
@keydown.enter="renameTable"
@keydown.enter="renameTable()"
/>
</a-form-item>
</a-form>

47
packages/nc-gui/components/general/EmojiIcons.vue

@ -3,6 +3,10 @@ import { Icon } from '@iconify/vue'
import InfiniteLoading from 'v3-infinite-loading'
import { emojiIcons } from '#imports'
const props = defineProps<{
showReset?: boolean
}>()
const emit = defineEmits(['selectIcon'])
let search = $ref('')
@ -23,30 +27,39 @@ const load = () => {
}
}
const selectIcon = (icon: string) => {
const selectIcon = (icon?: string) => {
search = ''
emit('selectIcon', `emojione:${icon}`)
emit('selectIcon', icon && `emojione:${icon}`)
}
</script>
<template>
<div class="p-1 w-[280px] h-[280px] flex flex-col gap-1 justify-start nc-emoji" data-testid="nc-emoji-container">
<div @click.stop>
<input
v-model="search"
data-testid="nc-emoji-filter"
class="p-1 text-xs border-1 w-full overflow-y-auto"
placeholder="Search"
@input="toIndex = 60"
/>
<div>
<div class="p-1 w-[280px] h-[280px] flex flex-col gap-1 justify-start nc-emoji" data-testid="nc-emoji-container">
<div @click.stop>
<input
v-model="search"
data-testid="nc-emoji-filter"
class="p-1 text-xs border-1 w-full overflow-y-auto"
placeholder="Search"
@input="toIndex = 60"
/>
</div>
<div class="flex gap-1 flex-wrap w-full flex-shrink overflow-y-auto scrollbar-thin-dull">
<div v-for="icon of filteredIcons" :key="icon" @click="selectIcon(icon)">
<span class="cursor-pointer nc-emoji-item">
<Icon class="text-xl iconify" :icon="`emojione:${icon}`"></Icon>
</span>
</div>
<InfiniteLoading @infinite="load"><span /></InfiniteLoading>
</div>
</div>
<div class="flex gap-1 flex-wrap w-full flex-shrink overflow-y-auto scrollbar-thin-dull">
<div v-for="icon of filteredIcons" :key="icon" @click="selectIcon(icon)">
<span class="cursor-pointer nc-emoji-item">
<Icon class="text-xl iconify" :icon="`emojione:${icon}`"></Icon>
</span>
<div v-if="props.showReset" class="m-1">
<a-divider class="!my-2 w-full" />
<div class="p-1 mt-1 cursor-pointer text-xs inline-block border-gray-200 border-1 rounded" @click="selectIcon()">
<PhXCircleLight class="text-sm" />
Reset Icon
</div>
<InfiniteLoading @infinite="load"><span /></InfiniteLoading>
</div>
</div>
</template>

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

@ -183,7 +183,7 @@ function openKeyboardShortcutDialog() {
</a-list-item>
<a-list-item @click="openKeyboardShortcutDialog">
<div class="ml-3 flex items-center text-sm">
<div class="ml-3 flex items-center text-sm cursor-pointer">
<component :is="iconMap.keyboard" class="text-lg text-primary" />
<span class="ml-4">{{ $t('title.keyboardShortcut') }}</span>
</div>

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

@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { ColumnReqType, ColumnType, GridType, TableType, ViewType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType, GridType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import {
ActiveViewInj,
@ -44,6 +44,7 @@ import {
useRoute,
useSmartsheetStoreOrThrow,
useUIPermission,
useUndoRedo,
useViewData,
watch,
} from '#imports'
@ -73,6 +74,8 @@ const hasEditPermission = $computed(() => isUIAllowed('xcDatatableEditable'))
const route = useRoute()
const router = useRouter()
const { addUndo, clone, defineViewScope } = useUndoRedo()
// todo: get from parent ( inject or use prop )
const isView = false
@ -455,6 +458,61 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
const columnObj = fields.value[ctx.col]
if (isVirtualCol(columnObj)) {
addUndo({
undo: {
fn: async (ctx: { row: number; col: number }, col: ColumnType, row: Row, pg: PaginatedType) => {
if (paginationData.value.pageSize === pg.pageSize) {
if (paginationData.value.page !== pg.page) {
await changePage(pg.page!)
}
const rowId = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = data.value[ctx.row]
const columnObj = fields.value[ctx.col]
if (
columnObj.title &&
rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
columnObj.id === col.id
) {
rowObj.row[columnObj.title] = row.row[columnObj.title]
await rowRefs[ctx.row]!.addLTARRef(rowObj.row[columnObj.title], columnObj)
await rowRefs[ctx.row]!.syncLTARRefs(rowObj.row)
activeCell.col = ctx.col
activeCell.row = ctx.row
scrollToCell?.()
} else {
throw new Error('Record could not be found')
}
} else {
throw new Error('Page size changed')
}
},
args: [clone(ctx), clone(columnObj), clone(rowObj), clone(paginationData.value)],
},
redo: {
fn: async (ctx: { row: number; col: number }, col: ColumnType, row: Row, pg: PaginatedType) => {
if (paginationData.value.pageSize === pg.pageSize) {
if (paginationData.value.page !== pg.page) {
await changePage(pg.page!)
}
const rowId = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = data.value[ctx.row]
const columnObj = fields.value[ctx.col]
if (rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && columnObj.id === col.id) {
await rowRefs[ctx.row]!.clearLTARCell(columnObj)
activeCell.col = ctx.col
activeCell.row = ctx.row
scrollToCell?.()
} else {
throw new Error('Record could not be found')
}
} else {
throw new Error('Page size changed')
}
},
args: [clone(ctx), clone(columnObj), clone(rowObj), clone(paginationData.value)],
},
scope: defineViewScope({ view: view.value }),
})
await rowRefs[ctx.row]!.clearLTARCell(columnObj)
return
}
@ -789,7 +847,7 @@ const closeAddColumnDropdown = () => {
:data-testid="`grid-row-${rowIndex}`"
>
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-testid="`cell-Id-${rowIndex}`">
<div class="items-center flex gap-1 min-w-[55px]">
<div class="items-center flex gap-1 min-w-[60px]">
<div
v-if="!readOnly || !isLocked"
class="nc-row-no text-xs text-gray-500"

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

@ -12,6 +12,7 @@ import {
IsPublicInj,
MetaInj,
OpenNewRecordFormHookInj,
extractPkFromRow,
iconMap,
inject,
isImage,
@ -21,6 +22,7 @@ import {
provide,
useAttachment,
useKanbanViewStoreOrThrow,
useUndoRedo,
} from '#imports'
import type { Row as RowType } from '~/lib'
@ -76,12 +78,15 @@ const {
deleteStack,
shouldScrollToRight,
deleteRow,
moveHistory,
} = useKanbanViewStoreOrThrow()
const { isUIAllowed } = useUIPermission()
const { appInfo } = $(useGlobal())
const { addUndo, defineViewScope } = useUndoRedo()
provide(IsFormInj, ref(false))
provide(IsGalleryInj, ref(false))
@ -210,7 +215,7 @@ function onMoveCallback(event: { draggedContext: { futureIndex: number } }) {
}
}
async function onMoveStack(event: any) {
async function onMoveStack(event: any, undo = false) {
if (event.moved) {
const { oldIndex, newIndex } = event.moved
const { fk_grp_col_id, meta: stack_meta } = kanbanMetaData.value
@ -221,17 +226,52 @@ async function onMoveStack(event: any) {
await updateKanbanMeta({
meta: stackMetaObj,
})
if (!undo) {
addUndo({
undo: {
fn: async (e: any) => {
const temp = groupingFieldColOptions.value.splice(e.moved.newIndex, 1)
groupingFieldColOptions.value.splice(e.moved.oldIndex, 0, temp[0])
await onMoveStack(e, true)
},
args: [{ moved: { oldIndex, newIndex } }],
},
redo: {
fn: async (e: any) => {
const temp = groupingFieldColOptions.value.splice(e.moved.oldIndex, 1)
groupingFieldColOptions.value.splice(e.moved.newIndex, 0, temp[0])
await onMoveStack(e, true)
},
args: [{ moved: { oldIndex, newIndex } }, true],
},
scope: defineViewScope({ view: view.value }),
})
}
}
}
async function onMove(event: any, stackKey: string) {
if (event.added) {
const ele = event.added.element
moveHistory.value.unshift({
op: 'added',
pk: extractPkFromRow(event.added.element.row, meta.value!.columns!),
index: event.added.newIndex,
stack: stackKey,
})
ele.row[groupingField.value] = stackKey
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! + 1)
await updateOrSaveRow(ele)
} else if (event.removed) {
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! - 1)
moveHistory.value.unshift({
op: 'removed',
pk: extractPkFromRow(event.removed.element.row, meta.value!.columns!),
index: event.removed.oldIndex,
stack: stackKey,
})
}
}
@ -273,7 +313,7 @@ const openNewRecordFormHookHandler = async () => {
const newRow = await addEmptyRow()
// preset the grouping field value
newRow.row = {
[groupingField.value]: selectedStackTitle.value,
[groupingField.value]: selectedStackTitle.value === '' ? null : selectedStackTitle.value,
}
// increase total count by 1
countByStack.value.set(null, countByStack.value.get(null)! + 1)

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

@ -22,7 +22,7 @@ const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow()
const { isNew, state, syncLTARRefs, clearLTARCell } = useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
const { isNew, state, syncLTARRefs, clearLTARCell, addLTARRef } = useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
// on changing isNew(new record insert) status sync LTAR cell values
watch(isNew, async (nextVal, prevVal) => {
@ -49,6 +49,7 @@ provide(ReloadRowDataHookInj, reloadHook)
defineExpose({
syncLTARRefs,
clearLTARCell,
addLTARRef,
})
</script>

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

@ -11,7 +11,6 @@ import {
ReloadRowDataHookInj,
computedInject,
createEventHook,
iconMap,
inject,
message,
provide,
@ -302,7 +301,7 @@ export default {
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '←']" />
</template>
<component :is="iconMap.chevronLeft" class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" />
<GeneralIcon icon="chevronLeft" class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" />
</a-tooltip>
<a-tooltip v-if="!props.lastRow" placement="bottom">
@ -310,7 +309,7 @@ export default {
{{ $t('labels.nextRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '→']" />
</template>
<component :is="iconMap.chevronRight" class="cursor-pointer nc-next-arrow" @click="onNext" />
<GeneralIcon icon="chevronRight" class="cursor-pointer nc-next-arrow" @click="onNext" />
</a-tooltip>
</template>
<div class="w-[500px] mx-auto">
@ -384,7 +383,7 @@ export default {
.nc-prev-arrow,
.nc-next-arrow {
@apply absolute opacity-70 rounded-full transition-transform transition-background transition-opacity transform bg-white hover:(bg-gray-200) active:(scale-125 opacity-100) text-xl;
@apply w-7 h-7 flex items-center justify-center absolute opacity-70 rounded-full transition-transform transition-background transition-opacity transform bg-white hover:(bg-gray-200) active:(scale-125 opacity-100) !text-xl;
}
.nc-prev-arrow {

82
packages/nc-gui/components/smartsheet/header/Menu.vue

@ -18,7 +18,9 @@ import {
useMetas,
useNuxtApp,
useSmartsheetStoreOrThrow,
useUndoRedo,
} from '#imports'
import type { UndoRedoAction } from '~~/lib'
const { virtual = false } = defineProps<{ virtual?: boolean }>()
@ -42,6 +44,8 @@ const { t } = useI18n()
const { getMeta } = useMetas()
const { addUndo, defineModelScope, defineViewScope } = useUndoRedo()
const deleteColumn = () =>
Modal.confirm({
title: h('div', ['Do you want to delete ', h('span', { class: 'font-weight-bold' }, [column?.value?.title]), ' column ?']),
@ -69,6 +73,8 @@ const deleteColumn = () =>
const setAsDisplayValue = async () => {
try {
const currentDisplayValue = meta?.value?.columns?.find((f) => f.pv)
await $api.dbTableColumn.primaryColumnSet(column?.value?.id as string)
await getMeta(meta?.value?.id as string, true)
@ -79,6 +85,36 @@ const setAsDisplayValue = async () => {
message.success(t('msg.success.primaryColumnUpdated'))
$e('a:column:set-primary')
addUndo({
redo: {
fn: async (id: string) => {
await $api.dbTableColumn.primaryColumnSet(id)
await getMeta(meta?.value?.id as string, true)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
// Successfully updated as primary column
message.success(t('msg.success.primaryColumnUpdated'))
},
args: [column?.value?.id as string],
},
undo: {
fn: async (id: string) => {
await $api.dbTableColumn.primaryColumnSet(id)
await getMeta(meta?.value?.id as string, true)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
// Successfully updated as primary column
message.success(t('msg.success.primaryColumnUpdated'))
},
args: [currentDisplayValue?.id],
},
scope: defineModelScope({ model: meta.value }),
})
} catch (e) {
message.error(t('msg.error.primaryColumnUpdateFailed'))
}
@ -87,11 +123,37 @@ const setAsDisplayValue = async () => {
const sortByColumn = async (direction: 'asc' | 'desc') => {
try {
$e('a:sort:add', { from: 'column-menu' })
await $api.dbTableSort.create(view.value?.id as string, {
const data: any = await $api.dbTableSort.create(view.value?.id as string, {
fk_column_id: column!.value.id,
direction,
push_to_top: true,
})
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction) {
const data: any = await $api.dbTableSort.create(view.value?.id as string, {
fk_column_id: column!.value.id,
direction,
push_to_top: true,
})
this.undo.args = [data.id]
eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD)
reloadDataHook?.trigger()
},
args: [],
},
undo: {
fn: async function undo(id: string) {
await $api.dbTableSort.delete(id)
eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD)
reloadDataHook?.trigger()
},
args: [data.id],
},
scope: defineViewScope({ view: view.value }),
})
eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD)
reloadDataHook?.trigger()
} catch (e: any) {
@ -209,6 +271,24 @@ const hideField = async () => {
await $api.dbViewColumn.update(view.value!.id!, currentColumn!.id!, { show: false })
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
addUndo({
redo: {
fn: async function redo(id: string) {
await $api.dbViewColumn.update(view.value!.id!, id, { show: false })
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
},
args: [currentColumn!.id],
},
undo: {
fn: async function undo(id: string) {
await $api.dbViewColumn.update(view.value!.id!, id, { show: true })
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
},
args: [currentColumn!.id],
},
scope: defineViewScope({ view: view.value }),
})
}
</script>

8
packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts

@ -52,13 +52,13 @@ const renderIcon = (column: ColumnType, relationColumn?: ColumnType) => {
case UITypes.Rollup:
switch ((relationColumn?.colOptions as LinkToAnotherRecordType)?.type) {
case RelationTypes.MANY_TO_MANY:
return { icon: iconMap, color: 'text-accent' }
return { icon: iconMap.rollup, color: 'text-accent' }
case RelationTypes.HAS_MANY:
return { icon: iconMap, color: 'text-yellow-500' }
return { icon: iconMap.rollup, color: 'text-yellow-500' }
case RelationTypes.BELONGS_TO:
return { icon: iconMap, color: 'text-sky-500' }
return { icon: iconMap.rollup, color: 'text-sky-500' }
}
return { icon: iconMap, color: 'text-grey' }
return { icon: iconMap.rollup, color: 'text-grey' }
case UITypes.Count:
return { icon: CountIcon, color: 'text-grey' }
}

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

@ -18,6 +18,7 @@ import {
useI18n,
useNuxtApp,
useRouter,
useUndoRedo,
viewTypeAlias,
watch,
} from '#imports'
@ -46,6 +47,8 @@ const { api } = useApi()
const router = useRouter()
const { addUndo, defineModelScope } = useUndoRedo()
/** Selected view(s) for menu */
const selected = ref<string[]>([])
@ -84,16 +87,20 @@ function validate(view: ViewType) {
return true
}
let sortable: Sortable
function onSortStart(evt: SortableEvent) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging = true
}
async function onSortEnd(evt: SortableEvent) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging = false
async function onSortEnd(evt: SortableEvent, undo = false) {
if (!undo) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging = false
}
if (views.length < 2) return
@ -101,6 +108,32 @@ async function onSortEnd(evt: SortableEvent) {
if (newIndex === oldIndex) return
if (!undo) {
addUndo({
redo: {
fn: async () => {
const ord = sortable.toArray()
const temp = ord.splice(oldIndex, 1)
ord.splice(newIndex, 0, temp[0])
sortable.sort(ord)
await onSortEnd(evt, true)
},
args: [],
},
undo: {
fn: async () => {
const ord = sortable.toArray()
const temp = ord.splice(newIndex, 1)
ord.splice(oldIndex, 0, temp[0])
sortable.sort(ord)
await onSortEnd({ ...evt, oldIndex: newIndex, newIndex: oldIndex }, true)
},
args: [],
},
scope: defineModelScope({ view: activeView.value }),
})
}
const children = evt.to.children as unknown as HTMLLIElement[]
const previousEl = children[newIndex - 1]
@ -135,8 +168,6 @@ async function onSortEnd(evt: SortableEvent) {
$e('a:view:reorder')
}
let sortable: Sortable
const initSortable = (el: HTMLElement) => {
if (sortable) sortable.destroy()
@ -165,7 +196,7 @@ function changeView(view: ViewType) {
}
/** Rename a view */
async function onRename(view: ViewType) {
async function onRename(view: ViewType, originalTitle?: string, undo = false) {
try {
await api.dbView.update(view.id!, {
title: view.title,
@ -178,6 +209,28 @@ async function onRename(view: ViewType) {
},
})
if (!undo) {
addUndo({
redo: {
fn: (v: ViewType, title: string) => {
const tempTitle = v.title
v.title = title
onRename(v, tempTitle, true)
},
args: [view, view.title],
},
undo: {
fn: (v: ViewType, title: string) => {
const tempTitle = v.title
v.title = title
onRename(v, tempTitle, true)
},
args: [view, originalTitle],
},
scope: defineModelScope({ view: activeView.value }),
})
}
// View renamed successfully
message.success(t('msg.success.viewRenamed'))
} catch (e: any) {

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

@ -27,7 +27,7 @@ interface Emits {
(event: 'changeView', view: Record<string, any>): void
(event: 'rename', view: ViewType): void
(event: 'rename', view: ViewType, originalTitle: string | undefined): void
(event: 'delete', view: ViewType): void
@ -142,7 +142,7 @@ async function onRename() {
return
}
emits('rename', vModel.value)
emits('rename', vModel.value, originalTitle)
onStopEdit()
}
@ -183,7 +183,11 @@ function onStopEdit() {
</component>
<template v-if="isUIAllowed('viewIconCustomisation')" #overlay>
<GeneralEmojiIcons class="shadow bg-white p-2" @select-icon="emits('selectIcon', $event)" />
<GeneralEmojiIcons
class="shadow bg-white p-2"
:show-reset="!!view.meta?.icon"
@select-icon="emits('selectIcon', $event)"
/>
</template>
</a-dropdown>
</div>

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

@ -21,7 +21,7 @@ const onClick = () => {
>
<component
:is="iconMap.plus"
:class="{ 'cursor-pointer text-gray-500 group-hover:(text-primary)': !isLocked, 'disabled': isLocked }"
:class="{ 'cursor-pointer group-hover:(text-primary)': !isLocked, 'disabled': isLocked }"
@click="onClick"
/>
</div>

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

@ -364,7 +364,7 @@ defineExpose({
</div>
<div class="flex gap-2 mb-2 mt-4">
<a-button class="elevation-0 text-capitalize" type="primary" ghost @click.stop="addFilter">
<a-button class="elevation-0 text-capitalize" type="primary" ghost @click.stop="addFilter()">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" />
<!-- Add Filter -->
@ -372,7 +372,7 @@ defineExpose({
</div>
</a-button>
<a-button v-if="!webHook" class="text-capitalize !text-gray-500" @click.stop="addFilterGroup">
<a-button v-if="!webHook" class="text-capitalize !text-gray-500" @click.stop="addFilterGroup()">
<div class="flex items-center gap-1">
<!-- Add Filter Group -->
<component :is="iconMap.plus" />

177
packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue

@ -3,6 +3,7 @@ import type { ColumnType, GalleryType, KanbanType } from 'nocodb-sdk'
import { UITypes, ViewTypes, isVirtualCol } from 'nocodb-sdk'
import Draggable from 'vuedraggable'
import type { SelectProps } from 'ant-design-vue'
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface'
import {
ActiveViewInj,
FieldsInj,
@ -18,6 +19,7 @@ import {
useMenuCloseOnEsc,
useNuxtApp,
useSmartsheetStoreOrThrow,
useUndoRedo,
useViewColumns,
watch,
} from '#imports'
@ -55,6 +57,8 @@ const {
const { eventBus } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
eventBus.on((event) => {
if (event === SmartsheetStoreEvents.FIELD_RELOAD) {
loadViewColumns()
@ -79,10 +83,44 @@ const gridDisplayValueField = computed(() => {
return filteredFieldList.value?.find((field) => field.fk_column_id === pvCol?.id)
})
const onMove = (_event: { moved: { newIndex: number } }) => {
const onMove = (_event: { moved: { newIndex: number; oldIndex: number } }, undo = false) => {
// todo : sync with server
if (!fields.value) return
if (!undo) {
addUndo({
undo: {
fn: () => {
if (!fields.value) return
const temp = fields.value[_event.moved.newIndex]
fields.value[_event.moved.newIndex] = fields.value[_event.moved.oldIndex]
fields.value[_event.moved.oldIndex] = temp
onMove(
{
moved: {
newIndex: _event.moved.oldIndex,
oldIndex: _event.moved.newIndex,
},
},
true,
)
},
args: [],
},
redo: {
fn: () => {
if (!fields.value) return
const temp = fields.value[_event.moved.oldIndex]
fields.value[_event.moved.oldIndex] = fields.value[_event.moved.newIndex]
fields.value[_event.moved.newIndex] = temp
onMove(_event, true)
},
args: [],
},
scope: defineViewScope({ view: activeView.value }),
})
}
if (fields.value.length < 2) return
fields.value.forEach((field, index) => {
@ -108,6 +146,27 @@ const coverOptions = computed<SelectProps['options']>(() => {
return [{ value: null, label: 'No Image' }, ...filterFields]
})
const updateCoverImage = async (val?: string | null) => {
if (
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) &&
activeView.value?.id &&
activeView.value?.view
) {
if (activeView.value?.type === ViewTypes.GALLERY) {
await $api.dbView.galleryUpdate(activeView.value?.id, {
fk_cover_image_col_id: val,
})
;(activeView.value.view as GalleryType).fk_cover_image_col_id = val
} else if (activeView.value?.type === ViewTypes.KANBAN) {
await $api.dbView.kanbanUpdate(activeView.value?.id, {
fk_cover_image_col_id: val,
})
;(activeView.value.view as KanbanType).fk_cover_image_col_id = val
}
reloadViewMetaHook?.trigger()
}
}
const coverImageColumnId = computed({
get: () => {
const fk_cover_image_col_id =
@ -121,23 +180,20 @@ const coverImageColumnId = computed({
return null
},
set: async (val) => {
if (
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) &&
activeView.value?.id &&
activeView.value?.view
) {
if (activeView.value?.type === ViewTypes.GALLERY) {
await $api.dbView.galleryUpdate(activeView.value?.id, {
fk_cover_image_col_id: val,
})
;(activeView.value.view as GalleryType).fk_cover_image_col_id = val
} else if (activeView.value?.type === ViewTypes.KANBAN) {
await $api.dbView.kanbanUpdate(activeView.value?.id, {
fk_cover_image_col_id: val,
})
;(activeView.value.view as KanbanType).fk_cover_image_col_id = val
}
reloadViewMetaHook?.trigger()
if (val !== coverImageColumnId.value) {
addUndo({
undo: {
fn: await updateCoverImage,
args: [coverImageColumnId.value],
},
redo: {
fn: await updateCoverImage,
args: [val],
},
scope: defineViewScope({ view: activeView.value }),
})
await updateCoverImage(val)
}
},
})
@ -149,6 +205,83 @@ const getIcon = (c: ColumnType) =>
const open = ref(false)
const toggleFieldVisibility = (e: CheckboxChangeEvent, field: any, index: number) => {
addUndo({
undo: {
fn: (v: boolean) => {
field.show = !v
saveOrUpdate(field, index)
},
args: [e.target.checked],
},
redo: {
fn: (v: boolean) => {
field.show = v
saveOrUpdate(field, index)
},
args: [e.target.checked],
},
scope: defineViewScope({ view: activeView.value }),
})
saveOrUpdate(field, index)
}
const toggleSystemFields = (e: CheckboxChangeEvent) => {
addUndo({
undo: {
fn: (v: boolean) => {
showSystemFields.value = !v
},
args: [e.target.checked],
},
redo: {
fn: (v: boolean) => {
showSystemFields.value = v
},
args: [e.target.checked],
},
scope: defineViewScope({ view: activeView.value }),
})
}
const onShowAll = () => {
addUndo({
undo: {
fn: async () => {
await hideAll()
},
args: [],
},
redo: {
fn: async () => {
await showAll()
},
args: [],
},
scope: defineViewScope({ view: activeView.value }),
})
showAll()
}
const onHideAll = () => {
addUndo({
undo: {
fn: async () => {
await showAll()
},
args: [],
},
redo: {
fn: async () => {
await hideAll()
},
args: [],
},
scope: defineViewScope({ view: activeView.value }),
})
hideAll()
}
useMenuCloseOnEsc(open)
</script>
@ -208,7 +341,7 @@ useMenuCloseOnEsc(open)
v-e="['a:fields:show-hide']"
class="shrink"
:disabled="field.isViewEssentialField"
@change="saveOrUpdate(field, index)"
@change="toggleFieldVisibility($event, field, index)"
>
<div class="flex items-center">
<component :is="getIcon(metaColumnById[field.fk_column_id])" />
@ -253,18 +386,18 @@ useMenuCloseOnEsc(open)
<a-divider class="!my-2" />
<div v-if="!isPublic" class="p-2 py-1 flex nc-fields-show-system-fields" @click.stop>
<a-checkbox v-model:checked="showSystemFields" class="!items-center">
<a-checkbox v-model:checked="showSystemFields" class="!items-center" @change="toggleSystemFields">
<span class="text-xs"> {{ $t('activity.showSystemFields') }}</span>
</a-checkbox>
</div>
<div class="p-2 flex gap-2" @click.stop>
<a-button size="small" class="!text-xs text-gray-500 text-capitalize" @click.stop="showAll()">
<a-button size="small" class="!text-xs text-gray-500 text-capitalize" @click.stop="onShowAll">
<!-- Show All -->
{{ $t('general.showAll') }}
</a-button>
<a-button size="small" class="!text-xs text-gray-500 text-capitalize" @click.stop="hideAll()">
<a-button size="small" class="!text-xs text-gray-500 text-capitalize" @click.stop="onHideAll">
<!-- Hide All -->
{{ $t('general.hideAll') }}
</a-button>

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

@ -28,7 +28,7 @@ const onClick = () => {
<div class="nc-toolbar-btn flex min-w-32px w-32px h-32px items-center justify-center select-none">
<component
:is="iconMap.reload"
class="cursor-pointer text-gray-500 group-hover:(text-primary) nc-toolbar-reload-btn"
class="cursor-pointer group-hover:(text-primary) nc-toolbar-reload-btn"
:class="isReloading ? 'animate-spin' : ''"
@click="onClick"
/>

21
packages/nc-gui/components/smartsheet/toolbar/RowHeight.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { GridType } from 'nocodb-sdk'
import { ActiveViewInj, IsLockedInj, iconMap, inject, ref, storeToRefs, useMenuCloseOnEsc } from '#imports'
import { ActiveViewInj, IsLockedInj, iconMap, inject, ref, storeToRefs, useMenuCloseOnEsc, useUndoRedo } from '#imports'
const { isSharedBase } = storeToRefs(useProject())
@ -12,11 +12,28 @@ const isLocked = inject(IsLockedInj, ref(false))
const { $api } = useNuxtApp()
const { addUndo, defineViewScope } = useUndoRedo()
const open = ref(false)
const updateRowHeight = async (rh: number) => {
const updateRowHeight = async (rh: number, undo = false) => {
if (view.value?.id) {
if (rh === (view.value.view as GridType).row_height) return
if (!undo) {
addUndo({
redo: {
fn: (r: number) => updateRowHeight(r, true),
args: [rh],
},
undo: {
fn: (r: number) => updateRowHeight(r, true),
args: [(view.value.view as GridType).row_height || 0],
},
scope: defineViewScope({ view: view.value }),
})
}
try {
if (!isPublic.value && !isSharedBase.value) {
await $api.dbView.gridUpdate(view.value.id, {

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

@ -266,13 +266,10 @@ const copyIframeCode = async () => {
width="min(100vw,720px)"
wrap-class-name="nc-modal-share-view"
>
<div
data-testid="nc-modal-share-view__link"
class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100"
>
<div class="flex-1 h-min text-xs text-gray-500">{{ sharedViewUrl }}</div>
<div class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100">
<div data-testid="nc-modal-share-view__link" class="flex-1 h-min text-xs text-gray-500">{{ sharedViewUrl }}</div>
<a v-e="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank">
<a v-e="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank" class="flex items-center !no-underline">
<component :is="iconMap.share" class="text-sm text-gray-500" />
</a>

29
packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue

@ -14,6 +14,7 @@ import {
ref,
useKanbanViewStoreOrThrow,
useMenuCloseOnEsc,
useUndoRedo,
useViewColumns,
watch,
} from '#imports'
@ -32,6 +33,8 @@ const { fields, loadViewColumns, metaColumnById } = useViewColumns(activeView, m
const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField } = useKanbanViewStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
const open = ref(false)
useMenuCloseOnEsc(open)
@ -46,16 +49,32 @@ watch(
{ immediate: true },
)
const updateGroupingField = async (v: string) => {
await updateKanbanMeta({
fk_grp_col_id: v,
})
await loadKanbanMeta()
await loadKanbanData()
;(activeView.value?.view as KanbanType).fk_grp_col_id = v
}
const groupingFieldColumnId = computed({
get: () => kanbanMetaData.value.fk_grp_col_id,
set: async (val) => {
if (val) {
await updateKanbanMeta({
fk_grp_col_id: val,
addUndo({
undo: {
fn: await updateGroupingField,
args: [kanbanMetaData.value.fk_grp_col_id],
},
redo: {
fn: await updateGroupingField,
args: [val],
},
scope: defineViewScope({ view: activeView.value }),
})
await loadKanbanMeta()
await loadKanbanData()
;(activeView.value?.view as KanbanType).fk_grp_col_id = val
await updateGroupingField(val)
}
},
})

6
packages/nc-gui/components/virtual-cell/QrCode.vue

@ -21,7 +21,11 @@ const qrCodeOptions: QRCode.QRCodeToDataURLOptions = {
quality: 1,
},
}
const rowHeight = inject(RowHeightInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const qrCode = useQRCode(qrValue, {
...qrCodeOptions,

5
packages/nc-gui/components/virtual-cell/barcode/Barcode.vue

@ -32,7 +32,10 @@ const showBarcode = computed(() => barcodeValue?.value.length > 0 && !tooManyCha
const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning } = useShowNotEditableWarning()
const rowHeight = inject(RowHeightInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
</script>
<template>

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

@ -697,15 +697,16 @@ onMounted(async () => {
<a-row>
<a-col :span="24">
<div class="text-gray-600">
<em>Use context variable <strong>data</strong> to refer the record under consideration</em>
<a-tooltip bottom>
<template #title>
<span> <strong>data</strong> : Row data <br /> </span>
</template>
<component :is="iconMap.info" class="ml-2" />
</a-tooltip>
<div class="flex items-center">
<em>Use context variable <strong>data</strong> to refer the record under consideration</em>
<a-tooltip bottom>
<template #title>
<span> <strong>data</strong> : Row data <br /> </span>
</template>
<component :is="iconMap.info" class="ml-2" />
</a-tooltip>
</div>
<div class="mt-3">
<a href="https://docs.nocodb.com/developer-resources/webhooks/" target="_blank">
<!-- Document Reference -->

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

@ -1,4 +1,4 @@
import clone from 'just-clone'
import rfdc from 'rfdc'
import type { ColumnReqType, ColumnType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
@ -18,6 +18,8 @@ import {
watch,
} from '#imports'
const clone = rfdc()
const useForm = Form.useForm
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]

83
packages/nc-gui/composables/useExpandedFormStore.ts

@ -21,8 +21,9 @@ import {
useProject,
useProvideSmartsheetRowStore,
useSharedView,
useUndoRedo,
} from '#imports'
import type { Row } from '~/lib'
import type { Row, UndoRedoAction } from '~/lib'
const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((meta: Ref<TableType>, row: Ref<Row>) => {
const { $e, $state, $api } = useNuxtApp()
@ -51,6 +52,10 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const { sharedView } = useSharedView()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
// getters
const displayValue = computed(() => {
if (row?.value?.row) {
@ -135,7 +140,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
$e('a:row-expand:comment')
}
const save = async (ltarState: Record<string, any> = {}) => {
const save = async (ltarState: Record<string, any> = {}, undo = false) => {
let data
try {
const isNewRow = row.value.rowMeta?.new ?? false
@ -160,6 +165,47 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
rowMeta: {},
oldRow: { ...data },
})
if (!undo) {
const id = extractPkFromRow(data, meta.value?.columns as ColumnType[])
const pkData = rowPkData(row.value.row, meta.value?.columns as ColumnType[])
// TODO remove linked record
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, rowData: any) {
await $api.dbTableRow.create('noco', project.value.title as string, meta.value.title, { ...pkData, ...rowData })
if (activeView.value?.type === ViewTypes.KANBAN) {
const { loadKanbanData } = useKanbanViewStoreOrThrow()
await loadKanbanData()
}
reloadTrigger?.trigger()
},
args: [clone(insertObj)],
},
undo: {
fn: async function undo(this: UndoRedoAction, id: string) {
const res: any = await $api.dbViewRow.delete(
'noco',
project.value.id as string,
meta.value?.id as string,
activeView.value?.id as string,
id,
)
if (res.message) {
throw new Error(res.message)
}
if (activeView.value?.type === ViewTypes.KANBAN) {
const { loadKanbanData } = useKanbanViewStoreOrThrow()
await loadKanbanData()
}
reloadTrigger?.trigger()
},
args: [id],
},
scope: defineViewScope({ view: activeView.value }),
})
}
} else {
const updateOrInsertObj = [...changedColumns.value].reduce((obj, col) => {
obj[col] = row.value.row[col]
@ -174,6 +220,39 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
await $api.dbTableRow.update(NOCO, project.value.title as string, meta.value.title, id, updateOrInsertObj)
if (!undo) {
const undoObject = [...changedColumns.value].reduce((obj, col) => {
obj[col] = row.value.oldRow[col]
return obj
}, {} as Record<string, any>)
addUndo({
redo: {
fn: async (id: string, data: Record<string, any>) => {
await $api.dbTableRow.update(NOCO, project.value.title as string, meta.value.title, id, data)
if (activeView.value?.type === ViewTypes.KANBAN) {
const { loadKanbanData } = useKanbanViewStoreOrThrow()
await loadKanbanData()
}
reloadTrigger?.trigger()
},
args: [id, clone(updateOrInsertObj)],
},
undo: {
fn: async (id: string, data: Record<string, any>) => {
await $api.dbTableRow.update(NOCO, project.value.title as string, meta.value.title, id, data)
if (activeView.value?.type === ViewTypes.KANBAN) {
const { loadKanbanData } = useKanbanViewStoreOrThrow()
await loadKanbanData()
}
reloadTrigger?.trigger()
},
args: [id, clone(undoObject)],
},
scope: defineViewScope({ view: activeView.value }),
})
}
for (const key of Object.keys(updateOrInsertObj)) {
// audit
$api.utils

37
packages/nc-gui/composables/useGridViewColumnWidth.ts

@ -1,8 +1,19 @@
import type { ColumnType, GridColumnType, GridType, ViewType } from 'nocodb-sdk'
import type { ColumnType, GridColumnType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { IsPublicInj, computed, inject, ref, useMetas, useNuxtApp, useStyleTag, useUIPermission, watch } from '#imports'
import {
IsPublicInj,
computed,
inject,
ref,
useMetas,
useNuxtApp,
useStyleTag,
useUIPermission,
useUndoRedo,
watch,
} from '#imports'
export function useGridViewColumnWidth(view: Ref<GridType | undefined>) {
export function useGridViewColumnWidth(view: Ref<ViewType | undefined>) {
const { css, load: loadCss, unload: unloadCss } = useStyleTag('')
const { isUIAllowed } = useUIPermission()
@ -11,12 +22,14 @@ export function useGridViewColumnWidth(view: Ref<GridType | undefined>) {
const { metas } = useMetas()
const { addUndo, defineViewScope } = useUndoRedo()
const gridViewCols = ref<Record<string, GridColumnType>>({})
const resizingCol = ref('')
const resizingColWidth = ref('200px')
const isPublic = inject(IsPublicInj, ref(false))
const columns = computed<ColumnType[]>(() => metas.value?.[(view.value as ViewType)?.fk_model_id as string]?.columns || [])
const columns = computed<ColumnType[]>(() => metas.value?.[view.value?.fk_model_id as string]?.columns || [])
watch(
[gridViewCols, resizingCol, resizingColWidth],
@ -54,7 +67,21 @@ export function useGridViewColumnWidth(view: Ref<GridType | undefined>) {
* or when view changes reload columns width */
watch([() => columns.value?.length, () => view?.value?.id], loadGridViewColumns)
const updateWidth = async (id: string, width: string) => {
const updateWidth = async (id: string, width: string, undo = false) => {
if (!undo) {
addUndo({
redo: {
fn: (w: string) => updateWidth(id, w, true),
args: [width],
},
undo: {
fn: (w: string) => updateWidth(id, w, true),
args: [gridViewCols.value[id].width],
},
scope: defineViewScope({ view: view.value }),
})
}
if (gridViewCols?.value?.[id]) {
gridViewCols.value[id].width = width
}

147
packages/nc-gui/composables/useKanbanViewStore.ts

@ -1,6 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import type { Api, ColumnType, KanbanType, SelectOptionType, SelectOptionsType, TableType, ViewType } from 'nocodb-sdk'
import type { Row } from '~/lib'
import type { Row, UndoRedoAction } from '~/lib'
import {
IsPublicInj,
SharedViewPasswordInj,
@ -14,6 +14,7 @@ import {
parseProp,
provide,
ref,
rowPkData,
storeToRefs,
useApi,
useFieldQuery,
@ -24,6 +25,7 @@ import {
useSharedView,
useSmartsheetStoreOrThrow,
useUIPermission,
useUndoRedo,
} from '#imports'
type GroupingFieldColOptionsType = SelectOptionType & { collapsed: boolean }
@ -58,6 +60,11 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const { search } = useFieldQuery()
const { addUndo, clone, defineViewScope } = useUndoRedo()
// save history of stack changes for undo/redo
const moveHistory = ref<{ op: 'added' | 'removed'; pk: string; stack: string; index: number }[]>([])
const sqlUi = ref(
(meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0],
)
@ -311,10 +318,21 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
await $api.dbView.kanbanUpdate(viewMeta.value.id, updateObj)
}
async function insertRow(row: Record<string, any>, rowIndex = formattedData.value.get(null)!.length) {
function findRowInState(rowData: Record<string, any>) {
const pk: Record<string, string> = rowPkData(rowData, meta?.value?.columns as ColumnType[])
for (const rows of formattedData.value.values()) {
for (const row of rows) {
if (Object.keys(pk).every((k) => pk[k] === row.row[k])) {
return row
}
}
}
}
async function insertRow(row: Record<string, any>, rowIndex = formattedData.value.get(null)!.length, undo = false) {
try {
const insertObj = (meta?.value?.columns as ColumnType[]).reduce((o: Record<string, any>, col) => {
if (!col.ai && row?.[col.title as string] !== null) {
if ((!col.ai || undo) && row?.[col.title as string] !== null) {
o[col.title!] = row?.[col.title as string]
}
return o
@ -328,6 +346,31 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
insertObj,
)
if (!undo) {
const id = extractPkFromRow(insertedData, meta.value?.columns as ColumnType[])
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, row: Row, rowIndex: number) {
const pkData = rowPkData(row.row, meta.value?.columns as ColumnType[])
row.row = { ...pkData, ...row.row }
await insertRow(row, rowIndex, true)
addOrEditStackRow(row, true)
},
args: [clone(row), rowIndex],
},
undo: {
fn: async function undo(this: UndoRedoAction, id: string) {
await deleteRowById(id)
const row = findRowInState(insertedData)
if (row) removeRowFromTargetStack(row)
},
args: [id],
},
scope: defineViewScope({ view: viewMeta.value as ViewType }),
})
}
formattedData.value.get(null)?.splice(rowIndex ?? 0, 1, {
row: insertedData,
rowMeta: {},
@ -340,7 +383,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
}
}
async function updateRowProperty(toUpdate: Row, property: string) {
async function updateRowProperty(toUpdate: Row, property: string, undo = false) {
try {
const id = extractPkFromRow(toUpdate.row, meta?.value?.columns as ColumnType[])
@ -367,9 +410,49 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
prev_value: getHTMLEncodedText(toUpdate.oldRow[property]),
})
/** update row data(to sync formula and other related columns) */
Object.assign(toUpdate.row, updatedRowData)
Object.assign(toUpdate.oldRow, updatedRowData)
if (!undo) {
const oldRowIndex = moveHistory.value.find((ele) => ele.op === 'removed' && ele.pk === id)
const nextRowIndex = moveHistory.value.find((ele) => ele.op === 'added' && ele.pk === id)
addUndo({
redo: {
fn: async function redo(toUpdate: Row, property: string) {
const updatedData = await updateRowProperty(toUpdate, property, true)
const row = findRowInState(toUpdate.row)
if (row) {
Object.assign(row.row, updatedData)
if (row.row[groupingField.value] !== row.oldRow[groupingField.value])
addOrEditStackRow(row, false, nextRowIndex?.index)
Object.assign(row.oldRow, updatedData)
}
},
args: [clone(toUpdate), property],
},
undo: {
fn: async function undo(toUpdate: Row, property: string) {
const updatedData = await updateRowProperty(
{ row: toUpdate.oldRow, oldRow: toUpdate.row, rowMeta: toUpdate.rowMeta },
property,
true,
)
const row = findRowInState(toUpdate.row)
if (row) {
Object.assign(row.row, updatedData)
if (row.row[groupingField.value] !== row.oldRow[groupingField.value])
addOrEditStackRow(row, false, oldRowIndex?.index)
Object.assign(row.oldRow, updatedData)
}
},
args: [clone(toUpdate), property],
},
scope: defineViewScope({ view: viewMeta.value as ViewType }),
})
/** update row data(to sync formula and other related columns) */
Object.assign(toUpdate.row, updatedRowData)
Object.assign(toUpdate.oldRow, updatedRowData)
}
return updatedRowData
} catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`)
}
@ -463,7 +546,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
return formattedData.value.get(null)![addAfter]
}
function addOrEditStackRow(row: Row, isNewRow: boolean) {
function addOrEditStackRow(row: Row, isNewRow: boolean, rowIndex?: number) {
const stackTitle = row.row[groupingField.value]
const oldStackTitle = row.oldRow[groupingField.value]
@ -471,7 +554,11 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
// add a new record
if (stackTitle) {
// push the row to target stack
formattedData.value.get(stackTitle)!.push(row)
if (rowIndex !== undefined) {
formattedData.value.get(stackTitle)!.splice(rowIndex, 0, row)
} else {
formattedData.value.get(stackTitle)!.push(row)
}
// increase the current count in the target stack by 1
countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! + 1)
// clear the one under uncategorized since we don't reload the view
@ -496,11 +583,25 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
// add new row to countByStack & formattedData
countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! + 1)
formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, row])
if (rowIndex !== undefined) {
const targetStack = formattedData.value.get(stackTitle)!
targetStack.splice(rowIndex, 0, row)
formattedData.value.set(stackTitle, targetStack)
} else {
formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, row])
}
} else {
// update the row in formattedData
const updatedRow = formattedData.value.get(stackTitle)!
updatedRow[idxToUpdateOrDelete] = row
if (rowIndex !== undefined) {
updatedRow.splice(idxToUpdateOrDelete, 1)
updatedRow.splice(rowIndex, 0, row)
} else {
updatedRow[idxToUpdateOrDelete] = row
}
formattedData.value.set(oldStackTitle, updatedRow)
}
}
@ -530,8 +631,29 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
countByStack.value.set(null, countByStack.value.get(null)! - 1)
}
async function deleteRow(row: Row) {
async function deleteRow(row: Row, undo = false) {
try {
if (!undo) {
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, r: Row) {
await deleteRow(r, true)
},
args: [clone(row)],
},
undo: {
fn: async function undo(this: UndoRedoAction, row: Row) {
const pkData = rowPkData(row.row, meta.value?.columns as ColumnType[])
row.row = { ...pkData, ...row.row }
await insertRow(row.row, undefined, true)
addOrEditStackRow(row, true)
},
args: [clone(row)],
},
scope: defineViewScope({ view: viewMeta.value as ViewType }),
})
}
if (!row.rowMeta.new) {
const id = (meta?.value?.columns as ColumnType[])
?.filter((c) => c.pk)
@ -594,6 +716,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
removeRowFromUncategorizedStack,
shouldScrollToRight,
deleteRow,
moveHistory,
}
},
'kanban-view-store',

41
packages/nc-gui/composables/useLTARStore.ts

@ -43,6 +43,10 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const { $api } = useNuxtApp()
const activeView = inject(ActiveViewInj, ref())
const { addUndo, clone, defineViewScope } = useUndoRedo()
const sharedViewPassword = inject(SharedViewPasswordInj, ref(null))
const childrenExcludedList = ref<DataApiResponse | undefined>()
@ -245,7 +249,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
})
}
const unlink = async (row: Record<string, any>) => {
const unlink = async (row: Record<string, any>, { metaValue = meta.value }: { metaValue?: TableType } = {}, undo = false) => {
// const column = meta.columns.find(c => c.id === this.column.colOptions.fk_child_column_id);
// todo: handle if new record
// if (this.isNew) {
@ -264,12 +268,27 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
await $api.dbTableRow.nestedRemove(
NOCO,
project.value.title as string,
meta.value.title,
metaValue.title,
rowId.value,
colOptions.type as 'mm' | 'hm',
encodeURIComponent(column?.value?.title),
getRelatedTableRowId(row) as string,
)
if (!undo) {
addUndo({
redo: {
fn: (row: Record<string, any>) => unlink(row, {}, true),
args: [clone(row)],
},
undo: {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
fn: (row: Record<string, any>) => link(row, {}, true),
args: [clone(row)],
},
scope: defineViewScope({ view: activeView.value }),
})
}
} catch (e: any) {
message.error(`${t('msg.error.unlinkFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
}
@ -277,7 +296,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
reloadData?.(false)
}
const link = async (row: Record<string, any>) => {
const link = async (row: Record<string, any>, { metaValue = meta.value }: { metaValue?: TableType } = {}, undo = false) => {
// todo: handle new record
// const pid = this._extractRowId(parent, this.parentMeta);
// const id = this._extractRowId(this.row, this.meta);
@ -295,13 +314,27 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
await $api.dbTableRow.nestedAdd(
NOCO,
project.value.title as string,
meta.value.title as string,
metaValue.title as string,
rowId.value,
colOptions.type as 'mm' | 'hm',
encodeURIComponent(column?.value?.title),
getRelatedTableRowId(row) as string,
)
await loadChildrenList()
if (!undo) {
addUndo({
redo: {
fn: (row: Record<string, any>) => link(row, {}, true),
args: [clone(row)],
},
undo: {
fn: (row: Record<string, any>) => unlink(row, {}, true),
args: [clone(row)],
},
scope: defineViewScope({ view: activeView.value }),
})
}
} catch (e: any) {
message.error(`Linking failed: ${await extractSdkResponseErrorMsg(e)}`)
}

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

@ -51,7 +51,11 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
return message.info(t('msg.info.valueAlreadyInList'))
}
state.value[column.title!]!.push(value)
if (Array.isArray(value)) {
state.value[column.title!]!.push(...value)
} else {
state.value[column.title!]!.push(value)
}
} else if (isBt(column)) {
state.value[column.title!] = value
}
@ -119,6 +123,9 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
{ metaValue },
)
}
// clear LTAR refs after sync
state.value[column.title!] = null
}
}

180
packages/nc-gui/composables/useUndoRedo.ts

@ -0,0 +1,180 @@
import type { Ref } from 'vue'
import rfdc from 'rfdc'
import type { ProjectType, TableType, ViewType } from 'nocodb-sdk'
import { createSharedComposable, ref, useRouter } from '#imports'
import type { UndoRedoAction } from '~/lib'
export const useUndoRedo = createSharedComposable(() => {
const clone = rfdc()
const router = useRouter()
const route = $(router.currentRoute)
// keys: projectType | projectId | type | title | viewTitle
const scope = computed<{ key: string; param: string }[]>(() => {
const tempScope: { key: string; param: string }[] = [{ key: 'root', param: 'root' }]
for (const [key, param] of Object.entries(route.params)) {
if (Array.isArray(param)) {
tempScope.push({ key, param: param.join(',') })
} else {
tempScope.push({ key, param })
}
}
return tempScope
})
const isSameScope = (sc: { key: string; param: string }[]) => {
return sc.every((s) => {
return scope.value.some(
// viewTitle is optional for default view
(s2) =>
(s.key === 'viewTitle' && s2.key === 'viewTitle' && s2.param === '') || (s.key === s2.key && s.param === s2.param),
)
})
}
const undoQueue: Ref<UndoRedoAction[]> = ref([])
const redoQueue: Ref<UndoRedoAction[]> = ref([])
const addUndo = (action: UndoRedoAction, fromRedo = false) => {
// remove all redo actions that are in the same scope
if (!fromRedo) redoQueue.value = redoQueue.value.filter((a) => !isSameScope(a.scope || []))
undoQueue.value.push(action)
}
const addRedo = (action: UndoRedoAction) => {
redoQueue.value.push(action)
}
const undo = async () => {
let actionIndex = -1
for (let i = undoQueue.value.length - 1; i >= 0; i--) {
const elScope = undoQueue.value[i].scope || [{ key: 'root', param: 'root' }]
if (isSameScope(elScope)) {
actionIndex = i
break
}
}
if (actionIndex === -1) return
const action = undoQueue.value.splice(actionIndex, 1)[0]
if (action) {
try {
await action.undo.fn.apply(action, action.undo.args)
addRedo(action)
} catch (e) {
message.warn('Error while undoing action, it is skipped.')
}
}
}
const redo = async () => {
let actionIndex = -1
for (let i = redoQueue.value.length - 1; i >= 0; i--) {
const elScope = redoQueue.value[i].scope || [{ key: 'root', param: 'root' }]
if (isSameScope(elScope)) {
actionIndex = i
break
}
}
if (actionIndex === -1) return
const action = redoQueue.value.splice(actionIndex, 1)[0]
if (action) {
try {
await action.redo.fn.apply(action, action.redo.args)
addUndo(action, true)
} catch (e) {
message.warn('Error while redoing action, it is skipped.')
}
}
}
const defineRootScope = () => {
return [{ key: 'root', param: 'root' }]
}
const defineProjectScope = (param: { project?: ProjectType; model?: TableType; view?: ViewType; project_id?: string }) => {
if (param.project) {
return [{ key: 'projectId', param: param.project.id! }]
} else if (param.model) {
return [{ key: 'projectId', param: param.model.project_id! }]
} else if (param.view) {
return [{ key: 'projectId', param: param.view.project_id! }]
} else {
return [{ key: 'projectId', param: param.project_id! }]
}
}
const defineModelScope = (param: { model?: TableType; view?: ViewType; project_id?: string; model_id?: string }) => {
if (param.model) {
return [
{ key: 'projectId', param: param.model.project_id! },
{ key: 'title', param: param.model.id! },
]
} else if (param.view) {
return [
{ key: 'projectId', param: param.view.project_id! },
{ key: 'title', param: param.view.fk_model_id! },
]
} else {
return [
{ key: 'projectId', param: param.project_id! },
{ key: 'title', param: param.model_id! },
]
}
}
const defineViewScope = (param: { view?: ViewType; project_id?: string; model_id?: string; title?: string }) => {
if (param.view) {
return [
{ key: 'projectId', param: param.view.project_id! },
{ key: 'title', param: param.view.fk_model_id! },
{ key: 'viewTitle', param: param.view.title! },
]
} else {
return [
{ key: 'projectId', param: param.project_id! },
{ key: 'title', param: param.model_id! },
{ key: 'viewTitle', param: param.title! },
]
}
}
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (cmdOrCtrl && !e.altKey) {
switch (e.keyCode) {
case 90: {
e.preventDefault()
// CMD + z and CMD + shift + z
if (!e.shiftKey) {
if (undoQueue.value.length) {
undo()
}
} else {
if (redoQueue.value.length) {
redo()
}
}
break
}
}
}
})
return {
addUndo,
undo,
clone,
defineRootScope,
defineProjectScope,
defineModelScope,
defineViewScope,
}
})

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

@ -25,6 +25,8 @@ export function useViewColumns(
() => isPublic.value || !isUIAllowed('hideAllColumns') || !isUIAllowed('showAllColumns') || isSharedBase.value,
)
const localChanges = ref<Field[]>([])
const isColumnViewEssential = (column: ColumnType) => {
// TODO: consider at some point ti delegate this via a cleaner design pattern to view specific check logic
// which could be inside of a view specific helper class (and generalized via an interface)
@ -76,6 +78,16 @@ export function useViewColumns(
}
})
.sort((a: Field, b: Field) => a.order - b.order)
if (isLocalMode.value && fields.value) {
for (const field of localChanges.value) {
const fieldIndex = fields.value.findIndex((f) => f.fk_column_id === field.fk_column_id)
if (fieldIndex !== undefined && fieldIndex > -1) {
fields.value[fieldIndex] = field
fields.value = fields.value.sort((a: Field, b: Field) => a.order - b.order)
}
}
}
}
}
@ -128,20 +140,20 @@ export function useViewColumns(
}
const saveOrUpdate = async (field: any, index: number) => {
if (isPublic.value && fields.value) {
if (isLocalMode.value && fields.value) {
fields.value[index] = field
meta.value!.columns = meta.value!.columns?.map((column: ColumnType) => {
if (column.id === field.fk_column_id) {
return {
...column,
...field,
id: field.fk_column_id,
}
}
return column
})
await loadViewColumns()
reloadData?.()
localChanges.value.push(field)
}
if (isUIAllowed('fieldsSync')) {
@ -156,6 +168,7 @@ export function useViewColumns(
return insertedField
}
}
await loadViewColumns()
reloadData?.()
}

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

@ -12,6 +12,7 @@ import {
message,
populateInsertObject,
ref,
rowPkData,
storeToRefs,
until,
useApi,
@ -25,7 +26,7 @@ import {
useSmartsheetStoreOrThrow,
useUIPermission,
} from '#imports'
import type { Row } from '~/lib'
import type { Row, UndoRedoAction } from '~/lib'
const formatData = (list: Record<string, any>[]) =>
list.map((row) => ({
@ -55,6 +56,8 @@ export function useViewData(
const { getMeta } = useMetas()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const appInfoDefaultLimit = appInfo.defaultLimit || 25
const _paginationData = ref<PaginatedType>({ page: 1, pageSize: appInfoDefaultLimit })
@ -221,10 +224,20 @@ export function useViewData(
: await $api.dbView.galleryRead(viewMeta.value.id)
}
const findIndexByPk = (pk: Record<string, string>) => {
for (const [i, row] of Object.entries(formattedData.value)) {
if (Object.keys(pk).every((k) => pk[k] === row.row[k])) {
return parseInt(i)
}
}
return -1
}
async function insertRow(
currentRow: Row,
ltarState: Record<string, any> = {},
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
undo = false,
) {
const row = currentRow.row
if (currentRow.rowMeta) currentRow.rowMeta.saving = true
@ -234,6 +247,7 @@ export function useViewData(
ltarState,
getMeta,
row,
undo,
})
if (missingRequiredColumns.size) return
@ -246,11 +260,58 @@ export function useViewData(
insertObj,
)
Object.assign(currentRow, {
row: { ...insertedData, ...row },
rowMeta: { ...(currentRow.rowMeta || {}), new: undefined },
oldRow: { ...insertedData },
})
if (!undo) {
Object.assign(currentRow, {
row: { ...insertedData, ...row },
rowMeta: { ...(currentRow.rowMeta || {}), new: undefined },
oldRow: { ...insertedData },
})
const id = extractPkFromRow(insertedData, metaValue?.columns as ColumnType[])
const pkData = rowPkData(insertedData, metaValue?.columns as ColumnType[])
const rowIndex = findIndexByPk(pkData)
addUndo({
redo: {
fn: async function redo(
this: UndoRedoAction,
row: Row,
ltarState: Record<string, any>,
pg: { page: number; pageSize: number },
) {
row.row = { ...pkData, ...row.row }
const insertedData = await insertRow(row, ltarState, undefined, true)
if (rowIndex !== -1 && pg.pageSize === paginationData.value.pageSize) {
if (pg.page === paginationData.value.page) {
formattedData.value.splice(rowIndex, 0, {
row: { ...row, ...insertedData },
rowMeta: row.rowMeta,
oldRow: row.oldRow,
})
} else {
await changePage(pg.page)
}
} else {
await loadData()
}
},
args: [
clone(currentRow),
clone(ltarState),
{ page: paginationData.value.page, pageSize: paginationData.value.pageSize },
],
},
undo: {
fn: async function undo(this: UndoRedoAction, id: string) {
await deleteRowById(id)
if (rowIndex !== -1) formattedData.value.splice(rowIndex, 1)
paginationData.value.totalRows = paginationData.value.totalRows! - 1
},
args: [id],
},
scope: defineViewScope({ view: viewMeta.value }),
})
}
await syncCount()
return insertedData
@ -267,6 +328,7 @@ export function useViewData(
toUpdate: Row,
property: string,
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
undo = false,
) {
if (toUpdate.rowMeta) toUpdate.rowMeta.saving = true
@ -297,25 +359,74 @@ export function useViewData(
prev_value: getHTMLEncodedText(toUpdate.oldRow[property]),
})
/** update row data(to sync formula and other related columns)
* update only formula, rollup and auto updated datetime columns data to avoid overwriting any changes made by user
*/
Object.assign(
toUpdate.row,
metaValue!.columns!.reduce<Record<string, any>>((acc: Record<string, any>, col: ColumnType) => {
if (
col.uidt === UITypes.Formula ||
col.uidt === UITypes.QrCode ||
col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup ||
col.au ||
col.cdf?.includes(' on update ')
)
acc[col.title!] = updatedRowData[col.title!]
return acc
}, {} as Record<string, any>),
)
Object.assign(toUpdate.oldRow, updatedRowData)
if (!undo) {
addUndo({
redo: {
fn: async function redo(toUpdate: Row, property: string, pg: { page: number; pageSize: number }) {
const updatedData = await updateRowProperty(toUpdate, property, undefined, true)
if (pg.page === paginationData.value.page && pg.pageSize === paginationData.value.pageSize) {
const rowIndex = findIndexByPk(rowPkData(toUpdate.row, meta?.value?.columns as ColumnType[]))
if (rowIndex !== -1) {
const row = formattedData.value[rowIndex]
Object.assign(row.row, updatedData)
Object.assign(row.oldRow, updatedData)
} else {
await loadData()
}
} else {
await changePage(pg.page)
}
},
args: [clone(toUpdate), property, { page: paginationData.value.page, pageSize: paginationData.value.pageSize }],
},
undo: {
fn: async function undo(toUpdate: Row, property: string, pg: { page: number; pageSize: number }) {
const updatedData = await updateRowProperty(
{ row: toUpdate.oldRow, oldRow: toUpdate.row, rowMeta: toUpdate.rowMeta },
property,
undefined,
true,
)
if (pg.page === paginationData.value.page && pg.pageSize === paginationData.value.pageSize) {
const rowIndex = findIndexByPk(rowPkData(toUpdate.row, meta?.value?.columns as ColumnType[]))
if (rowIndex !== -1) {
const row = formattedData.value[rowIndex]
Object.assign(row.row, updatedData)
Object.assign(row.oldRow, updatedData)
} else {
await loadData()
}
} else {
await changePage(pg.page)
}
},
args: [clone(toUpdate), property, { page: paginationData.value.page, pageSize: paginationData.value.pageSize }],
},
scope: defineViewScope({ view: viewMeta.value }),
})
/** update row data(to sync formula and other related columns)
* update only formula, rollup and auto updated datetime columns data to avoid overwriting any changes made by user
*/
Object.assign(
toUpdate.row,
metaValue!.columns!.reduce<Record<string, any>>((acc: Record<string, any>, col: ColumnType) => {
if (
col.uidt === UITypes.Formula ||
col.uidt === UITypes.QrCode ||
col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup ||
col.au ||
col.cdf?.includes(' on update ')
)
acc[col.title!] = updatedRowData[col.title!]
return acc
}, {} as Record<string, any>),
)
Object.assign(toUpdate.oldRow, updatedRowData)
}
return updatedRowData
} catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`)
} finally {
@ -354,7 +465,10 @@ export function useViewData(
$e('a:grid:pagination')
}
async function deleteRowById(id: string) {
async function deleteRowById(
id: string,
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
) {
if (!id) {
throw new Error("Delete not allowed for table which doesn't have primary Key")
}
@ -362,8 +476,8 @@ export function useViewData(
const res: any = await $api.dbViewRow.delete(
'noco',
project.value.id as string,
meta.value?.id as string,
viewMeta.value?.id as string,
metaValue?.id as string,
viewMetaValue?.id as string,
id,
)
@ -378,7 +492,7 @@ export function useViewData(
return true
}
async function deleteRow(rowIndex: number) {
async function deleteRow(rowIndex: number, undo?: boolean) {
try {
const row = formattedData.value[rowIndex]
if (!row.rowMeta.new) {
@ -387,6 +501,44 @@ export function useViewData(
.map((c) => row.row[c.title!])
.join('___')
if (!undo) {
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, id: string) {
await deleteRowById(id)
const pk: Record<string, string> = rowPkData(row.row, meta?.value?.columns as ColumnType[])
const rowIndex = findIndexByPk(pk)
if (rowIndex !== -1) formattedData.value.splice(rowIndex, 1)
paginationData.value.totalRows = paginationData.value.totalRows! - 1
},
args: [id],
},
undo: {
fn: async function undo(
this: UndoRedoAction,
row: Row,
ltarState: Record<string, any>,
pg: { page: number; pageSize: number },
) {
const pkData = rowPkData(row.row, meta.value?.columns as ColumnType[])
row.row = { ...pkData, ...row.row }
await insertRow(row, ltarState, {}, true)
if (rowIndex !== -1 && pg.pageSize === paginationData.value.pageSize) {
if (pg.page === paginationData.value.page) {
formattedData.value.splice(rowIndex, 0, row)
} else {
await changePage(pg.page)
}
} else {
await loadData()
}
},
args: [clone(row), {}, { page: paginationData.value.page, pageSize: paginationData.value.pageSize }],
},
scope: defineViewScope({ view: viewMeta.value }),
})
}
const deleted = await deleteRowById(id as string)
if (!deleted) {
return
@ -403,6 +555,7 @@ export function useViewData(
async function deleteSelectedRows() {
let row = formattedData.value.length
const removedRowsData: { id?: string; row: Row; rowIndex: number }[] = []
while (row--) {
try {
const { row: rowObj, rowMeta } = formattedData.value[row] as Record<string, any>
@ -419,6 +572,7 @@ export function useViewData(
if (!successfulDeletion) {
continue
}
removedRowsData.push({ id, row: clone(formattedData.value[row]), rowIndex: row })
}
formattedData.value.splice(row, 1)
} catch (e: any) {
@ -426,6 +580,46 @@ export function useViewData(
}
}
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, removedRowsData: { id?: string; row: Row; rowIndex: number }[]) {
for (const { id, row } of removedRowsData) {
await deleteRowById(id as string)
const pk: Record<string, string> = rowPkData(row.row, meta?.value?.columns as ColumnType[])
const rowIndex = findIndexByPk(pk)
if (rowIndex !== -1) formattedData.value.splice(rowIndex, 1)
paginationData.value.totalRows = paginationData.value.totalRows! - 1
}
await syncPagination()
},
args: [removedRowsData],
},
undo: {
fn: async function undo(
this: UndoRedoAction,
removedRowsData: { id?: string; row: Row; rowIndex: number }[],
pg: { page: number; pageSize: number },
) {
for (const { row, rowIndex } of removedRowsData.slice().reverse()) {
const pkData = rowPkData(row.row, meta.value?.columns as ColumnType[])
row.row = { ...pkData, ...row.row }
await insertRow(row, {}, {}, true)
if (rowIndex !== -1 && pg.pageSize === paginationData.value.pageSize) {
if (pg.page === paginationData.value.page) {
formattedData.value.splice(rowIndex, 0, row)
} else {
await changePage(pg.page)
}
} else {
await loadData()
}
}
},
args: [removedRowsData, { page: paginationData.value.page, pageSize: paginationData.value.pageSize }],
},
scope: defineViewScope({ view: viewMeta.value }),
})
await syncCount()
await syncPagination()
}

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

@ -20,7 +20,7 @@ import {
watch,
} from '#imports'
import { TabMetaInj } from '~/context'
import type { Filter, TabItem } from '~/lib'
import type { Filter, TabItem, UndoRedoAction } from '~/lib'
export function useViewFilters(
view: Ref<ViewType | undefined>,
@ -46,6 +46,8 @@ export function useViewFilters(
const { metas } = useMetas()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const _filters = ref<Filter[]>([])
const nestedMode = computed(() => isPublic.value || !isUIAllowed('filterSync') || !isUIAllowed('filterChildrenRead'))
@ -107,6 +109,19 @@ export function useViewFilters(
}, {})
})
const lastFilters = ref<Filter[]>([])
watchOnce(filters, (filters: Filter[]) => {
lastFilters.value = clone(filters)
})
// get delta between two objects and return the changed fields (value is from b)
const getFieldDelta = (a: any, b: any) => {
return Object.entries(b)
.filter(([key, val]) => a[key] !== val && key in a)
.reduce((a, [key, v]) => ({ ...a, [key]: v }), {})
}
const isComparisonOpAllowed = (
filter: FilterType,
compOp: {
@ -230,39 +245,40 @@ export function useViewFilters(
}
}
const deleteFilter = async (filter: Filter, i: number) => {
// if shared or sync permission not allowed simply remove it from array
if (nestedMode.value) {
filters.value.splice(i, 1)
filters.value = [...filters.value]
reloadData?.()
} else {
if (filter.id) {
// if auto-apply disabled mark it as disabled
if (!autoApply?.value) {
filter.status = 'delete'
// if auto-apply enabled invoke delete api and remove from array
// no splice is required here
} else {
try {
await $api.dbTableFilter.delete(filter.id)
reloadData?.()
filters.value.splice(i, 1)
} catch (e: any) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
}
const saveOrUpdate = async (filter: Filter, i: number, force = false, undo = false) => {
if (!view.value) return
if (!undo) {
const lastFilter = lastFilters.value[i]
if (lastFilter) {
const delta = clone(getFieldDelta(filter, lastFilter))
if (Object.keys(delta).length > 0) {
addUndo({
undo: {
fn: (prop: string, data: any) => {
const f = filters.value[i]
if (f) {
f[prop as keyof Filter] = data
saveOrUpdate(f, i, force, true)
}
},
args: [Object.keys(delta)[0], Object.values(delta)[0]],
},
redo: {
fn: (prop: string, data: any) => {
const f = filters.value[i]
if (f) {
f[prop as keyof Filter] = data
saveOrUpdate(f, i, force, true)
}
},
args: [Object.keys(delta)[0], filter[Object.keys(delta)[0] as keyof Filter]],
},
scope: defineViewScope({ view: activeView.value }),
})
}
// if not synced yet remove it from array
} else {
filters.value.splice(i, 1)
}
$e('a:filter:delete', { length: nonDeletedFilters.value.length })
}
}
const saveOrUpdate = async (filter: Filter, i: number, force = false) => {
if (!view.value) return
try {
if (nestedMode.value) {
@ -270,7 +286,7 @@ export function useViewFilters(
filters.value = [...filters.value]
} else if (!autoApply?.value && !force) {
filter.status = filter.id ? 'update' : 'create'
} else if (filter.id) {
} else if (filter.id && filter.status !== 'create') {
await $api.dbTableFilter.update(filter.id, {
...filter,
fk_parent_id: parentId,
@ -290,13 +306,88 @@ export function useViewFilters(
message.error(await extractSdkResponseErrorMsg(e))
}
lastFilters.value = clone(filters.value)
reloadData?.()
}
const deleteFilter = async (filter: Filter, i: number, undo = false) => {
if (!undo && !filter.is_group) {
addUndo({
undo: {
fn: async (fl: Filter) => {
fl.status = 'create'
filters.value.splice(i, 0, fl)
await saveOrUpdate(fl, i, false, true)
},
args: [clone(filter)],
},
redo: {
fn: async (index: number) => {
await deleteFilter(filters.value[index], index, true)
},
args: [i],
},
scope: defineViewScope({ view: activeView.value }),
})
}
// if shared or sync permission not allowed simply remove it from array
if (nestedMode.value) {
filters.value.splice(i, 1)
filters.value = [...filters.value]
reloadData?.()
} else {
if (filter.id) {
// if auto-apply disabled mark it as disabled
if (!autoApply?.value) {
filter.status = 'delete'
// if auto-apply enabled invoke delete api and remove from array
// no splice is required here
} else {
try {
await $api.dbTableFilter.delete(filter.id)
reloadData?.()
filters.value.splice(i, 1)
} catch (e: any) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
}
}
// if not synced yet remove it from array
} else {
filters.value.splice(i, 1)
}
$e('a:filter:delete', { length: nonDeletedFilters.value.length })
}
}
const saveOrUpdateDebounced = useDebounceFn(saveOrUpdate, 500)
const addFilter = () => {
const addFilter = async (undo = false) => {
filters.value.push(placeholderFilter())
if (!undo) {
addUndo({
undo: {
fn: async function undo(this: UndoRedoAction, i: number) {
this.redo.args = [i, clone(filters.value[i])]
await deleteFilter(filters.value[i], i, true)
},
args: [filters.value.length - 1],
},
redo: {
fn: async (i: number, fl: Filter) => {
fl.status = 'create'
filters.value.splice(i, 0, fl)
await saveOrUpdate(fl, i, false, true)
},
args: [],
},
scope: defineViewScope({ view: activeView.value }),
})
}
lastFilters.value = clone(filters.value)
$e('a:filter:add', { length: filters.value.length })
}
@ -317,6 +408,8 @@ export function useViewFilters(
await saveOrUpdate(filters.value[index], index, true)
lastFilters.value = clone(filters.value)
$e('a:filter:add', { length: filters.value.length, group: true })
}

117
packages/nc-gui/composables/useViewSorts.ts

@ -27,12 +27,20 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
const { isSharedBase } = storeToRefs(useProject())
const { addUndo, clone, defineViewScope } = useUndoRedo()
const reloadHook = inject(ReloadViewDataHookInj)
const isPublic = inject(IsPublicInj, ref(false))
const tabMeta = inject(TabMetaInj, ref({ sortsState: new Map() } as TabItem))
const lastSorts = ref<SortType[]>([])
watchOnce(sorts, (sorts: SortType[]) => {
lastSorts.value = clone(sorts)
})
const loadSorts = async () => {
if (isPublic.value) {
// todo: sorts missing on `ViewType`
@ -57,7 +65,46 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
}
}
const saveOrUpdate = async (sort: SortType, i: number) => {
// get delta between two objects and return the changed fields (value is from b)
const getDelta = (a: any, b: any) => {
return Object.entries(b)
.filter(([key, val]) => a[key] !== val && key in a)
.reduce((a, [key, v]) => ({ ...a, [key]: v }), {})
}
const saveOrUpdate = async (sort: SortType, i: number, undo = false) => {
if (!undo) {
const lastSort = lastSorts.value[i]
if (lastSort) {
const delta = clone(getDelta(sort, lastSort))
if (Object.keys(delta).length > 0) {
addUndo({
undo: {
fn: (prop: string, data: any) => {
const f = sorts.value[i]
if (f) {
f[prop] = data
saveOrUpdate(f, i, true)
}
},
args: [Object.keys(delta)[0], Object.values(delta)[0]],
},
redo: {
fn: (prop: string, data: any) => {
const f = sorts.value[i]
if (f) {
f[prop] = data
saveOrUpdate(f, i, true)
}
},
args: [Object.keys(delta)[0], sort[Object.keys(delta)[0]]],
},
scope: defineViewScope({ view: view.value }),
})
}
}
}
if (isPublic.value || isSharedBase.value) {
sorts.value[i] = sort
sorts.value = [...sorts.value]
@ -81,21 +128,11 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
console.error(e)
message.error(await extractSdkResponseErrorMsg(e))
}
}
const addSort = () => {
sorts.value = [
...sorts.value,
{
direction: 'asc',
},
]
$e('a:sort:add', { length: sorts?.value?.length })
tabMeta.value.sortsState!.set(view.value!.id!, sorts.value)
lastSorts.value = clone(sorts.value)
}
const deleteSort = async (sort: SortType, i: number) => {
const deleteSort = async (sort: SortType, i: number, undo = false) => {
try {
if (isUIAllowed('sortSync') && sort.id && !isPublic.value && !isSharedBase.value) {
await $api.dbTableSort.delete(sort.id)
@ -103,6 +140,27 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
sorts.value.splice(i, 1)
sorts.value = [...sorts.value]
if (!undo) {
addUndo({
redo: {
fn: async () => {
await deleteSort(sort, i, true)
},
args: [],
},
undo: {
fn: () => {
sorts.value.splice(i, 0, sort)
saveOrUpdate(sort, i, true)
},
args: [clone(sort), i],
},
scope: defineViewScope({ view: view.value }),
})
}
lastSorts.value = clone(sorts.value)
tabMeta.value.sortsState!.set(view.value!.id!, sorts.value)
reloadHook?.trigger()
@ -113,5 +171,38 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
}
}
const addSort = (undo = false) => {
sorts.value = [
...sorts.value,
{
direction: 'asc',
},
]
$e('a:sort:add', { length: sorts?.value?.length })
if (!undo) {
addUndo({
undo: {
fn: async () => {
await deleteSort(sorts.value[sorts.value.length - 1], sorts.value.length - 1, true)
},
args: [],
},
redo: {
fn: () => {
addSort(true)
},
args: [],
},
scope: defineViewScope({ view: view.value }),
})
}
lastSorts.value = clone(sorts.value)
tabMeta.value.sortsState!.set(view.value!.id!, sorts.value)
}
return { sorts, loadSorts, addSort, deleteSort, saveOrUpdate }
}

1
packages/nc-gui/just-clone-shims.d.ts vendored

@ -1 +0,0 @@
declare module 'just-clone'

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

@ -101,7 +101,7 @@
"form": "Formulaire",
"kanban": "Kanban",
"calendar": "Calendrier",
"map": "Map"
"map": "Carte"
},
"user": "Utilisateur",
"users": "Utilisateurs",
@ -210,7 +210,7 @@
"advancedSettings": "Paramètres avancés",
"codeSnippet": "Extrait de code",
"keyboardShortcut": "Raccourcis clavier",
"generateRandomName": "Generate Random Name",
"generateRandomName": "Générer un nom aléatoire",
"findRowByScanningCode": "Find row by scanning a QR or Barcode"
},
"labels": {
@ -221,7 +221,7 @@
"viewName": "Vue",
"viewLink": "Lien de vue",
"columnName": "Nom de la colonne",
"columnToScanFor": "Column to scan",
"columnToScanFor": "Colonne à scanner",
"columnType": "Type de colonne",
"roleName": "Nom de rôle",
"roleDescription": "Description du rôle",
@ -396,7 +396,7 @@
"saveAndExit": "Enregistrer et quitter",
"saveAndStay": "Enregistrer et rester",
"insertRow": "Insérer une nouvelle ligne",
"duplicateRow": "Duplicate Row",
"duplicateRow": "Dupliquer la ligne",
"deleteRow": "Supprimer la ligne",
"deleteSelectedRow": "Supprimer les lignes sélectionnées",
"importExcel": "Importer depuis Excel",
@ -548,7 +548,7 @@
"noRowFoundForCode": "No row found for this code for the selected column"
},
"map": {
"overLimit": "You're over the limit.",
"overLimit": "Vous avez dépassé la limite.",
"closeLimit": "You're getting close to the limit.",
"limitNumber": "The limit of markers shown in a Map View is 1000 records."
},
@ -634,7 +634,7 @@
"gallery": "Ajouter une vue Galerie",
"form": "Ajouter une vue Formulaire",
"kanban": "Ajouter une vue Kanban",
"map": "Add Map View",
"map": "Ajouter la vue Carte",
"calendar": "Ajouter une vue Calendrier"
},
"tablesMetadataInSync": "Les métadonnées de tables sont en synchronisation",
@ -698,7 +698,7 @@
"allowedSpecialCharList": "Liste des caractères spéciaux autorisés"
},
"invalidURL": "URL invalide",
"invalidEmail": "Invalid Email",
"invalidEmail": "Email invalide",
"internalError": "Une erreur interne est survenue",
"templateGeneratorNotFound": "Le générateur de modèles est introuvable !",
"fileUploadFailed": "Échec du téléversement du fichier",
@ -779,7 +779,7 @@
"userDeletedFromProject": "Suppression réussie de l'utilisateur du projet",
"inviteEmailSent": "Email d'invitation envoyé avec succès",
"inviteURLCopied": "URL de l'invitation copiée dans le presse-papiers",
"commentCopied": "Comment copied to clipboard",
"commentCopied": "Commentaire copié dans le presse-papier",
"passwordResetURLCopied": "URL de réinitialisation du mot de passe copiée dans le presse-papiers",
"shareableURLCopied": "Copie de l'URL de la base partageable dans le presse-papiers !",
"embeddableHTMLCodeCopied": "Copie du code HTML intégrable !",

242
packages/nc-gui/lang/uk.json

@ -1,6 +1,6 @@
{
"general": {
"home": "Додому",
"home": "Головна",
"load": "Завантажити",
"open": "Відкрити",
"close": "Закрити",
@ -26,31 +26,31 @@
"install": "Встановити",
"show": "Показати",
"hide": "Приховати",
"showAll": "Показати усе",
"hideAll": "Приховати усе",
"showAll": "Показати все",
"hideAll": "Приховати все",
"showMore": "Показати більше",
"showOptions": "Показати опції",
"hideOptions": "Приховати опції",
"hideOptions": "Сховати опції",
"showMenu": "Показати меню",
"hideMenu": "Приховати меню",
"addAll": "Додати усе",
"removeAll": "Видалити усе",
"hideMenu": "Сховати меню",
"addAll": "Додати все",
"removeAll": "Видалити все",
"signUp": "Зареєструватися",
"signIn": "Увійти",
"signOut": "Вийти",
"required": "Обов'язково",
"enableScanner": "Enable Scanner for filling",
"preferred": "Бажане",
"mandatory": "Обов’язкове",
"required": "Обовʼязково",
"enableScanner": "Увімкнути сканер для заповнення",
"preferred": "Бажано",
"mandatory": "Обовʼязково",
"loading": "Завантаження ...",
"title": "Заголовок",
"upload": "Завантажити в хмару",
"upload": "Завантажити",
"download": "Завантажити на ПК",
"default": "За замовчуванням",
"more": "Більше",
"less": "Менше",
"event": "Подія",
"condition": "Умова",
"condition": "Стан",
"after": "Після",
"before": "Раніше",
"search": "Пошук",
@ -76,7 +76,7 @@
"hideField": "Приховати поле",
"sortAsc": "За зростанням",
"sortDesc": "За спаданням",
"geoDataField": "GeoData Field"
"geoDataField": "Поле геоданих"
},
"objects": {
"project": "Проєкт",
@ -92,7 +92,7 @@
"record": "запис",
"records": "записи",
"webhook": "Webhook",
"webhooks": "Webhooks",
"webhooks": "Вебхуки",
"view": "Вигляд",
"views": "Вигляди",
"viewType": {
@ -101,7 +101,7 @@
"form": "Форма",
"kanban": "Канбан",
"calendar": "Календар",
"map": "Map"
"map": "Мапа"
},
"user": "Користувач",
"users": "Користувачі",
@ -113,15 +113,15 @@
"editor": "Редактор",
"commenter": "Коментатор",
"viewer": "Глядач",
"orgLevelCreator": "Рівень Розробника",
"orgLevelViewer": "Рівень Глядача"
"orgLevelCreator": "Творець рівня організації",
"orgLevelViewer": "Глядач рівня організації"
},
"sqlVIew": "SQL вигляд"
},
"datatype": {
"ID": "Ідентифікатор",
"ForeignKey": "Зовнішній ключ",
"SingleLineText": "Короткий текст",
"SingleLineText": "Однорядковий текст",
"LongText": "Довгий текст",
"Attachment": "Вкладення",
"Checkbox": "Прапорець",
@ -132,32 +132,32 @@
"Year": "Рік",
"Time": "Час",
"PhoneNumber": "Номер телефону",
"Email": "E-mail",
"Email": "Пошта",
"URL": "URL",
"Number": "Число",
"Decimal": "Дробове",
"Currency": "Валюта",
"Percent": "Відсоток",
"Duration": "Тривалість",
"GeoData": "GeoData",
"GeoData": "Геодані",
"Rating": "Рейтинг",
"Formula": "Формула",
"Rollup": "Накопичення",
"Count": "Кількість",
"Lookup": ідставляння",
"Lookup": ошук",
"DateTime": "Дата і час",
"CreateTime": "Час створення",
"LastModifiedTime": "Час останньої зміни",
"AutoNumber": "Лічильник",
"AutoNumber": "Автоматичне число",
"Barcode": "Штрих-код",
"Button": "Кнопка",
"Password": "Пароль",
"relationProperties": {
"noAction": "Бездіяльність",
"noAction": "Немає дій",
"cascade": "Каскадне оновлення",
"restrict": "Обмежити",
"setNull": "Встановити NULL",
"setDefault": "За замовчуванням"
"setDefault": "Встановити за замовчуванням"
}
},
"filterOperation": {
@ -171,7 +171,7 @@
"isNotNull": "не рівне null"
},
"title": {
"erdView": "Вигляд ERD",
"erdView": "ERD вигляд",
"newProj": "Новий проєкт",
"myProject": "Мої проєкти",
"formTitle": "Назва форми",
@ -185,10 +185,10 @@
"apiTokenMgmt": "Управління токенами API",
"rolesMgmt": "Керування ролями",
"projMeta": "Метадані проєкту",
"metaMgmt": "Мета-менеджмент",
"metaMgmt": "Метаменеджмент",
"metadata": "Метадані",
"exportImportMeta": "Експорт/Імпорт Метаданих",
"uiACL": "Контроль доступу UI",
"exportImportMeta": "Експорт/Імпорт метаданих",
"uiACL": "Контроль доступу до інтерфейсу",
"metaOperations": "Операції з метаданими",
"audit": "Аудит",
"auditLogs": "Журнал аудиту",
@ -196,10 +196,10 @@
"dbCredentials": "Облікові дані бази даних",
"advancedParameters": "SSL і розширені параметри",
"headCreateProject": "Створити проєкт | NocoDB",
"headLogin": "Вхід до | NocoDB",
"headLogin": "Увійти | NocoDB",
"resetPassword": "Скинути пароль",
"teamAndSettings": "Команда та налаштування",
"apiDocs": "Документація API",
"apiDocs": "API-документація",
"importFromAirtable": "Імпортувати з Airtable",
"generateToken": "Генерувати токен",
"APIsAndSupport": "API та Підтримка",
@ -210,8 +210,8 @@
"advancedSettings": "Додаткові налаштування",
"codeSnippet": "Фрагмент коду",
"keyboardShortcut": "Клавіатурні скорочення",
"generateRandomName": "Згенерувати випадкове імя",
"findRowByScanningCode": "Find row by scanning a QR or Barcode"
"generateRandomName": "Згенерувати випадкове імʼя",
"findRowByScanningCode": "Знайти рядок, відсканувавши QR-код або штрих-код"
},
"labels": {
"createdBy": "Автор",
@ -219,16 +219,16 @@
"projName": "Назва проєкту",
"tableName": "Назва таблиці",
"viewName": "Назва вигляду",
"viewLink": "Вигляд посилання",
"viewLink": "Переглянути посилання",
"columnName": "Назва стовпця",
"columnToScanFor": "Column to scan",
"columnToScanFor": "Стовпець для сканування",
"columnType": "Тип стовпця",
"roleName": "Ім'я ролі",
"roleName": "Імʼя ролі",
"roleDescription": "Опис ролі",
"databaseType": "Тип в базі даних",
"lengthValue": "Довжина/значення",
"lengthValue": "Довжина/Значення",
"dbType": "Тип бази даних",
"sqliteFile": "Файл SQLite",
"sqliteFile": "SQLite файл",
"hostAddress": "Назва серверу/ІP Адреса",
"port": "Номер порту",
"username": "Ім'я користувача",
@ -238,7 +238,7 @@
"action": "Дія",
"actions": "Дії",
"operation": "Операція",
"operationSub": "Sub Operation",
"operationSub": "Субоперація",
"operationType": "Тип операції",
"operationSubType": "Підтип операції",
"description": "Опис",
@ -247,7 +247,7 @@
"where": "Де",
"cache": "Кеш",
"chat": "Чат",
"email": "E-mail",
"email": "Пошта",
"storage": "Сховище",
"uiAcl": "UI-ACL",
"models": "Моделі",
@ -255,37 +255,37 @@
"created": "Створений",
"sqlOutput": "Вивід SQL",
"addOption": "Додати опцію",
"qrCodeValueColumn": "Стовпчик зі значенням QR коду",
"barcodeValueColumn": "Стовпчик зі значенням штрих коду",
"qrCodeValueColumn": "Стовпчик зі значенням QR-коду",
"barcodeValueColumn": "Стовпець зі значенням штрих-коду",
"barcodeFormat": "Формат штрих-коду",
"qrCodeValueTooLong": "Забагато символів для QR-коду",
"barcodeValueTooLong": "Забагато символів для штрих-коду",
"currentLocation": "Current Location",
"lng": "Lng",
"lat": "Lat",
"currentLocation": "Розташування",
"lng": "Довгота",
"lat": "Широта",
"aggregateFunction": "Агрегатна функція",
"dbCreateIfNotExists": "База даних: створити, якщо не існує",
"clientKey": "Ключ клієнта",
"clientCert": "Сертифікат клієнта",
"serverCA": "Сервер СА",
"requriedCa": "Потрібно-CA",
"requriedCa": "Потрібно CA",
"requriedIdentity": "Потрібна ідентифікація",
"inflection": {
"tableName": "Перехрестя - Назва таблиці",
"columnName": "Перехрестя - Назва стовпця"
},
"community": {
"starUs1": "Оцінка",
"starUs2": "ми на Github",
"bookDemo": "Забронюйте безоплатну демонстрацію",
"starUs1": "Оцінити",
"starUs2": "ми на GitHub",
"bookDemo": "Забронюйте безкоштовну демонстрацію",
"getAnswered": "Отримайте відповіді на запитання",
"joinDiscord": "Приєднатися до Discord",
"joinCommunity": "Приєднатися до спільноти NocoDB",
"joinDiscord": "Ми є в Discord",
"joinCommunity": "Приєднуйтесь до спільноти NocoDB",
"joinReddit": "Приєднатися /r/NocoDB",
"followNocodb": "Перейти до NocoDB"
"followNocodb": "Слідкуйте за NocoDB"
},
"docReference": "Довідник",
"selectUserRole": кажіть роль користувача",
"selectUserRole": иберіть роль користувача",
"childTable": "Дочірня таблиця",
"childColumn": "Дочірній стовпець",
"linkToAnotherRecord": "Посилання на інший запис",
@ -294,7 +294,7 @@
"account": "Обліковий запис",
"language": "Мова",
"primaryColor": "Основний колір",
"accentColor": "Акцентний колір",
"accentColor": "Додатковий колір",
"customTheme": "Користувацька тема",
"requestDataSource": "Запитати потрібне вам джерело даних?",
"apiKey": "Ключ API",
@ -302,19 +302,19 @@
"importData": "Імпорт даних",
"importSecondaryViews": "Імпорт іншого вигляду",
"importRollupColumns": "Імпорт підсумкових стовпців",
"importLookupColumns": "Імпорт стовпців підставлення",
"importLookupColumns": "Імпорт стовпців пошуку",
"importAttachmentColumns": "Імпорт стовпців вкладень",
"importFormulaColumns": "Імпорт стовпців формул",
"noData": "Дані відсутні",
"goToDashboard": ерейти до Панелі керування",
"goToDashboard": анель керування",
"importing": "Імпорт",
"flattenNested": "Вкладені",
"downloadAllowed": "Завантаження дозволене",
"weAreHiring": "Вакансії!",
"weAreHiring": "Ми шукаємо!",
"primaryKey": "Первинний ключ",
"hasMany": "має багато",
"belongsTo": "належить до",
"manyToMany": "має зв'язок \"багато до багатьох\"",
"manyToMany": "має звʼязок \"багато-до-багатьох\"",
"extraConnectionParameters": "Додаткові параметри підключення",
"commentsOnly": "Тільки коментарі",
"documentation": "Документація",
@ -344,7 +344,7 @@
"excel": "Створити проєкт з Excel",
"template": "Створити проєкт з шаблону"
},
"OkSaveProject": "Підтвердити та Зберегти проєкт",
"OkSaveProject": "Підтвердити & Зберегти",
"upgrade": {
"available": "Доступне оновлення",
"releaseNote": "Список змін",
@ -353,8 +353,8 @@
"translate": "Допоможіть з перекладом",
"account": {
"authToken": "Копіювати токен авторизації",
"swagger": "Swagger: REST APIs",
"projInfo": "Копіювання інформації про проєкт",
"swagger": "Swagger: REST API",
"projInfo": "Скопіювати інформацію про проект",
"themes": "Теми"
},
"sort": "Сортувати",
@ -375,9 +375,9 @@
"newUser": "Новий користувач",
"editUser": "Редагувати користувача",
"deleteUser": "Видалити користувача з проєкту",
"resendInvite": "Повторно надіслати запрошення на E-mail",
"copyInviteURL": "Копіювати URL-адресу запрошення",
"copyPasswordResetURL": "Копіювати URL-адресу для скидання пароля",
"resendInvite": "Повторно надіслати запрошення на пошту",
"copyInviteURL": "Скопіювати URL-адресу запрошення",
"copyPasswordResetURL": "Скопіювати URL-адресу для оновлення паролю",
"newRole": "Нова роль",
"reloadRoles": "Перезавантажити ролі",
"nextPage": "Наступна сторінка",
@ -390,13 +390,13 @@
"renameTable": "Перейменувати таблицю",
"deleteTable": "Видалити таблицю",
"addField": "Додати нове поле до цієї таблиці",
"setDisplay": "Set as Display value",
"setDisplay": "Встановити як значення для відображення",
"addRow": "Додати новий рядок",
"saveRow": "Зберегти рядок",
"saveAndExit": "Зберегти та вийти",
"saveAndStay": "Зберегти та залишитись",
"saveAndStay": "Зберегти & Залишитись",
"insertRow": "Вставити новий рядок",
"duplicateRow": "Duplicate Row",
"duplicateRow": "Дублювати рядок",
"deleteRow": "Видалити рядок",
"deleteSelectedRow": "Видалити вибрані рядки",
"importExcel": "Імпортувати з Excel",
@ -412,8 +412,8 @@
"changePwd": "Змінити пароль",
"createView": "Створити вигляд",
"shareView": "Поділитися виглядом",
"findRowByCodeScan": "Find row by scan",
"fillByCodeScan": "Fill by scan",
"findRowByCodeScan": "Знайти рядок за скануванням",
"fillByCodeScan": "Заповнити за допомогою сканування",
"listSharedView": "Список спільних виглядів",
"ListView": "Список виглядів",
"copyView": "Копіювати вигляд",
@ -429,13 +429,13 @@
"openTab": "Відкрийте нову вкладку",
"iFrame": "Копіювати вбудований HTML-код",
"addWebhook": "Додати новий Webhook",
"enableWebhook": "Enable Webhook",
"testWebhook": "Test Webhook",
"copyWebhook": "Copy Webhook",
"deleteWebhook": "Delete Webhook",
"enableWebhook": "Увімкнути вебхук",
"testWebhook": "Перевірити вебхук",
"copyWebhook": "Скопіювати вебхук",
"deleteWebhook": "Видалити вебхук",
"newToken": "Додати новий токен",
"exportZip": "Експорт Zip-файлу",
"importZip": "Імпорт Zip-файлу",
"exportZip": "Експорт zip-файлу",
"importZip": "Імпорт zip-файлу",
"metaSync": "Синхронізувати",
"settings": "Налаштування",
"previewAs": "Попередній перегляд як",
@ -459,7 +459,7 @@
"showColumns": "Показати стовпці",
"showPkAndFk": "Показати первинні та зовнішні ключі",
"showSqlViews": "Показати SQL вигляд",
"showMMTables": "Показати таблиці \"Багато до багатьох\"",
"showMMTables": "Показати таблиці \"багато-до-багатьох\"",
"showJunctionTableNames": "Показати ім'я таблиці для з'єднання"
},
"kanban": {
@ -467,42 +467,42 @@
"deleteStack": "Видалити стек",
"stackedBy": "Групувати по",
"chooseGroupingField": "Виберіть поле групування",
"addOrEditStack": "Додавання/Редагування Стеку"
"addOrEditStack": "Додати / Редагувати стек"
},
"map": {
"mappedBy": "Mapped By",
"chooseMappingField": "Choose a Mapping Field",
"chooseMappingField": "Виберіть поле для мапування",
"openInGoogleMaps": "Google Maps",
"openInOpenStreetMap": "OSM"
},
"toggleMobileMode": "Toggle Mobile Mode"
"toggleMobileMode": "Увімкнути мобільний режим"
},
"tooltip": {
"saveChanges": "Зберегти зміни",
"xcDB": "Створити новий проєкт",
"extDB": "Підтримує MySQL, PostgreSQL, SQL Server та SQLite",
"apiRest": "Доступно за допомогою REST APIs",
"apiGQL": "Доступно через GraphQL APIs",
"apiRest": "Доступно через REST API",
"apiGQL": "Доступно через API GraphQL",
"theme": {
"dark": "Він доступний у чорному кольорі (^⇧B)",
"light": "Чи є він у чорному кольорі? (^⇧B)"
},
"addTable": "Додати нову таблицю",
"inviteMore": "Запросіть більше користувачів",
"toggleNavDraw": "Перемикнути навігаційний драйвер",
"toggleNavDraw": "Ввімвкнути висувне меню",
"reloadApiToken": "Перезавантажити API токен",
"generateNewApiToken": "Створити новий API токен",
"addRole": "Додати нову роль",
"reloadList": "Перезавантажити список",
"metaSync": "Синхронізувати метаданні",
"sqlMigration": "Перезавантажити міграції",
"updateRestart": "Оновити та перезавантажити",
"updateRestart": "Оновити & Перезавантажити",
"cancelReturn": "Скасувати та повернутися",
"exportMetadata": "Експортуйте всі метадані з мета-таблиць до мета-каталогу.",
"importMetadata": "Імпортувати всі метадані з мета-каталогів до мета-таблиць.",
"clearMetadata": "Очистити всі метадані з мета-таблиць.",
"clientKey": "Виберіть файл ключа",
"clientCert": "Виберіть файл сертифікату",
"clientKey": "Виберіть .key файл",
"clientCert": "Виберіть .cert файл",
"clientCA": "Виберіть файл CA"
},
"placeholder": {
@ -521,7 +521,7 @@
"searchModels": "Пошук моделі",
"noItemsFound": "Не знайдено жодного елементу",
"defaultValue": "Значення за замовчуванням",
"filterByEmail": "Фільтр E-mail",
"filterByEmail": "Фільтр за поштою",
"filterQuery": "Фільтр запитів",
"selectField": "Виберіть поле"
},
@ -542,39 +542,39 @@
"orgViewer": "Глядач не може створювати нові проєкти, але він може отримати доступ до будь-якого відкритого проєкту."
},
"codeScanner": {
"loadingScanner": "Loading the scanner...",
"selectColumn": "Select a column (QR code or Barcode) that you want to use for finding a row by scanning.",
"moreThanOneRowFoundForCode": "More than one row found for this code. Currently only unique codes are supported.",
"noRowFoundForCode": "No row found for this code for the selected column"
"loadingScanner": "Завантаження сканера...",
"selectColumn": "Виберіть стовпець, QR-код або штрих-код, який ви хочете використовувати для пошуку рядка за допомогою сканування.",
"moreThanOneRowFoundForCode": "Для цього коду знайдено більше одного рядка. Наразі підтримуються лише унікальні коди.",
"noRowFoundForCode": "Для цього коду не знайдено жодного рядка у вибраному стовпчику"
},
"map": {
"overLimit": "You're over the limit.",
"closeLimit": "You're getting close to the limit.",
"limitNumber": "The limit of markers shown in a Map View is 1000 records."
"overLimit": "Ви перевищили ліміт.",
"closeLimit": "Ви наближаєтесь до лімітів.",
"limitNumber": "Обмеження на кількість маркерів, що відображаються в поданні мапи, становить 1000 записів."
},
"footerInfo": "Рядків на сторінці",
"upload": "Виберіть файл для завантаження",
"upload_sub": "або перетягніть файл",
"excelSupport": "Підтримується: .xls, .xlsx, .xlsm, .ods, .ots",
"excelURL": "Введіть URL-адресу до файлу Excel",
"csvURL": "Введіть URL-адресу до файлу CSV",
"footMsg": "# рядків для аналізу для визначення типу даних",
"excelSupport": "Підтримуються: .xls, .xlsx, .xlsm, .ods, .ots",
"excelURL": "Введіть посилання до файлу Excel",
"csvURL": "Введіть посилання до файлу CSV",
"footMsg": "# рядків, які потрібно проаналізувати, щоб вивести тип даних",
"excelImport": "аркуш(і) які доступні для імпорту",
"exportMetadata": "Ви хочете експортувати метадані з мета-таблиць?",
"importMetadata": "Ви хочете імпортувати метадані з мета-таблиць?",
"clearMetadata": "Ви хочете очистити метадані з мета-таблиць?",
"projectEmptyMessage": "Початок роботи, створіть новий проєкт",
"stopProject": "Ви хочете завершити проєкт?",
"projectEmptyMessage": "Почніть зі створення нового проекту",
"stopProject": "Ви хочете зупинити проєкт?",
"startProject": "Ви хочете розпочати проєкт?",
"restartProject": "Ви хочете перезапустити проєкт?",
"deleteProject": "Ви хочете видалити проєкт?",
"shareBasePrivate": "Створіть загальнодоступну базу лише для читання",
"shareBasePrivate": "Створіть загальнодоступну базу, доступну лише для читання",
"shareBasePublic": "Будь-хто в Інтернеті може переглядати за допомогою цього посилання",
"userInviteNoSMTP": "Схоже, ваша поштова програма не налаштована! Будь ласка, скопіюйте вище посилання на запрошення і надішліть його",
"userInviteNoSMTP": "Схоже, ви ще не налаштували SMTP! Будь ласка, скопіюйте посилання на запрошення та надішліть його на адресу",
"dragDropHide": "Перетягніть поля сюди, щоб приховати",
"formInput": "Введіть назву форми для введення",
"formHelpText": "Додайте текст довідки",
"onlyCreator": "Видимий тільки для Редактора",
"formHelpText": "Додайте допоміжний текст",
"onlyCreator": "Видимий тільки для редактора",
"formDesc": "Додати опис форми",
"beforeEnablePwd": "Обмежити доступ за допомогою пароля",
"afterEnablePwd": "Доступ обмежено паролем",
@ -584,24 +584,24 @@
"apiOptions": "Доступ до проєкту через",
"submitAnotherForm": "Показати кнопку \"Надіслати іншу форму\"",
"showBlankForm": "Показати порожню форму через 5 секунд",
"emailForm": "Надішліть мені повідомлення на E-mail",
"emailForm": "Надішліть мені повідомлення на пошту",
"showSysFields": "Показати системні поля",
"filterAutoApply": "Автоматичне застосування",
"showMessage": "Показати це повідомлення",
"viewNotShared": "До поточного вигляду немає доступу!",
"showAllViews": "Показати усі спільні вигляди цієї таблиці",
"collabView": "Співробітники з дозволом на редагування або вище можуть змінювати конфігурацію перегляду.",
"collabView": "Учасники з дозволом на редагування або вище можуть змінювати конфігурацію перегляду.",
"lockedView": "Ніхто не може відредагувати конфігурацію перегляду, доки вона не розблокована.",
"personalView": "Тільки ви можете відредагувати конфігурацію. Іншим співробітникам прихована зміна переглядів за замовчуванням.",
"ownerDesc": "Може додати/видалити редакторів. А також повне редагування структур і полів бази даних.",
"personalView": "Тільки ви можете відредагувати конфігурацію. Іншим учасникам прихована зміна переглядів за замовчуванням.",
"ownerDesc": "Може додавати/видаляти редакторів. Також може повністю редагувати структури та поля бази даних.",
"creatorDesc": "Може повністю редагувати структуру бази даних та значення.",
"editorDesc": "Може редагувати записи, але не може змінити структуру бази даних/полів.",
"commenterDesc": "Може переглядати та коментувати записи, але нічого не може редагувати",
"viewerDesc": "Може переглядати записи, але нічого не може редагувати",
"addUser": "Додати нового користувача",
"staticRoleInfo": "Системні ролі не можуть бути відредаговані",
"exportZip": "Експортувати дані проєкту до Zip-файлу та завантажити.",
"importZip": "Імпортувати дані проєкту до Zip-файлу та перезавантажити.",
"exportZip": "Експортувати дані проєкту до zip-файлу та завантажити.",
"importZip": "Імпортувати дані проєкту до zip-файлу та перезавантажити.",
"importText": "Імпортувати проєкт NocoDB завантаживши zip-файл метаданих",
"metaNoChange": "Не виявлено жодних змін",
"sqlMigration": "Схема міграції буде створена автоматично. Створіть таблицю та оновіть цю сторінку.",
@ -613,9 +613,9 @@
},
"sponsor": {
"header": "Ви можете допомогти нам!",
"message": "Ми є крихітною командою, яка працює повний робочий день, щоб зробити NocoDB Open-source. Ми вважаємо, що інструмент, як NocoDB, повинен бути доступним та вільним для кожного, щодо розв'язання проблем в Інтернеті."
"message": "Ми - невелика команда, яка працює на повну ставку, щоб зробити NocoDB відкритим. Ми віримо, що такий інструмент, як NocoDB, повинен бути доступним безкоштовно для кожного розв'язувача проблем в Інтернеті."
},
"loginMsg": "Увійдіть до NocoDB",
"loginMsg": "Увійти до NocoDB",
"passwordRecovery": {
"message_1": "Вкажіть адресу електронної пошти, яку ви вказали, при реєстрації.",
"message_2": "Ми надішлемо вам електронний лист із посиланням, щоб скинути пароль.",
@ -634,7 +634,7 @@
"gallery": "Додати вигляд галереї",
"form": "Додати вигляд форми",
"kanban": "Додати вигляд Kanban",
"map": "Add Map View",
"map": "Додати вид мапи",
"calendar": "Додати вигляд календаря"
},
"tablesMetadataInSync": "Таблиці метаданих синхронізуються",
@ -666,11 +666,11 @@
"deleteViewConfirmation": "Ви впевнені, що хочете видалити цей вигляд?",
"deleteTableConfirmation": "Ви хочете видалити таблицю",
"showM2mTables": "Показати M2M таблиці",
"showM2mTablesDesc": "Many-to-many relation is supported via a junction table & is hidden by default. Enable this option to list all such tables along with existing tables.",
"showNullInCells": "Show NULL in Cells",
"showNullInCellsDesc": "Display 'NULL' tag in cells holding NULL value. This helps differentiate against cells holding EMPTY string.",
"showNullAndEmptyInFilter": "Show NULL and EMPTY in Filter",
"showNullAndEmptyInFilterDesc": "Enable 'additional' filters to differentiate fields containing NULL & Empty Strings. Default support for Blank treats both NULL & Empty strings alike.",
"showM2mTablesDesc": "Звʼязок \"багато-до-багатьох\" підтримується через таблицю зʼєднань і за замовчуванням прихований. Увімкніть цю опцію, щоб перерахувати всі такі таблиці разом з існуючими.",
"showNullInCells": "Показати NULL в комірках",
"showNullInCellsDesc": "Відображати тег 'NULL' у клітинках, що містять NULL-значення. Це допомагає відрізнити клітинки, що містять ПУСТИЙ рядок.",
"showNullAndEmptyInFilter": "Показувати NULL та EMPTY у фільтрі",
"showNullAndEmptyInFilterDesc": "Увімкніть \"додаткові\" фільтри для розрізнення полів, що містять NULL та порожні рядки. За замовчуванням підтримка пропусків однаково обробляє як NULL, так і порожні рядки.",
"deleteKanbanStackConfirmation": "Видалення цього стека також вилучить опцію вибору `{stackToBeDeleted}` зі стека `{groupingField}`. Записи буде переміщено до не категоризованого стека.",
"computedFieldEditWarning": "Обчислюване поле: вміст доступний лише для читання. Використовуйте меню редагування стовпця для зміни конфігурації",
"computedFieldDeleteWarning": "Обчислюване поле: вміст доступний лише для читання. Не вдалося очистити вміст.",
@ -698,7 +698,7 @@
"allowedSpecialCharList": "Дозволений список спеціальних символів"
},
"invalidURL": "Неправильна URL-адреса",
"invalidEmail": "Invalid Email",
"invalidEmail": "Неправильна електронна адреса",
"internalError": "Сталась внутрішня помилка",
"templateGeneratorNotFound": "Генератор шаблонів не знайдено!",
"fileUploadFailed": "Не вдалося завантажити файл",
@ -726,7 +726,7 @@
"nameShouldStartWithAnAlphabetOr_": "Ім'я повинно починатися з літери або _",
"followingCharactersAreNotAllowed": "Наступні символи не допускаються",
"columnNameRequired": "Ім'я стовпця є обов'язковим",
"columnNameExceedsCharacters": "The length of column name exceeds the max {value} characters",
"columnNameExceedsCharacters": "Довжина назви стовпця перевищує максимальну кількість в {value} символів",
"projectNameExceeds50Characters": "Назва проєкту перевищує 50 символів",
"projectNameCannotStartWithSpace": "Назва проєкту не може починатися з пробілу",
"requiredField": "Обов'язкове поле",
@ -759,7 +759,7 @@
},
"success": {
"columnDuplicated": "Стовпець успішно продубльовано",
"rowDuplicatedWithoutSavedYet": "Row duplicated (not saved)",
"rowDuplicatedWithoutSavedYet": "Рядок продубльовано (не збережено)",
"updatedUIACL": "Успішно оновлено UI ACL для таблиць",
"pluginUninstalled": "Плагін успішно видалено",
"pluginSettingsSaved": "Налаштування плагіну успішно збережено",
@ -779,7 +779,7 @@
"userDeletedFromProject": "Користувача успішно видалено з проєкту",
"inviteEmailSent": "Лист запрошення успішно відправлено на електронну пошту",
"inviteURLCopied": "URL запрошення скопійоване в буфер обміну",
"commentCopied": "Comment copied to clipboard",
"commentCopied": "Коментар скопійовано до буфера обміну",
"passwordResetURLCopied": "URL-адресу скидання пароля скопійовано в буфер обміну",
"shareableURLCopied": "URL адресу спільної бази скопійовано в буфер обміну!",
"embeddableHTMLCodeCopied": "Скопійовано вбудований HTML-код!",

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

@ -104,3 +104,9 @@ export type importFileList = (UploadFile & { data: string | ArrayBuffer })[]
export type streamImportFileList = UploadFile[]
export type Nullable<T> = { [K in keyof T]: T[K] | null }
export interface UndoRedoAction {
undo: { fn: Function; args: any[] }
redo: { fn: Function; args: any[] }
scope?: { key: string; param: string }[]
}

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

@ -25,7 +25,6 @@
"httpsnippet": "^2.0.0",
"jsbarcode": "^3.11.5",
"jsep": "^1.3.6",
"just-clone": "^6.1.1",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.2",
"leaflet.markercluster": "^1.5.3",
@ -35,6 +34,7 @@
"papaparse": "^5.3.2",
"pinia": "^2.0.33",
"qrcode": "^1.5.1",
"rfdc": "^1.3.0",
"socket.io-client": "^4.5.1",
"sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2",
@ -110,7 +110,7 @@
}
},
"../nocodb-sdk": {
"version": "0.105.3",
"version": "0.106.0-beta.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",
@ -10911,11 +10911,6 @@
"node": ">=4.0"
}
},
"node_modules/just-clone": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/just-clone/-/just-clone-6.1.1.tgz",
"integrity": "sha512-V24KLIid8uaG7ayOymGfheNHtxgrbpzj1UznQnF9vQZMHlKGTSLT3WWmFx62OXSQPwk1Tn+uo+H5/Xhb4bL9pA=="
},
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
@ -14623,6 +14618,11 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz",
"integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA=="
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -26325,11 +26325,6 @@
"object.assign": "^4.1.2"
}
},
"just-clone": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/just-clone/-/just-clone-6.1.1.tgz",
"integrity": "sha512-V24KLIid8uaG7ayOymGfheNHtxgrbpzj1UznQnF9vQZMHlKGTSLT3WWmFx62OXSQPwk1Tn+uo+H5/Xhb4bL9pA=="
},
"jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
@ -29067,6 +29062,11 @@
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
},
"rfdc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz",
"integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA=="
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",

2
packages/nc-gui/package.json

@ -49,7 +49,6 @@
"httpsnippet": "^2.0.0",
"jsbarcode": "^3.11.5",
"jsep": "^1.3.6",
"just-clone": "^6.1.1",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.2",
"leaflet.markercluster": "^1.5.3",
@ -59,6 +58,7 @@
"papaparse": "^5.3.2",
"pinia": "^2.0.33",
"qrcode": "^1.5.1",
"rfdc": "^1.3.0",
"socket.io-client": "^4.5.1",
"sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2",

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

@ -509,11 +509,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</a-sub-menu>
</template>
<!-- Language -->
<a-sub-menu
key="language"
class="lang-menu !py-0"
popup-class-name="scrollbar-thin-dull min-w-50 max-h-90vh !overflow-auto"
>
<a-sub-menu key="language" class="lang-menu !py-0">
<template #title>
<div class="nc-project-menu-item group">
<component :is="iconMap.translate" class="group-hover:text-accent nc-language" />
@ -529,7 +525,9 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<template #expandIcon></template>
<LazyGeneralLanguageMenu />
<div class="scrollbar-thin-dull min-w-50 max-h-90vh">
<LazyGeneralLanguageMenu />
</div>
</a-sub-menu>
<!-- Account -->

15
packages/nc-gui/utils/dataUtils.ts

@ -11,6 +11,17 @@ export const extractPkFromRow = (row: Record<string, any>, columns: ColumnType[]
)
}
export const rowPkData = (row: Record<string, any>, columns: ColumnType[]) => {
const pkData: Record<string, string> = {}
const pks = columns?.filter((c) => c.pk)
if (row && pks && pks.length) {
for (const pk of pks) {
if (pk.title) pkData[pk.title] = row[pk.title]
}
}
return pkData
}
// a function to populate insert object and verify if all required fields are present
export async function populateInsertObject({
getMeta,
@ -18,12 +29,14 @@ export async function populateInsertObject({
meta,
ltarState,
throwError,
undo = false,
}: {
meta: TableType
ltarState: Record<string, any>
getMeta: (tableIdOrTitle: string, force?: boolean) => Promise<TableType | null>
row: Record<string, any>
throwError?: boolean
undo?: boolean
}) {
const missingRequiredColumns = new Set()
const insertObj = await meta.columns?.reduce(async (_o: Promise<any>, col) => {
@ -51,7 +64,7 @@ export async function populateInsertObject({
missingRequiredColumns.add(col.title)
}
if (!col.ai && row?.[col.title as string] !== null) {
if ((!col.ai || undo) && row?.[col.title as string] !== null) {
o[col.title as string] = row?.[col.title as string]
}

1670
packages/nc-gui/utils/iconUtils.ts

File diff suppressed because it is too large Load Diff

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

@ -1,6 +1,6 @@
{
"name": "nc-lib-gui",
"version": "0.105.3",
"version": "0.106.0-beta.0",
"description": "NocoDB GUI",
"author": {
"name": "NocoDB",

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

@ -1,12 +1,12 @@
{
"name": "nocodb-sdk",
"version": "0.105.3",
"version": "0.106.0-beta.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "nocodb-sdk",
"version": "0.105.3",
"version": "0.106.0-beta.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",

4
packages/nocodb-sdk/package.json

@ -1,6 +1,6 @@
{
"name": "nocodb-sdk",
"version": "0.105.3",
"version": "0.106.0-beta.0",
"description": "NocoDB SDK",
"main": "build/main/index.js",
"typings": "build/main/index.d.ts",
@ -63,4 +63,4 @@
"prettier": {
"singleQuote": true
}
}
}

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

@ -902,7 +902,7 @@ export interface GalleryType {
/** Model for Bool */
deleted?: BoolType;
/** Foreign Key to Cover Image Column */
fk_cover_image_col_id?: string;
fk_cover_image_col_id?: StringOrNullType;
/** Foreign Key to Model */
fk_model_id?: string;
/** Foreign Key to View */
@ -1229,7 +1229,7 @@ export interface KanbanType {
/** View ID */
fk_view_id?: IdType;
/** Cover Image Column ID */
fk_cover_image_col_id?: IdType;
fk_cover_image_col_id?: StringOrNullType;
/** Kanban Columns */
columns?: KanbanColumnType[];
/** Meta Info for Kanban */
@ -2203,6 +2203,8 @@ export interface ViewType {
show: BoolType;
/** Should show system fields in this view? */
show_system_fields?: BoolType;
/** Is this view default view for the model? */
is_default?: BoolType;
/** View Title */
title: string;
/** View Type */

20
packages/nocodb/package-lock.json generated

@ -1,12 +1,12 @@
{
"name": "nocodb",
"version": "0.105.3",
"version": "0.106.0-beta.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "nocodb",
"version": "0.105.3",
"version": "0.106.0-beta.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@google-cloud/storage": "^5.7.2",
@ -67,7 +67,7 @@
"mysql2": "^2.2.5",
"nanoid": "^3.1.20",
"nc-help": "0.2.87",
"nc-lib-gui": "0.105.3",
"nc-lib-gui": "0.106.0-beta.0",
"nc-plugin": "0.1.2",
"ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk",
@ -156,7 +156,7 @@
}
},
"../nocodb-sdk": {
"version": "0.105.3",
"version": "0.106.0-beta.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",
@ -11330,9 +11330,9 @@
}
},
"node_modules/nc-lib-gui": {
"version": "0.105.3",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.105.3.tgz",
"integrity": "sha512-w07Y2+nBiUQYiUyURwH9nqvzzxXsz8MALU/MhmWyNe3Z0YhBvPPLLUxtG1WR3USLI925YT0BsAZTn+iSP3ooPw==",
"version": "0.106.0-beta.0",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.106.0-beta.0.tgz",
"integrity": "sha512-DN6H5lvGHhOF6/X3yCd/IMm5DIiFwllcEfOncCer5/46jXNO1e84VpCnxFkD4lfZBi6Di05ioa5lsV/ydFn5Xw==",
"dependencies": {
"express": "^4.17.1"
}
@ -28042,9 +28042,9 @@
}
},
"nc-lib-gui": {
"version": "0.105.3",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.105.3.tgz",
"integrity": "sha512-w07Y2+nBiUQYiUyURwH9nqvzzxXsz8MALU/MhmWyNe3Z0YhBvPPLLUxtG1WR3USLI925YT0BsAZTn+iSP3ooPw==",
"version": "0.106.0-beta.0",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.106.0-beta.0.tgz",
"integrity": "sha512-DN6H5lvGHhOF6/X3yCd/IMm5DIiFwllcEfOncCer5/46jXNO1e84VpCnxFkD4lfZBi6Di05ioa5lsV/ydFn5Xw==",
"requires": {
"express": "^4.17.1"
}

4
packages/nocodb/package.json

@ -1,6 +1,6 @@
{
"name": "nocodb",
"version": "0.105.3",
"version": "0.106.0-beta.0",
"description": "NocoDB Backend",
"main": "dist/bundle.js",
"author": {
@ -109,7 +109,7 @@
"mysql2": "^2.2.5",
"nanoid": "^3.1.20",
"nc-help": "0.2.87",
"nc-lib-gui": "0.105.3",
"nc-lib-gui": "0.106.0-beta.0",
"nc-plugin": "0.1.2",
"ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk",

21
packages/nocodb/src/lib/controllers/dbData/oldData.ctl.ts

@ -21,7 +21,7 @@ export async function dataList(req: Request, res: Response) {
dbDriver: await NcConnectionMgrv2.get(base),
});
const requestObj = await getAst({
const { ast } = await getAst({
query: req.query,
model,
view,
@ -36,7 +36,7 @@ export async function dataList(req: Request, res: Response) {
} catch (e) {}
const data = await nocoExecute(
requestObj,
ast,
await baseModel.list(listArgs),
{},
listArgs
@ -132,17 +132,14 @@ async function dataRead(req: Request, res: Response) {
dbDriver: await NcConnectionMgrv2.get(base),
});
const { ast } = await getAst({
query: req.query,
model,
view,
});
res.json(
await nocoExecute(
await getAst({
query: req.query,
model,
view,
}),
await baseModel.readByPk(req.params.rowId),
{},
{}
)
await nocoExecute(ast, await baseModel.readByPk(req.params.rowId), {}, {})
);
}

4
packages/nocodb/src/lib/controllers/publicControllers/publicDataExport.ctl.ts

@ -136,7 +136,7 @@ async function getDbRows(model, view: View, req: Request) {
dbDriver: await NcConnectionMgrv2.get(base),
});
const requestObj = await getAst({
const { ast } = await getAst({
query: req.query,
model,
view,
@ -159,7 +159,7 @@ async function getDbRows(model, view: View, req: Request) {
elapsed = temp[0] * 1000 + temp[1] / 1000000
) {
const rows = await nocoExecute(
requestObj,
ast,
await baseModel.list({ ...listArgs, offset, limit }),
{},
listArgs

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

@ -4,6 +4,7 @@ import DataLoader from 'dataloader';
import {
AuditOperationSubTypes,
AuditOperationTypes,
isSystemColumn,
isVirtualCol,
RelationTypes,
UITypes,
@ -38,6 +39,7 @@ import genRollupSelectv2 from './genRollupSelectv2';
import sortV2 from './sortV2';
import conditionV2 from './conditionV2';
import { sanitize, unsanitize } from './helpers/sanitize';
import type { GridViewColumn } from '../../../../models';
import type { SortType } from 'nocodb-sdk';
import type { Knex } from 'knex';
import type FormulaColumn from '../../../../models/FormulaColumn';
@ -64,6 +66,21 @@ async function populatePk(model: Model, insertObj: any) {
}
}
function checkColumnRequired(
column: Column<any>,
fields: string[],
extractPkAndPv?: boolean
) {
// if primary key or foreign key included in fields, it's required
if (column.pk || column.uidt === UITypes.ForeignKey) return true;
if (extractPkAndPv && column.pv) return true;
// check fields defined and if not, then select all
// if defined check if it is in the fields
return !fields || fields.includes(column.title);
}
/**
* Base class for models
*
@ -97,14 +114,23 @@ class BaseModelSqlv2 {
autoBind(this);
}
public async readByPk(id?: any): Promise<any> {
public async readByPk(id?: any, validateFormula = false): Promise<any> {
const qb = this.dbDriver(this.tnPath);
await this.selectObject({ qb });
await this.selectObject({ qb, validateFormula });
qb.where(_wherePk(this.model.primaryKeys, id));
const data = (await this.execAndParse(qb))?.[0];
let data;
try {
data = (await this.execAndParse(qb))?.[0];
} catch (e) {
if (validateFormula || !haveFormulaColumn(await this.model.getColumns()))
throw e;
console.log(e);
return this.readByPk(id, true);
}
if (data) {
const proto = await this.getProto();
@ -129,11 +155,12 @@ class BaseModelSqlv2 {
where?: string;
filterArr?: Filter[];
sort?: string | string[];
} = {}
} = {},
validateFormula = false
): Promise<any> {
const { where, ...rest } = this._getListArgs(args as any);
const qb = this.dbDriver(this.tnPath);
await this.selectObject({ qb });
await this.selectObject({ qb, validateFormula });
const aliasColObjMap = await this.model.getAliasColObjMap();
const sorts = extractSortsObject(rest?.sort, aliasColObjMap);
@ -162,7 +189,16 @@ class BaseModelSqlv2 {
qb.orderBy(this.model.primaryKey.column_name);
}
const data = await qb.first();
let data;
try {
data = await qb.first();
} catch (e) {
if (validateFormula || !haveFormulaColumn(await this.model.getColumns()))
throw e;
console.log(e);
return this.findOne(args, true);
}
if (data) {
const proto = await this.getProto();
@ -179,13 +215,21 @@ class BaseModelSqlv2 {
filterArr?: Filter[];
sortArr?: Sort[];
sort?: string | string[];
fieldsSet?: Set<string>;
} = {},
ignoreViewFilterAndSort = false
ignoreViewFilterAndSort = false,
validateFormula = false
): Promise<any> {
const { where, ...rest } = this._getListArgs(args as any);
const { where, fields, ...rest } = this._getListArgs(args as any);
const qb = this.dbDriver(this.tnPath);
await this.selectObject({ qb });
await this.selectObject({
qb,
fieldsSet: args.fieldsSet,
viewId: this.viewId,
validateFormula,
});
if (+rest?.shuffle) {
await this.shuffle({ qb });
}
@ -256,8 +300,17 @@ class BaseModelSqlv2 {
if (!ignoreViewFilterAndSort) applyPaginate(qb, rest);
const proto = await this.getProto();
const data = await this.execAndParse(qb);
let data;
try {
data = await this.execAndParse(qb);
} catch (e) {
if (validateFormula || !haveFormulaColumn(await this.model.getColumns()))
throw e;
console.log(e);
return this.list(args, ignoreViewFilterAndSort, true);
}
return data?.map((d) => {
d.__proto__ = proto;
return d;
@ -379,7 +432,10 @@ class BaseModelSqlv2 {
return await qb;
}
async multipleHmList({ colId, ids }, args: { limit?; offset? } = {}) {
async multipleHmList(
{ colId, ids },
args: { limit?; offset?; fieldsSet?: Set<string> } = {}
) {
try {
const { where, sort, ...rest } = this._getListArgs(args as any);
// todo: get only required fields
@ -407,7 +463,11 @@ class BaseModelSqlv2 {
const parentTn = this.getTnPath(parentTable);
const qb = this.dbDriver(childTn);
await childModel.selectObject({ qb });
await childModel.selectObject({
qb,
extractPkAndPv: true,
fieldsSet: args.fieldsSet,
});
await this.applySortAndFilter({ table: childTable, where, qb, sort });
const childQb = this.dbDriver.queryBuilder().from(
@ -435,6 +495,8 @@ class BaseModelSqlv2 {
.as('list')
);
// console.log(childQb.toQuery())
const children = await this.execAndParse(childQb, childTable);
const proto = await (
await Model.getBaseModelSQL({
@ -520,7 +582,10 @@ class BaseModelSqlv2 {
}
}
async hmList({ colId, id }, args: { limit?; offset? } = {}) {
async hmList(
{ colId, id },
args: { limit?; offset?; fieldSet?: Set<string> } = {}
) {
try {
const { where, sort, ...rest } = this._getListArgs(args as any);
// todo: get only required fields
@ -560,7 +625,7 @@ class BaseModelSqlv2 {
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
await childModel.selectObject({ qb });
await childModel.selectObject({ qb, fieldsSet: args.fieldSet });
const children = await this.execAndParse(qb, childTable);
@ -619,7 +684,7 @@ class BaseModelSqlv2 {
public async multipleMmList(
{ colId, parentIds },
args: { limit?; offset? } = {}
args: { limit?; offset?; fieldsSet?: Set<string> } = {}
) {
const { where, sort, ...rest } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
@ -652,7 +717,7 @@ class BaseModelSqlv2 {
const qb = this.dbDriver(rtn).join(vtn, `${vtn}.${vrcn}`, `${rtn}.${rcn}`);
await childModel.selectObject({ qb });
await childModel.selectObject({ qb, fieldsSet: args.fieldsSet });
await this.applySortAndFilter({ table: childTable, where, qb, sort });
@ -698,7 +763,10 @@ class BaseModelSqlv2 {
return parentIds.map((id) => gs[id] || []);
}
public async mmList({ colId, parentId }, args: { limit?; offset? } = {}) {
public async mmList(
{ colId, parentId },
args: { limit?; offset?; fieldsSet?: Set<string> } = {}
) {
const { where, sort, ...rest } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId
@ -738,7 +806,7 @@ class BaseModelSqlv2 {
.where(_wherePk(parentTable.primaryKeys, parentId))
);
await childModel.selectObject({ qb });
await childModel.selectObject({ qb, fieldsSet: args.fieldsSet });
await this.applySortAndFilter({ table: childTable, where, qb, sort });
@ -1212,7 +1280,11 @@ class BaseModelSqlv2 {
});
}
private async getSelectQueryBuilderForFormula(column: Column<any>) {
private async getSelectQueryBuilderForFormula(
column: Column<any>,
tableAlias?: string,
validateFormula = false
) {
const formula = await column.getColOptions<FormulaColumn>();
if (formula.error) throw new Error(`Formula error: ${formula.error}`);
const qb = await formulaQueryBuilderv2(
@ -1220,7 +1292,10 @@ class BaseModelSqlv2 {
null,
this.dbDriver,
this.model,
column
column,
{},
tableAlias,
validateFormula
);
return qb;
}
@ -1364,6 +1439,7 @@ class BaseModelSqlv2 {
{
// limit: ids.length,
where: `(${pCol.column_name},in,${ids.join(',')})`,
fieldsSet: (readLoader as any).args?.fieldsSet,
},
true
);
@ -1376,13 +1452,15 @@ class BaseModelSqlv2 {
});
// defining HasMany count method within GQL Type class
proto[column.title] = async function () {
proto[column.title] = async function (args?: any) {
if (
this?.[cCol?.title] === null ||
this?.[cCol?.title] === undefined
)
return null;
(readLoader as any).args = args;
return await readLoader.load(this?.[cCol?.title]);
};
// todo : handle mm
@ -1410,7 +1488,7 @@ class BaseModelSqlv2 {
this.config.limitMin
);
obj.offset = Math.max(+(args.offset || args.o) || 0, 0);
obj.fields = args.fields || args.f || '*';
obj.fields = args.fields || args.f;
obj.sort = args.sort || args.s;
return obj;
}
@ -1425,16 +1503,70 @@ class BaseModelSqlv2 {
}
}
// todo:
// pass view id as argument
// add option to get only pk and pv
public async selectObject({
qb,
columns: _columns,
fields: _fields,
extractPkAndPv,
viewId,
fieldsSet,
alias,
validateFormula,
}: {
fieldsSet?: Set<string>;
qb: Knex.QueryBuilder;
columns?: Column[];
fields?: string[] | string;
extractPkAndPv?: boolean;
viewId?: string;
alias?: string;
validateFormula?: boolean;
}): Promise<void> {
let viewOrTableColumns: Column[] | { fk_column_id?: string }[];
const res = {};
const columns = _columns ?? (await this.model.getColumns());
for (const column of columns) {
let view: View;
let fields: string[];
if (fieldsSet?.size) {
viewOrTableColumns = _columns || (await this.model.getColumns());
} else {
view = await View.get(viewId);
const viewColumns = viewId && (await View.getColumns(viewId));
fields = Array.isArray(_fields) ? _fields : _fields?.split(',');
// const columns = _columns ?? (await this.model.getColumns());
// for (const column of columns) {
viewOrTableColumns =
_columns || viewColumns || (await this.model.getColumns());
}
for (const viewOrTableColumn of viewOrTableColumns) {
const column =
viewOrTableColumn instanceof Column
? viewOrTableColumn
: await Column.get({
colId: (viewOrTableColumn as GridViewColumn).fk_column_id,
});
// hide if column marked as hidden in view
// of if column is system field and system field is hidden
if (
fieldsSet
? !fieldsSet.has(column.title)
: !extractPkAndPv &&
!(viewOrTableColumn instanceof Column) &&
(!(viewOrTableColumn as GridViewColumn)?.show ||
(!view?.show_system_fields &&
column.uidt !== UITypes.ForeignKey &&
!column.pk &&
isSystemColumn(column)))
)
continue;
if (!checkColumnRequired(column, fields, extractPkAndPv)) continue;
switch (column.uidt) {
case 'LinkToAnotherRecord':
case 'Lookup':
@ -1454,7 +1586,9 @@ class BaseModelSqlv2 {
case UITypes.Formula:
try {
const selectQb = await this.getSelectQueryBuilderForFormula(
qrValueColumn
qrValueColumn,
alias,
validateFormula
);
qb.select({
[column.column_name]: selectQb.builder,
@ -1486,7 +1620,9 @@ class BaseModelSqlv2 {
case UITypes.Formula:
try {
const selectQb = await this.getSelectQueryBuilderForFormula(
barcodeValueColumn
barcodeValueColumn,
alias,
validateFormula
);
qb.select({
[column.column_name]: selectQb.builder,
@ -1509,7 +1645,9 @@ class BaseModelSqlv2 {
{
try {
const selectQb = await this.getSelectQueryBuilderForFormula(
column
column,
alias,
validateFormula
);
qb.select(
this.dbDriver.raw(`?? as ??`, [
@ -1517,7 +1655,8 @@ class BaseModelSqlv2 {
sanitize(column.title),
])
);
} catch {
} catch (e) {
console.log(e);
// return dummy select
qb.select(
this.dbDriver.raw(`'ERR' as ??`, [sanitize(column.title)])
@ -1532,6 +1671,7 @@ class BaseModelSqlv2 {
// tn: this.title,
knex: this.dbDriver,
// column,
alias,
columnOptions: (await column.getColOptions()) as RollupColumn,
})
).builder.as(sanitize(column.title))
@ -1539,7 +1679,7 @@ class BaseModelSqlv2 {
break;
default:
res[sanitize(column.title || column.column_name)] = sanitize(
`${this.model.table_name}.${column.column_name}`
`${alias || this.model.table_name}.${column.column_name}`
);
break;
}
@ -2659,7 +2799,7 @@ class BaseModelSqlv2 {
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
await this.selectObject({ qb });
await this.selectObject({ qb, extractPkAndPv: true });
// todo: refactor and move to a method (applyFilterAndSort)
const aliasColObjMap = await this.model.getAliasColObjMap();
@ -3077,4 +3217,8 @@ function getCompositePk(primaryKeys: Column[], row) {
return primaryKeys.map((c) => row[c.title]).join('___');
}
function haveFormulaColumn(columns: Column[]) {
return columns.some((c) => c.uidt === UITypes.Formula);
}
export { BaseModelSqlv2 };

69
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulav2/formulaQueryBuilderv2.ts

@ -53,7 +53,8 @@ async function _formulaQueryBuilder(
alias,
knex: XKnex,
model: Model,
aliasToColumn = {}
aliasToColumn = {},
tableAlias?: string
) {
// formula may include double curly brackets in previous version
// convert to single curly bracket here for compatibility
@ -74,7 +75,8 @@ async function _formulaQueryBuilder(
alias,
knex,
model,
{ ...aliasToColumn, [col.id]: null }
{ ...aliasToColumn, [col.id]: null },
tableAlias
);
builder.sql = '(' + builder.sql + ')';
aliasToColumn[col.id] = builder;
@ -104,7 +106,9 @@ async function _formulaQueryBuilder(
selectQb = knex(`${parentModel.table_name} as ${alias}`).where(
`${alias}.${parentColumn.column_name}`,
knex.raw(`??`, [
`${childModel.table_name}.${childColumn.column_name}`,
`${tableAlias ?? childModel.table_name}.${
childColumn.column_name
}`,
])
);
break;
@ -113,7 +117,9 @@ async function _formulaQueryBuilder(
selectQb = knex(`${childModel.table_name} as ${alias}`).where(
`${alias}.${childColumn.column_name}`,
knex.raw(`??`, [
`${parentModel.table_name}.${parentColumn.column_name}`,
`${tableAlias ?? parentModel.table_name}.${
parentColumn.column_name
}`,
])
);
break;
@ -134,7 +140,9 @@ async function _formulaQueryBuilder(
.where(
`${assocAlias}.${mmChildColumn.column_name}`,
knex.raw(`??`, [
`${childModel.table_name}.${childColumn.column_name}`,
`${tableAlias ?? childModel.table_name}.${
childColumn.column_name
}`,
])
);
}
@ -402,6 +410,7 @@ async function _formulaQueryBuilder(
const qb = await genRollupSelectv2({
knex,
columnOptions: (await col.getColOptions()) as RollupColumn,
alias: tableAlias,
});
aliasToColumn[col.id] = knex.raw(qb.builder).wrap('(', ')');
}
@ -428,7 +437,9 @@ async function _formulaQueryBuilder(
.where(
`${parentModel.table_name}.${parentColumn.column_name}`,
knex.raw(`??`, [
`${childModel.table_name}.${childColumn.column_name}`,
`${tableAlias ?? childModel.table_name}.${
childColumn.column_name
}`,
])
);
} else if (relation.type == 'hm') {
@ -437,7 +448,9 @@ async function _formulaQueryBuilder(
.where(
`${childModel.table_name}.${childColumn.column_name}`,
knex.raw(`??`, [
`${parentModel.table_name}.${parentColumn.column_name}`,
`${tableAlias ?? parentModel.table_name}.${
parentColumn.column_name
}`,
])
);
@ -490,7 +503,9 @@ async function _formulaQueryBuilder(
.where(
`${mmModel.table_name}.${mmChildColumn.column_name}`,
knex.raw(`??`, [
`${childModel.table_name}.${childColumn.column_name}`,
`${tableAlias ?? childModel.table_name}.${
childColumn.column_name
}`,
])
);
selectQb = (fn) =>
@ -775,18 +790,27 @@ async function _formulaQueryBuilder(
return { builder: fn(tree, alias) };
}
function getTnPath(tb: Model, knex) {
function getTnPath(tb: Model, knex, tableAlias?: string) {
const schema = knex.searchPath?.();
if (knex.clientType() === 'mssql' && schema) {
return knex.raw('??.??', [schema, tb.table_name]);
} else if (knex.clientType() === 'snowflake') {
return [
knex.client.config.connection.database,
knex.client.config.connection.schema,
return knex.raw(`??.??${tableAlias ? ' as ??' : ''}`, [
schema,
tb.table_name,
].join('.');
...(tableAlias ? [tableAlias] : []),
]);
} else if (knex.clientType() === 'snowflake') {
return (
[
knex.client.config.connection.database,
knex.client.config.connection.schema,
tb.table_name,
].join('.') + (tableAlias ? ` as ${tableAlias}` : '')
);
} else {
return tb.table_name;
return knex.raw(`??${tableAlias ? ' as ??' : ''}`, [
tb.table_name,
...(tableAlias ? [tableAlias] : []),
]);
}
}
@ -796,7 +820,9 @@ export default async function formulaQueryBuilderv2(
knex: XKnex,
model: Model,
column?: Column,
aliasToColumn = {}
aliasToColumn = {},
tableAlias?: string,
validateFormula = false
) {
// register jsep curly hook once only
jsep.plugins.register(jsepCurlyHook);
@ -806,13 +832,18 @@ export default async function formulaQueryBuilderv2(
alias,
knex,
model,
aliasToColumn
aliasToColumn,
tableAlias
);
if (!validateFormula) return qb;
try {
// dry run qb.builder to see if it will break the grid view or not
// if so, set formula error and show empty selectQb instead
await knex(getTnPath(model, knex)).select(qb.builder).as('dry-run-only');
await knex(getTnPath(model, knex, tableAlias))
.select(qb.builder)
.as('dry-run-only');
// if column is provided, i.e. formula has been created
if (column) {

155
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/helpers/getAst.ts

@ -1,7 +1,11 @@
import { isSystemColumn, UITypes } from 'nocodb-sdk';
import View from '../../../../../models/View';
import type Model from '../../../../../models/Model';
import type LinkToAnotherRecordColumn from '../../../../../models/LinkToAnotherRecordColumn';
import { isSystemColumn, RelationTypes, UITypes } from 'nocodb-sdk';
import { View } from '../../../../../models';
import type {
Column,
LinkToAnotherRecordColumn,
LookupColumn,
Model,
} from '../../../../../models';
const getAst = async ({
query,
@ -9,23 +13,40 @@ const getAst = async ({
includePkByDefault = true,
model,
view,
dependencyFields = {
...(query || {}),
nested: { ...(query?.nested || {}) },
fieldsSet: new Set(),
},
}: {
query?: RequestQuery;
extractOnlyPrimaries?: boolean;
includePkByDefault?: boolean;
model: Model;
view?: View;
dependencyFields?: DependantFields;
}) => {
// set default values of dependencyFields and nested
dependencyFields.nested = dependencyFields.nested || {};
dependencyFields.fieldsSet = dependencyFields.fieldsSet || new Set();
if (!model.columns?.length) await model.getColumns();
// extract only pk and pv
if (extractOnlyPrimaries) {
return {
const ast = {
...(model.primaryKeys
? model.primaryKeys.reduce((o, pk) => ({ ...o, [pk.title]: 1 }), {})
: {}),
...(model.displayValue ? { [model.displayValue.title]: 1 } : {}),
};
await Promise.all(
model.primaryKeys.map((c) => extractDependencies(c, dependencyFields))
);
await extractDependencies(model.displayValue, dependencyFields);
return { ast, dependencyFields };
}
let fields = query?.fields || query?.f;
@ -45,7 +66,7 @@ const getAst = async ({
{}
);
return model.columns.reduce(async (obj, col) => {
const ast = await model.columns.reduce(async (obj, col) => {
let value: number | boolean | { [key: string]: any } = 1;
const nestedFields =
query?.nested?.[col.title]?.fields || query?.nested?.[col.title]?.f;
@ -55,10 +76,19 @@ const getAst = async ({
.getColOptions<LinkToAnotherRecordColumn>()
.then((colOpt) => colOpt.getRelatedTable());
value = await getAst({
const { ast } = await getAst({
model,
query: query?.nested?.[col.title],
dependencyFields: (dependencyFields.nested[col.title] =
dependencyFields.nested[col.title] || {
nested: {},
fieldsSet: new Set(),
}),
});
value = ast;
// todo: include field relative to the relation => pk / fk
} else {
value = (Array.isArray(fields) ? fields : fields.split(',')).reduce(
(o, f) => ({ ...o, [f]: 1 }),
@ -70,26 +100,104 @@ const getAst = async ({
.getColOptions<LinkToAnotherRecordColumn>()
.then((colOpt) => colOpt.getRelatedTable());
value = await getAst({
model,
query: query?.nested?.[col.title],
extractOnlyPrimaries: nestedFields !== '*',
});
value = (
await getAst({
model,
query: query?.nested?.[col.title],
extractOnlyPrimaries: nestedFields !== '*',
dependencyFields: (dependencyFields.nested[col.title] =
dependencyFields.nested[col.title] || {
nested: {},
fieldsSet: new Set(),
}),
})
).ast;
}
const isRequested =
allowedCols && (!includePkByDefault || !col.pk)
? allowedCols[col.id] &&
(!isSystemColumn(col) || view.show_system_fields) &&
(!fields?.length || fields.includes(col.title)) &&
value
: fields?.length
? fields.includes(col.title) && value
: value;
if (isRequested || col.pk) await extractDependencies(col, dependencyFields);
return {
...(await obj),
[col.title]:
allowedCols && (!includePkByDefault || !col.pk)
? allowedCols[col.id] &&
(!isSystemColumn(col) || view.show_system_fields) &&
(!fields?.length || fields.includes(col.title)) &&
value
: fields?.length
? fields.includes(col.title) && value
: value,
[col.title]: isRequested,
};
}, Promise.resolve({}));
return { ast, dependencyFields };
};
const extractDependencies = async (
column: Column,
dependencyFields: DependantFields = {
nested: {},
fieldsSet: new Set(),
}
) => {
switch (column.uidt) {
case UITypes.Lookup:
await extractLookupDependencies(column, dependencyFields);
break;
case UITypes.LinkToAnotherRecord:
await extractRelationDependencies(column, dependencyFields);
break;
default:
dependencyFields.fieldsSet.add(column.title);
break;
}
};
const extractLookupDependencies = async (
lookUpColumn: Column<LookupColumn>,
dependencyFields: DependantFields = {
nested: {},
fieldsSet: new Set(),
}
) => {
const lookupColumnOpts = await lookUpColumn.getColOptions();
const relationColumn = await lookupColumnOpts.getRelationColumn();
await extractRelationDependencies(relationColumn, dependencyFields);
await extractDependencies(
await lookupColumnOpts.getLookupColumn(),
(dependencyFields.nested[relationColumn.title] = dependencyFields.nested[
relationColumn.title
] || {
nested: {},
fieldsSet: new Set(),
})
);
};
const extractRelationDependencies = async (
relationColumn: Column<LinkToAnotherRecordColumn>,
dependencyFields: DependantFields = {
nested: {},
fieldsSet: new Set(),
}
) => {
const relationColumnOpts = await relationColumn.getColOptions();
switch (relationColumnOpts.type) {
case RelationTypes.HAS_MANY:
dependencyFields.fieldsSet.add(
await relationColumnOpts.getParentColumn().then((col) => col.title)
);
break;
case RelationTypes.BELONGS_TO:
case RelationTypes.MANY_TO_MANY:
dependencyFields.fieldsSet.add(
await relationColumnOpts.getChildColumn().then((col) => col.title)
);
break;
}
};
type RequestQuery = {
@ -100,4 +208,9 @@ type RequestQuery = {
};
};
interface DependantFields {
fieldsSet?: Set<string>;
nested?: DependantFields;
}
export default getAst;

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

@ -406,7 +406,7 @@ export default class Column<T = any> implements ColumnType {
}
}
public async getColOptions<T>(ncMeta = Noco.ncMeta): Promise<T> {
public async getColOptions<U = T>(ncMeta = Noco.ncMeta): Promise<U> {
let res: any;
switch (this.uidt) {

22
packages/nocodb/src/lib/services/column.svc.ts

@ -129,7 +129,16 @@ export async function columnUpdate(param: {
try {
// test the query to see if it is valid in db level
const dbDriver = await NcConnectionMgrv2.get(base);
await formulaQueryBuilderv2(colBody.formula, null, dbDriver, table);
await formulaQueryBuilderv2(
colBody.formula,
null,
dbDriver,
table,
null,
{},
null,
true
);
} catch (e) {
console.error(e);
NcError.badRequest('Invalid Formula');
@ -934,7 +943,16 @@ export async function columnAdd(param: {
try {
// test the query to see if it is valid in db level
const dbDriver = await NcConnectionMgrv2.get(base);
await formulaQueryBuilderv2(colBody.formula, null, dbDriver, table);
await formulaQueryBuilderv2(
colBody.formula,
null,
dbDriver,
table,
null,
{},
null,
true
);
} catch (e) {
console.error(e);
NcError.badRequest('Invalid Formula');

15
packages/nocodb/src/lib/services/dbData/helpers.ts

@ -242,14 +242,15 @@ export async function getDbRows(param: {
temp = process.hrtime(startTime),
elapsed = temp[0] * 1000 + temp[1] / 1000000
) {
const { ast, dependencyFields } = await getAst({
query: query,
includePkByDefault: false,
model: view.model,
view,
});
const rows = await nocoExecute(
await getAst({
query: query,
includePkByDefault: false,
model: view.model,
view,
}),
await baseModel.list({ ...listArgs, offset, limit }),
ast,
await baseModel.list({ ...listArgs, ...dependencyFields, offset, limit }),
{},
query
);

46
packages/nocodb/src/lib/services/dbData/index.ts

@ -114,9 +114,9 @@ export async function getDataList(param: {
dbDriver: await NcConnectionMgrv2.get(base),
});
const requestObj = await getAst({ model, query, view });
const { ast, dependencyFields } = await getAst({ model, query, view });
const listArgs: any = { ...query };
const listArgs: any = dependencyFields;
try {
listArgs.filterArr = JSON.parse(listArgs.filterArrJson);
} catch (e) {}
@ -127,12 +127,7 @@ export async function getDataList(param: {
let data = [];
let count = 0;
try {
data = await nocoExecute(
requestObj,
await baseModel.list(listArgs),
{},
listArgs
);
data = await nocoExecute(ast, await baseModel.list(listArgs), {}, listArgs);
count = await baseModel.count(listArgs);
} catch (e) {
console.log(e);
@ -170,15 +165,10 @@ export async function getFindOne(param: {
args.sortArr = JSON.parse(args.sortArrJson);
} catch (e) {}
const data = await baseModel.findOne(args);
return data
? await nocoExecute(
await getAst({ model, query: args, view }),
data,
{},
{}
)
: {};
const { ast, dependencyFields } = await getAst({ model, query: args, view });
const data = await baseModel.findOne({ ...args, dependencyFields });
return data ? await nocoExecute(ast, data, {}, {}) : {};
}
export async function getDataGroupBy(param: {
@ -225,12 +215,9 @@ export async function dataRead(
NcError.notFound('Row not found');
}
return await nocoExecute(
await getAst({ model, query: param.query, view }),
row,
{},
param.query
);
const { ast } = await getAst({ model, query: param.query, view });
return await nocoExecute(ast, row, {}, param.query);
}
export async function dataExist(
@ -279,7 +266,7 @@ export async function getGroupedDataList(param: {
dbDriver: await NcConnectionMgrv2.get(base),
});
const requestObj = await getAst({ model, query, view });
const { ast } = await getAst({ model, query, view });
const listArgs: any = { ...query };
try {
@ -298,12 +285,7 @@ export async function getGroupedDataList(param: {
...listArgs,
groupColumnId: param.columnId,
});
data = await nocoExecute(
{ key: 1, value: requestObj },
groupedData,
{},
listArgs
);
data = await nocoExecute({ key: 1, value: ast }, groupedData, {}, listArgs);
const countArr = await baseModel.groupedListCount({
...listArgs,
groupColumnId: param.columnId,
@ -650,8 +632,10 @@ export async function dataReadByViewId(param: {
dbDriver: await NcConnectionMgrv2.get(base),
});
const { ast } = await getAst({ model, query: param.query });
return await nocoExecute(
await getAst({ model, query: param.query }),
ast,
await baseModel.readByPk(param.rowId),
{},
{}

33
packages/nocodb/src/lib/services/public/publicData.svc.ts

@ -59,20 +59,20 @@ export async function dataList(param: {
let count = 0;
try {
data = await nocoExecute(
await getAst({
query: param.query,
model,
view,
}),
await baseModel.list(listArgs),
{},
listArgs
);
const { ast } = await getAst({
query: param.query,
model,
view,
});
data = await nocoExecute(ast, await baseModel.list(listArgs), {}, listArgs);
count = await baseModel.count(listArgs);
} catch (e) {
console.log(e);
// show empty result instead of throwing error here
// e.g. search some text in a numeric field
NcError.internalServerError('Please try after some time');
}
return new PagedResponseImpl(data, { ...param.query, count });
@ -128,7 +128,7 @@ async function getGroupedDataList(param: {
dbDriver: await NcConnectionMgrv2.get(base),
});
const requestObj = await getAst({ model, query: param.query, view });
const { ast } = await getAst({ model, query: param.query, view });
const listArgs: any = { ...query };
try {
@ -148,12 +148,7 @@ async function getGroupedDataList(param: {
...listArgs,
groupColumnId,
});
data = await nocoExecute(
{ key: 1, value: requestObj },
groupedData,
{},
listArgs
);
data = await nocoExecute({ key: 1, value: ast }, groupedData, {}, listArgs);
const countArr = await baseModel.groupedListCount({
...listArgs,
groupColumnId,
@ -304,7 +299,7 @@ export async function relDataList(param: {
dbDriver: await NcConnectionMgrv2.get(base),
});
const requestObj = await getAst({
const { ast } = await getAst({
query: param.query,
model,
extractOnlyPrimaries: true,
@ -314,7 +309,7 @@ export async function relDataList(param: {
let count = 0;
try {
data = data = await nocoExecute(
requestObj,
ast,
await baseModel.list(param.query),
{},
param.query

4
packages/nocodb/src/lib/services/public/publicDataExport.svc.ts

@ -46,7 +46,7 @@ export async function getDbRows(param: {
dbDriver: await NcConnectionMgrv2.get(base),
});
const requestObj = await getAst({
const { ast } = await getAst({
query: param.query,
model: param.model,
view: param.view,
@ -69,7 +69,7 @@ export async function getDbRows(param: {
elapsed = temp[0] * 1000 + temp[1] / 1000000
) {
const rows = await nocoExecute(
requestObj,
ast,
await baseModel.list({ ...listArgs, offset, limit }),
{},
listArgs

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

@ -15629,7 +15629,7 @@
"$ref": "#/components/schemas/Bool"
},
"fk_cover_image_col_id": {
"type": "string",
"$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to Cover Image Column"
},
"fk_model_id": {
@ -16498,7 +16498,7 @@
"description": "View ID"
},
"fk_cover_image_col_id": {
"$ref": "#/components/schemas/Id",
"$ref": "#/components/schemas/StringOrNull",
"description": "Cover Image Column ID"
},
"columns": {
@ -19505,6 +19505,10 @@
"$ref": "#/components/schemas/Bool",
"description": "Should show system fields in this view?"
},
"is_default": {
"$ref": "#/components/schemas/Bool",
"description": "Is this view default view for the model?"
},
"title": {
"description": "View Title",
"type": "string"

11
tests/playwright/package-lock.json generated

@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"body-parser": "^1.20.1",
"dayjs": "^1.11.7",
"express": "^4.18.2",
"nocodb-sdk": "file:../../packages/nocodb-sdk",
"xlsx": "^0.18.5"
@ -1183,6 +1184,11 @@
"node": ">= 8"
}
},
"node_modules/dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -5796,6 +5802,11 @@
"which": "^2.0.1"
}
},
"dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",

1
tests/playwright/package.json

@ -44,6 +44,7 @@
},
"dependencies": {
"body-parser": "^1.20.1",
"dayjs": "^1.11.7",
"express": "^4.18.2",
"nocodb-sdk": "file:../../packages/nocodb-sdk",
"xlsx": "^0.18.5"

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

@ -35,6 +35,7 @@ export class LinkRecord extends BasePage {
}
async select(cardTitle: string) {
await this.rootPage.waitForTimeout(100);
await this.get().locator(`.ant-card:has-text("${cardTitle}"):visible`).click();
}

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

@ -399,4 +399,20 @@ export class ColumnPageObject extends BasePage {
// close sort menu
await this.grid.toolbar.clickSort();
}
async resize(param: { src: string; dst: string }) {
const { src, dst } = param;
const [fromStack, toStack] = await Promise.all([
this.rootPage.locator(`[data-title="${src}"] >> .resizer`),
this.rootPage.locator(`[data-title="${dst}"] >> .resizer`),
]);
await fromStack.dragTo(toStack);
}
async getWidth(param: { title: string }) {
const { title } = param;
const cell = await this.rootPage.locator(`th[data-title="${title}"]`);
return await cell.evaluate(el => el.getBoundingClientRect().width);
}
}

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

@ -87,7 +87,8 @@ export class GridPage extends BasePage {
uiAction: clickOnColumnHeaderToSave,
requestUrlPathToMatch: 'api/v1/db/data/noco',
httpMethodsToMatch: ['POST'],
responseJsonMatcher: resJson => resJson?.[columnHeader] === rowValue,
// numerical types are returned in number format from the server
responseJsonMatcher: resJson => String(resJson?.[columnHeader]) === String(rowValue),
});
} else {
await clickOnColumnHeaderToSave();
@ -122,7 +123,8 @@ export class GridPage extends BasePage {
// since edit row on an empty row will emit POST request
'POST',
],
responseJsonMatcher: resJson => resJson?.[columnHeader] === value,
// numerical types are returned in number format from the server
responseJsonMatcher: resJson => String(resJson?.[columnHeader]) === String(value),
});
} else {
await clickOnColumnHeaderToSave();
@ -142,8 +144,8 @@ export class GridPage extends BasePage {
return await expect(this.get().locator(`td[data-testid="cell-Title-${index}"]`)).toHaveCount(0);
}
async deleteRow(index: number) {
await this.get().getByTestId(`cell-Title-${index}`).click({
async deleteRow(index: number, title = 'Title') {
await this.get().getByTestId(`cell-${title}-${index}`).click({
button: 'right',
});

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

@ -29,20 +29,21 @@ export class KanbanPage extends BasePage {
// todo: Implement
async addOption() {}
// todo: Implement
async dragDropCard(param: { from: string; to: string }) {
// const { from, to } = param;
// const srcStack = await this.get().locator(`.nc-kanban-stack`).nth(1);
// const dstStack = await this.get().locator(`.nc-kanban-stack`).nth(2);
// const fromCard = await srcStack.locator(`.nc-kanban-item`).nth(1);
// const toCard = await dstStack.locator(`.nc-kanban-item`).nth(1);
// const [fromCard, toCard] = await Promise.all([
// srcStack.locator(`.nc-kanban-item[data-draggable="true"]`).nth(0),
// dstStack.locator(`.nc-kanban-item[data-draggable="true"]`).nth(0),
// ]);
// const fromCard = await this.get().locator(`.nc-kanban-item`).nth(0);
// const toCard = await this.get().locator(`.nc-kanban-item`).nth(25);
// await fromCard.dragTo(toCard);
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);
console.log(await fromCard.allTextContents());
console.log(await toCard.allTextContents());
await fromCard.dragTo(toCard, {
force: true,
sourcePosition: { x: 10, y: 10 },
targetPosition: { x: 10, y: 10 },
});
}
async dragDropStack(param: { from: number; to: number }) {

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

@ -23,10 +23,24 @@ export class TreeViewPage extends BasePage {
}
async verifyVisibility({ isVisible }: { isVisible: boolean }) {
if (isVisible) {
await expect(this.get()).toBeVisible();
await this.rootPage.waitForTimeout(1000);
const domElement = await this.get();
// get width of treeview dom element
const width = (await domElement.boundingBox()).width;
// if (isVisible) {
// await expect(this.get()).toBeVisible();
// } else {
// await expect(this.get()).not.toBeVisible();
// }
// border for treeview is 1px
// if not-visible, width should be < 5;
if (!isVisible) {
expect(width).toBeLessThan(5);
} else {
await expect(this.get()).not.toBeVisible();
expect(width).toBeGreaterThan(5);
}
}

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

@ -280,7 +280,7 @@ export class CellPageObject extends BasePage {
async unlinkVirtualCell({ index, columnHeader }: CellProps) {
const cell = this.get({ index, columnHeader });
await cell.click();
await cell.locator('.nc-icon.unlink-icon').click();
await cell.locator('.unlink-icon').first().click();
}
async verifyRoleAccess(param: { role: string }) {

46
tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts

@ -15,16 +15,33 @@ export class ToolbarFieldsPage extends BasePage {
}
// todo: Click and toggle are similar method. Remove one of them
async toggle({ title, isLocallySaved }: { title: string; isLocallySaved?: boolean }) {
async toggle({
title,
isLocallySaved,
validateResponse = true,
}: {
title: string;
isLocallySaved?: boolean;
validateResponse?: boolean;
}) {
await this.toolbar.clickFields();
// hack
await this.rootPage.waitForTimeout(100);
const toggleColumn = () =>
this.get().locator(`[data-testid="nc-fields-menu-${title}"]`).locator('input[type="checkbox"]').click();
await this.waitForResponse({
uiAction: toggleColumn,
requestUrlPathToMatch: isLocallySaved ? '/api/v1/db/public/' : '/api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
});
if (validateResponse) {
await this.waitForResponse({
uiAction: toggleColumn,
requestUrlPathToMatch: isLocallySaved ? '/api/v1/db/public/' : '/api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
});
} else {
await toggleColumn();
}
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.clickFields();
}
@ -78,4 +95,21 @@ export class ToolbarFieldsPage extends BasePage {
});
await this.toolbar.clickFields();
}
async getFieldsTitles() {
let fields: string[] = await this.rootPage.locator(`.nc-grid-header .name`).allTextContents();
return fields;
}
async dragDropFields(param: { from: number; to: number }) {
await this.toolbar.clickFields();
const { from, to } = param;
const [fromStack, toStack] = await Promise.all([
this.get().locator(`.cursor-move`).nth(from),
this.get().locator(`.cursor-move`).nth(to),
]);
await fromStack.dragTo(toStack);
await this.toolbar.clickFields();
}
}

36
tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts

@ -38,53 +38,53 @@ export class ToolbarFilterPage extends BasePage {
}
async add({
columnTitle,
opType,
opSubType,
title,
operation,
subOperation,
value,
isLocallySaved,
locallySaved = false,
dataType,
openModal = false,
}: {
columnTitle: string;
opType: string;
opSubType?: string; // for date datatype
title: string;
operation: string;
subOperation?: string; // for date datatype
value?: string;
isLocallySaved: boolean;
locallySaved?: boolean;
dataType?: string;
openModal?: boolean;
}) {
if (!openModal) await this.get().locator(`button:has-text("Add Filter")`).first().click();
const selectedField = await this.rootPage.locator('.nc-filter-field-select').textContent();
if (selectedField !== columnTitle) {
if (selectedField !== title) {
await this.rootPage.locator('.nc-filter-field-select').last().click();
await this.rootPage
.locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
.locator(`div[label="${columnTitle}"]:visible`)
.locator(`div[label="${title}"]:visible`)
.click();
}
const selectedOpType = await this.rootPage.locator('.nc-filter-operation-select').textContent();
if (selectedOpType !== opType) {
if (selectedOpType !== operation) {
await this.rootPage.locator('.nc-filter-operation-select').click();
// first() : filter list has >, >=
await this.rootPage
.locator('.nc-dropdown-filter-comp-op')
.locator(`.ant-select-item:has-text("${opType}")`)
.locator(`.ant-select-item:has-text("${operation}")`)
.first()
.click();
}
// subtype for date
if (dataType === UITypes.Date && opSubType) {
if (dataType === UITypes.Date && subOperation) {
const selectedSubType = await this.rootPage.locator('.nc-filter-sub_operation-select').textContent();
if (selectedSubType !== opSubType) {
if (selectedSubType !== subOperation) {
await this.rootPage.locator('.nc-filter-sub_operation-select').click();
// first() : filter list has >, >=
await this.rootPage
.locator('.nc-dropdown-filter-comp-sub-op')
.locator(`.ant-select-item:has-text("${opSubType}")`)
.locator(`.ant-select-item:has-text("${subOperation}")`)
.first()
.click();
}
@ -115,7 +115,7 @@ export class ToolbarFilterPage extends BasePage {
await this.rootPage.locator(`.ant-btn-primary:has-text("Ok")`).click();
break;
case UITypes.Date:
if (opSubType === 'exact date') {
if (subOperation === 'exact date') {
await this.get().locator('.nc-filter-value-select').click();
await this.rootPage.locator(`.ant-picker-dropdown:visible`);
await this.rootPage.locator(`.ant-picker-cell-inner:has-text("${value}")`).click();
@ -124,7 +124,7 @@ export class ToolbarFilterPage extends BasePage {
await this.waitForResponse({
uiAction: fillFilter,
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
});
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.parent.waitLoading();
@ -174,7 +174,7 @@ export class ToolbarFilterPage extends BasePage {
await this.waitForResponse({
uiAction: fillFilter,
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
});
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.parent.waitLoading();

23
tests/playwright/pages/Dashboard/common/Toolbar/Sort.ts

@ -25,27 +25,19 @@ export class ToolbarSortPage extends BasePage {
).toHaveText(direction);
}
async add({
columnTitle,
isAscending,
isLocallySaved,
}: {
columnTitle: string;
isAscending: boolean;
isLocallySaved: boolean;
}) {
async add({ title, ascending, locallySaved }: { title: string; ascending: boolean; locallySaved: boolean }) {
// open sort menu
await this.toolbar.clickSort();
await this.get().locator(`button:has-text("Add Sort Option")`).click();
// read content of the dropdown
const col = await this.rootPage.locator('.nc-sort-field-select').textContent();
if (col !== columnTitle) {
const col = await this.rootPage.locator('.nc-sort-field-select').last().textContent();
if (col !== title) {
await this.rootPage.locator('.nc-sort-field-select').last().click();
await this.rootPage
.locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
.locator(`div[label="${columnTitle}"]`)
.locator(`div[label="${title}"]`)
.last()
.click();
}
@ -68,14 +60,15 @@ export class ToolbarSortPage extends BasePage {
const selectSortDirection = () =>
this.rootPage
.locator('.nc-dropdown-sort-dir')
.last()
.locator('.ant-select-item')
.nth(isAscending ? 0 : 1)
.nth(ascending ? 0 : 1)
.click();
await this.waitForResponse({
uiAction: selectSortDirection,
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
});
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
// close sort menu
@ -88,7 +81,7 @@ export class ToolbarSortPage extends BasePage {
// open sort menu
await this.toolbar.clickSort();
await this.get().locator('.nc-sort-item-remove-btn').click();
await this.get().locator('.nc-sort-item-remove-btn').last().click();
// close sort menu
await this.toolbar.clickSort();

18
tests/playwright/tests/columnCheckbox.spec.ts

@ -22,10 +22,10 @@ test.describe('Checkbox - cell, filter, sort', () => {
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'checkbox',
opType: param.opType,
title: 'checkbox',
operation: param.opType,
value: param.value,
isLocallySaved: false,
locallySaved: false,
dataType: 'Checkbox',
});
await toolbar.clickFilter();
@ -94,18 +94,18 @@ test.describe('Checkbox - cell, filter, sort', () => {
// Sort column
await toolbar.sort.add({
columnTitle: 'checkbox',
isAscending: true,
isLocallySaved: false,
title: 'checkbox',
ascending: true,
locallySaved: false,
});
await validateRowArray(['1b', '1d', '1e', '1a', '1c', '1f']);
await toolbar.sort.reset();
// sort descending & validate
await toolbar.sort.add({
columnTitle: 'checkbox',
isAscending: false,
isLocallySaved: false,
title: 'checkbox',
ascending: false,
locallySaved: false,
});
await validateRowArray(['1a', '1c', '1f', '1b', '1d', '1e']);
await toolbar.sort.reset();

18
tests/playwright/tests/columnMultiSelect.spec.ts

@ -241,10 +241,10 @@ test.describe('Multi select - filters', () => {
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'MultiSelect',
opType: param.opType,
title: 'MultiSelect',
operation: param.opType,
value: param.value,
isLocallySaved: false,
locallySaved: false,
dataType: 'MultiSelect',
});
await toolbar.clickFilter();
@ -267,18 +267,18 @@ test.describe('Multi select - filters', () => {
// Sort column
await toolbar.sort.add({
columnTitle: 'MultiSelect',
isAscending: true,
isLocallySaved: false,
title: 'MultiSelect',
ascending: true,
locallySaved: false,
});
await validateRowArray(['1', '3', '4', '2', '5', '6']);
await toolbar.sort.reset();
// sort descending & validate
await toolbar.sort.add({
columnTitle: 'MultiSelect',
isAscending: false,
isLocallySaved: false,
title: 'MultiSelect',
ascending: false,
locallySaved: false,
});
await validateRowArray(['6', '5', '2', '4', '3', '1']);
await toolbar.sort.reset();

18
tests/playwright/tests/columnRating.spec.ts

@ -22,10 +22,10 @@ test.describe('Rating - cell, filter, sort', () => {
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'rating',
opType: param.opType,
title: 'rating',
operation: param.opType,
value: param.value,
isLocallySaved: false,
locallySaved: false,
dataType: 'Rating',
});
await toolbar.clickFilter();
@ -88,18 +88,18 @@ test.describe('Rating - cell, filter, sort', () => {
// Sort column
await toolbar.sort.add({
columnTitle: 'rating',
isAscending: true,
isLocallySaved: false,
title: 'rating',
ascending: true,
locallySaved: false,
});
await validateRowArray(['1b', '1d', '1e', '1f', '1c', '1a']);
await toolbar.sort.reset();
// sort descending & validate
await toolbar.sort.add({
columnTitle: 'rating',
isAscending: false,
isLocallySaved: false,
title: 'rating',
ascending: false,
locallySaved: false,
});
await validateRowArray(['1a', '1c', '1f', '1b', '1d', '1e']);
await toolbar.sort.reset();

18
tests/playwright/tests/columnSingleSelect.spec.ts

@ -150,10 +150,10 @@ test.describe('Single select - filter & sort', () => {
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'SingleSelect',
opType: param.opType,
title: 'SingleSelect',
operation: param.opType,
value: param.value,
isLocallySaved: false,
locallySaved: false,
dataType: 'SingleSelect',
});
await toolbar.clickFilter();
@ -171,18 +171,18 @@ test.describe('Single select - filter & sort', () => {
// Sort column
await toolbar.sort.add({
columnTitle: 'SingleSelect',
isAscending: true,
isLocallySaved: false,
title: 'SingleSelect',
ascending: true,
locallySaved: false,
});
await validateRowArray(['1', '3', '4', '2']);
await toolbar.sort.reset();
// sort descending & validate
await toolbar.sort.add({
columnTitle: 'SingleSelect',
isAscending: false,
isLocallySaved: false,
title: 'SingleSelect',
ascending: false,
locallySaved: false,
});
await validateRowArray(['2', '4', '3', '1']);
await toolbar.sort.reset();

6
tests/playwright/tests/expandedFormUrl.spec.ts

@ -151,10 +151,10 @@ test.describe('Expanded record duplicate & delete options', () => {
// create filter to narrow down the number of records
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'FirstName',
opType: 'is equal',
title: 'FirstName',
operation: 'is equal',
value: 'NICK',
isLocallySaved: false,
locallySaved: false,
});
await toolbar.clickFilter();

58
tests/playwright/tests/filters.spec.ts

@ -5,6 +5,7 @@ import { ToolbarPage } from '../pages/Dashboard/common/Toolbar';
import { UITypes } from 'nocodb-sdk';
import { Api } from 'nocodb-sdk';
import { rowMixedValue } from '../setup/xcdb-records';
import dayjs from 'dayjs';
let dashboard: DashboardPage, toolbar: ToolbarPage;
let context: any;
@ -60,11 +61,11 @@ async function verifyFilter_withFixedModal(param: {
}
await toolbar.filter.add({
columnTitle: param.column,
opType: param.opType,
opSubType: param.opSubType,
title: param.column,
operation: param.opType,
subOperation: param.opSubType,
value: param.value,
isLocallySaved: false,
locallySaved: false,
dataType: param?.dataType,
openModal: true,
});
@ -90,11 +91,11 @@ async function verifyFilter(param: {
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: param.column,
opType: param.opType,
opSubType: param.opSubType,
title: param.column,
operation: param.opType,
subOperation: param.opSubType,
value: param.value,
isLocallySaved: false,
locallySaved: false,
dataType: param?.dataType,
});
await toolbar.clickFilter();
@ -654,19 +655,23 @@ test.describe('Filter Tests: Select based', () => {
// Date & Time related
//
function getUTCEpochTime(date) {
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0);
}
test.describe('Filter Tests: Date based', () => {
const today = new Date().setHours(0, 0, 0, 0);
const tomorrow = new Date(new Date().setDate(new Date().getDate() + 1)).setHours(0, 0, 0, 0);
const yesterday = new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0);
const oneWeekAgo = new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0);
const oneWeekFromNow = new Date(new Date().setDate(new Date().getDate() + 7)).setHours(0, 0, 0, 0);
const oneMonthAgo = new Date(new Date().setMonth(new Date().getMonth() - 1)).setHours(0, 0, 0, 0);
const oneMonthFromNow = new Date(new Date().setMonth(new Date().getMonth() + 1)).setHours(0, 0, 0, 0);
const daysAgo45 = new Date(new Date().setDate(new Date().getDate() - 45)).setHours(0, 0, 0, 0);
const daysFromNow45 = new Date(new Date().setDate(new Date().getDate() + 45)).setHours(0, 0, 0, 0);
const thisMonth15 = new Date(new Date().setDate(15)).setHours(0, 0, 0, 0);
const oneYearAgo = new Date(new Date().setFullYear(new Date().getFullYear() - 1)).setHours(0, 0, 0, 0);
const oneYearFromNow = new Date(new Date().setFullYear(new Date().getFullYear() + 1)).setHours(0, 0, 0, 0);
const today = getUTCEpochTime(new Date());
const tomorrow = getUTCEpochTime(new Date(new Date().setDate(new Date().getDate() + 1)));
const yesterday = getUTCEpochTime(new Date(new Date().setDate(new Date().getDate() - 1)));
const oneWeekAgo = getUTCEpochTime(new Date(new Date().setDate(new Date().getDate() - 7)));
const oneWeekFromNow = getUTCEpochTime(new Date(new Date().setDate(new Date().getDate() + 7)));
const oneMonthAgo = getUTCEpochTime(dayjs().subtract(1, 'month').toDate());
const oneMonthFromNow = getUTCEpochTime(dayjs().add(1, 'month').toDate());
const daysAgo45 = getUTCEpochTime(new Date(new Date().setDate(new Date().getDate() - 45)));
const daysFromNow45 = getUTCEpochTime(new Date(new Date().setDate(new Date().getDate() + 45)));
const thisMonth15 = getUTCEpochTime(new Date(new Date().setDate(15)));
const oneYearAgo = getUTCEpochTime(new Date(new Date().setFullYear(new Date().getFullYear() - 1)));
const oneYearFromNow = getUTCEpochTime(new Date(new Date().setFullYear(new Date().getFullYear() + 1)));
async function dateTimeBasedFilterTest(dataType, setCount) {
await dashboard.closeTab({ title: 'Team & Auth' });
@ -679,8 +684,7 @@ test.describe('Filter Tests: Date based', () => {
// records array with time set to 00:00:00; store time in unix epoch
const recordsTimeSetToZero = records.list.map(r => {
const date = new Date(r[dataType]);
date.setHours(0, 0, 0, 0);
return date.getTime();
return getUTCEpochTime(date);
});
const isFilterList = [
@ -977,11 +981,11 @@ test.describe('Filter Tests: Date based', () => {
});
test('Date : filters-1', async () => {
await dateTimeBasedFilterTest('Date', 1);
await dateTimeBasedFilterTest('Date', 0);
});
test('Date : filters-2', async () => {
await dateTimeBasedFilterTest('Date', 2);
await dateTimeBasedFilterTest('Date', 1);
});
});
@ -1271,10 +1275,10 @@ test.describe('Filter Tests: Toggle button', () => {
await toolbar.clickFilter({ networkValidation: false });
await toolbar.filter.add({
columnTitle: 'Country',
opType: 'is null',
title: 'Country',
operation: 'is null',
value: null,
isLocallySaved: false,
locallySaved: false,
dataType: 'SingleLineText',
});
await toolbar.clickFilter({ networkValidation: false });

12
tests/playwright/tests/metaSync.spec.ts

@ -258,17 +258,17 @@ test.describe('Meta sync', () => {
await dashboard.grid.toolbar.clickFields();
await dashboard.grid.toolbar.sort.add({
columnTitle: 'Col2',
isAscending: false,
isLocallySaved: false,
title: 'Col2',
ascending: false,
locallySaved: false,
});
await dashboard.grid.toolbar.clickFilter();
await dashboard.grid.toolbar.filter.add({
columnTitle: 'Col2',
opType: '>=',
title: 'Col2',
operation: '>=',
value: '5',
isLocallySaved: false,
locallySaved: false,
});
await dashboard.grid.toolbar.clickFilter();

8
tests/playwright/tests/toolbarOperations.spec.ts

@ -48,7 +48,7 @@ test.describe('Toolbar operations (GRID)', () => {
await validateFirstRow('Afghanistan');
// Sort column
await toolbar.sort.add({ columnTitle: 'Country', isAscending: false, isLocallySaved: false });
await toolbar.sort.add({ title: 'Country', ascending: false, locallySaved: false });
await validateFirstRow('Zambia');
// reset sort
@ -58,10 +58,10 @@ test.describe('Toolbar operations (GRID)', () => {
// Filter column
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'Country',
title: 'Country',
value: 'India',
opType: 'is equal',
isLocallySaved: false,
operation: 'is equal',
locallySaved: false,
});
await toolbar.clickFilter();

684
tests/playwright/tests/undo-redo.spec.ts

@ -0,0 +1,684 @@
import { expect, Page, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import setup from '../setup';
import { Api, UITypes } from 'nocodb-sdk';
import { rowMixedValue } from '../setup/xcdb-records';
import { GridPage } from '../pages/Dashboard/Grid';
import { ToolbarPage } from '../pages/Dashboard/common/Toolbar';
let dashboard: DashboardPage,
grid: GridPage,
toolbar: ToolbarPage,
context: any,
api: Api<any>,
records: Record<string, any>,
table: any,
cityTable: any,
countryTable: any;
const validateResponse = false;
/**
This change provides undo/redo on multiple actions over UI.
Scope Actions
------------------------------
Row Create, Update, Delete
LTAR Link, Unlink
Fields Show/hide, Reorder
Sort Add, Update, Delete
Filters Add, Update, Delete (Excluding Filter Groups)
Row Height Update
Column width Update
View Rename
Table Rename
**/
async function undo({ page }: { page: Page }) {
const isMac = await grid.isMacOs();
if (validateResponse) {
await dashboard.grid.waitForResponse({
uiAction: () => page.keyboard.press(isMac ? 'Meta+z' : 'Control+z'),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: `/api/v1/db/data/noco/`,
responseJsonMatcher: json => json.pageInfo,
});
} else {
await page.keyboard.press(isMac ? 'Meta+z' : 'Control+z');
await page.waitForTimeout(100);
}
}
test.describe('Undo Redo', () => {
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
toolbar = dashboard.grid.toolbar;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Number',
title: 'Number',
uidt: UITypes.Number,
pv: true,
},
{
column_name: 'Decimal',
title: 'Decimal',
uidt: UITypes.Decimal,
},
{
column_name: 'Currency',
title: 'Currency',
uidt: UITypes.Currency,
},
];
try {
const project = await api.project.read(context.project.id);
table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'numberBased',
title: 'numberBased',
columns: columns,
});
const rowAttributes = [];
for (let i = 0; i < 10; i++) {
const row = {
Number: rowMixedValue(columns[1], i),
Decimal: rowMixedValue(columns[2], i),
Currency: rowMixedValue(columns[3], i),
};
rowAttributes.push(row);
}
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, rowAttributes);
records = await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 100 });
} catch (e) {
console.log(e);
}
// reload page after api calls
await page.reload();
});
async function verifyRecords(values: any[] = []) {
// inserted values
const expectedValues = [33, NaN, 456, 333, 267, 34, 8754, 3234, 44, 33, ...values];
const currentRecords: Record<string, any> = await api.dbTableRow.list('noco', context.project.id, table.id, {
fields: ['Number'],
limit: 100,
});
// verify if expectedValues are same as currentRecords
expect(currentRecords.list.map(r => parseInt(r.Number))).toEqual(expectedValues);
// verify row count
await dashboard.grid.verifyTotalRowCount({ count: expectedValues.length });
}
test('Row: Create, Update, Delete', async ({ page }) => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
// Row.Create
await grid.addNewRow({ index: 10, value: '333', columnHeader: 'Number', networkValidation: true });
await grid.addNewRow({ index: 11, value: '444', columnHeader: 'Number', networkValidation: true });
await verifyRecords([333, 444]);
// Row.Update
await grid.editRow({ index: 10, value: '555', columnHeader: 'Number', networkValidation: true });
await grid.editRow({ index: 11, value: '666', columnHeader: 'Number', networkValidation: true });
await verifyRecords([555, 666]);
// Row.Delete
await grid.deleteRow(10, 'Number');
await grid.deleteRow(10, 'Number');
await verifyRecords([]);
// Undo : Row.Delete
await undo({ page });
await verifyRecords([666]);
await undo({ page });
await verifyRecords([555, 666]);
// Undo : Row.Update
await undo({ page });
await verifyRecords([555, 444]);
await undo({ page });
await verifyRecords([333, 444]);
// Undo : Row.Create
await undo({ page });
await verifyRecords([333]);
await undo({ page });
await verifyRecords([]);
});
test('Fields: Hide, Show, Reorder', async ({ page }) => {
async function verifyFieldsOrder(fields: string[]) {
const fieldTitles = await toolbar.fields.getFieldsTitles();
expect(fieldTitles).toEqual(fields);
}
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
await verifyFieldsOrder(['Number', 'Decimal', 'Currency']);
// Hide Decimal
await toolbar.fields.toggle({ title: 'Decimal', isLocallySaved: false });
await verifyFieldsOrder(['Number', 'Currency']);
// Hide Currency
await toolbar.fields.toggle({ title: 'Currency', isLocallySaved: false });
await verifyFieldsOrder(['Number']);
// Un hide Decimal
await toolbar.fields.toggle({ title: 'Decimal', isLocallySaved: false });
await verifyFieldsOrder(['Number', 'Decimal']);
// Un hide Currency
await toolbar.fields.toggle({ title: 'Currency', isLocallySaved: false });
await verifyFieldsOrder(['Number', 'Decimal', 'Currency']);
// Undo : un hide Currency
await undo({ page });
await verifyFieldsOrder(['Number', 'Decimal']);
// Undo : un hide Decimal
await undo({ page });
await verifyFieldsOrder(['Number']);
// Undo : hide Currency
await undo({ page });
await verifyFieldsOrder(['Number', 'Currency']);
// Undo : hide Decimal
await undo({ page });
await verifyFieldsOrder(['Number', 'Decimal', 'Currency']);
// reorder test
await toolbar.fields.dragDropFields({ from: 1, to: 0 });
await verifyFieldsOrder(['Number', 'Currency', 'Decimal']);
// Undo : reorder
await undo({ page });
await verifyFieldsOrder(['Number', 'Decimal', 'Currency']);
});
test('Fields: Sort', async ({ page }) => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
async function verifyRecords({ sorted }: { sorted: boolean }) {
// inserted values
const expectedSorted = [NaN, 33, 33, 34, 44, 267, 333, 456, 3234, 8754];
const expectedUnsorted = [33, NaN, 456, 333, 267, 34, 8754, 3234, 44, 33];
const currentRecords: Record<string, any> = await api.dbTableRow.list('noco', context.project.id, table.id, {
fields: ['Number'],
limit: 100,
sort: sorted ? ['Number'] : [],
});
// verify if expectedValues are same as currentRecords
expect(currentRecords.list.map(r => parseInt(r.Number))).toEqual(sorted ? expectedSorted : expectedUnsorted);
}
await toolbar.sort.add({ title: 'Number', ascending: true, locallySaved: false });
await verifyRecords({ sorted: true });
await toolbar.sort.reset();
await verifyRecords({ sorted: false });
await undo({ page });
await verifyRecords({ sorted: true });
await undo({ page });
await verifyRecords({ sorted: false });
});
test('Fields: Filter', async ({ page }) => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
async function verifyRecords({ filtered }: { filtered: boolean }) {
// inserted values
const expectedFiltered = [33, 33];
const expectedUnfiltered = [33, NaN, 456, 333, 267, 34, 8754, 3234, 44, 33];
const currentRecords: Record<string, any> = await api.dbTableRow.list('noco', context.project.id, table.id, {
fields: ['Number'],
limit: 100,
where: filtered ? '(Number,eq,33)' : '',
});
// verify if expectedValues are same as currentRecords
expect(currentRecords.list.map(r => parseInt(r.Number))).toEqual(
filtered ? expectedFiltered : expectedUnfiltered
);
}
await toolbar.clickFilter();
await toolbar.filter.add({ title: 'Number', operation: '=', value: '33' });
await toolbar.clickFilter();
await verifyRecords({ filtered: true });
await toolbar.filter.reset();
await verifyRecords({ filtered: false });
await undo({ page });
await verifyRecords({ filtered: true });
await undo({ page });
await verifyRecords({ filtered: false });
});
test('Row height', async ({ page }) => {
async function verifyRowHeight({ height }: { height: string }) {
await dashboard.grid.rowPage.getRecordHeight(0).then(readValue => {
expect(readValue).toBe(height);
});
}
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
const timeOut = 200;
await verifyRowHeight({ height: '1.5rem' });
// set row height & verify
await toolbar.clickRowHeight();
await toolbar.rowHeight.click({ title: 'Tall' });
await new Promise(resolve => setTimeout(resolve, timeOut));
await verifyRowHeight({ height: '6rem' });
await toolbar.clickRowHeight();
await toolbar.rowHeight.click({ title: 'Medium' });
await new Promise(resolve => setTimeout(resolve, timeOut));
await verifyRowHeight({ height: '3rem' });
await undo({ page });
await new Promise(resolve => setTimeout(resolve, timeOut));
await verifyRowHeight({ height: '6rem' });
await undo({ page });
await new Promise(resolve => setTimeout(resolve, timeOut));
await verifyRowHeight({ height: '1.5rem' });
});
test('Column width', async ({ page }) => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
const originalWidth = await dashboard.grid.column.getWidth({ title: 'Number' });
await dashboard.grid.column.resize({ src: 'Number', dst: 'Decimal' });
await dashboard.rootPage.waitForTimeout(100);
const modifiedWidth = await dashboard.grid.column.getWidth({ title: 'Number' });
expect(modifiedWidth).toBeGreaterThan(originalWidth);
await undo({ page });
expect(await dashboard.grid.column.getWidth({ title: 'Number' })).toBe(originalWidth);
});
});
test.describe('Undo Redo - Table & view rename operations', () => {
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
toolbar = dashboard.grid.toolbar;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Number',
title: 'Number',
uidt: UITypes.Number,
pv: true,
},
{
column_name: 'SingleSelect',
title: 'SingleSelect',
uidt: UITypes.SingleSelect,
dtxp: "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'",
},
];
try {
const project = await api.project.read(context.project.id);
table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'selectBased',
title: 'selectBased',
columns: columns,
});
const rowAttributes = [];
for (let i = 0; i < 10; i++) {
const row = {
Number: rowMixedValue(columns[1], i),
SingleSelect: rowMixedValue(columns[2], i),
};
rowAttributes.push(row);
}
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, rowAttributes);
records = await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 100 });
} catch (e) {
console.log(e);
}
// reload page after api calls
await page.reload();
});
test('Table & View rename', async ({ page }) => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'selectBased' });
// table rename
await dashboard.treeView.renameTable({ title: 'selectBased', newTitle: 'newNameForTest' });
await dashboard.treeView.verifyTable({ title: 'newNameForTest' });
await dashboard.rootPage.waitForTimeout(100);
await undo({ page });
await dashboard.rootPage.waitForTimeout(100);
await dashboard.treeView.verifyTable({ title: 'selectBased' });
// View rename
const viewTypes = ['Grid', 'Gallery', 'Form', 'Kanban'];
for (let i = 0; i < viewTypes.length; i++) {
switch (viewTypes[i]) {
case 'Grid':
await dashboard.viewSidebar.createGridView({
title: 'Grid',
});
break;
case 'Gallery':
await dashboard.viewSidebar.createGalleryView({
title: 'Gallery',
});
break;
case 'Form':
await dashboard.viewSidebar.createFormView({
title: 'Form',
});
break;
case 'Kanban':
await dashboard.viewSidebar.createKanbanView({
title: 'Kanban',
});
break;
default:
break;
}
await dashboard.viewSidebar.renameView({ title: viewTypes[i], newTitle: 'newNameForTest' });
await dashboard.viewSidebar.verifyView({ title: 'newNameForTest', index: 1 });
await new Promise(resolve => setTimeout(resolve, 100));
await undo({ page });
await dashboard.viewSidebar.verifyView({ title: viewTypes[i], index: 1 });
await dashboard.viewSidebar.deleteView({ title: viewTypes[i] });
}
});
});
test.describe('Undo Redo - LTAR', () => {
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const cityColumns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'City',
title: 'City',
uidt: UITypes.SingleLineText,
pv: true,
},
];
const countryColumns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Country',
title: 'Country',
uidt: UITypes.SingleLineText,
pv: true,
},
];
try {
const project = await api.project.read(context.project.id);
cityTable = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'City',
title: 'City',
columns: cityColumns,
});
countryTable = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'Country',
title: 'Country',
columns: countryColumns,
});
const cityRowAttributes = [{ City: 'Mumbai' }, { City: 'Pune' }, { City: 'Delhi' }, { City: 'Bangalore' }];
await api.dbTableRow.bulkCreate('noco', context.project.id, cityTable.id, cityRowAttributes);
const countryRowAttributes = [
{ Country: 'India' },
{ Country: 'USA' },
{ Country: 'UK' },
{ Country: 'Australia' },
];
await api.dbTableRow.bulkCreate('noco', context.project.id, countryTable.id, countryRowAttributes);
// create LTAR Country has-many City
await api.dbTableColumn.create(countryTable.id, {
column_name: 'CityList',
title: 'CityList',
uidt: UITypes.LinkToAnotherRecord,
parentId: countryTable.id,
childId: cityTable.id,
type: 'hm',
});
// await api.dbTableRow.nestedAdd('noco', context.project.id, countryTable.id, '1', 'hm', 'CityList', '1');
} catch (e) {
console.log(e);
}
// reload page after api calls
await page.reload();
});
async function verifyRecords(values: any[] = []) {
// inserted values
const expectedValues = [...values];
const currentRecords: Record<string, any> = await api.dbTableRow.list('noco', context.project.id, countryTable.id, {
fields: ['CityList'],
limit: 100,
});
// verify if expectedValues array includes all the values in currentRecords
// currentRecords [ { Id: 1, City: 'Mumbai' }, { Id: 3, City: 'Delhi' } ]
// expectedValues [ 'Mumbai', 'Delhi' ]
currentRecords.list[0].CityList.forEach((record: any) => {
expect(expectedValues).toContain(record.City);
});
expect(currentRecords.list[0].CityList.length).toBe(expectedValues.length);
}
async function undo({ page, values }: { page: Page; values: string[] }) {
const isMac = await grid.isMacOs();
await dashboard.grid.waitForResponse({
uiAction: () => page.keyboard.press(isMac ? 'Meta+z' : 'Control+z'),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: `/api/v1/db/data/noco/`,
responseJsonMatcher: json => json.pageInfo,
});
await verifyRecords(values);
}
test('Row: Link, Unlink', async ({ page }) => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'Country' });
await grid.cell.inCellAdd({ index: 0, columnHeader: 'CityList' });
await dashboard.linkRecord.select('Mumbai');
await grid.cell.inCellAdd({ index: 0, columnHeader: 'CityList' });
await dashboard.linkRecord.select('Delhi');
await grid.cell.unlinkVirtualCell({ index: 0, columnHeader: 'CityList' });
await grid.cell.unlinkVirtualCell({ index: 0, columnHeader: 'CityList' });
await verifyRecords([]);
await undo({ page, values: ['Delhi'] });
await undo({ page, values: ['Mumbai', 'Delhi'] });
await undo({ page, values: ['Mumbai'] });
await undo({ page, values: [] });
});
});
test.describe('Undo Redo - Select based', () => {
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Title',
title: 'Title',
uidt: UITypes.SingleLineText,
pv: true,
},
{
column_name: 'select',
title: 'select',
uidt: UITypes.SingleSelect,
dtxp: "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'",
},
];
try {
const project = await api.project.read(context.project.id);
table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'selectSample',
title: 'selectSample',
columns,
});
const RowAttributes = [
{ Title: 'Mumbai', select: 'jan' },
{ Title: 'Pune', select: 'feb' },
{ Title: 'Delhi', select: 'mar' },
{ Title: 'Bangalore', select: 'jan' },
];
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, RowAttributes);
} catch (e) {
console.log(e);
}
// reload page after api calls
await page.reload();
});
test.skip('Kanban', async ({ page }) => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'selectSample' });
await dashboard.viewSidebar.createKanbanView({
title: 'Kanban',
});
const kanban = dashboard.kanban;
// Drag drop stack
await kanban.verifyStackOrder({
order: ['Uncategorized', 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
});
// verify drag drop stack
await kanban.dragDropStack({
from: 1, // jan
to: 2, // feb
});
await kanban.verifyStackOrder({
order: ['Uncategorized', 'feb', 'jan', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
});
// undo drag drop stack
await undo({ page });
await kanban.verifyStackOrder({
order: ['Uncategorized', 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
});
// drag drop card
await kanban.verifyCardCount({
count: [0, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
});
await kanban.dragDropCard({ from: { stack: 1, card: 0 }, to: { stack: 2, card: 0 } });
await kanban.verifyCardCount({
count: [0, 1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
});
// undo drag drop card
await undo({ page });
await kanban.verifyCardCount({
count: [0, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
});
});
});

30
tests/playwright/tests/viewGridShare.spec.ts

@ -34,17 +34,17 @@ test.describe('Shared view', () => {
await dashboard.grid.toolbar.fields.toggle({ title: 'Address2' });
// sort
await dashboard.grid.toolbar.sort.add({
columnTitle: 'District',
isAscending: false,
isLocallySaved: false,
title: 'District',
ascending: false,
locallySaved: false,
});
// filter
await dashboard.grid.toolbar.clickFilter();
await dashboard.grid.toolbar.filter.add({
columnTitle: 'Address',
title: 'Address',
value: 'Ab',
opType: 'is like',
isLocallySaved: false,
operation: 'is like',
locallySaved: false,
});
await dashboard.grid.toolbar.clickFilter();
@ -103,18 +103,18 @@ test.describe('Shared view', () => {
// create new sort & filter criteria in shared view
await sharedPage.grid.toolbar.sort.reset();
await sharedPage.grid.toolbar.sort.add({
columnTitle: 'Address',
isAscending: true,
isLocallySaved: true,
title: 'Address',
ascending: true,
locallySaved: true,
});
if (isMysql(context)) {
await sharedPage.grid.toolbar.clickFilter();
await sharedPage.grid.toolbar.filter.add({
columnTitle: 'District',
title: 'District',
value: 'Ta',
opType: 'is like',
isLocallySaved: true,
operation: 'is like',
locallySaved: true,
});
await sharedPage.grid.toolbar.clickFilter();
}
@ -198,10 +198,10 @@ test.describe('Shared view', () => {
});
await sharedPage2.grid.toolbar.clickFilter();
await sharedPage2.grid.toolbar.filter.add({
columnTitle: 'Country',
title: 'Country',
value: 'New Country',
opType: 'is like',
isLocallySaved: true,
operation: 'is like',
locallySaved: true,
});
await sharedPage2.grid.toolbar.clickFilter();

24
tests/playwright/tests/viewKanban.spec.ts

@ -119,9 +119,9 @@ test.describe('View', () => {
// verify sort
await toolbar.sort.add({
columnTitle: 'Title',
isAscending: false,
isLocallySaved: false,
title: 'Title',
ascending: false,
locallySaved: false,
});
// verify card order
const order2 = [
@ -150,10 +150,10 @@ test.describe('View', () => {
networkValidation: true,
});
await toolbar.filter.add({
columnTitle: 'Title',
opType: 'is like',
title: 'Title',
operation: 'is like',
value: 'BA',
isLocallySaved: false,
locallySaved: false,
});
await toolbar.clickFilter();
@ -193,17 +193,17 @@ test.describe('View', () => {
});
await toolbar.sort.add({
columnTitle: 'Title',
isAscending: false,
isLocallySaved: false,
title: 'Title',
ascending: false,
locallySaved: false,
});
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'Title',
opType: 'is like',
title: 'Title',
operation: 'is like',
value: 'BA',
isLocallySaved: false,
locallySaved: false,
});
await toolbar.clickFilter();

Loading…
Cancel
Save