Browse Source

Merge branch 'develop' into fix/tab-control

pull/5172/head
Wing-Kam Wong 2 years ago
parent
commit
98a50173b5
  1. 41
      .github/workflows/ci-cd.yml
  2. 6
      .github/workflows/publish-api-docs.yml
  3. 9
      .github/workflows/release-timely-executables.yml
  4. 4
      charts/nocodb/templates/pvc.yaml
  5. 4
      packages/nc-gui/assets/style.scss
  6. 7
      packages/nc-gui/components.d.ts
  7. 2
      packages/nc-gui/components/account/ResetPassword.vue
  8. 6
      packages/nc-gui/components/account/UserList.vue
  9. 2
      packages/nc-gui/components/cell/Checkbox.vue
  10. 2
      packages/nc-gui/components/cell/ClampedText.vue
  11. 1
      packages/nc-gui/components/cell/Currency.vue
  12. 155
      packages/nc-gui/components/cell/GeoData.vue
  13. 3
      packages/nc-gui/components/cell/MultiSelect.vue
  14. 13
      packages/nc-gui/components/cell/SingleSelect.vue
  15. 4
      packages/nc-gui/components/cell/Text.vue
  16. 6
      packages/nc-gui/components/cell/TextArea.vue
  17. 3
      packages/nc-gui/components/cell/attachment/utils.ts
  18. 8
      packages/nc-gui/components/dashboard/TreeView.vue
  19. 2
      packages/nc-gui/components/dashboard/settings/AppStore.vue
  20. 4
      packages/nc-gui/components/dashboard/settings/AuditTab.vue
  21. 20
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  22. 6
      packages/nc-gui/components/dashboard/settings/Metadata.vue
  23. 6
      packages/nc-gui/components/dashboard/settings/Misc.vue
  24. 3
      packages/nc-gui/components/dashboard/settings/UIAcl.vue
  25. 5
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  26. 5
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  27. 7
      packages/nc-gui/components/dlg/AirtableImport.vue
  28. 19
      packages/nc-gui/components/dlg/KeyboardShortcuts.vue
  29. 12
      packages/nc-gui/components/dlg/QuickImport.vue
  30. 11
      packages/nc-gui/components/dlg/TableCreate.vue
  31. 5
      packages/nc-gui/components/dlg/TableRename.vue
  32. 66
      packages/nc-gui/components/dlg/ViewCreate.vue
  33. 4
      packages/nc-gui/components/erd/View.vue
  34. 4
      packages/nc-gui/components/general/HelpAndSupport.vue
  35. 8
      packages/nc-gui/components/general/MiniSidebar.vue
  36. 57
      packages/nc-gui/components/general/ShortcutLabel.vue
  37. 42
      packages/nc-gui/components/shared-view/Map.vue
  38. 3
      packages/nc-gui/components/smartsheet/ApiSnippet.vue
  39. 19
      packages/nc-gui/components/smartsheet/Cell.vue
  40. 7
      packages/nc-gui/components/smartsheet/Grid.vue
  41. 266
      packages/nc-gui/components/smartsheet/Map.vue
  42. 12
      packages/nc-gui/components/smartsheet/Toolbar.vue
  43. 18
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  44. 16
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  45. 4
      packages/nc-gui/components/smartsheet/column/LookupOptions.vue
  46. 4
      packages/nc-gui/components/smartsheet/column/RollupOptions.vue
  47. 2
      packages/nc-gui/components/smartsheet/column/SpecificDBTypeOptions.vue
  48. 83
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  49. 148
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  50. 18
      packages/nc-gui/components/smartsheet/header/Cell.vue
  51. 11
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  52. 24
      packages/nc-gui/components/smartsheet/header/Menu.vue
  53. 216
      packages/nc-gui/components/smartsheet/sidebar/MenuBottom.vue
  54. 5
      packages/nc-gui/components/smartsheet/sidebar/index.vue
  55. 15
      packages/nc-gui/components/smartsheet/sidebar/toolbar/GeodataSwitcher.vue
  56. 4
      packages/nc-gui/components/smartsheet/sidebar/toolbar/index.vue
  57. 107
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  58. 3
      packages/nc-gui/components/smartsheet/toolbar/ExportSubActions.vue
  59. 5
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  60. 13
      packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue
  61. 113
      packages/nc-gui/components/smartsheet/toolbar/MappedBy.vue
  62. 3
      packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue
  63. 4
      packages/nc-gui/components/smartsheet/toolbar/RowHeight.vue
  64. 12
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  65. 5
      packages/nc-gui/components/smartsheet/toolbar/SharedViewList.vue
  66. 25
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  67. 58
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  68. 5
      packages/nc-gui/components/tabs/Smartsheet.vue
  69. 8
      packages/nc-gui/components/tabs/auth/ApiTokenManagement.vue
  70. 8
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  71. 14
      packages/nc-gui/components/tabs/auth/user-management/ShareBase.vue
  72. 3
      packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue
  73. 21
      packages/nc-gui/components/template/Editor.vue
  74. 35
      packages/nc-gui/components/virtual-cell/components/ListItems.vue
  75. 7
      packages/nc-gui/components/webhook/Drawer.vue
  76. 31
      packages/nc-gui/components/webhook/Editor.vue
  77. 5
      packages/nc-gui/components/webhook/List.vue
  78. 2
      packages/nc-gui/composables/useApi/interceptors.ts
  79. 7
      packages/nc-gui/composables/useAttachment.ts
  80. 5
      packages/nc-gui/composables/useColumnCreateStore.ts
  81. 12
      packages/nc-gui/composables/useExpandedFormStore.ts
  82. 12
      packages/nc-gui/composables/useGlobal/actions.ts
  83. 1
      packages/nc-gui/composables/useGlobal/state.ts
  84. 1
      packages/nc-gui/composables/useGlobal/types.ts
  85. 27
      packages/nc-gui/composables/useKanbanViewStore.ts
  86. 3
      packages/nc-gui/composables/useLTARStore.ts
  87. 179
      packages/nc-gui/composables/useMapViewDataStore.ts
  88. 4
      packages/nc-gui/composables/useMetas.ts
  89. 2
      packages/nc-gui/composables/useMultiSelect/index.ts
  90. 6
      packages/nc-gui/composables/useSelectedCellKeyupListener/index.ts
  91. 9
      packages/nc-gui/composables/useSharedFormViewStore.ts
  92. 36
      packages/nc-gui/composables/useSharedView.ts
  93. 3
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  94. 21
      packages/nc-gui/composables/useSmartsheetStore.ts
  95. 4
      packages/nc-gui/composables/useTable.ts
  96. 27
      packages/nc-gui/composables/useViewColumns.ts
  97. 26
      packages/nc-gui/composables/useViewData.ts
  98. 33
      packages/nc-gui/composables/useViewFilters.ts
  99. 5
      packages/nc-gui/composables/useViewSorts.ts
  100. 22
      packages/nc-gui/lang/ar.json
  101. Some files were not shown because too many files have changed in this diff Show More

41
.github/workflows/ci-cd.yml

@ -63,6 +63,47 @@ jobs:
- name: run unit tests - name: run unit tests
working-directory: ./packages/nocodb working-directory: ./packages/nocodb
run: npm run test:unit run: npm run test:unit
unit-tests-pg:
runs-on: ubuntu-20.04
timeout-minutes: 40
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 16.15.0
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules
with:
# npm cache files are stored in `~/.npm` on Linux/macOS
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: setup pg
working-directory: ./
run: docker-compose -f ./tests/playwright/scripts/docker-compose-playwright-pg.yml up -d &
- name: install dependencies nocodb-sdk
working-directory: ./packages/nocodb-sdk
run: npm install
- name: build nocodb-sdk
working-directory: ./packages/nocodb-sdk
run: npm run build:main
- name: Install dependencies
working-directory: ./packages/nocodb
run: npm install
- name: run unit tests
working-directory: ./packages/nocodb
run: npm run test:unit:pg
playwright-mysql-1: playwright-mysql-1:
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }} if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
uses: ./.github/workflows/playwright-test-workflow.yml uses: ./.github/workflows/playwright-test-workflow.yml

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

@ -4,7 +4,7 @@ on:
push: push:
branches: [ master ] branches: [ master ]
paths: paths:
- "scripts/sdk/swagger.json" - "packages/nocodb/src/schema/swagger.json"
release: release:
types: [ published ] types: [ published ]
@ -22,7 +22,7 @@ jobs:
env: env:
API_TOKEN_GITHUB: ${{ secrets.GH_TOKEN }} API_TOKEN_GITHUB: ${{ secrets.GH_TOKEN }}
with: with:
source_file: 'scripts/sdk/swagger.json' source_file: 'packages/nocodb/src/schema/swagger.json'
destination_repo: 'nocodb/noco-apis-doc' destination_repo: 'nocodb/noco-apis-doc'
destination_folder: 'src' destination_folder: 'src'
user_email: 'oof1lab@gmail.com' user_email: 'oof1lab@gmail.com'
@ -34,7 +34,7 @@ jobs:
env: env:
API_TOKEN_GITHUB: ${{ secrets.GH_TOKEN }} API_TOKEN_GITHUB: ${{ secrets.GH_TOKEN }}
with: with:
source_file: 'scripts/sdk/swagger.json' source_file: 'packages/nocodb/src/schema/swagger.json'
destination_repo: 'nocodb/noco-apis-doc' destination_repo: 'nocodb/noco-apis-doc'
destination_folder: 'meta-src' destination_folder: 'meta-src'
user_email: 'oof1lab@gmail.com' user_email: 'oof1lab@gmail.com'

9
.github/workflows/release-timely-executables.yml

@ -62,6 +62,10 @@ jobs:
./make.sh ./make.sh
sudo cp ./ldid /usr/local/bin sudo cp ./ldid /usr/local/bin
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Update nocodb-timely - name: Update nocodb-timely
env: env:
TAG: ${{ github.event.inputs.tag || inputs.tag }} TAG: ${{ github.event.inputs.tag || inputs.tag }}
@ -75,11 +79,6 @@ jobs:
git tag $TAG git tag $TAG
git push --tags git push --tags
- uses: actions/setup-node@v3
with:
node-version: 16
- name : Install dependencies and build executables - name : Install dependencies and build executables
run: | run: |
# install npm dependendencies # install npm dependendencies

4
charts/nocodb/templates/pvc.yaml

@ -5,10 +5,10 @@ metadata:
labels: labels:
{{- include "nocodb.selectorLabels" . | nindent 8 }} {{- include "nocodb.selectorLabels" . | nindent 8 }}
spec: spec:
accessModes:
- ReadWriteMany
resources: resources:
requests: requests:
storage: {{ .Values.storage.size }} storage: {{ .Values.storage.size }}
storageClassName: {{ .Values.storage.storageClassName }} storageClassName: {{ .Values.storage.storageClassName }}
accessModes:
{{- default (toYaml .Values.storage.accessModes) "- ReadWriteMany" | nindent 4 }}
volumeMode: Filesystem volumeMode: Filesystem

4
packages/nc-gui/assets/style.scss

@ -287,6 +287,10 @@ a {
@apply !shadow-none rounded hover:(ring-1 ring-primary ring-opacity-100) focus:(ring-1 ring-accent ring-opacity-100); @apply !shadow-none rounded hover:(ring-1 ring-primary ring-opacity-100) focus:(ring-1 ring-accent ring-opacity-100);
} }
.nc-warning-info {
@apply !shadow-none rounded ring-1 ring-red-600
}
.ant-modal { .ant-modal {
@apply !top-[50px]; @apply !top-[50px];
} }

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

@ -89,6 +89,7 @@ declare module '@vue/runtime-core' {
IcTwotoneWidthNormal: typeof import('~icons/ic/twotone-width-normal')['default'] IcTwotoneWidthNormal: typeof import('~icons/ic/twotone-width-normal')['default']
LogosGoogleGmail: typeof import('~icons/logos/google-gmail')['default'] LogosGoogleGmail: typeof import('~icons/logos/google-gmail')['default']
LogosMysqlIcon: typeof import('~icons/logos/mysql-icon')['default'] LogosMysqlIcon: typeof import('~icons/logos/mysql-icon')['default']
LogosOracle: typeof import('~icons/logos/oracle')['default']
LogosPostgresql: typeof import('~icons/logos/postgresql')['default'] LogosPostgresql: typeof import('~icons/logos/postgresql')['default']
LogosRedditIcon: typeof import('~icons/logos/reddit-icon')['default'] LogosRedditIcon: typeof import('~icons/logos/reddit-icon')['default']
LogosSnowflakeIcon: typeof import('~icons/logos/snowflake-icon')['default'] LogosSnowflakeIcon: typeof import('~icons/logos/snowflake-icon')['default']
@ -203,6 +204,8 @@ declare module '@vue/runtime-core' {
MdiLogin: typeof import('~icons/mdi/login')['default'] MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default'] MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['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'] MdiMenu: typeof import('~icons/mdi/menu')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default'] MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default'] MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default']
@ -249,8 +252,12 @@ declare module '@vue/runtime-core' {
NcIconsRowHeightMedium: typeof import('~icons/nc-icons/row-height-medium')['default'] NcIconsRowHeightMedium: typeof import('~icons/nc-icons/row-height-medium')['default']
NcIconsRowHeightShort: typeof import('~icons/nc-icons/row-height-short')['default'] NcIconsRowHeightShort: typeof import('~icons/nc-icons/row-height-short')['default']
NcIconsRowHeightTall: typeof import('~icons/nc-icons/row-height-tall')['default'] NcIconsRowHeightTall: typeof import('~icons/nc-icons/row-height-tall')['default']
PhChatTextThin: typeof import('~icons/ph/chat-text-thin')['default']
PhCloudLightningDuotone: typeof import('~icons/ph/cloud-lightning-duotone')['default'] PhCloudLightningDuotone: typeof import('~icons/ph/cloud-lightning-duotone')['default']
PhCloudLightningThin: typeof import('~icons/ph/cloud-lightning-thin')['default']
PhFileCsv: typeof import('~icons/ph/file-csv')['default'] PhFileCsv: typeof import('~icons/ph/file-csv')['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'] RiLineHeight: typeof import('~icons/ri/line-height')['default']
RiTeamFill: typeof import('~icons/ri/team-fill')['default'] RiTeamFill: typeof import('~icons/ri/team-fill')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

2
packages/nc-gui/components/account/ResetPassword.vue

@ -54,7 +54,7 @@ const passwordChange = async () => {
message.success(t('msg.success.passwordChanged')) message.success(t('msg.success.passwordChanged'))
signOut() await signOut()
navigateTo('/signin') navigateTo('/signin')
} }

6
packages/nc-gui/components/account/UserList.vue

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Modal, message } from 'ant-design-vue' import { Modal, message } from 'ant-design-vue'
import type { RequestParams, UserType } from 'nocodb-sdk' import type { OrgUserReqType, RequestParams, UserType } from 'nocodb-sdk'
import { Role, extractSdkResponseErrorMsg, useApi, useCopy, useDashboard, useNuxtApp } from '#imports' import { Role, extractSdkResponseErrorMsg, useApi, useCopy, useDashboard, useNuxtApp } from '#imports'
import type { User } from '~/lib' import type { User } from '~/lib'
@ -42,9 +42,11 @@ const loadUsers = async (page = currentPage, limit = currentLimit) => {
query: searchText.value, query: searchText.value,
}, },
} as RequestParams) } as RequestParams)
if (!response) return if (!response) return
pagination.total = response.pageInfo.totalRows ?? 0 pagination.total = response.pageInfo.totalRows ?? 0
pagination.pageSize = 10 pagination.pageSize = 10
users = response.list as UserType[] users = response.list as UserType[]
@ -59,7 +61,7 @@ const updateRole = async (userId: string, roles: Role) => {
try { try {
await api.orgUsers.update(userId, { await api.orgUsers.update(userId, {
roles, roles,
} as unknown as UserType) } as OrgUserReqType)
message.success(t('msg.success.roleUpdated')) message.success(t('msg.success.roleUpdated'))
$e('a:org-user:role-updated', { role: roles }) $e('a:org-user:role-updated', { role: roles })

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

@ -89,7 +89,7 @@ useSelectedCellKeyupListener(active, (e) => {
<style scoped lang="scss"> <style scoped lang="scss">
.nc-cell-hover-show { .nc-cell-hover-show {
opacity: 0; opacity: 0.3;
transition: 0.3s opacity; transition: 0.3s opacity;
&:hover { &:hover {

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

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

1
packages/nc-gui/components/cell/Currency.vue

@ -81,6 +81,7 @@ onMounted(() => {
@keydown.delete.stop @keydown.delete.stop
@selectstart.capture.stop @selectstart.capture.stop
@mousedown.stop @mousedown.stop
@contextmenu.stop
/> />
<span v-else-if="vModel === null && showNull" class="nc-null">NULL</span> <span v-else-if="vModel === null && showNull" class="nc-null">NULL</span>

155
packages/nc-gui/components/cell/GeoData.vue

@ -0,0 +1,155 @@
<script lang="ts" setup>
import type { GeoLocationType } from 'nocodb-sdk'
import { Modal as AModal, latLongToJoinedString, useVModel } from '#imports'
interface Props {
modelValue?: string | null
}
interface Emits {
(event: 'update:modelValue', model: GeoLocationType): void
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const vModel = useVModel(props, 'modelValue', emits)
let isExpanded = $ref(false)
let isLoading = $ref(false)
let isLocationSet = $ref(false)
const [latitude, longitude] = (vModel.value || '').split(';')
const latLongStr = computed(() => {
const [latitude, longitude] = (vModel.value || '').split(';')
if (latitude) isLocationSet = true
return latitude && longitude ? `${latitude}; ${longitude}` : 'Set location'
})
const formState = reactive({
latitude,
longitude,
})
const handleFinish = () => {
vModel.value = latLongToJoinedString(parseFloat(formState.latitude), parseFloat(formState.longitude))
isExpanded = false
}
const clear = () => {
isExpanded = false
formState.latitude = latitude
formState.longitude = longitude
}
const onClickSetCurrentLocation = () => {
isLoading = true
const onSuccess = (position) => {
const crd = position.coords
formState.latitude = crd.latitude
formState.longitude = crd.longitude
isLoading = false
}
const onError = (err) => {
console.error(`ERROR(${err.code}): ${err.message}`)
isLoading = false
}
const options = {
enableHighAccuracy: true,
timeout: 20000,
maximumAge: 2000,
}
navigator.geolocation.getCurrentPosition(onSuccess, onError, options)
}
</script>
<template>
<a-dropdown :is="isExpanded ? AModal : 'div'" v-model:visible="isExpanded" trigger="click">
<div
v-if="!isLocationSet"
class="group cursor-pointer flex gap-1 items-center mx-auto max-w-32 justify-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
>
<div class="flex items-center gap-2" data-testid="nc-geo-data-set-location-button">
<MdiMapMarker class="transform dark:(!text-white) group-hover:(!text-accent scale-120) text-gray-500 text-[0.75rem]" />
<div class="group-hover:text-primary text-gray-500 dark:text-gray-200 dark:group-hover:!text-white text-xs">
{{ latLongStr }}
</div>
</div>
</div>
<div v-else data-testid="nc-geo-data-lat-long-set">{{ latLongStr }}</div>
<template #overlay>
<a-form :model="formState" class="flex flex-col" @finish="handleFinish">
<a-form-item>
<div class="flex mt-4 items-center mx-2">
<div class="mr-2">{{ $t('labels.lat') }}:</div>
<a-input
v-model:value="formState.latitude"
data-testid="nc-geo-data-latitude"
type="number"
step="0.0000001"
:min="-90"
required
:max="90"
@keydown.stop
@selectstart.capture.stop
@mousedown.stop
/>
</div>
</a-form-item>
<a-form-item>
<div class="flex items-center mx-2">
<div class="mr-2">{{ $t('labels.lng') }}:</div>
<a-input
v-model:value="formState.longitude"
data-testid="nc-geo-data-longitude"
type="number"
step="0.0000001"
required
:min="-180"
:max="180"
@keydown.stop
@selectstart.capture.stop
@mousedown.stop
/>
</div>
</a-form-item>
<a-form-item>
<div class="flex items-center mr-2">
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin text-gray-500': isLoading }" />
<a-button class="ml-2" @click="onClickSetCurrentLocation">{{ $t('labels.yourLocation') }}</a-button>
</div>
</a-form-item>
<a-form-item>
<div class="ml-auto mr-2">
<a-button type="text" @click="clear">{{ $t('general.cancel') }}</a-button>
<a-button type="primary" html-type="submit" data-testid="nc-geo-data-save">{{ $t('general.submit') }}</a-button>
</div>
</a-form-item>
</a-form>
</template>
</a-dropdown>
</template>
<style scoped lang="scss">
input[type='number']:focus {
@apply ring-transparent;
}
input[type='number'] {
width: 180px;
}
.ant-form-item {
margin-bottom: 1rem;
}
.ant-dropdown-menu {
align-items: flex-end;
}
</style>

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

@ -32,6 +32,7 @@ interface Props {
modelValue?: string | string[] modelValue?: string | string[]
rowIndex?: number rowIndex?: number
disableOptionCreation?: boolean disableOptionCreation?: boolean
location?: 'cell' | 'filter'
} }
const { modelValue, disableOptionCreation } = defineProps<Props>() const { modelValue, disableOptionCreation } = defineProps<Props>()
@ -336,7 +337,7 @@ useEventListener(document, 'click', handleClose, true)
v-for="op of options" v-for="op of options"
:key="op.id || op.title" :key="op.id || op.title"
:value="op.title" :value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`" :data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
:class="`nc-select-option-${column.title}-${op.title}`" :class="`nc-select-option-${column.title}-${op.title}`"
@click.stop @click.stop
> >

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

@ -19,6 +19,7 @@ import {
isDrawerOrModalExist, isDrawerOrModalExist,
ref, ref,
useEventListener, useEventListener,
useProject,
useRoles, useRoles,
useSelectedCellKeyupListener, useSelectedCellKeyupListener,
watch, watch,
@ -247,12 +248,12 @@ useEventListener(document, 'click', handleClose, true)
<a-select <a-select
ref="aselect" ref="aselect"
v-model:value="vModel" v-model:value="vModel"
class="w-full" class="w-full overflow-hidden"
:class="{ 'caret-transparent': !hasEditRoles }" :class="{ 'caret-transparent': !hasEditRoles }"
:allow-clear="!column.rqd && editAllowed" :allow-clear="!column.rqd && editAllowed"
:bordered="false" :bordered="false"
:open="isOpen && (active || editable)" :open="isOpen && editAllowed"
:disabled="readOnly || !(active || editable)" :disabled="readOnly || !editAllowed"
:show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel === null))" :show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel === null))"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && (active || editable) ? 'active' : ''}`" :dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && (active || editable) ? 'active' : ''}`"
:show-search="isOpen && (active || editable)" :show-search="isOpen && (active || editable)"
@ -328,6 +329,12 @@ useEventListener(document, 'click', handleClose, true)
@apply !px-0; @apply !px-0;
} }
:deep(.ant-select-selection-search) {
// following a-select with mode = multiple | tags
// initial width will block @mouseover in Grid.vue
@apply !w-[5px];
}
:deep(.ant-select-selection-search-input) { :deep(.ant-select-selection-search-input) {
@apply !text-xs; @apply !text-xs;
} }

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

@ -14,6 +14,8 @@ const { showNull } = useGlobal()
const editEnabled = inject(EditModeInj) const editEnabled = inject(EditModeInj)
const rowHeight = inject(RowHeightInj)
const readonly = inject(ReadonlyInj, ref(false)) const readonly = inject(ReadonlyInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits) const vModel = useVModel(props, 'modelValue', emits)
@ -42,5 +44,5 @@ const focus: VNodeRef = (el) => {
<span v-else-if="vModel === null && showNull" class="nc-null">NULL</span> <span v-else-if="vModel === null && showNull" class="nc-null">NULL</span>
<LazyCellClampedText v-else :value="vModel" :lines="1" /> <LazyCellClampedText v-else :value="vModel" :lines="rowHeight" />
</template> </template>

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

@ -45,3 +45,9 @@ const focus: VNodeRef = (el) => (el as HTMLTextAreaElement)?.focus()
<span v-else>{{ vModel }}</span> <span v-else>{{ vModel }}</span>
</template> </template>
<style>
textarea:focus {
box-shadow: none;
}
</style>

3
packages/nc-gui/components/cell/attachment/utils.ts

@ -13,6 +13,7 @@ import {
isImage, isImage,
message, message,
ref, ref,
storeToRefs,
useApi, useApi,
useAttachment, useAttachment,
useFileDialog, useFileDialog,
@ -51,7 +52,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
/** for image carousel */ /** for image carousel */
const selectedImage = ref() const selectedImage = ref()
const { project } = useProject() const { project } = storeToRefs(useProject())
const { api, isLoading } = useApi() const { api, isLoading } = useApi()

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

@ -17,6 +17,7 @@ import {
reactive, reactive,
ref, ref,
resolveComponent, resolveComponent,
storeToRefs,
useDialog, useDialog,
useGlobal, useGlobal,
useNuxtApp, useNuxtApp,
@ -35,9 +36,12 @@ const { addTab, updateTab } = useTabs()
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const { bases, tables, loadTables, isSharedBase } = useProject() const projectStore = useProject()
const { activeTab } = useTabs() const { loadTables } = projectStore
const { bases, tables, isSharedBase } = storeToRefs(projectStore)
const { activeTab } = storeToRefs(useTabs())
const { deleteTable } = useTable() const { deleteTable } = useTable()

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

@ -20,7 +20,7 @@ const fetchPluginApps = async () => {
apps = plugins.map((p) => ({ apps = plugins.map((p) => ({
...p, ...p,
tags: p.tags ? p.tags.split(',') : [], tags: p.tags ? p.tags.split(',') : [],
parsedInput: p.input && JSON.parse(p.input), parsedInput: p.input && JSON.parse(p.input as string),
})) }))
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))

4
packages/nc-gui/components/dashboard/settings/AuditTab.vue

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Tooltip as ATooltip, Empty } from 'ant-design-vue' import { Tooltip as ATooltip, Empty } from 'ant-design-vue'
import type { AuditType } from 'nocodb-sdk' import type { AuditType } from 'nocodb-sdk'
import { h, onMounted, timeAgo, useGlobal, useI18n, useNuxtApp, useProject } from '#imports' import { h, onMounted, storeToRefs, timeAgo, useGlobal, useI18n, useNuxtApp, useProject } from '#imports'
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { project } = useProject() const { project } = storeToRefs(useProject())
const { t } = useI18n() const { t } = useI18n()

20
packages/nc-gui/components/dashboard/settings/DataSources.vue

@ -7,7 +7,7 @@ import Metadata from './Metadata.vue'
import UIAcl from './UIAcl.vue' import UIAcl from './UIAcl.vue'
import Erd from './Erd.vue' import Erd from './Erd.vue'
import { ClientType, DataSourcesSubTab } from '~/lib' import { ClientType, DataSourcesSubTab } from '~/lib'
import { useNuxtApp, useProject } from '#imports' import { storeToRefs, useNuxtApp, useProject } from '#imports'
interface Props { interface Props {
state: string state: string
@ -19,16 +19,24 @@ const props = defineProps<Props>()
const emits = defineEmits(['update:state', 'update:reload', 'awaken']) const emits = defineEmits(['update:state', 'update:reload', 'awaken'])
const vState = useVModel(props, 'state', emits) const vState = useVModel(props, 'state', emits)
const vReload = useVModel(props, 'reload', emits) const vReload = useVModel(props, 'reload', emits)
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const { project, loadProject } = useProject() const projectStore = useProject()
const { loadProject } = projectStore
const { project } = storeToRefs(projectStore)
let sources = $ref<BaseType[]>([]) let sources = $ref<BaseType[]>([])
let activeBaseId = $ref('') let activeBaseId = $ref('')
let metadiffbases = $ref<string[]>([]) let metadiffbases = $ref<string[]>([])
let clientType = $ref<ClientType>(ClientType.MYSQL) let clientType = $ref<ClientType>(ClientType.MYSQL)
let isReloading = $ref(false) let isReloading = $ref(false)
let forceAwakened = $ref(false) let forceAwakened = $ref(false)
async function loadBases() { async function loadBases() {
@ -38,8 +46,8 @@ async function loadBases() {
isReloading = true isReloading = true
vReload.value = true vReload.value = true
const baseList = await $api.base.list(project.value?.id) const baseList = await $api.base.list(project.value?.id)
if (baseList.bases.list && baseList.bases.list.length) { if (baseList.list && baseList.list.length) {
sources = baseList.bases.list sources = baseList.list
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@ -245,7 +253,7 @@ watch(
@click="baseAction(sources[0].id, DataSourcesSubTab.Metadata)" @click="baseAction(sources[0].id, DataSourcesSubTab.Metadata)"
> >
<div class="flex items-center gap-2 text-gray-600 font-light"> <div class="flex items-center gap-2 text-gray-600 font-light">
<a-tooltip v-if="metadiffbases.includes(sources[0].id as string)"> <a-tooltip v-if="metadiffbases.includes(sources[0].id)">
<template #title>Out of sync</template> <template #title>Out of sync</template>
<MdiDatabaseAlert class="text-lg group-hover:text-accent text-primary" /> <MdiDatabaseAlert class="text-lg group-hover:text-accent text-primary" />
</a-tooltip> </a-tooltip>
@ -314,7 +322,7 @@ watch(
@click="baseAction(base.id, DataSourcesSubTab.Metadata)" @click="baseAction(base.id, DataSourcesSubTab.Metadata)"
> >
<div class="flex items-center gap-2 text-gray-600 font-light"> <div class="flex items-center gap-2 text-gray-600 font-light">
<a-tooltip v-if="metadiffbases.includes(base.id as string)"> <a-tooltip v-if="metadiffbases.includes(base.id)">
<template #title>Out of sync</template> <template #title>Out of sync</template>
<MdiDatabaseAlert class="text-lg group-hover:text-accent text-primary" /> <MdiDatabaseAlert class="text-lg group-hover:text-accent text-primary" />
</a-tooltip> </a-tooltip>

6
packages/nc-gui/components/dashboard/settings/Metadata.vue

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Empty, extractSdkResponseErrorMsg, h, message, useI18n, useNuxtApp, useProject } from '#imports' import { Empty, extractSdkResponseErrorMsg, h, message, storeToRefs, useI18n, useNuxtApp, useProject } from '#imports'
const props = defineProps<{ const props = defineProps<{
baseId: string baseId: string
@ -9,7 +9,9 @@ const emit = defineEmits(['baseSynced'])
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { project, loadTables } = useProject() const projectStore = useProject()
const { loadTables } = projectStore
const { project } = storeToRefs(projectStore)
const { t } = useI18n() const { t } = useI18n()

6
packages/nc-gui/components/dashboard/settings/Misc.vue

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface' import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface'
import { useGlobal, useProject, watch } from '#imports' import { storeToRefs, useGlobal, useProject, watch } from '#imports'
const { includeM2M, showNull } = useGlobal() const { includeM2M, showNull } = useGlobal()
const { project, updateProject, projectMeta, loadTables, hasEmptyOrNullFilters } = useProject() const projectStore = useProject()
const { updateProject, loadTables, hasEmptyOrNullFilters } = projectStore
const { project, projectMeta } = storeToRefs(projectStore)
watch(includeM2M, async () => await loadTables()) watch(includeM2M, async () => await loadTables())

3
packages/nc-gui/components/dashboard/settings/UIAcl.vue

@ -6,6 +6,7 @@ import {
h, h,
message, message,
onMounted, onMounted,
storeToRefs,
useGlobal, useGlobal,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
@ -20,7 +21,7 @@ const { t } = useI18n()
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const { project } = useProject() const { project } = storeToRefs(useProject())
const { includeM2M } = useGlobal() const { includeM2M } = useGlobal()

5
packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue

@ -20,6 +20,7 @@ import {
projectTitleValidator, projectTitleValidator,
readFile, readFile,
ref, ref,
storeToRefs,
useApi, useApi,
useGlobal, useGlobal,
useI18n, useI18n,
@ -33,7 +34,9 @@ const emit = defineEmits(['baseCreated'])
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const { project, loadProject } = useProject() const projectStore = useProject()
const { loadProject } = projectStore
const { project } = storeToRefs(projectStore)
const useForm = Form.useForm const useForm = Form.useForm

5
packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue

@ -19,6 +19,7 @@ import {
projectTitleValidator, projectTitleValidator,
readFile, readFile,
ref, ref,
storeToRefs,
useApi, useApi,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
@ -31,7 +32,9 @@ const props = defineProps<{
const emit = defineEmits(['baseUpdated']) const emit = defineEmits(['baseUpdated'])
const { project, loadProject } = useProject() const projectStore = useProject()
const { loadProject } = projectStore
const { project } = storeToRefs(projectStore)
const useForm = Form.useForm const useForm = Form.useForm

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

@ -12,6 +12,7 @@ import {
onBeforeUnmount, onBeforeUnmount,
onMounted, onMounted,
ref, ref,
storeToRefs,
useGlobal, useGlobal,
useNuxtApp, useNuxtApp,
useProject, useProject,
@ -31,7 +32,11 @@ const baseURL = appInfo.ncSiteUrl
const { $state } = useNuxtApp() const { $state } = useNuxtApp()
const { project, loadTables } = useProject() const projectStore = useProject()
const { loadTables } = projectStore
const { project } = storeToRefs(projectStore)
const showGoToDashboardButton = ref(false) const showGoToDashboardButton = ref(false)

19
packages/nc-gui/components/dlg/KeyboardShortcuts.vue

@ -15,6 +15,9 @@ const dialogShow = computed({
const renderCmdOrCtrlKey = () => { const renderCmdOrCtrlKey = () => {
return isMac() ? '⌘' : 'CTRL' return isMac() ? '⌘' : 'CTRL'
} }
const renderAltOrOptlKey = () => {
return isMac() ? '⌥' : 'ALT'
}
const shortcutList = [ const shortcutList = [
{ {
@ -197,6 +200,22 @@ const shortcutList = [
keys: [renderCmdOrCtrlKey(), 'Enter'], keys: [renderCmdOrCtrlKey(), 'Enter'],
behaviour: 'Save current expanded form item', behaviour: 'Save current expanded form item',
}, },
{
keys: [renderAltOrOptlKey(), '→'],
behaviour: 'Switch to next row',
},
{
keys: [renderAltOrOptlKey(), '←'],
behaviour: 'Switch to previous row',
},
{
keys: [renderAltOrOptlKey(), 'S'],
behaviour: 'Save current expanded form item',
},
{
keys: [renderAltOrOptlKey(), 'N'],
behaviour: 'Create a new row',
},
], ],
}, },
] ]

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

@ -18,6 +18,7 @@ import {
message, message,
reactive, reactive,
ref, ref,
storeToRefs,
useI18n, useI18n,
useProject, useProject,
useVModel, useVModel,
@ -31,13 +32,13 @@ interface Props {
importDataOnly?: boolean importDataOnly?: boolean
} }
const { importType, importDataOnly = false, ...rest } = defineProps<Props>() const { importType, importDataOnly = false, baseId, ...rest } = defineProps<Props>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const { t } = useI18n() const { t } = useI18n()
const { tables } = useProject() const { tables } = storeToRefs(useProject())
const activeKey = ref('uploadTab') const activeKey = ref('uploadTab')
@ -61,7 +62,7 @@ const isParsingData = ref(false)
const useForm = Form.useForm const useForm = Form.useForm
const importState = reactive({ const defaultImportState = {
fileList: [] as importFileList | streamImportFileList, fileList: [] as importFileList | streamImportFileList,
url: '', url: '',
jsonEditor: {}, jsonEditor: {},
@ -72,7 +73,8 @@ const importState = reactive({
firstRowAsHeaders: true, firstRowAsHeaders: true,
shouldImportData: true, shouldImportData: true,
}, },
}) }
const importState = reactive(defaultImportState)
const isImportTypeJson = computed(() => importType === 'json') const isImportTypeJson = computed(() => importType === 'json')
@ -176,6 +178,8 @@ async function handleImport() {
return message.error(await extractSdkResponseErrorMsg(e)) return message.error(await extractSdkResponseErrorMsg(e))
} finally { } finally {
importLoading.value = false importLoading.value = false
templateEditorModal.value = false
Object.assign(importState, defaultImportState)
} }
dialogShow.value = false dialogShow.value = false
} }

11
packages/nc-gui/components/dlg/TableCreate.vue

@ -78,14 +78,19 @@ const systemColumnsCheckboxInfo = SYSTEM_COLUMNS.map((c, index) => ({
disabled: index === 0, disabled: index === 0,
})) }))
const creating = ref(false)
const _createTable = async () => { const _createTable = async () => {
try { try {
creating.value = true
await validate() await validate()
await createTable()
} catch (e: any) { } catch (e: any) {
e.errorFields.map((f: Record<string, any>) => message.error(f.errors.join(','))) e.errorFields.map((f: Record<string, any>) => message.error(f.errors.join(',')))
if (e.errorFields.length) return if (e.errorFields.length) return
} finally {
creating.value = false
} }
await createTable()
} }
onMounted(() => { onMounted(() => {
@ -109,7 +114,9 @@ onMounted(() => {
<template #footer> <template #footer>
<a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button> <a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" size="large" type="primary" @click="_createTable">{{ $t('general.submit') }}</a-button> <a-button key="submit" size="large" type="primary" :loading="creating" @click="_createTable"
>{{ $t('general.submit') }}
</a-button>
</template> </template>
<div class="pl-10 pr-10 pt-5"> <div class="pl-10 pr-10 pt-5">

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

@ -8,6 +8,7 @@ import {
message, message,
nextTick, nextTick,
reactive, reactive,
storeToRefs,
useI18n, useI18n,
useMetas, useMetas,
useNuxtApp, useNuxtApp,
@ -38,7 +39,9 @@ const dialogShow = useVModel(props, 'modelValue', emit)
const { updateTab } = useTabs() const { updateTab } = useTabs()
const { loadTables, tables, project, isMysql, isMssql, isPg } = useProject() const projectStore = useProject()
const { loadTables, isMysql, isMssql, isPg } = projectStore
const { project } = storeToRefs(projectStore)
const inputEl = $ref<ComponentPublicInstance>() const inputEl = $ref<ComponentPublicInstance>()

66
packages/nc-gui/components/dlg/ViewCreate.vue

@ -2,7 +2,7 @@
import type { ComponentPublicInstance } from '@vue/runtime-core' import type { ComponentPublicInstance } from '@vue/runtime-core'
import type { Form as AntForm, SelectProps } from 'ant-design-vue' import type { Form as AntForm, SelectProps } from 'ant-design-vue'
import { capitalize } from '@vue/runtime-core' import { capitalize } from '@vue/runtime-core'
import type { FormType, GalleryType, GridType, KanbanType, TableType, ViewType } from 'nocodb-sdk' import type { FormType, GalleryType, GridType, KanbanType, MapType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, ViewTypes } from 'nocodb-sdk' import { UITypes, ViewTypes } from 'nocodb-sdk'
import { import {
computed, computed,
@ -25,13 +25,14 @@ interface Props {
title?: string title?: string
selectedViewId?: string selectedViewId?: string
groupingFieldColumnId?: string groupingFieldColumnId?: string
geoDataFieldColumnId?: string
views: ViewType[] views: ViewType[]
meta: TableType meta: TableType
} }
interface Emits { interface Emits {
(event: 'update:modelValue', value: boolean): void (event: 'update:modelValue', value: boolean): void
(event: 'created', value: GridType | KanbanType | GalleryType | FormType): void (event: 'created', value: GridType | KanbanType | GalleryType | FormType | MapType): void
} }
interface Form { interface Form {
@ -40,9 +41,10 @@ interface Form {
copy_from_id: string | null copy_from_id: string | null
// for kanban view only // for kanban view only
fk_grp_col_id: string | null fk_grp_col_id: string | null
fk_geo_data_col_id: string | null
} }
const { views = [], meta, selectedViewId, groupingFieldColumnId, ...props } = defineProps<Props>() const { views = [], meta, selectedViewId, groupingFieldColumnId, geoDataFieldColumnId, ...props } = defineProps<Props>()
const emits = defineEmits<Emits>() const emits = defineEmits<Emits>()
@ -61,9 +63,10 @@ const form = reactive<Form>({
type: props.type, type: props.type,
copy_from_id: null, copy_from_id: null,
fk_grp_col_id: null, fk_grp_col_id: null,
fk_geo_data_col_id: null,
}) })
const singleSelectFieldOptions = ref<SelectProps['options']>([]) const viewSelectFieldOptions = ref<SelectProps['options']>([])
const viewNameRules = [ const viewNameRules = [
// name is required // name is required
@ -72,7 +75,7 @@ const viewNameRules = [
{ {
validator: (_: unknown, v: string) => validator: (_: unknown, v: string) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
views.every((v1) => ((v1 as GridType | KanbanType | GalleryType).alias || v1.title) !== v) views.every((v1) => ((v1 as GridType | KanbanType | GalleryType | MapType).alias || v1.title) !== v)
? resolve(true) ? resolve(true)
: reject(new Error(`View name should be unique`)) : reject(new Error(`View name should be unique`))
}), }),
@ -80,10 +83,9 @@ const viewNameRules = [
}, },
] ]
const groupingFieldColumnRules = [ const groupingFieldColumnRules = [{ required: true, message: `${t('general.groupingField')} ${t('general.required')}` }]
// name is required
{ required: true, message: `${t('general.groupingField')} ${t('general.required')}` }, const geoDataFieldColumnRules = [{ required: true, message: `${t('general.geoDataField')} ${t('general.required')}` }]
]
const typeAlias = computed( const typeAlias = computed(
() => () =>
@ -92,6 +94,7 @@ const typeAlias = computed(
[ViewTypes.GALLERY]: 'gallery', [ViewTypes.GALLERY]: 'gallery',
[ViewTypes.FORM]: 'form', [ViewTypes.FORM]: 'form',
[ViewTypes.KANBAN]: 'kanban', [ViewTypes.KANBAN]: 'kanban',
[ViewTypes.MAP]: 'map',
}[props.type]), }[props.type]),
) )
@ -113,7 +116,7 @@ function init() {
// preset the grouping field column // preset the grouping field column
if (props.type === ViewTypes.KANBAN) { if (props.type === ViewTypes.KANBAN) {
singleSelectFieldOptions.value = meta viewSelectFieldOptions.value = meta
.columns!.filter((el) => el.uidt === UITypes.SingleSelect) .columns!.filter((el) => el.uidt === UITypes.SingleSelect)
.map((field) => { .map((field) => {
return { return {
@ -127,7 +130,26 @@ function init() {
form.fk_grp_col_id = groupingFieldColumnId form.fk_grp_col_id = groupingFieldColumnId
} else { } else {
// take the first option // take the first option
form.fk_grp_col_id = singleSelectFieldOptions.value?.[0]?.value as string form.fk_grp_col_id = viewSelectFieldOptions.value?.[0]?.value as string
}
}
if (props.type === ViewTypes.MAP) {
viewSelectFieldOptions.value = meta
.columns!.filter((el) => el.uidt === UITypes.GeoData)
.map((field) => {
return {
value: field.id,
label: field.title,
}
})
if (geoDataFieldColumnId) {
// take from the one from copy view
form.fk_geo_data_col_id = geoDataFieldColumnId
} else {
// take the first option
form.fk_geo_data_col_id = viewSelectFieldOptions.value?.[0]?.value as string
} }
} }
@ -150,7 +172,7 @@ async function onSubmit() {
if (!_meta || !_meta.id) return if (!_meta || !_meta.id) return
try { try {
let data: GridType | KanbanType | GalleryType | FormType | null = null let data: GridType | KanbanType | GalleryType | FormType | MapType | null = null
switch (form.type) { switch (form.type) {
case ViewTypes.GRID: case ViewTypes.GRID:
@ -164,6 +186,9 @@ async function onSubmit() {
break break
case ViewTypes.KANBAN: case ViewTypes.KANBAN:
data = await api.dbView.kanbanCreate(_meta.id, form) data = await api.dbView.kanbanCreate(_meta.id, form)
break
case ViewTypes.MAP:
data = await api.dbView.mapCreate(_meta.id, form)
} }
if (data) { if (data) {
@ -207,12 +232,27 @@ async function onSubmit() {
<a-select <a-select
v-model:value="form.fk_grp_col_id" v-model:value="form.fk_grp_col_id"
class="w-full nc-kanban-grouping-field-select" class="w-full nc-kanban-grouping-field-select"
:options="singleSelectFieldOptions" :options="viewSelectFieldOptions"
:disabled="groupingFieldColumnId" :disabled="groupingFieldColumnId"
placeholder="Select a Grouping Field" placeholder="Select a Grouping Field"
not-found-content="No Single Select Field can be found. Please create one first." not-found-content="No Single Select Field can be found. Please create one first."
/> />
</a-form-item> </a-form-item>
<a-form-item
v-if="form.type === ViewTypes.MAP"
:label="$t('general.geoDataField')"
name="fk_geo_data_col_id"
:rules="geoDataFieldColumnRules"
>
<a-select
v-model:value="form.fk_geo_data_col_id"
class="w-full"
:options="viewSelectFieldOptions"
:disabled="geoDataFieldColumnId"
placeholder="Select a GeoData Field"
not-found-content="No GeoData Field can be found. Please create one first."
/>
</a-form-item>
</a-form> </a-form>
<template #footer> <template #footer>

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

@ -2,11 +2,11 @@
import type { LinkToAnotherRecordType, TableType } from 'nocodb-sdk' import type { LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import type { ERDConfig } from './utils' import type { ERDConfig } from './utils'
import { reactive, ref, useMetas, useProject, watch } from '#imports' import { reactive, ref, storeToRefs, useMetas, useProject, watch } from '#imports'
const props = defineProps<{ table?: TableType; baseId?: string }>() const props = defineProps<{ table?: TableType; baseId?: string }>()
const { tables: projectTables } = useProject() const { tables: projectTables } = storeToRefs(useProject())
const { metas, getMeta } = useMetas() const { metas, getMeta } = useMetas()

4
packages/nc-gui/components/general/HelpAndSupport.vue

@ -1,11 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, useGlobal, useProject, useRoute } from '#imports' import { ref, storeToRefs, useGlobal, useProject, useRoute } from '#imports'
const showDrawer = ref(false) const showDrawer = ref(false)
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const { project } = useProject() const { project } = storeToRefs(useProject())
const route = useRoute() const route = useRoute()

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

@ -1,18 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, navigateTo, useGlobal, useProject, useRoute, useSidebar } from '#imports' import { computed, navigateTo, storeToRefs, useGlobal, useProject, useRoute, useSidebar } from '#imports'
const { signOut, signedIn, user, currentVersion } = useGlobal() const { signOut, signedIn, user, currentVersion } = useGlobal()
const { isOpen } = useSidebar('nc-mini-sidebar', { isOpen: true }) const { isOpen } = useSidebar('nc-mini-sidebar', { isOpen: true })
const { project } = useProject() const { project } = storeToRefs(useProject())
const route = useRoute() const route = useRoute()
const email = computed(() => user.value?.email ?? '---') const email = computed(() => user.value?.email ?? '---')
const logout = () => { const logout = async () => {
signOut() await signOut()
navigateTo('/signin') navigateTo('/signin')
} }
</script> </script>

57
packages/nc-gui/components/general/ShortcutLabel.vue

@ -0,0 +1,57 @@
<script lang="ts" setup>
import { isMac } from '#imports'
const props = defineProps<{
keys: string[]
}>()
const isMacOs = isMac()
const getLabel = (key: string) => {
if (isMacOs) {
switch (key.toLowerCase()) {
case 'alt':
return '⌥'
case 'shift':
return '⇧'
case 'meta':
return '⌘'
case 'control':
case 'ctrl':
return '⌃'
case 'enter':
return '↩'
}
}
switch (key.toLowerCase()) {
case 'arrowup':
return '↑'
case 'arrowdown':
return '↓'
case 'arrowleft':
return '←'
case 'arrowright':
return '→'
}
return key
}
</script>
<template>
<div class="nc-shortcut-label-wrapper">
<div v-for="(key, index) in props.keys" :key="index" class="nc-shortcut-label">
<span>{{ getLabel(key) }}</span>
</div>
</div>
</template>
<style scoped>
.nc-shortcut-label-wrapper {
@apply flex gap-1;
}
.nc-shortcut-label {
@apply text-[0.7rem] leading-6 min-w-5 min-h-5 text-center relative z-0 after:(content-[''] left-0 top-0 -z-1 bg-current opacity-10 absolute w-full h-full rounded) px-1;
}
</style>

42
packages/nc-gui/components/shared-view/Map.vue

@ -0,0 +1,42 @@
<script setup lang="ts">
import {
ActiveViewInj,
FieldsInj,
IsPublicInj,
MetaInj,
ReadonlyInj,
ReloadViewDataHookInj,
useProvideMapViewStore,
} from '#imports'
const { sharedView, meta, sorts, nestedFilters } = useSharedView()
const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, ref(true))
provide(MetaInj, meta)
provide(ActiveViewInj, sharedView)
provide(FieldsInj, ref(meta.value?.columns || []))
provide(IsPublicInj, ref(true))
useProvideSmartsheetStore(sharedView, meta, true, sorts, nestedFilters)
useProvideMapViewStore(meta, sharedView, true)
</script>
<template>
<div class="nc-container h-full mt-1.5 px-12">
<div class="flex flex-col h-full flex-1 min-w-0">
<LazySmartsheetToolbar />
<div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50">
<LazySmartsheetMap />
</div>
</div>
</div>
</template>

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

@ -6,6 +6,7 @@ import {
inject, inject,
message, message,
ref, ref,
storeToRefs,
useCopy, useCopy,
useGlobal, useGlobal,
useI18n, useI18n,
@ -24,7 +25,7 @@ const emits = defineEmits(['update:modelValue'])
const { t } = useI18n() const { t } = useI18n()
const { project } = $(useProject()) const { project } = $(storeToRefs(useProject()))
const { appInfo, token } = $(useGlobal()) const { appInfo, token } = $(useGlobal())

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

@ -21,6 +21,7 @@ import {
isDuration, isDuration,
isEmail, isEmail,
isFloat, isFloat,
isGeoData,
isInt, isInt,
isJSON, isJSON,
isManualSaved, isManualSaved,
@ -38,6 +39,7 @@ import {
isYear, isYear,
provide, provide,
ref, ref,
storeToRefs,
toRef, toRef,
useDebounceFn, useDebounceFn,
useProject, useProject,
@ -84,7 +86,7 @@ const isLocked = inject(IsLockedInj, ref(false))
const { currentRow } = useSmartsheetRowStoreOrThrow() const { currentRow } = useSmartsheetRowStoreOrThrow()
const { sqlUis } = useProject() const { sqlUis } = storeToRefs(useProject())
const sqlUi = ref(column.value?.base_id ? sqlUis.value[column.value?.base_id] : Object.values(sqlUis.value)[0]) const sqlUi = ref(column.value?.base_id ? sqlUis.value[column.value?.base_id] : Object.values(sqlUis.value)[0])
@ -100,7 +102,9 @@ const syncValue = useDebounceFn(
) )
const vModel = computed({ const vModel = computed({
get: () => props.modelValue, get: () => {
return props.modelValue
},
set: (val) => { set: (val) => {
if (val !== props.modelValue) { if (val !== props.modelValue) {
currentRow.value.rowMeta.changed = true currentRow.value.rowMeta.changed = true
@ -136,6 +140,15 @@ const isNumericField = computed(() => {
isDuration(column.value) isDuration(column.value)
) )
}) })
// disable contexxtmenu event propagation when cell is in
// editable state and typable (e.g. text area)
// this is to prevent the custom grid view context menu from opening
const onContextmenu = (e: MouseEvent) => {
if (props.editEnabled && isTypableInputColumn(column.value)) {
e.stopPropagation()
}
}
</script> </script>
<template> <template>
@ -148,9 +161,11 @@ const isNumericField = computed(() => {
]" ]"
@keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)" @keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)" @keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)"
@contextmenu="onContextmenu"
> >
<template v-if="column"> <template v-if="column">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" /> <LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" /> <LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" /> <LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellSingleSelect v-else-if="isSingleSelect(column)" v-model="vModel" :row-index="props.rowIndex" /> <LazyCellSingleSelect v-else-if="isSingleSelect(column)" v-model="vModel" :row-index="props.rowIndex" />

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

@ -118,6 +118,7 @@ const {
selectedAllRecords, selectedAllRecords,
removeRowIfNew, removeRowIfNew,
navigateToSiblingRow, navigateToSiblingRow,
getExpandedRowIndex,
} = useViewData(meta, view, xWhere) } = useViewData(meta, view, xWhere)
const { getMeta } = useMetas() const { getMeta } = useMetas()
@ -980,6 +981,8 @@ const closeAddColumnDropdown = () => {
:row-id="routeQuery.rowId" :row-id="routeQuery.rowId"
:view="view" :view="view"
show-next-prev-icons show-next-prev-icons
:first-row="getExpandedRowIndex() === 0"
:last-row="getExpandedRowIndex() === data.length - 1"
@next="navigateToSiblingRow(NavigateDir.NEXT)" @next="navigateToSiblingRow(NavigateDir.NEXT)"
@prev="navigateToSiblingRow(NavigateDir.PREV)" @prev="navigateToSiblingRow(NavigateDir.PREV)"
/> />
@ -1056,7 +1059,7 @@ const closeAddColumnDropdown = () => {
position: sticky !important; position: sticky !important;
left: 80px; left: 80px;
z-index: 5; z-index: 5;
@apply border-r-1 border-r-gray-300; @apply border-r-2 border-r-gray-300;
} }
tbody td:nth-child(2) { tbody td:nth-child(2) {
@ -1064,7 +1067,7 @@ const closeAddColumnDropdown = () => {
left: 80px; left: 80px;
z-index: 4; z-index: 4;
background: white; background: white;
@apply shadow-lg border-r-1 border-r-gray-300; @apply border-r-2 border-r-gray-300;
} }
} }

266
packages/nc-gui/components/smartsheet/Map.vue

@ -0,0 +1,266 @@
<script lang="ts" setup>
import 'leaflet/dist/leaflet.css'
import L, { LatLng } from 'leaflet'
import 'leaflet.markercluster'
import { ViewTypes } from 'nocodb-sdk'
import { IsPublicInj, OpenNewRecordFormHookInj, latLongToJoinedString, onMounted, provide, ref } from '#imports'
import type { Row as RowType } from '~/lib'
const route = useRoute()
const router = useRouter()
const reloadViewDataHook = inject(ReloadViewDataHookInj)
const reloadViewMetaHook = inject(ReloadViewMetaHookInj)
const { formattedData, loadMapData, loadMapMeta, mapMetaData, geoDataFieldColumn, addEmptyRow, paginationData } =
useMapViewStoreOrThrow()
const markersClusterGroupRef = ref<L.MarkerClusterGroup>()
const mapContainerRef = ref<HTMLElement>()
const myMapRef = ref<L.Map>()
const isPublic = inject(IsPublicInj, ref(false))
const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref())
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj, createEventHook())
const expandedFormDlg = ref(false)
const expandedFormRow = ref<RowType>()
const expandedFormRowState = ref<Record<string, any>>()
const fallBackCenterLocation = {
lat: 51,
lng: 0.0,
}
const getMapZoomLocalStorageKey = (viewId: string) => {
return `mapView.${viewId}.zoom`
}
const getMapCenterLocalStorageKey = (viewId: string) => `mapView.${viewId}.center`
const expandForm = (row: RowType, state?: Record<string, any>) => {
const rowId = extractPkFromRow(row.row, meta.value!.columns!)
if (rowId) {
router.push({
query: {
...route.query,
rowId,
},
})
} else {
expandedFormRow.value = row
expandedFormRowState.value = state
expandedFormDlg.value = true
}
}
openNewRecordFormHook?.on(async () => {
const newRow = await addEmptyRow()
expandForm(newRow)
})
const expandedFormOnRowIdDlg = computed({
get() {
return !!route.query.rowId
},
set(val) {
if (!val)
router.push({
query: {
...route.query,
rowId: undefined,
},
})
},
})
const addMarker = (lat: number, long: number, row: RowType) => {
if (markersClusterGroupRef.value == null) {
throw new Error('Marker cluster is null')
}
const newMarker = L.marker([lat, long], {
alt: `${lat}, ${long}`,
}).on('click', () => {
expandForm(row)
})
markersClusterGroupRef.value?.addLayer(newMarker)
}
const resetZoomAndCenterBasedOnLocalStorage = () => {
if (mapMetaData?.value?.fk_view_id == null) {
return
}
const initialZoomLevel = parseInt(localStorage.getItem(getMapZoomLocalStorageKey(mapMetaData.value.fk_view_id)) || '10')
const initialCenterLocalStorageStr = localStorage.getItem(getMapCenterLocalStorageKey(mapMetaData.value.fk_view_id))
const initialCenter = initialCenterLocalStorageStr ? JSON.parse(initialCenterLocalStorageStr) : fallBackCenterLocation
myMapRef?.value?.setView([initialCenter.lat, initialCenter.lng], initialZoomLevel)
}
onBeforeMount(async () => {
await loadMapMeta()
await loadMapData()
})
onMounted(async () => {
const myMap = L.map(mapContainerRef.value!, {
center: new LatLng(10, 10),
zoom: 2,
})
myMapRef.value = myMap
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(myMap)
markersClusterGroupRef.value = L.markerClusterGroup({
iconCreateFunction(cluster: { getChildCount: () => number }) {
return L.divIcon({
html: `${cluster.getChildCount()}`,
className: 'bg-pink rounded-full flex items-center justify-center geo-map-marker-cluster',
iconSize: new L.Point(40, 40),
})
},
})
myMap.addLayer(markersClusterGroupRef.value)
myMap.on('zoomend', function () {
if (localStorage != null && mapMetaData?.value?.fk_view_id) {
localStorage.setItem(getMapZoomLocalStorageKey(mapMetaData.value.fk_view_id), myMap.getZoom().toString())
}
})
myMap.on('moveend', function () {
if (localStorage != null && mapMetaData?.value?.fk_view_id) {
localStorage.setItem(getMapCenterLocalStorageKey(mapMetaData?.value?.fk_view_id), JSON.stringify(myMap.getCenter()))
}
})
myMap.on('contextmenu', async function (e) {
const { lat, lng } = e.latlng
const newRow = await addEmptyRow()
if (geoDataFieldColumn.value?.title) {
newRow.row[geoDataFieldColumn.value.title] = latLongToJoinedString(lat, lng)
}
expandForm(newRow)
})
})
reloadViewMetaHook?.on(async () => {
await loadMapMeta()
})
reloadViewDataHook?.on(async () => {
await loadMapData()
})
provide(ReloadRowDataHookInj, reloadViewDataHook)
watch([formattedData, mapMetaData, markersClusterGroupRef], () => {
if (formattedData.value == null || mapMetaData.value?.fk_view_id == null || markersClusterGroupRef.value == null) {
return
}
resetZoomAndCenterBasedOnLocalStorage()
markersClusterGroupRef.value?.clearLayers()
formattedData.value?.forEach((row) => {
const primaryGeoDataColumnTitle = geoDataFieldColumn.value?.title
if (primaryGeoDataColumnTitle == null) {
throw new Error('Cannot find primary geo data column title')
}
const primaryGeoDataValue = row.row[primaryGeoDataColumnTitle]
if (primaryGeoDataValue == null) {
return
}
const [lat, long] = primaryGeoDataValue.split(';').map(parseFloat)
addMarker(lat, long, row)
})
})
watch(view, async (nextView) => {
if (nextView?.type === ViewTypes.MAP) {
await loadMapMeta()
await loadMapData()
}
})
const count = computed(() => paginationData.value.totalRows)
</script>
<template>
<div class="flex flex-col h-full w-full no-underline" data-testid="nc-map-wrapper">
<div id="mapContainer" ref="mapContainerRef" class="w-full h-screen">
<a-tooltip placement="bottom" class="h-2 w-auto max-w-fit-content absolute top-3 right-3 p-2 z-500 cursor-default">
<template #title>
<span v-if="count > 1000"> {{ $t('msg.info.map.overLimit') }} </span>
<span v-else-if="count > 900"> {{ $t('msg.info.map.closeLimit') }} </span>
<span> {{ $t('msg.info.map.limitNumber') }} </span>
</template>
<div v-if="count > 900" class="nc-warning-info flex min-w-32px h-32px items-center gap-1 px-2 bg-white">
<div>{{ count }} {{ $t('objects.records') }}</div>
<mdi-map-marker-alert />
</div>
</a-tooltip>
</div>
</div>
<Suspense v-if="!isPublic">
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:row="expandedFormRow"
:state="expandedFormRowState"
:meta="meta"
:view="view"
/>
</Suspense>
<Suspense v-if="!isPublic">
<LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
:key="route.query.rowId"
v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta"
:row-id="route.query.rowId"
:view="view"
/>
</Suspense>
</template>
<style scoped lang="scss">
:global(.geo-map-marker-cluster) {
background-color: pink;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
</style>
<style>
.no-underline a {
text-decoration: none !important;
}
.leaflet-popup-content-wrapper {
max-height: 255px;
overflow: scroll;
}
</style>

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

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { IsPublicInj, inject, ref, useSharedView, useSidebar, useSmartsheetStoreOrThrow, useUIPermission } from '#imports' import { IsPublicInj, inject, ref, useSharedView, useSidebar, useSmartsheetStoreOrThrow, useUIPermission } from '#imports'
const { isGrid, isForm, isGallery, isKanban, isSqlView } = useSmartsheetStoreOrThrow() const { isGrid, isForm, isGallery, isKanban, isMap, isSqlView } = useSmartsheetStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
@ -18,7 +18,7 @@ const { allowCSVDownload } = useSharedView()
style="z-index: 7" style="z-index: 7"
> >
<LazySmartsheetToolbarViewActions <LazySmartsheetToolbarViewActions
v-if="(isGrid || isGallery || isKanban) && !isPublic && isUIAllowed('dataInsert')" v-if="(isGrid || isGallery || isKanban || isMap) && !isPublic && isUIAllowed('dataInsert')"
:show-system-fields="false" :show-system-fields="false"
class="ml-1" class="ml-1"
/> />
@ -29,15 +29,17 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarKanbanStackEditOrAdd v-if="isKanban" /> <LazySmartsheetToolbarKanbanStackEditOrAdd v-if="isKanban" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban" :show-system-fields="false" /> <LazySmartsheetToolbarMappedBy v-if="isMap" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban" /> <LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban || isMap" :show-system-fields="false" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap" />
<LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban" /> <LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarRowHeight v-if="isGrid" /> <LazySmartsheetToolbarRowHeight v-if="isGrid" />
<LazySmartsheetToolbarShareView v-if="(isForm || isGrid || isKanban || isGallery) && !isPublic" /> <LazySmartsheetToolbarShareView v-if="(isForm || isGrid || isKanban || isGallery || isMap) && !isPublic" />
<LazySmartsheetToolbarExport v-if="(!isPublic && !isUIAllowed('dataInsert')) || (isPublic && allowCSVDownload)" /> <LazySmartsheetToolbarExport v-if="(!isPublic && !isUIAllowed('dataInsert')) || (isPublic && allowCSVDownload)" />
<div class="flex-1" /> <div class="flex-1" />

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

@ -57,9 +57,13 @@ const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup] const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup]
const geoDataToggleCondition = (t) => {
return geodataToggleState.show ? geodataToggleState.show : !t.name.includes(UITypes.GeoData)
}
const uiTypesOptions = computed<typeof uiTypes>(() => { const uiTypesOptions = computed<typeof uiTypes>(() => {
return [ return [
...uiTypes.filter((t) => !isEdit.value || !t.virtual), ...uiTypes.filter((t) => geoDataToggleCondition(t) && (!isEdit.value || !t.virtual)),
...(!isEdit.value && meta?.value?.columns?.every((c) => !c.pk) ...(!isEdit.value && meta?.value?.columns?.every((c) => !c.pk)
? [ ? [
{ {
@ -80,8 +84,12 @@ const reloadMetaAndData = async () => {
} }
} }
const saving = ref(false)
async function onSubmit() { async function onSubmit() {
saving.value = true
const saved = await addOrUpdate(reloadMetaAndData, props.columnPosition) const saved = await addOrUpdate(reloadMetaAndData, props.columnPosition)
saving.value = false
if (!saved) return if (!saved) return
@ -178,6 +186,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<LazySmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" /> <LazySmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" />
<LazySmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" /> <LazySmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" />
<LazySmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" v-model:value="formState" /> <LazySmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" v-model:value="formState" />
<LazySmartsheetColumnGeoDataOptions v-if="formState.uidt === UITypes.GeoData" v-model:value="formState" />
<LazySmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" v-model:value="formState" /> <LazySmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" v-model:value="formState" />
<LazySmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" v-model:value="formState" /> <LazySmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" v-model:value="formState" />
<LazySmartsheetColumnCheckboxOptions v-if="formState.uidt === UITypes.Checkbox" v-model:value="formState" /> <LazySmartsheetColumnCheckboxOptions v-if="formState.uidt === UITypes.Checkbox" v-model:value="formState" />
@ -223,7 +232,10 @@ useEventListener('keydown', (e: KeyboardEvent) => {
v-model:value="formState" v-model:value="formState"
/> />
<LazySmartsheetColumnAdvancedOptions v-model:value="formState" :advanced-db-options="advancedDbOptions" /> <LazySmartsheetColumnAdvancedOptions
v-model:value="formState"
:advanced-db-options="advancedDbOptions || formState.uidt === UITypes.SpecificDBType"
/>
</div> </div>
</Transition> </Transition>
@ -234,7 +246,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
{{ $t('general.cancel') }} {{ $t('general.cancel') }}
</a-button> </a-button>
<a-button html-type="submit" type="primary" @click.prevent="onSubmit"> <a-button html-type="submit" type="primary" :loading="saving" @click.prevent="onSubmit">
<!-- Save --> <!-- Save -->
{{ $t('general.save') }} {{ $t('general.save') }}
</a-button> </a-button>

16
packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ModelTypes, MssqlUi, SqliteUi } from 'nocodb-sdk' import { ModelTypes, MssqlUi, SqliteUi } from 'nocodb-sdk'
import { MetaInj, inject, ref, useProject, useVModel } from '#imports' import { MetaInj, inject, ref, storeToRefs, useProject, useVModel } from '#imports'
import MdiPlusIcon from '~icons/mdi/plus-circle-outline' import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline' import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
@ -10,13 +10,15 @@ const props = defineProps<{
const emit = defineEmits(['update:value']) const emit = defineEmits(['update:value'])
const { appInfo } = $(useGlobal())
const vModel = useVModel(props, 'value', emit) const vModel = useVModel(props, 'value', emit)
const meta = $(inject(MetaInj, ref())) const meta = $(inject(MetaInj, ref()))
const { setAdditionalValidations, validateInfos, onDataTypeChange, sqlUi } = useColumnCreateStoreOrThrow() const { setAdditionalValidations, validateInfos, onDataTypeChange, sqlUi } = useColumnCreateStoreOrThrow()
const { tables } = $(useProject()) const { tables } = $(storeToRefs(useProject()))
setAdditionalValidations({ setAdditionalValidations({
childId: [{ required: true, message: 'Required' }], childId: [{ required: true, message: 'Required' }],
@ -31,10 +33,10 @@ if (!vModel.value.childTable) vModel.value.childTable = meta?.table_name
if (!vModel.value.parentTable) vModel.value.parentTable = vModel.value.rtn || '' if (!vModel.value.parentTable) vModel.value.parentTable = vModel.value.rtn || ''
if (!vModel.value.parentColumn) vModel.value.parentColumn = vModel.value.rcn || '' if (!vModel.value.parentColumn) vModel.value.parentColumn = vModel.value.rcn || ''
if (!vModel.value.type) vModel.value.type = 'hm' if (!vModel.value.type) vModel.value.type = 'mm'
if (!vModel.value.onUpdate) vModel.value.onUpdate = onUpdateDeleteOptions[0] if (!vModel.value.onUpdate) vModel.value.onUpdate = onUpdateDeleteOptions[0]
if (!vModel.value.onDelete) vModel.value.onDelete = onUpdateDeleteOptions[0] if (!vModel.value.onDelete) vModel.value.onDelete = onUpdateDeleteOptions[0]
if (!vModel.value.virtual) vModel.value.virtual = sqlUi === SqliteUi if (!vModel.value.virtual) vModel.value.virtual = appInfo.isCloud || sqlUi === SqliteUi
if (!vModel.value.alias) vModel.value.alias = vModel.value.column_name if (!vModel.value.alias) vModel.value.alias = vModel.value.column_name
const advancedOptions = $(ref(false)) const advancedOptions = $(ref(false))
@ -55,7 +57,7 @@ const filterOption = (value: string, option: { key: string }) => option.key.toLo
<div class="border-2 p-6"> <div class="border-2 p-6">
<a-form-item v-bind="validateInfos.type" class="nc-ltar-relation-type"> <a-form-item v-bind="validateInfos.type" class="nc-ltar-relation-type">
<a-radio-group v-model:value="vModel.type" name="type" v-bind="validateInfos.type"> <a-radio-group v-model:value="vModel.type" name="type" v-bind="validateInfos.type">
<a-radio value="hm">Has Many</a-radio> <a-radio value="hm" :disabled="appInfo.isCloud">Has Many</a-radio>
<a-radio value="mm">Many To Many</a-radio> <a-radio value="mm">Many To Many</a-radio>
</a-radio-group> </a-radio-group>
</a-form-item> </a-form-item>
@ -127,7 +129,9 @@ const filterOption = (value: string, option: { key: string }) => option.key.toLo
<div class="flex flex-row"> <div class="flex flex-row">
<a-form-item> <a-form-item>
<a-checkbox v-model:checked="vModel.virtual" name="virtual" @change="onDataTypeChange">Virtual Relation</a-checkbox> <a-checkbox v-model:checked="vModel.virtual" :disabled="appInfo.isCloud" name="virtual" @change="onDataTypeChange"
>Virtual Relation</a-checkbox
>
</a-form-item> </a-form-item>
</div> </div>
</div> </div>

4
packages/nc-gui/components/smartsheet/column/LookupOptions.vue

@ -3,7 +3,7 @@ import { onMounted } from '@vue/runtime-core'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk' import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes, isSystemColumn } from 'nocodb-sdk' import { UITypes, isSystemColumn } from 'nocodb-sdk'
import { getRelationName } from './utils' import { getRelationName } from './utils'
import { MetaInj, inject, ref, useColumnCreateStoreOrThrow, useMetas, useProject, useVModel } from '#imports' import { MetaInj, inject, ref, storeToRefs, useColumnCreateStoreOrThrow, useMetas, useProject, useVModel } from '#imports'
const props = defineProps<{ const props = defineProps<{
value: any value: any
@ -17,7 +17,7 @@ const meta = $(inject(MetaInj, ref()))
const { setAdditionalValidations, validateInfos, onDataTypeChange, isEdit } = useColumnCreateStoreOrThrow() const { setAdditionalValidations, validateInfos, onDataTypeChange, isEdit } = useColumnCreateStoreOrThrow()
const { tables } = $(useProject()) const { tables } = $(storeToRefs(useProject()))
const { metas } = $(useMetas()) const { metas } = $(useMetas())

4
packages/nc-gui/components/smartsheet/column/RollupOptions.vue

@ -3,7 +3,7 @@ import { onMounted } from '@vue/runtime-core'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk' import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { getRelationName } from './utils' import { getRelationName } from './utils'
import { MetaInj, inject, ref, useColumnCreateStoreOrThrow, useMetas, useProject, useVModel } from '#imports' import { MetaInj, inject, ref, storeToRefs, useColumnCreateStoreOrThrow, useMetas, useProject, useVModel } from '#imports'
const props = defineProps<{ const props = defineProps<{
value: any value: any
@ -17,7 +17,7 @@ const meta = $(inject(MetaInj, ref()))
const { setAdditionalValidations, validateInfos, onDataTypeChange, isEdit } = useColumnCreateStoreOrThrow() const { setAdditionalValidations, validateInfos, onDataTypeChange, isEdit } = useColumnCreateStoreOrThrow()
const { tables } = $(useProject()) const { tables } = $(storeToRefs(useProject()))
const { metas } = $(useMetas()) const { metas } = $(useMetas())

2
packages/nc-gui/components/smartsheet/column/SpecificDBTypeOptions.vue

@ -1,3 +1,3 @@
<template> <template>
<div class="mt-4 mb-2" /> <div />
</template> </template>

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

@ -18,7 +18,7 @@ const route = useRoute()
const { meta, isSqlView } = useSmartsheetStoreOrThrow() const { meta, isSqlView } = useSmartsheetStoreOrThrow()
const { commentsDrawer, displayValue, primaryKey, save: _save, loadRow } = useExpandedFormStoreOrThrow() const { commentsDrawer, displayValue, primaryKey, save: _save, loadRow, saveRowAndStay } = useExpandedFormStoreOrThrow()
const { isNew, syncLTARRefs, state } = useSmartsheetRowStoreOrThrow() const { isNew, syncLTARRefs, state } = useSmartsheetRowStoreOrThrow()
@ -26,8 +26,6 @@ const { isUIAllowed } = useUIPermission()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook()) const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const saveRowAndStay = ref(0)
const save = async () => { const save = async () => {
if (isNew.value) { if (isNew.value) {
const data = await _save(state.value) const data = await _save(state.value)
@ -103,17 +101,6 @@ const onConfirmDeleteRowClick = async () => {
</h5> </h5>
<div class="flex-1" /> <div class="flex-1" />
<a-tooltip placement="bottom">
<template #title>
<div class="text-center w-full">{{ $t('general.reload') }}</div>
</template>
<mdi-reload
v-if="!isNew"
class="nc-icon-transition cursor-pointer select-none text-gray-500 mx-1 min-w-4"
@click="loadRow"
/>
</a-tooltip>
<a-tooltip placement="bottom"> <a-tooltip placement="bottom">
<template #title> <template #title>
<!-- todo: i18n --> <!-- todo: i18n -->
@ -139,32 +126,6 @@ const onConfirmDeleteRowClick = async () => {
/> />
</a-tooltip> </a-tooltip>
<a-tooltip v-if="!isSqlView" placement="bottom">
<!-- Duplicate row -->
<template #title>
<div class="text-center w-full">{{ $t('activity.duplicateRow') }}</div>
</template>
<MdiContentCopy
v-if="isUIAllowed('xcDatatableEditable') && !isNew"
v-e="['c:row-expand:duplicate']"
class="nc-icon-transition cursor-pointer select-none nc-duplicate-row text-gray-500 mx-1 min-w-4"
@click="!isNew && emit('duplicateRow')"
/>
</a-tooltip>
<a-tooltip v-if="!isSqlView" placement="bottom">
<!-- Delete row -->
<template #title>
<div class="text-center w-full">{{ $t('activity.deleteRow') }}</div>
</template>
<MdiDeleteOutline
v-if="isUIAllowed('xcDatatableEditable') && !isNew"
v-e="['c:row-expand:delete']"
class="nc-icon-transition cursor-pointer select-none nc-delete-row text-gray-500 mx-1 min-w-4"
@click="!isNew && onDeleteRowClick()"
/>
</a-tooltip>
<a-dropdown-button class="nc-expand-form-save-btn" type="primary" :disabled="!isUIAllowed('tableRowUpdate')" @click="save"> <a-dropdown-button class="nc-expand-form-save-btn" type="primary" :disabled="!isUIAllowed('tableRowUpdate')" @click="save">
<template #icon><MdiMenuDown /></template> <template #icon><MdiMenuDown /></template>
@ -194,17 +155,39 @@ const onConfirmDeleteRowClick = async () => {
</div> </div>
</a-dropdown-button> </a-dropdown-button>
<a-tooltip placement="bottom"> <a-dropdown>
<!-- Close --> <MdiDotsVertical class="nc-icon-transition" />
<template #title> <template #overlay>
<div class="text-center w-full">{{ $t('general.close') }}</div> <a-menu>
<a-menu-item v-if="!isNew" @click="loadRow">
<div v-e="['c:row-expand:reload']" class="py-2 flex gap-2 items-center">
<mdi-reload class="nc-icon-transition cursor-pointer select-none text-gray-500 mx-1 min-w-4" />
{{ $t('general.reload') }}
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('xcDatatableEditable') && !isNew" @click="!isNew && emit('duplicateRow')">
<div v-e="['c:row-expand:duplicate']" class="py-2 flex gap-2 a">
<MdiContentCopy class="nc-icon-transition cursor-pointer select-none nc-duplicate-row text-gray-500 mx-1 min-w-4" />
{{ $t('activity.duplicateRow') }}
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('xcDatatableEditable') && !isNew" @click="!isNew && onDeleteRowClick()">
<div v-e="['c:row-expand:delete']" class="py-2 flex gap-2 items-center">
<MdiDeleteOutline class="nc-icon-transition cursor-pointer select-none nc-delete-row text-gray-500 mx-1 min-w-4" />
{{ $t('activity.deleteRow') }}
</div>
</a-menu-item>
<a-menu-item @click="emit('cancel')">
<div v-e="['c:row-expand:delete']" class="py-2 flex gap-2 items-center">
<MdiCloseCircleOutline
class="nc-icon-transition cursor-pointer select-none nc-delete-row text-gray-500 mx-1 min-w-4"
/>
{{ $t('general.close') }}
</div>
</a-menu-item>
</a-menu>
</template> </template>
<MdiCloseCircleOutline </a-dropdown>
class="nc-icon-transition cursor-pointer select-none nc-close-form text-gray-500 mx-1 min-w-4"
@click="emit('cancel')"
/>
</a-tooltip>
<a-modal v-model:visible="showDeleteRowModal" title="Delete row?" @ok="onConfirmDeleteRowClick"> <a-modal v-model:visible="showDeleteRowModal" title="Delete row?" @ok="onConfirmDeleteRowClick">
<p>Are you sure you want to delete this row?</p> <p>Are you sure you want to delete this row?</p>
</a-modal> </a-modal>

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

@ -3,6 +3,7 @@ import type { TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { import {
CellClickHookInj,
FieldsInj, FieldsInj,
IsFormInj, IsFormInj,
IsKanbanInj, IsKanbanInj,
@ -21,6 +22,7 @@ import {
useVModel, useVModel,
watch, watch,
} from '#imports' } from '#imports'
import { useActiveKeyupListener } from '~/composables/useSelectedCellKeyupListener'
import type { Row } from '~/lib' import type { Row } from '~/lib'
interface Props { interface Props {
@ -33,12 +35,16 @@ interface Props {
rowId?: string rowId?: string
view?: ViewType view?: ViewType
showNextPrevIcons?: boolean showNextPrevIcons?: boolean
firstRow?: boolean
lastRow?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev']) const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev'])
const key = ref(0)
const { t } = useI18n() const { t } = useI18n()
const row = ref(props.row) const row = ref(props.row)
@ -49,6 +55,9 @@ const meta = toRef(props, 'meta')
const router = useRouter() const router = useRouter()
// override cell click hook to avoid unexpected behavior at form fields
provide(CellClickHookInj, null)
const fields = computedInject(FieldsInj, (_fields) => { const fields = computedInject(FieldsInj, (_fields) => {
if (props.useMetaFields) { if (props.useMetaFields) {
return (meta.value.columns ?? []).filter((col) => !isSystemColumn(col)) return (meta.value.columns ?? []).filter((col) => !isSystemColumn(col))
@ -60,7 +69,16 @@ const isKanban = inject(IsKanbanInj, ref(false))
provide(MetaInj, meta) provide(MetaInj, meta)
const { commentsDrawer, changedColumns, state: rowState, isNew, loadRow } = useProvideExpandedFormStore(meta, row) const {
commentsDrawer,
changedColumns,
state: rowState,
isNew,
loadRow,
saveRowAndStay,
syncLTARRefs,
save,
} = useProvideExpandedFormStore(meta, row)
const duplicatingRowInProgress = ref(false) const duplicatingRowInProgress = ref(false)
@ -122,6 +140,25 @@ const onDuplicateRow = () => {
}, 500) }, 500)
} }
const onNext = async () => {
if (changedColumns.value.size > 0) {
await Modal.confirm({
title: 'Do you want to save the changes?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
await save()
emits('next')
},
onCancel: () => {
emits('next')
},
})
} else {
emits('next')
}
}
const reloadParentRowHook = inject(ReloadRowDataHookInj, createEventHook()) const reloadParentRowHook = inject(ReloadRowDataHookInj, createEventHook())
// override reload trigger and use it to reload grid and the form itself // override reload trigger and use it to reload grid and the form itself
@ -132,7 +169,6 @@ reloadHook.on(() => {
if (isNew.value) return if (isNew.value) return
loadRow() loadRow()
}) })
provide(ReloadRowDataHookInj, reloadHook) provide(ReloadRowDataHookInj, reloadHook)
if (isKanban.value) { if (isKanban.value) {
@ -147,10 +183,94 @@ if (isKanban.value) {
const cellWrapperEl = ref<HTMLElement>() const cellWrapperEl = ref<HTMLElement>()
onMounted(() => { onMounted(() => {
setTimeout(() => { setTimeout(() => (cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus())
;(cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus()
})
}) })
const addNewRow = () => {
setTimeout(async () => {
row.value = {
row: {},
oldRow: {},
rowMeta: { new: true },
}
rowState.value = {}
key.value++
isExpanded.value = true
}, 500)
}
// attach keyboard listeners to switch between rows
// using alt + left/right arrow keys
useActiveKeyupListener(
isExpanded,
async (e: KeyboardEvent) => {
if (!e.altKey) return
if (e.key === 'ArrowLeft') {
e.stopPropagation()
emits('prev')
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
onNext()
}
// on alt + s save record
else if (e.code === 'KeyS') {
// remove focus from the active input if any
document.activeElement?.blur()
e.stopPropagation()
if (isNew.value) {
const data = await save(rowState.value)
await syncLTARRefs(data)
reloadHook?.trigger(null)
} else {
await save()
reloadHook?.trigger(null)
}
if (!saveRowAndStay.value) {
onClose()
}
// on alt + n create new record
} else if (e.code === 'KeyN') {
// remove focus from the active input if any to avoid unwanted input
;(document.activeElement as HTMLInputElement)?.blur?.()
if (changedColumns.value.size > 0) {
await Modal.confirm({
title: 'Do you want to save the changes?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
await save()
reloadHook?.trigger(null)
addNewRow()
},
onCancel: () => {
addNewRow()
},
})
} else if (isNew.value) {
await Modal.confirm({
title: 'Do you want to save the record?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
const data = await save(rowState.value)
await syncLTARRefs(data)
reloadHook?.trigger(null)
addNewRow()
},
onCancel: () => {
addNewRow()
},
})
} else {
addNewRow()
}
}
},
{ immediate: true },
)
</script> </script>
<script lang="ts"> <script lang="ts">
@ -171,21 +291,25 @@ export default {
> >
<SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" @duplicate-row="onDuplicateRow" /> <SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" @duplicate-row="onDuplicateRow" />
<div class="!bg-gray-100 rounded flex-1"> <div :key="key" class="!bg-gray-100 rounded flex-1">
<div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]"> <div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]">
<div class="flex-1 overflow-auto scrollbar-thin-dull nc-form-fields-container relative"> <div class="flex-1 overflow-auto scrollbar-thin-dull nc-form-fields-container relative">
<template v-if="props.showNextPrevIcons"> <template v-if="props.showNextPrevIcons">
<a-tooltip placement="bottom"> <a-tooltip v-if="!props.firstRow" placement="bottom">
<template #title> <template #title>
{{ $t('labels.nextRow') }} {{ $t('labels.prevRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '←']" />
</template> </template>
<MdiChevronRight class="cursor-pointer nc-next-arrow" @click="$emit('next')" /> <MdiChevronLeft class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" />
</a-tooltip> </a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="!props.lastRow" placement="bottom">
<template #title> <template #title>
{{ $t('labels.prevRow') }} {{ $t('labels.nextRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '→']" />
</template> </template>
<MdiChevronLeft class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" /> <MdiChevronRight class="cursor-pointer nc-next-arrow" @click="onNext" />
</a-tooltip> </a-tooltip>
</template> </template>
<div class="w-[500px] mx-auto"> <div class="w-[500px] mx-auto">

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

@ -7,6 +7,7 @@ interface Props {
required?: boolean | number required?: boolean | number
hideMenu?: boolean hideMenu?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const hideMenu = toRef(props, 'hideMenu') const hideMenu = toRef(props, 'hideMenu')
@ -34,6 +35,12 @@ const closeAddColumnDropdown = () => {
columnOrder.value = null columnOrder.value = null
editColumnDropdown.value = false editColumnDropdown.value = false
} }
const openHeaderMenu = () => {
if (!isForm.value && isUIAllowed('edit-column')) {
editColumnDropdown.value = true
}
}
</script> </script>
<template> <template>
@ -44,10 +51,11 @@ const closeAddColumnDropdown = () => {
<SmartsheetHeaderCellIcon v-if="column" /> <SmartsheetHeaderCellIcon v-if="column" />
<span <span
v-if="column" v-if="column"
class="name cursor-pointer" class="name"
:class="{ 'cursor-pointer': !isForm && isUIAllowed('edit-column') }"
style="white-space: nowrap" style="white-space: nowrap"
:title="column.title" :title="column.title"
@dblclick="editColumnDropdown = true" @dblclick="openHeaderMenu"
>{{ column.title }}</span >{{ column.title }}</span
> >
@ -56,11 +64,7 @@ const closeAddColumnDropdown = () => {
<template v-if="!hideMenu"> <template v-if="!hideMenu">
<div class="flex-1" /> <div class="flex-1" />
<LazySmartsheetHeaderMenu <LazySmartsheetHeaderMenu v-if="!isForm && isUIAllowed('edit-column')" @add-column="addField" @edit="openHeaderMenu" />
v-if="!isForm && isUIAllowed('edit-column')"
@add-column="addField"
@edit="editColumnDropdown = true"
/>
</template> </template>
<a-dropdown <a-dropdown

11
packages/nc-gui/components/smartsheet/header/CellIcon.ts

@ -15,6 +15,7 @@ import {
isDuration, isDuration,
isEmail, isEmail,
isFloat, isFloat,
isGeoData,
isInt, isInt,
isJSON, isJSON,
isPercent, isPercent,
@ -29,6 +30,7 @@ import {
isTime, isTime,
isURL, isURL,
isYear, isYear,
storeToRefs,
toRef, toRef,
useProject, useProject,
} from '#imports' } from '#imports'
@ -44,6 +46,7 @@ import CalendarIcon from '~icons/mdi/calendar'
import SingleSelectIcon from '~icons/mdi/arrow-down-drop-circle' import SingleSelectIcon from '~icons/mdi/arrow-down-drop-circle'
import MultiSelectIcon from '~icons/mdi/format-list-bulleted-square' import MultiSelectIcon from '~icons/mdi/format-list-bulleted-square'
import DatetimeIcon from '~icons/mdi/calendar-clock' import DatetimeIcon from '~icons/mdi/calendar-clock'
import GeoDataIcon from '~icons/mdi/map-marker'
import RatingIcon from '~icons/mdi/star' import RatingIcon from '~icons/mdi/star'
import GenericIcon from '~icons/mdi/square-rounded' import GenericIcon from '~icons/mdi/square-rounded'
import NumericIcon from '~icons/mdi/numeric' import NumericIcon from '~icons/mdi/numeric'
@ -58,12 +61,16 @@ import DurationIcon from '~icons/mdi/timer-outline'
const renderIcon = (column: ColumnType, abstractType: any) => { const renderIcon = (column: ColumnType, abstractType: any) => {
if (isPrimaryKey(column)) { if (isPrimaryKey(column)) {
return KeyIcon return KeyIcon
} else if (isSpecificDBType(column)) {
return SpecificDBTypeIcon
} else if (isJSON(column)) { } else if (isJSON(column)) {
return JSONIcon return JSONIcon
} else if (isDate(column, abstractType)) { } else if (isDate(column, abstractType)) {
return CalendarIcon return CalendarIcon
} else if (isDateTime(column, abstractType)) { } else if (isDateTime(column, abstractType)) {
return DatetimeIcon return DatetimeIcon
} else if (isGeoData(column)) {
return GeoDataIcon
} else if (isSet(column)) { } else if (isSet(column)) {
return MultiSelectIcon return MultiSelectIcon
} else if (isSingleSelect(column)) { } else if (isSingleSelect(column)) {
@ -98,8 +105,6 @@ const renderIcon = (column: ColumnType, abstractType: any) => {
return NumericIcon return NumericIcon
} else if (isString(column, abstractType)) { } else if (isString(column, abstractType)) {
return StringIcon return StringIcon
} else if (isSpecificDBType(column)) {
return SpecificDBTypeIcon
} else { } else {
return GenericIcon return GenericIcon
} }
@ -119,7 +124,7 @@ export default defineComponent({
const column = inject(ColumnInj, columnMeta) const column = inject(ColumnInj, columnMeta)
const { sqlUis } = useProject() const { sqlUis } = storeToRefs(useProject())
const sqlUi = ref(column.value?.base_id ? sqlUis.value[column.value?.base_id] : Object.values(sqlUis.value)[0]) const sqlUi = ref(column.value?.base_id ? sqlUis.value[column.value?.base_id] : Object.values(sqlUis.value)[0])

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

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { LinkToAnotherRecordType } from 'nocodb-sdk' import type { ColumnReqType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk' import { RelationTypes, UITypes } from 'nocodb-sdk'
import { import {
ActiveViewInj, ActiveViewInj,
@ -9,8 +9,6 @@ import {
Modal, Modal,
ReloadViewDataHookInj, ReloadViewDataHookInj,
SmartsheetStoreEvents, SmartsheetStoreEvents,
defineEmits,
defineProps,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
getUniqueColumnName, getUniqueColumnName,
inject, inject,
@ -62,7 +60,7 @@ const deleteColumn = () =>
} }
$e('a:column:delete') $e('a:column:delete')
} catch (e) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
}, },
@ -95,7 +93,7 @@ const sortByColumn = async (direction: 'asc' | 'desc') => {
}) })
eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD) eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD)
reloadDataHook?.trigger() reloadDataHook?.trigger()
} catch (e) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
} }
@ -107,7 +105,7 @@ const duplicateColumn = async () => {
const duplicateColumnName = getUniqueColumnName(`${column!.value.title}_copy`, meta!.value!.columns!) const duplicateColumnName = getUniqueColumnName(`${column!.value.title}_copy`, meta!.value!.columns!)
// construct column create payload // construct column create payload
switch (column.value.uidt) { switch (column?.value.uidt) {
case UITypes.LinkToAnotherRecord: case UITypes.LinkToAnotherRecord:
case UITypes.Lookup: case UITypes.Lookup:
case UITypes.Rollup: case UITypes.Rollup:
@ -144,14 +142,14 @@ const duplicateColumn = async () => {
} }
try { try {
const gridViewColumnList = await $api.dbViewColumn.list(view.value?.id as string) const gridViewColumnList = (await $api.dbViewColumn.list(view.value?.id as string)).list
const currentColumnIndex = gridViewColumnList.findIndex((f) => f.fk_column_id === column!.value.id) const currentColumnIndex = gridViewColumnList.findIndex((f) => f.fk_column_id === column!.value.id)
let newColumnOrder let newColumnOrder
if (currentColumnIndex === gridViewColumnList.length - 1) { if (currentColumnIndex === gridViewColumnList.length - 1) {
newColumnOrder = gridViewColumnList[currentColumnIndex].order + 1 newColumnOrder = gridViewColumnList[currentColumnIndex].order! + 1
} else { } else {
newColumnOrder = (gridViewColumnList[currentColumnIndex].order! + gridViewColumnList[currentColumnIndex + 1]?.order) / 2 newColumnOrder = (gridViewColumnList[currentColumnIndex].order! + gridViewColumnList[currentColumnIndex + 1].order!) / 2
} }
await $api.dbTableColumn.create(meta!.value!.id!, { await $api.dbTableColumn.create(meta!.value!.id!, {
@ -161,7 +159,7 @@ const duplicateColumn = async () => {
order: newColumnOrder, order: newColumnOrder,
view_id: view.value?.id as string, view_id: view.value?.id as string,
}, },
}) } as ColumnReqType)
await getMeta(meta!.value!.id!, true) await getMeta(meta!.value!.id!, true)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD) eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
@ -175,7 +173,7 @@ const duplicateColumn = async () => {
// add column before or after current column // add column before or after current column
const addColumn = async (before = false) => { const addColumn = async (before = false) => {
const gridViewColumnList = await $api.dbViewColumn.list(view.value?.id as string) const gridViewColumnList = (await $api.dbViewColumn.list(view.value?.id as string)).list
const currentColumnIndex = gridViewColumnList.findIndex((f) => f.fk_column_id === column!.value.id) const currentColumnIndex = gridViewColumnList.findIndex((f) => f.fk_column_id === column!.value.id)
@ -204,11 +202,11 @@ const addColumn = async (before = false) => {
// hide the field in view // hide the field in view
const hideField = async () => { const hideField = async () => {
const gridViewColumnList = await $api.dbViewColumn.list(view.value?.id as string) const gridViewColumnList = (await $api.dbViewColumn.list(view.value?.id as string)).list
const currentColumn = gridViewColumnList.find((f) => f.fk_column_id === column!.value.id) const currentColumn = gridViewColumnList.find((f) => f.fk_column_id === column!.value.id)
await $api.dbViewColumn.update(view.value.id, currentColumn.id, { show: false }) await $api.dbViewColumn.update(view.value!.id!, currentColumn!.id!, { show: false })
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD) eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
} }
</script> </script>

216
packages/nc-gui/components/smartsheet/sidebar/MenuBottom.vue

@ -2,12 +2,12 @@
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import { useNuxtApp, useSmartsheetStoreOrThrow, viewIcons } from '#imports' import { useNuxtApp, useSmartsheetStoreOrThrow, viewIcons } from '#imports'
const emits = defineEmits<Emits>()
interface Emits { interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string }): void (event: 'openModal', data: { type: ViewTypes; title?: string }): void
} }
const emits = defineEmits<Emits>()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const { isSqlView } = useSmartsheetStoreOrThrow() const { isSqlView } = useSmartsheetStoreOrThrow()
@ -20,101 +20,121 @@ function onOpenModal(type: ViewTypes, title = '') {
<template> <template>
<a-menu :selected-keys="[]" class="flex flex-col"> <a-menu :selected-keys="[]" class="flex flex-col">
<div> <h3 class="px-3 text-xs font-semibold flex items-center gap-4 text-gray-500">
<h3 class="px-3 text-xs font-semibold flex items-center gap-4 text-gray-500"> {{ $t('activity.createView') }}
{{ $t('activity.createView') }} </h3>
</h3>
<a-menu-item
<a-menu-item key="grid"
key="grid" class="group !flex !items-center !my-0 !h-2.5rem nc-create-grid-view"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-grid-view" @click="onOpenModal(ViewTypes.GRID)"
@click="onOpenModal(ViewTypes.GRID)" >
> <a-tooltip :mouse-enter-delay="1" placement="left">
<a-tooltip :mouse-enter-delay="1" placement="left"> <template #title>
<template #title> {{ $t('msg.info.addView.grid') }}
{{ $t('msg.info.addView.grid') }} </template>
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2"> <component :is="viewIcons[ViewTypes.GRID].icon" :style="{ color: viewIcons[ViewTypes.GRID].color }" />
<component :is="viewIcons[ViewTypes.GRID].icon" :style="{ color: viewIcons[ViewTypes.GRID].color }" />
<div>{{ $t('objects.viewType.grid') }}</div>
<div>{{ $t('objects.viewType.grid') }}</div>
<div class="flex-1" />
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
<mdi-plus class="group-hover:text-primary" /> </div>
</div> </a-tooltip>
</a-tooltip> </a-menu-item>
</a-menu-item>
<a-menu-item
<a-menu-item key="gallery"
key="gallery" class="group !flex !items-center !my-0 !h-2.5rem nc-create-gallery-view"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-gallery-view" @click="onOpenModal(ViewTypes.GALLERY)"
@click="onOpenModal(ViewTypes.GALLERY)" >
> <a-tooltip :mouse-enter-delay="1" placement="left">
<a-tooltip :mouse-enter-delay="1" placement="left"> <template #title>
<template #title> {{ $t('msg.info.addView.gallery') }}
{{ $t('msg.info.addView.gallery') }} </template>
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2"> <component :is="viewIcons[ViewTypes.GALLERY].icon" :style="{ color: viewIcons[ViewTypes.GALLERY].color }" />
<component :is="viewIcons[ViewTypes.GALLERY].icon" :style="{ color: viewIcons[ViewTypes.GALLERY].color }" />
<div>{{ $t('objects.viewType.gallery') }}</div>
<div>{{ $t('objects.viewType.gallery') }}</div>
<div class="flex-1" />
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
<mdi-plus class="group-hover:text-primary" /> </div>
</div> </a-tooltip>
</a-tooltip> </a-menu-item>
</a-menu-item>
<a-menu-item
<a-menu-item v-if="!isSqlView"
v-if="!isSqlView" key="form"
key="form" class="group !flex !items-center !my-0 !h-2.5rem nc-create-form-view"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-form-view" @click="onOpenModal(ViewTypes.FORM)"
@click="onOpenModal(ViewTypes.FORM)" >
> <a-tooltip :mouse-enter-delay="1" placement="left">
<a-tooltip :mouse-enter-delay="1" placement="left"> <template #title>
<template #title> {{ $t('msg.info.addView.form') }}
{{ $t('msg.info.addView.form') }} </template>
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2"> <component :is="viewIcons[ViewTypes.FORM].icon" :style="{ color: viewIcons[ViewTypes.FORM].color }" />
<component :is="viewIcons[ViewTypes.FORM].icon" :style="{ color: viewIcons[ViewTypes.FORM].color }" />
<div>{{ $t('objects.viewType.form') }}</div>
<div>{{ $t('objects.viewType.form') }}</div>
<div class="flex-1" />
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
<mdi-plus class="group-hover:text-primary" /> </div>
</div> </a-tooltip>
</a-tooltip> </a-menu-item>
</a-menu-item>
<a-menu-item
<a-menu-item key="kanban"
key="kanban" class="group !flex !items-center !my-0 !h-2.5rem nc-create-kanban-view"
class="group !flex !items-center !my-0 !h-2.5rem nc-create-kanban-view" @click="onOpenModal(ViewTypes.KANBAN)"
@click="onOpenModal(ViewTypes.KANBAN)" >
> <a-tooltip :mouse-enter-delay="1" placement="left">
<a-tooltip :mouse-enter-delay="1" placement="left"> <template #title>
<template #title> {{ $t('msg.info.addView.kanban') }}
{{ $t('msg.info.addView.kanban') }} </template>
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2"> <component :is="viewIcons[ViewTypes.KANBAN].icon" :style="{ color: viewIcons[ViewTypes.KANBAN].color }" />
<component :is="viewIcons[ViewTypes.KANBAN].icon" :style="{ color: viewIcons[ViewTypes.KANBAN].color }" />
<div>{{ $t('objects.viewType.kanban') }}</div>
<div>{{ $t('objects.viewType.kanban') }}</div>
<div class="flex-1" />
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
<mdi-plus class="group-hover:text-primary" /> </div>
</div> </a-tooltip>
</a-tooltip> </a-menu-item>
</a-menu-item> <a-menu-item
v-if="geodataToggleState.show"
<div class="w-full h-4" /> key="map"
</div> class="group !flex !items-center !my-0 !h-2.5rem nc-create-map-view"
@click="onOpenModal(ViewTypes.MAP)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.map') }}
</template>
<div class="nc-project-menu-item !py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.MAP].icon" :style="{ color: viewIcons[ViewTypes.MAP].color }" />
<div>{{ $t('objects.viewType.map') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<div class="w-full h-4" />
</a-menu> </a-menu>
</template> </template>

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

@ -7,6 +7,7 @@ import {
inject, inject,
ref, ref,
resolveComponent, resolveComponent,
storeToRefs,
useDialog, useDialog,
useNuxtApp, useNuxtApp,
useRoute, useRoute,
@ -21,11 +22,11 @@ const meta = inject(MetaInj, ref())
const activeView = inject(ActiveViewInj, ref()) const activeView = inject(ActiveViewInj, ref())
const { activeTab } = useTabs() const { activeTab } = storeToRefs(useTabs())
const { views, loadViews, isLoading } = useViews(meta) const { views, loadViews, isLoading } = useViews(meta)
const { lastOpenedViewMap } = useProject() const { lastOpenedViewMap } = storeToRefs(useProject())
const setLastOpenedViewId = (viewId?: string) => { const setLastOpenedViewId = (viewId?: string) => {
if (viewId && activeTab.value?.id) { if (viewId && activeTab.value?.id) {

15
packages/nc-gui/components/smartsheet/sidebar/toolbar/GeodataSwitcher.vue

@ -0,0 +1,15 @@
<script setup lang="ts">
function toggleGeodataFeature() {
geodataToggleState.show = !geodataToggleState.show
localStorage.setItem('geodataToggleState', JSON.stringify(geodataToggleState.show))
}
</script>
<template>
<a-tooltip placement="bottomRight">
<template #title>
<span> Toggle GeoData </span>
</template>
<mdi-map-marker class="cursor-pointer" data-testid="toggle-geodata-feature-icon" @click="toggleGeodataFeature" />
</a-tooltip>
</template>

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

@ -29,6 +29,10 @@ const onClick = () => {
<LazySmartsheetSidebarToolbarDebugMeta /> <LazySmartsheetSidebarToolbarDebugMeta />
<div class="dot" /> <div class="dot" />
<LazySmartsheetSidebarToolbarGeodataSwitcher />
<div class="dot" />
</template> </template>
<slot name="end" /> <slot name="end" />

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

@ -6,6 +6,7 @@ import {
MetaInj, MetaInj,
ReloadViewDataHookInj, ReloadViewDataHookInj,
comparisonOpList, comparisonOpList,
comparisonSubOpList,
computed, computed,
inject, inject,
ref, ref,
@ -54,6 +55,7 @@ const {
sync, sync,
saveOrUpdateDebounced, saveOrUpdateDebounced,
isComparisonOpAllowed, isComparisonOpAllowed,
isComparisonSubOpAllowed,
} = useViewFilters( } = useViewFilters(
activeView, activeView,
parentId, parentId,
@ -75,24 +77,43 @@ const filterPrevComparisonOp = ref<Record<string, string>>({})
const filterUpdateCondition = (filter: FilterType, i: number) => { const filterUpdateCondition = (filter: FilterType, i: number) => {
const col = getColumn(filter) const col = getColumn(filter)
if (!col) return
if ( if (
col.uidt === UITypes.SingleSelect && col.uidt === UITypes.SingleSelect &&
['anyof', 'nanyof'].includes(filterPrevComparisonOp.value[filter.id]) && ['anyof', 'nanyof'].includes(filterPrevComparisonOp.value[filter.id!]) &&
['eq', 'neq'].includes(filter.comparison_op!) ['eq', 'neq'].includes(filter.comparison_op!)
) { ) {
// anyof and nanyof can allow multiple selections, // anyof and nanyof can allow multiple selections,
// while `eq` and `neq` only allow one selection // while `eq` and `neq` only allow one selection
filter.value = '' filter.value = null
} else if (['blank', 'notblank', 'empty', 'notempty', 'null', 'notnull'].includes(filter.comparison_op!)) { } else if (['blank', 'notblank', 'empty', 'notempty', 'null', 'notnull'].includes(filter.comparison_op!)) {
// since `blank`, `empty`, `null` doesn't require value, // since `blank`, `empty`, `null` doesn't require value,
// hence remove the previous value // hence remove the previous value
filter.value = '' filter.value = null
filter.comparison_sub_op = null
} else if ([UITypes.Date, UITypes.DateTime].includes(col.uidt as UITypes)) {
// for date / datetime,
// the input type could be decimal or datepicker / datetime picker
// hence remove the previous value
filter.value = null
if (
!comparisonSubOpList(filter.comparison_op!)
.map((op) => op.value)
.includes(filter.comparison_sub_op!)
) {
if (filter.comparison_op === 'isWithin') {
filter.comparison_sub_op = 'pastNumberOfDays'
} else {
filter.comparison_sub_op = 'exactDate'
}
}
} }
saveOrUpdate(filter, i) saveOrUpdate(filter, i)
filterPrevComparisonOp.value[filter.id] = filter.comparison_op filterPrevComparisonOp.value[filter.id] = filter.comparison_op
$e('a:filter:update', { $e('a:filter:update', {
logical: filter.logical_op, logical: filter.logical_op,
comparison: filter.comparison_op, comparison: filter.comparison_op,
comparison_sub_op: filter.comparison_sub_op,
}) })
} }
@ -109,7 +130,7 @@ const types = computed(() => {
watch( watch(
() => activeView.value?.id, () => activeView.value?.id,
(n: string, o: string) => { (n, o) => {
// if nested no need to reload since it will get reloaded from parent // if nested no need to reload since it will get reloaded from parent
if (!nested && n !== o && (hookId || !webHook)) loadFilters(hookId as string) if (!nested && n !== o && (hookId || !webHook)) loadFilters(hookId as string)
}, },
@ -137,16 +158,30 @@ const applyChanges = async (hookId?: string, _nested = false) => {
} }
const selectFilterField = (filter: Filter, index: number) => { const selectFilterField = (filter: Filter, index: number) => {
const col = getColumn(filter)
if (!col) return
// when we change the field, // when we change the field,
// the corresponding default filter operator needs to be changed as well // the corresponding default filter operator needs to be changed as well
// since the existing one may not be supported for the new field // since the existing one may not be supported for the new field
// e.g. `eq` operator is not supported in checkbox field // e.g. `eq` operator is not supported in checkbox field
// hence, get the first option of the supported operators of the new field // hence, get the first option of the supported operators of the new field
filter.comparison_op = comparisonOpList(getColumn(filter)!.uidt as UITypes).filter((compOp) => filter.comparison_op = comparisonOpList(col.uidt as UITypes).filter((compOp) =>
isComparisonOpAllowed(filter, compOp), isComparisonOpAllowed(filter, compOp),
)?.[0].value )?.[0].value
if ([UITypes.Date, UITypes.DateTime].includes(col.uidt as UITypes) && !['blank', 'notblank'].includes(filter.comparison_op)) {
if (filter.comparison_op === 'isWithin') {
filter.comparison_sub_op = 'pastNumberOfDays'
} else {
filter.comparison_sub_op = 'exactDate'
}
} else {
// reset
filter.comparison_sub_op = null
}
// reset filter value as well // reset filter value as well
filter.value = '' filter.value = null
saveOrUpdate(filter, index) saveOrUpdate(filter, index)
} }
@ -163,10 +198,19 @@ defineExpose({
<template> <template>
<div <div
class="p-4 menu-filter-dropdown bg-gray-50 !border mt-4" class="p-4 menu-filter-dropdown bg-gray-50 !border"
:class="{ 'shadow min-w-[430px] max-h-[max(80vh,500px)] overflow-auto': !nested, 'border-1 w-full': nested }" :class="{
'min-w-[430px]': filters.length,
'shadow max-h-[max(80vh,500px)] overflow-auto': !nested,
'border-1 w-full': nested,
}"
> >
<div v-if="filters && filters.length" class="nc-filter-grid mb-2" @click.stop> <div
v-if="filters && filters.length"
class="nc-filter-grid mb-2"
:class="{ 'max-h-420px overflow-y-auto': !nested }"
@click.stop
>
<template v-for="(filter, i) in filters" :key="i"> <template v-for="(filter, i) in filters" :key="i">
<template v-if="filter.status !== 'delete'"> <template v-if="filter.status !== 'delete'">
<template v-if="filter.is_group"> <template v-if="filter.is_group">
@ -195,7 +239,7 @@ defineExpose({
</a-select> </a-select>
</div> </div>
<span class="col-span-3" /> <span class="col-span-3" />
<div class="col-span-5"> <div class="col-span-6">
<LazySmartsheetToolbarColumnFilter <LazySmartsheetToolbarColumnFilter
v-if="filter.id || filter.children" v-if="filter.id || filter.children"
:key="filter.id ?? i" :key="filter.id ?? i"
@ -261,24 +305,49 @@ defineExpose({
</template> </template>
</a-select> </a-select>
<span <a-select
v-if=" v-if="
filter.comparison_op && [UITypes.Date, UITypes.DateTime].includes(getColumn(filter)?.uidt) &&
['null', 'notnull', 'checked', 'notchecked', 'empty', 'notempty', 'blank', 'notblank'].includes( !['blank', 'notblank'].includes(filter.comparison_op)
filter.comparison_op,
)
" "
:key="`span${i}`" v-model:value="filter.comparison_sub_op"
/> :dropdown-match-select-width="false"
class="caption nc-filter-sub_operation-select"
:placeholder="$t('labels.operationSub')"
density="compact"
variant="solo"
:disabled="filter.readOnly"
hide-details
dropdown-class-name="nc-dropdown-filter-comp-sub-op"
@change="filterUpdateCondition(filter, i)"
>
<template v-for="compSubOp of comparisonSubOpList(filter.comparison_op)" :key="compSubOp.value">
<a-select-option v-if="isComparisonSubOpAllowed(filter, compSubOp)" :value="compSubOp.value">
{{ compSubOp.text }}
</a-select-option>
</template>
</a-select>
<span v-else />
<a-checkbox <a-checkbox
v-else-if="filter.field && types[filter.field] === 'boolean'" v-if="filter.field && types[filter.field] === 'boolean'"
v-model:checked="filter.value" v-model:checked="filter.value"
dense dense
:disabled="filter.readOnly" :disabled="filter.readOnly"
@change="saveOrUpdate(filter, i)" @change="saveOrUpdate(filter, i)"
/> />
<span
v-else-if="
filter.comparison_sub_op
? comparisonSubOpList(filter.comparison_op).find((op) => op.value === filter.comparison_sub_op)?.ignoreVal ??
false
: comparisonOpList(getColumn(filter)?.uidt).find((op) => op.value === filter.comparison_op)?.ignoreVal ?? false
"
:key="`span${i}`"
/>
<LazySmartsheetToolbarFilterInput <LazySmartsheetToolbarFilterInput
v-else v-else
class="nc-filter-value-select min-w-[120px]" class="nc-filter-value-select min-w-[120px]"
@ -315,7 +384,7 @@ defineExpose({
<style scoped> <style scoped>
.nc-filter-grid { .nc-filter-grid {
grid-template-columns: auto auto auto auto auto; grid-template-columns: auto auto auto auto auto auto;
@apply grid gap-[12px] items-center; @apply grid gap-[12px] items-center;
} }

3
packages/nc-gui/components/smartsheet/toolbar/ExportSubActions.vue

@ -10,6 +10,7 @@ import {
inject, inject,
message, message,
ref, ref,
storeToRefs,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
useProject, useProject,
@ -22,7 +23,7 @@ const isPublicView = inject(IsPublicInj, ref(false))
const fields = inject(FieldsInj, ref([])) const fields = inject(FieldsInj, ref([]))
const { project } = useProject() const { project } = storeToRefs(useProject())
const { $api } = useNuxtApp() const { $api } = useNuxtApp()

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

@ -55,6 +55,8 @@ const { eventBus } = useSmartsheetStoreOrThrow()
eventBus.on((event) => { eventBus.on((event) => {
if (event === SmartsheetStoreEvents.FIELD_RELOAD) { if (event === SmartsheetStoreEvents.FIELD_RELOAD) {
loadViewColumns() loadViewColumns()
} else if (event === SmartsheetStoreEvents.MAPPED_BY_COLUMN_CHANGE) {
loadViewColumns()
} }
}) })
@ -123,13 +125,11 @@ const coverImageColumnId = computed({
) { ) {
if (activeView.value?.type === ViewTypes.GALLERY) { if (activeView.value?.type === ViewTypes.GALLERY) {
await $api.dbView.galleryUpdate(activeView.value?.id, { await $api.dbView.galleryUpdate(activeView.value?.id, {
...activeView.value?.view,
fk_cover_image_col_id: val, fk_cover_image_col_id: val,
}) })
;(activeView.value.view as GalleryType).fk_cover_image_col_id = val ;(activeView.value.view as GalleryType).fk_cover_image_col_id = val
} else if (activeView.value?.type === ViewTypes.KANBAN) { } else if (activeView.value?.type === ViewTypes.KANBAN) {
await $api.dbView.kanbanUpdate(activeView.value?.id, { await $api.dbView.kanbanUpdate(activeView.value?.id, {
...activeView.value?.view,
fk_cover_image_col_id: val, fk_cover_image_col_id: val,
}) })
;(activeView.value.view as KanbanType).fk_cover_image_col_id = val ;(activeView.value.view as KanbanType).fk_cover_image_col_id = val
@ -204,6 +204,7 @@ useMenuCloseOnEsc(open)
v-model:checked="field.show" v-model:checked="field.show"
v-e="['a:fields:show-hide']" v-e="['a:fields:show-hide']"
class="shrink" class="shrink"
:disabled="field.isViewEssentialField"
@change="saveOrUpdate(field, index)" @change="saveOrUpdate(field, index)"
> >
<div class="flex items-center"> <div class="flex items-center">

13
packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import { storeToRefs } from 'pinia'
import { import {
ColumnInj, ColumnInj,
EditModeInj, EditModeInj,
@ -83,7 +84,8 @@ const checkTypeFunctions = {
type FilterType = keyof typeof checkTypeFunctions type FilterType = keyof typeof checkTypeFunctions
const { sqlUi } = $(useProject()) // todo: replace with sqlUis
const { sqlUi } = $(storeToRefs(useProject()))
const abstractType = $computed(() => (column.value?.dt && sqlUi ? sqlUi.getAbstractType(column.value) : null)) const abstractType = $computed(() => (column.value?.dt && sqlUi ? sqlUi.getAbstractType(column.value) : null))
@ -117,9 +119,13 @@ const componentMap: Partial<Record<FilterType, any>> = $computed(() => {
// use MultiSelect for SingleSelect columns for anyof / nanyof filters // use MultiSelect for SingleSelect columns for anyof / nanyof filters
isSingleSelect: ['anyof', 'nanyof'].includes(props.filter.comparison_op!) ? MultiSelect : SingleSelect, isSingleSelect: ['anyof', 'nanyof'].includes(props.filter.comparison_op!) ? MultiSelect : SingleSelect,
isMultiSelect: MultiSelect, isMultiSelect: MultiSelect,
isDate: DatePicker, isDate: ['daysAgo', 'daysFromNow', 'pastNumberOfDays', 'nextNumberOfDays'].includes(props.filter.comparison_sub_op!)
? Decimal
: DatePicker,
isYear: YearPicker, isYear: YearPicker,
isDateTime: DateTimePicker, isDateTime: ['daysAgo', 'daysFromNow', 'pastNumberOfDays', 'nextNumberOfDays'].includes(props.filter.comparison_sub_op!)
? Decimal
: DateTimePicker,
isTime: TimePicker, isTime: TimePicker,
isRating: Rating, isRating: Rating,
isDuration: Duration, isDuration: Duration,
@ -189,6 +195,7 @@ const hasExtraPadding = $computed(() => {
:column="column" :column="column"
class="flex" class="flex"
v-bind="componentProps" v-bind="componentProps"
location="filter"
/> />
</div> </div>
</template> </template>

113
packages/nc-gui/components/smartsheet/toolbar/MappedBy.vue

@ -0,0 +1,113 @@
<script setup lang="ts">
import type { MapType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { SelectProps } from 'ant-design-vue'
import {
ActiveViewInj,
IsLockedInj,
MetaInj,
ReloadViewDataHookInj,
computed,
inject,
ref,
useViewColumns,
watch,
} from '#imports'
const { eventBus } = useSmartsheetStoreOrThrow()
const meta = inject(MetaInj, ref())
const activeView = inject(ActiveViewInj, ref())
const reloadDataHook = inject(ReloadViewDataHookInj)!
const isLocked = inject(IsLockedInj, ref(false))
const { fields, loadViewColumns, metaColumnById } = useViewColumns(activeView, meta, () => reloadDataHook.trigger())
const { loadMapData, loadMapMeta, updateMapMeta, mapMetaData, geoDataFieldColumn } = useMapViewStoreOrThrow()
const mappedByDropdown = ref(false)
watch(
() => activeView.value?.id,
async (newVal, oldVal) => {
if (newVal !== oldVal && meta.value) {
await loadViewColumns()
}
},
{ immediate: true },
)
const geoDataMappingFieldColumnId = computed({
get: () => mapMetaData.value.fk_geo_data_col_id,
set: async (val) => {
if (val) {
await updateMapMeta({
fk_geo_data_col_id: val,
})
await loadMapMeta()
await loadMapData()
;(activeView.value?.view as MapType).fk_geo_data_col_id = val
eventBus.emit(SmartsheetStoreEvents.MAPPED_BY_COLUMN_CHANGE)
}
},
})
const geoDataFieldOptions = computed<SelectProps['options']>(() => {
return fields.value
?.filter((el) => el.fk_column_id && metaColumnById.value[el.fk_column_id].uidt === UITypes.GeoData)
.map((field) => {
return {
value: field.fk_column_id,
label: field.title,
}
})
})
const handleChange = () => {
mappedByDropdown.value = false
}
</script>
<template>
<a-dropdown v-model:visible="mappedByDropdown" :trigger="['click']">
<div class="nc-map-btn">
<a-button v-e="['c:map:change-grouping-field']" class="nc-map-stacked-by-menu-btn nc-toolbar-btn" :disabled="isLocked">
<div class="flex items-center gap-1">
<mdi-arrow-down-drop-circle-outline />
<span class="text-capitalize !text-sm font-weight-normal">
{{ $t('activity.map.mappedBy') }}
<span class="font-bold">{{ geoDataFieldColumn?.title }}</span>
</span>
<MdiMenuDown class="text-grey" />
</div>
</a-button>
</div>
<template #overlay>
<div
v-if="mappedByDropdown"
class="p-3 min-w-[280px] bg-gray-50 shadow-lg nc-table-toolbar-menu max-h-[max(80vh,500px)] overflow-auto !border"
@click.stop
>
<div>
<span class="font-bold"> {{ $t('activity.map.chooseMappingField') }}</span>
<a-divider class="!my-2" />
</div>
<div class="nc-fields-list py-1">
<div class="grouping-field">
<a-select
v-model:value="geoDataMappingFieldColumnId"
class="w-full nc-msp-grouping-field-select"
:options="geoDataFieldOptions"
placeholder="Select a Mapping Field"
@change="handleChange"
@click.stop
/>
</div>
</div>
</div>
</template>
</a-dropdown>
</template>

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

@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { RequestParams } from 'nocodb-sdk' import type { RequestParams } from 'nocodb-sdk'
import { ExportTypes } from 'nocodb-sdk' import { ExportTypes } from 'nocodb-sdk'
import { storeToRefs } from 'pinia'
import { import {
ActiveViewInj, ActiveViewInj,
FieldsInj, FieldsInj,
@ -27,7 +28,7 @@ const isPublicView = inject(IsPublicInj, ref(false))
const isView = false const isView = false
const { project } = useProject() const { project } = storeToRefs(useProject())
const { $api } = useNuxtApp() const { $api } = useNuxtApp()

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

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { GridType } from 'nocodb-sdk' import type { GridType } from 'nocodb-sdk'
import { ActiveViewInj, IsLockedInj, inject, ref, useMenuCloseOnEsc } from '#imports' import { ActiveViewInj, IsLockedInj, inject, ref, storeToRefs, useMenuCloseOnEsc } from '#imports'
const { isSharedBase } = useProject() const { isSharedBase } = storeToRefs(useProject())
const view = inject(ActiveViewInj, ref()) const view = inject(ActiveViewInj, ref())

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

@ -9,6 +9,7 @@ import {
message, message,
projectThemeColors, projectThemeColors,
ref, ref,
storeToRefs,
useCopy, useCopy,
useDashboard, useDashboard,
useI18n, useI18n,
@ -32,7 +33,7 @@ const { dashboardUrl } = useDashboard()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { isSharedBase } = useProject() const { isSharedBase } = storeToRefs(useProject())
let showShareModel = $ref(false) let showShareModel = $ref(false)
@ -116,6 +117,9 @@ const sharedViewUrl = computed(() => {
case ViewTypes.GALLERY: case ViewTypes.GALLERY:
viewType = 'gallery' viewType = 'gallery'
break break
case ViewTypes.MAP:
viewType = 'map'
break
default: default:
viewType = 'view' viewType = 'view'
} }
@ -350,7 +354,11 @@ const copyIframeCode = async () => {
<div <div
v-if=" v-if="
shared && (shared.type === ViewTypes.GRID || shared.type === ViewTypes.KANBAN || shared.type === ViewTypes.GALLERY) shared &&
(shared.type === ViewTypes.GRID ||
shared.type === ViewTypes.KANBAN ||
shared.type === ViewTypes.GALLERY ||
shared.type === ViewTypes.MAP)
" "
> >
<!-- Allow Download --> <!-- Allow Download -->

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

@ -34,7 +34,7 @@ const { dashboardUrl } = useDashboard()
const sharedViewList = ref<SharedViewType[]>() const sharedViewList = ref<SharedViewType[]>()
const loadSharedViewsList = async () => { const loadSharedViewsList = async () => {
sharedViewList.value = await $api.dbViewShare.list(meta.value?.id as string) sharedViewList.value = (await $api.dbViewShare.list(meta.value?.id as string)).list as SharedViewType[]
// todo: show active view in list separately // todo: show active view in list separately
// const index = sharedViewList.value.findIndex((v) => { // const index = sharedViewList.value.findIndex((v) => {
@ -56,6 +56,9 @@ const sharedViewUrl = (view: SharedViewType) => {
case ViewTypes.FORM: case ViewTypes.FORM:
viewType = 'form' viewType = 'form'
break break
case ViewTypes.MAP:
viewType = 'map'
break
case ViewTypes.KANBAN: case ViewTypes.KANBAN:
viewType = 'kanban' viewType = 'kanban'
break break

25
packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick } from '@vue/runtime-core'
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import { import {
ActiveViewInj, ActiveViewInj,
@ -22,7 +23,16 @@ const reloadDataHook = inject(ReloadViewDataHookInj)
const { eventBus } = useSmartsheetStoreOrThrow() const { eventBus } = useSmartsheetStoreOrThrow()
const { sorts, saveOrUpdate, loadSorts, addSort, deleteSort } = useViewSorts(view, () => reloadDataHook?.trigger()) const { sorts, saveOrUpdate, loadSorts, addSort: _addSort, deleteSort } = useViewSorts(view, () => reloadDataHook?.trigger())
const removeIcon = ref<HTMLElement>()
const addSort = () => {
_addSort()
nextTick(() => {
removeIcon.value?.[removeIcon.value?.length - 1]?.$el?.scrollIntoView()
})
}
eventBus.on((event) => { eventBus.on((event) => {
if (event === SmartsheetStoreEvents.SORT_RELOAD) { if (event === SmartsheetStoreEvents.SORT_RELOAD) {
@ -75,12 +85,18 @@ useMenuCloseOnEsc(open)
</div> </div>
<template #overlay> <template #overlay>
<div <div
class="bg-gray-50 p-6 shadow-lg menu-filter-dropdown min-w-[400px] max-h-[max(80vh,500px)] overflow-auto !border" :class="{ ' min-w-[400px]': sorts.length }"
class="bg-gray-50 p-6 shadow-lg menu-filter-dropdown max-h-[max(80vh,500px)] overflow-auto !border"
data-testid="nc-sorts-menu" data-testid="nc-sorts-menu"
> >
<div v-if="sorts?.length" class="sort-grid mb-2" @click.stop> <div v-if="sorts?.length" class="sort-grid mb-2 max-h-420px overflow-y-auto" @click.stop>
<template v-for="(sort, i) of sorts" :key="i"> <template v-for="(sort, i) of sorts" :key="i">
<MdiCloseBox class="nc-sort-item-remove-btn text-grey self-center" small @click.stop="deleteSort(sort, i)" /> <MdiCloseBox
ref="removeIcon"
class="nc-sort-item-remove-btn text-grey self-center"
small
@click.stop="deleteSort(sort, i)"
/>
<LazySmartsheetToolbarFieldListAutoCompleteDropdown <LazySmartsheetToolbarFieldListAutoCompleteDropdown
v-model="sort.fk_column_id" v-model="sort.fk_column_id"
@ -92,6 +108,7 @@ useMenuCloseOnEsc(open)
/> />
<a-select <a-select
ref=""
v-model:value="sort.direction" v-model:value="sort.direction"
class="shrink grow-0 nc-sort-dir-select !text-xs" class="shrink grow-0 nc-sort-dir-select !text-xs"
:label="$t('labels.operation')" :label="$t('labels.operation')"

58
packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue

@ -1,12 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Ref } from '@vue/reactivity'
import { import {
ActiveViewInj, ActiveViewInj,
IsLockedInj, IsLockedInj,
IsPublicInj, IsPublicInj,
MetaInj,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
inject, inject,
message, message,
ref, ref,
storeToRefs,
useI18n, useI18n,
useMenuCloseOnEsc, useMenuCloseOnEsc,
useNuxtApp, useNuxtApp,
@ -41,11 +44,26 @@ const showApiSnippetDrawer = ref(false)
const showErd = ref(false) const showErd = ref(false)
const quickImportDialog = ref(false) type QuickImportDialogType = 'csv' | 'excel' | 'json'
// TODO: add 'json' when it's ready
const quickImportDialogTypes: QuickImportDialogType[] = ['csv', 'excel']
const quickImportDialogs: Record<typeof quickImportDialogTypes[number], Ref<boolean>> = quickImportDialogTypes.reduce(
(acc: any, curr) => {
acc[curr] = ref(false)
return acc
},
{},
) as Record<QuickImportDialogType, Ref<boolean>>
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { isSharedBase } = useProject() const { isSharedBase } = storeToRefs(useProject())
const meta = inject(MetaInj, ref())
const currentBaseId = computed(() => meta.value?.base_id)
const Icon = computed(() => { const Icon = computed(() => {
switch (selectedView.value?.lock_type) { switch (selectedView.value?.lock_type) {
@ -173,19 +191,20 @@ useMenuCloseOnEsc(open)
</template> </template>
<template #expandIcon></template> <template #expandIcon></template>
<a-menu-item v-if="isUIAllowed('csvImport') && !isView && !isPublicView"> <template v-for="(dialog, type) in quickImportDialogs">
<div <a-menu-item v-if="isUIAllowed(`${type}Import`) && !isView && !isPublicView" :key="type">
v-e="['a:actions:upload-csv']" <div
class="nc-project-menu-item" v-e="[`a:actions:upload-${type}`]"
:class="{ disabled: isLocked }" class="nc-project-menu-item"
@click="!isLocked ? (quickImportDialog = true) : {}" :class="{ disabled: isLocked }"
> @click="!isLocked ? (dialog.value = true) : {}"
<MdiUploadOutline class="text-gray-500" /> >
<!-- Upload CSV --> <MdiUploadOutline class="text-gray-500" />
{{ $t('activity.uploadCSV') }} {{ `${$t('general.upload')} ${type.toUpperCase()}` }}
<div class="flex items-center text-gray-400"><MdiAlpha />version</div> <div class="flex items-center text-gray-400"><MdiAlpha />version</div>
</div> </div>
</a-menu-item> </a-menu-item>
</template>
</a-sub-menu> </a-sub-menu>
</template> </template>
@ -230,7 +249,14 @@ useMenuCloseOnEsc(open)
</template> </template>
</a-dropdown> </a-dropdown>
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-data-only="true" /> <LazyDlgQuickImport
v-for="type in quickImportDialogTypes"
:key="type"
v-model="quickImportDialogs[type].value"
:import-type="type"
:base-id="currentBaseId"
:import-data-only="true"
/>
<LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" /> <LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" />

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

@ -39,7 +39,7 @@ const fields = ref<ColumnType[]>([])
const meta = computed<TableType | undefined>(() => activeTab.value && metas.value[activeTab.value.id!]) const meta = computed<TableType | undefined>(() => activeTab.value && metas.value[activeTab.value.id!])
const { isGallery, isGrid, isForm, isKanban, isLocked } = useProvideSmartsheetStore(activeView, meta) const { isGallery, isGrid, isForm, isKanban, isLocked, isMap } = useProvideSmartsheetStore(activeView, meta)
const reloadEventHook = createEventHook<void | boolean>() const reloadEventHook = createEventHook<void | boolean>()
@ -48,6 +48,7 @@ const reloadViewMetaEventHook = createEventHook<void | boolean>()
const openNewRecordFormHook = createEventHook<void>() const openNewRecordFormHook = createEventHook<void>()
useProvideKanbanViewStore(meta, activeView) useProvideKanbanViewStore(meta, activeView)
useProvideMapViewStore(meta, activeView)
// todo: move to store // todo: move to store
provide(MetaInj, meta) provide(MetaInj, meta)
@ -81,6 +82,8 @@ provide(
<LazySmartsheetForm v-else-if="isForm && !$route.query.reload" /> <LazySmartsheetForm v-else-if="isForm && !$route.query.reload" />
<LazySmartsheetKanban v-else-if="isKanban" /> <LazySmartsheetKanban v-else-if="isKanban" />
<LazySmartsheetMap v-else-if="isMap" />
</div> </div>
</div> </div>
</template> </template>

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

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ApiTokenType } from 'nocodb-sdk' import type { ApiTokenType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, message, onMounted, useCopy, useI18n, useNuxtApp, useProject } from '#imports' import { extractSdkResponseErrorMsg, message, onMounted, storeToRefs, useCopy, useI18n, useNuxtApp, useProject } from '#imports'
interface ApiToken extends ApiTokenType { interface ApiToken extends ApiTokenType {
show?: boolean show?: boolean
@ -10,7 +10,7 @@ const { t } = useI18n()
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const { project } = $(useProject()) const { project } = $(storeToRefs(useProject()))
const { copy } = useCopy() const { copy } = useCopy()
@ -25,7 +25,7 @@ let selectedTokenData = $ref<ApiToken>({})
const loadApiTokens = async () => { const loadApiTokens = async () => {
if (!project?.id) return if (!project?.id) return
tokensInfo = await $api.apiToken.list(project.id) tokensInfo = (await $api.apiToken.list(project.id)).list
} }
const openNewTokenModal = () => { const openNewTokenModal = () => {
@ -40,7 +40,7 @@ const copyToken = async (token: string | undefined) => {
await copy(token) await copy(token)
// Copied to clipboard // Copied to clipboard
message.info(t('msg.info.copiedToClipboard')) message.info(t('msg.info.copiedToClipboard'))
} catch (e) { } catch (e: any) {
message.error(e.message) message.error(e.message)
} }
$e('c:api-token:copy') $e('c:api-token:copy')

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

@ -7,6 +7,7 @@ import {
onBeforeMount, onBeforeMount,
projectRoleTagColors, projectRoleTagColors,
ref, ref,
storeToRefs,
useApi, useApi,
useCopy, useCopy,
useDashboard, useDashboard,
@ -24,7 +25,7 @@ const { $e } = useNuxtApp()
const { api } = useApi() const { api } = useApi()
const { project } = useProject() const { project } = storeToRefs(useProject())
const { copy } = useCopy() const { copy } = useCopy()
@ -76,6 +77,11 @@ const inviteUser = async (user: User) => {
try { try {
if (!project.value?.id) return if (!project.value?.id) return
if (!user.roles) {
// mark it as editor by default
user.roles = 'editor'
}
await api.auth.projectUserAdd(project.value.id, user) await api.auth.projectUserAdd(project.value.id, user)
// Successfully added user to project // Successfully added user to project

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

@ -1,5 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { extractSdkResponseErrorMsg, message, onMounted, useCopy, useDashboard, useI18n, useNuxtApp, useProject } from '#imports' import {
extractSdkResponseErrorMsg,
message,
onMounted,
storeToRefs,
useCopy,
useDashboard,
useI18n,
useNuxtApp,
useProject,
} from '#imports'
interface ShareBase { interface ShareBase {
uuid?: string uuid?: string
@ -22,7 +32,7 @@ let base = $ref<null | ShareBase>(null)
const showEditBaseDropdown = $ref(false) const showEditBaseDropdown = $ref(false)
const { project } = useProject() const { project } = storeToRefs(useProject())
const { copy } = useCopy() const { copy } = useCopy()

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

@ -9,6 +9,7 @@ import {
projectRoleTagColors, projectRoleTagColors,
projectRoles, projectRoles,
ref, ref,
storeToRefs,
useActiveKeyupListener, useActiveKeyupListener,
useCopy, useCopy,
useDashboard, useDashboard,
@ -37,7 +38,7 @@ const emit = defineEmits(['closed', 'reload'])
const { t } = useI18n() const { t } = useI18n()
const { project } = useProject() const { project } = storeToRefs(useProject())
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()

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

@ -24,6 +24,7 @@ import {
parseStringDate, parseStringDate,
reactive, reactive,
ref, ref,
storeToRefs,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
useProject, useProject,
@ -67,7 +68,9 @@ const { $api } = useNuxtApp()
const { addTab } = useTabs() const { addTab } = useTabs()
const { sqlUis, project, loadTables } = useProject() const projectStrore = useProject()
const { loadTables } = projectStrore
const { sqlUis, project } = storeToRefs(projectStrore)
const sqlUi = ref(sqlUis.value[baseId] || Object.values(sqlUis.value)[0]) const sqlUi = ref(sqlUis.value[baseId] || Object.values(sqlUis.value)[0])
@ -405,10 +408,14 @@ async function importTemplate() {
const tableId = meta.value?.id const tableId = meta.value?.id
const projectName = project.value.title! const projectName = project.value.title!
const table_names = data.tables.map((t: Record<string, any>) => t.table_name)
await Promise.all( await Promise.all(
Object.keys(importData).map((key: string) => Object.keys(importData).map((key: string) =>
(async (k) => { (async (k) => {
if (!table_names.includes(k)) {
return
}
const data = importData[k] const data = importData[k]
const total = data.length const total = data.length
@ -458,7 +465,7 @@ async function importTemplate() {
// Successfully imported table data // Successfully imported table data
message.success(t('msg.success.tableDataImported')) message.success(t('msg.success.tableDataImported'))
} catch (e: any) { } catch (e: any) {
message.error(e.message) message.error(await extractSdkResponseErrorMsg(e))
} finally { } finally {
isImporting.value = false isImporting.value = false
} }
@ -633,6 +640,16 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
</span> </span>
</template> </template>
<template #extra>
<a-tooltip bottom>
<template #title>
<!-- TODO: i18n -->
<span>Delete Table</span>
</template>
<mdi-delete-outline v-if="data.tables.length > 1" class="text-lg mr-8" @click.stop="deleteTable(tableIdx)" />
</a-tooltip>
</template>
<a-table <a-table
v-if="srcDestMapping" v-if="srcDestMapping"
class="template-form" class="template-form"

35
packages/nc-gui/components/virtual-cell/components/ListItems.vue

@ -48,14 +48,20 @@ const saveRow = inject(SaveRowInj, () => {})
const selectedRowIndex = ref(0) const selectedRowIndex = ref(0)
const isAltKeyDown = ref(false)
const linkRow = async (row: Record<string, any>) => { const linkRow = async (row: Record<string, any>) => {
if (isNew.value) { if (isNew.value) {
addLTARRef(row, column?.value as ColumnType) addLTARRef(row, column?.value as ColumnType)
saveRow() saveRow!()
} else { } else {
await link(row) await link(row)
} }
vModel.value = false if (isAltKeyDown.value) {
loadChildrenExcludedList()
} else {
vModel.value = false
}
} }
/** reload list on modal open */ /** reload list on modal open */
@ -159,6 +165,27 @@ useSelectedCellKeyupListener(vModel, (e: KeyboardEvent) => {
const activeRow = (vNode?: InstanceType<typeof Card>) => { const activeRow = (vNode?: InstanceType<typeof Card>) => {
vNode?.$el?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) vNode?.$el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
} }
// set variable to true when alt key is pressed
const keyDownHandler = (e: KeyboardEvent) => {
isAltKeyDown.value = e.altKey
}
// set variable to false when key is released
const keyUpHandler = (e: KeyboardEvent) => {
isAltKeyDown.value = e.altKey
}
// add event listeners when vModel is true and remove when false
watch(vModel, (nextVal) => {
if (nextVal) {
document.addEventListener('keydown', keyDownHandler)
document.addEventListener('keyup', keyUpHandler)
} else {
document.removeEventListener('keydown', keyDownHandler)
document.removeEventListener('keyup', keyUpHandler)
}
})
</script> </script>
<template> <template>
@ -219,6 +246,10 @@ const activeRow = (vNode?: InstanceType<typeof Card>) => {
show-less-items show-less-items
/> />
</div> </div>
<div class="text-xs text-gray-400 text-center px-2 mt-4 pb-0">
* Use <kbd>ALT</kbd> / <kbd>OPT</kbd> + <kbd>Click</kbd> to select multiple records
</div>
</template> </template>
<a-empty v-else class="my-10" :image="Empty.PRESENTED_IMAGE_SIMPLE" /> <a-empty v-else class="my-10" :image="Empty.PRESENTED_IMAGE_SIMPLE" />

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

@ -19,6 +19,11 @@ async function editHook(hook: Record<string, any>) {
editOrAdd.value = true editOrAdd.value = true
currentHook.value = hook currentHook.value = hook
} }
async function addHook() {
editOrAdd.value = true
currentHook.value = undefined
}
</script> </script>
<template> <template>
@ -35,7 +40,7 @@ async function editHook(hook: Record<string, any>) {
<a-layout-content class="px-10 py-5 scrollbar-thin-primary"> <a-layout-content class="px-10 py-5 scrollbar-thin-primary">
<LazyWebhookEditor v-if="editOrAdd" :hook="currentHook" @back-to-list="editOrAdd = false" /> <LazyWebhookEditor v-if="editOrAdd" :hook="currentHook" @back-to-list="editOrAdd = false" />
<LazyWebhookList v-else @edit="editHook" @add="editOrAdd = true" /> <LazyWebhookList v-else @edit="editHook" @add="addHook" />
</a-layout-content> </a-layout-content>
<a-layout-footer class="!bg-white border-t flex"> <a-layout-footer class="!bg-white border-t flex">

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

@ -13,6 +13,7 @@ import {
reactive, reactive,
ref, ref,
useApi, useApi,
useGlobal,
useI18n, useI18n,
useNuxtApp, useNuxtApp,
watch, watch,
@ -32,6 +33,8 @@ const { $e } = useNuxtApp()
const { api, isLoading: loading } = useApi() const { api, isLoading: loading } = useApi()
const { appInfo } = $(useGlobal())
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const useForm = Form.useForm const useForm = Form.useForm
@ -170,16 +173,20 @@ const eventList = [
{ text: ['After', 'Delete'], value: ['after', 'delete'] }, { text: ['After', 'Delete'], value: ['after', 'delete'] },
] ]
const notificationList = [ const notificationList = computed(() => {
{ type: 'URL' }, return appInfo.isCloud
{ type: 'Email' }, ? [{ type: 'URL' }]
{ type: 'Slack' }, : [
{ type: 'Microsoft Teams' }, { type: 'URL' },
{ type: 'Discord' }, { type: 'Email' },
{ type: 'Mattermost' }, { type: 'Slack' },
{ type: 'Twilio' }, { type: 'Microsoft Teams' },
{ type: 'Whatsapp Twilio' }, { type: 'Discord' },
] { type: 'Mattermost' },
{ type: 'Twilio' },
{ type: 'Whatsapp Twilio' },
]
})
const methodList = [ const methodList = [
{ title: 'GET' }, { title: 'GET' },
@ -225,7 +232,7 @@ function onNotTypeChange(reset = false) {
} }
if (hook.notification.type === 'Slack') { if (hook.notification.type === 'Slack') {
slackChannels.value = (apps.value && apps.value.Slack && apps.Slack.parsedInput) || [] slackChannels.value = (apps.value && apps.value.Slack && apps.value.Slack.parsedInput) || []
} }
if (hook.notification.type === 'Microsoft Teams') { if (hook.notification.type === 'Microsoft Teams') {
@ -306,6 +313,7 @@ async function onEventChange() {
} }
async function loadPluginList() { async function loadPluginList() {
if (appInfo.isCloud) return
try { try {
const plugins = (await api.plugin.list()).list! const plugins = (await api.plugin.list()).list!
@ -653,6 +661,7 @@ onMounted(loadPluginList)
<LazySmartsheetToolbarColumnFilter <LazySmartsheetToolbarColumnFilter
v-if="hook.condition" v-if="hook.condition"
ref="filterRef" ref="filterRef"
class="mt-4"
:auto-save="false" :auto-save="false"
:show-loading="false" :show-loading="false"
:hook-id="hook.id" :hook-id="hook.id"

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

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HookType } from 'nocodb-sdk'
import { MetaInj, extractSdkResponseErrorMsg, inject, message, onMounted, ref, useI18n, useNuxtApp } from '#imports' import { MetaInj, extractSdkResponseErrorMsg, inject, message, onMounted, ref, useI18n, useNuxtApp } from '#imports'
const emit = defineEmits(['edit', 'add']) const emit = defineEmits(['edit', 'add'])
@ -13,9 +14,9 @@ const meta = inject(MetaInj, ref())
async function loadHooksList() { async function loadHooksList() {
try { try {
const hookList = (await $api.dbTableWebhook.list(meta.value?.id as string)).list as Record<string, any>[] const hookList = (await $api.dbTableWebhook.list(meta.value?.id as string)).list as HookType[]
hooks.value = hookList.map((hook) => { hooks.value = hookList.map((hook) => {
hook.notification = hook.notification && JSON.parse(hook.notification) hook.notification = typeof hook.notification === 'string' ? JSON.parse(hook.notification) : hook.notification
return hook return hook
}) })
} catch (e: any) { } catch (e: any) {

2
packages/nc-gui/composables/useApi/interceptors.ts

@ -66,7 +66,7 @@ export function addAxiosInterceptors(api: Api<any>) {
}) })
}) })
.catch(async (error) => { .catch(async (error) => {
state.signOut() await state.signOut()
// todo: handle new user // todo: handle new user
navigateTo('/signIn') navigateTo('/signIn')

7
packages/nc-gui/composables/useAttachment.ts

@ -1,4 +1,4 @@
import { mimeTypes, openLink, useGlobal } from '#imports' import { openLink, useGlobal } from '#imports'
const useAttachment = () => { const useAttachment = () => {
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
@ -15,11 +15,10 @@ const useAttachment = () => {
return item.data return item.data
} }
const sources = getPossibleAttachmentSrc(item) const sources = getPossibleAttachmentSrc(item)
const mimeType = mimeTypes[item?.mimetype?.split('/')?.pop() || 'txt']
for (const source of sources) { for (const source of sources) {
// test if the source is accessible or not // test if the source is accessible or not
const res = await fetch(source) const res = await fetch(source, { method: 'HEAD' })
if (res.ok && res.headers.get('Content-Type') === mimeType) { if (res.ok) {
return source return source
} }
} }

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

@ -10,6 +10,7 @@ import {
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
message, message,
ref, ref,
storeToRefs,
useI18n, useI18n,
useMetas, useMetas,
useNuxtApp, useNuxtApp,
@ -27,7 +28,9 @@ interface ValidationsObj {
const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState( const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState(
(meta: Ref<TableType | undefined>, column: Ref<ColumnType | undefined>) => { (meta: Ref<TableType | undefined>, column: Ref<ColumnType | undefined>) => {
const { project, sqlUis, isMysql: isMysqlFunc, isPg: isPgFunc, isMssql: isMssqlFunc } = useProject() const projectStore = useProject()
const { isMysql: isMysqlFunc, isPg: isPgFunc, isMssql: isMssqlFunc } = projectStore
const { project, sqlUis } = storeToRefs(projectStore)
const { $api } = useNuxtApp() const { $api } = useNuxtApp()

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

@ -11,6 +11,7 @@ import {
message, message,
populateInsertObject, populateInsertObject,
ref, ref,
storeToRefs,
useApi, useApi,
useI18n, useI18n,
useInjectionState, useInjectionState,
@ -30,17 +31,19 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const { t } = useI18n() const { t } = useI18n()
const commentsOnly = ref(false) const commentsOnly = ref(true)
const commentsAndLogs = ref<any[]>([]) const commentsAndLogs = ref<any[]>([])
const comment = ref('') const comment = ref('')
const commentsDrawer = ref(false) const commentsDrawer = ref(true)
const saveRowAndStay = ref(0)
const changedColumns = ref(new Set<string>()) const changedColumns = ref(new Set<string>())
const { project } = useProject() const { project } = storeToRefs(useProject())
const rowStore = useProvideSmartsheetRowStore(meta, row) const rowStore = useProvideSmartsheetRowStore(meta, row)
@ -101,7 +104,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
fk_model_id: meta.value.id as string, fk_model_id: meta.value.id as string,
comments_only: commentsOnly.value, comments_only: commentsOnly.value,
}) })
)?.reverse?.() || [] ).list?.reverse?.() || []
} }
const isYou = (email: string) => { const isYou = (email: string) => {
@ -243,6 +246,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
changedColumns, changedColumns,
loadRow, loadRow,
primaryKey, primaryKey,
saveRowAndStay,
} }
}, 'expanded-form-store') }, 'expanded-form-store')

12
packages/nc-gui/composables/useGlobal/actions.ts

@ -3,9 +3,15 @@ import { message, useNuxtApp } from '#imports'
export function useGlobalActions(state: State): Actions { export function useGlobalActions(state: State): Actions {
/** Sign out by deleting the token from localStorage */ /** Sign out by deleting the token from localStorage */
const signOut: Actions['signOut'] = () => { const signOut: Actions['signOut'] = async () => {
state.token.value = null state.token.value = null
state.user.value = null state.user.value = null
try {
if (state.token.value) {
const nuxtApp = useNuxtApp()
await nuxtApp.$api.auth.signout()
}
} catch {}
} }
/** Sign in by setting the token in localStorage */ /** Sign in by setting the token in localStorage */
@ -38,9 +44,9 @@ export function useGlobalActions(state: State): Actions {
signIn(response.data.token) signIn(response.data.token)
} }
}) })
.catch((err) => { .catch(async (err) => {
message.error(err.message || t('msg.error.youHaveBeenSignedOut')) message.error(err.message || t('msg.error.youHaveBeenSignedOut'))
signOut() await signOut()
}) })
.finally(resolve) .finally(resolve)
}) })

1
packages/nc-gui/composables/useGlobal/state.ts

@ -99,6 +99,7 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
version: '0.0.0', version: '0.0.0',
ncAttachmentFieldSize: 20, ncAttachmentFieldSize: 20,
ncMaxAttachmentsAllowed: 10, ncMaxAttachmentsAllowed: 10,
isCloud: false,
}) })
/** reactive token payload */ /** reactive token payload */

1
packages/nc-gui/composables/useGlobal/types.ts

@ -22,6 +22,7 @@ export interface AppInfo {
ee?: boolean ee?: boolean
ncAttachmentFieldSize: number ncAttachmentFieldSize: number
ncMaxAttachmentsAllowed: number ncMaxAttachmentsAllowed: number
isCloud: boolean
} }
export interface StoredState { export interface StoredState {

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

@ -13,6 +13,7 @@ import {
message, message,
provide, provide,
ref, ref,
storeToRefs,
useApi, useApi,
useFieldQuery, useFieldQuery,
useI18n, useI18n,
@ -40,7 +41,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const { api } = useApi() const { api } = useApi()
const { project } = useProject() const { project, sqlUis } = storeToRefs(useProject())
const { $e, $api } = useNuxtApp() const { $e, $api } = useNuxtApp()
@ -56,8 +57,6 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const { search } = useFieldQuery() const { search } = useFieldQuery()
const { sqlUis } = useProject()
const sqlUi = ref( const sqlUi = ref(
(meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0], (meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0],
) )
@ -154,7 +153,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
) )
} }
for (const data of groupData) { for (const data of groupData ?? []) {
const key = data.key const key = data.key
formattedData.value.set(key, formatData(data.value.list)) formattedData.value.set(key, formatData(data.value.list))
countByStack.value.set(key, data.value.pageInfo.totalRows || 0) countByStack.value.set(key, data.value.pageInfo.totalRows || 0)
@ -168,17 +167,26 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
where = `(${groupingField.value},is,null)` where = `(${groupingField.value},is,null)`
} }
if (xWhere.value) {
where = `${where} and ${xWhere.value}`
}
const response = !isPublic.value const response = !isPublic.value
? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, { ? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, {
...{ where: xWhere.value },
...params, ...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }), ...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }), ...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where, where,
}) })
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value, offset: params.offset }) : await fetchSharedViewData({
...params,
sortsArr: sorts.value,
filtersArr: nestedFilters.value,
offset: params.offset,
where,
})
formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, ...formatData(response.list)]) formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, ...formatData(response!.list!)])
} }
async function loadKanbanMeta() { async function loadKanbanMeta() {
@ -300,10 +308,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
async function updateKanbanMeta(updateObj: Partial<KanbanType>) { async function updateKanbanMeta(updateObj: Partial<KanbanType>) {
if (!viewMeta?.value?.id || !isUIAllowed('xcDatatableEditable')) return if (!viewMeta?.value?.id || !isUIAllowed('xcDatatableEditable')) return
await $api.dbView.kanbanUpdate(viewMeta.value.id, { await $api.dbView.kanbanUpdate(viewMeta.value.id, updateObj)
...kanbanMetaData.value,
...updateObj,
})
} }
async function insertRow(row: Record<string, any>, rowIndex = formattedData.value.get(null)!.length) { async function insertRow(row: Record<string, any>, rowIndex = formattedData.value.get(null)!.length) {

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

@ -11,6 +11,7 @@ import {
message, message,
reactive, reactive,
ref, ref,
storeToRefs,
useI18n, useI18n,
useInjectionState, useInjectionState,
useMetas, useMetas,
@ -38,7 +39,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
// state // state
const { metas, getMeta } = useMetas() const { metas, getMeta } = useMetas()
const { project } = useProject() const { project } = storeToRefs(useProject())
const { $api } = useNuxtApp() const { $api } = useNuxtApp()

179
packages/nc-gui/composables/useMapViewDataStore.ts

@ -0,0 +1,179 @@
import { reactive } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { ColumnType, MapType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import { IsPublicInj, ref, storeToRefs, useInjectionState, useMetas, useProject } from '#imports'
import type { Row } from '~/lib'
const storedValue = localStorage.getItem('geodataToggleState')
const initialState = storedValue ? JSON.parse(storedValue) : false
export const geodataToggleState = reactive({ show: initialState })
const formatData = (list: Record<string, any>[]) =>
list.map(
(row) =>
({
row: { ...row },
oldRow: { ...row },
rowMeta: {},
} as Row),
)
const [useProvideMapViewStore, useMapViewStore] = useInjectionState(
(
meta: Ref<TableType | undefined>,
viewMeta: Ref<(ViewType | MapType | undefined) & { id: string }> | ComputedRef<(ViewType & { id: string }) | undefined>,
shared = false,
where?: ComputedRef<string | undefined>,
) => {
if (!meta) {
throw new Error('Table meta is not available')
}
const formattedData = ref<Row[]>([])
const { api } = useApi()
const { project } = storeToRefs(useProject())
const { $api } = useNuxtApp()
const { isUIAllowed } = useUIPermission()
const isPublic = ref(shared) || inject(IsPublicInj, ref(false))
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow()
const { fetchSharedViewData } = useSharedView()
const mapMetaData = ref<MapType>({})
const geoDataFieldColumn = ref<ColumnType | undefined>()
const defaultPageSize = 1000
const paginationData = ref<PaginatedType>({ page: 1, pageSize: defaultPageSize })
const queryParams = computed(() => ({
limit: paginationData.value.pageSize ?? defaultPageSize,
where: where?.value ?? '',
}))
async function syncCount() {
const { count } = await $api.dbViewRow.count(
NOCO,
project?.value?.title as string,
meta?.value?.id as string,
viewMeta?.value?.id as string,
)
paginationData.value.totalRows = count
}
async function loadMapMeta() {
if (!viewMeta?.value?.id || !meta?.value?.columns) return
mapMetaData.value = await $api.dbView.mapRead(viewMeta.value.id)
geoDataFieldColumn.value =
(meta.value.columns as ColumnType[]).filter((f) => f.id === mapMetaData.value.fk_geo_data_col_id)[0] || {}
}
async function loadMapData() {
if ((!project?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic?.value) return
const res = !isPublic.value
? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, {
...queryParams.value,
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where: where?.value,
})
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value })
formattedData.value = formatData(res!.list)
}
async function updateMapMeta(updateObj: Partial<MapType>) {
if (!viewMeta?.value?.id || !isUIAllowed('xcDatatableEditable')) return
await $api.dbView.mapUpdate(viewMeta.value.id, updateObj)
}
const { getMeta } = useMetas()
async function insertRow(
currentRow: Row,
ltarState: Record<string, any> = {},
{
metaValue = meta.value,
viewMetaValue = viewMeta.value,
}: { metaValue?: MapType & { id: string }; viewMetaValue?: (ViewType | MapType) & { id: string } } = {},
) {
const row = currentRow.row
if (currentRow.rowMeta) currentRow.rowMeta.saving = true
try {
const { missingRequiredColumns, insertObj } = await populateInsertObject({
meta: metaValue!,
ltarState,
getMeta,
row,
})
if (missingRequiredColumns.size) return
const insertedData = await $api.dbViewRow.create(
NOCO,
project?.value.id as string,
metaValue?.id as string,
viewMetaValue?.id as string,
insertObj,
)
Object.assign(currentRow, {
row: { ...insertedData, ...row },
rowMeta: { ...(currentRow.rowMeta || {}), new: undefined },
oldRow: { ...insertedData },
})
syncCount()
return insertedData
} catch (error: any) {
message.error(await extractSdkResponseErrorMsg(error))
} finally {
if (currentRow.rowMeta) currentRow.rowMeta.saving = false
}
}
function addEmptyRow(addAfter = formattedData.value.length) {
formattedData.value.splice(addAfter, 0, {
row: {},
oldRow: {},
rowMeta: { new: true },
})
return formattedData.value[addAfter]
}
return {
formattedData,
loadMapData,
loadMapMeta,
updateMapMeta,
mapMetaData,
geoDataFieldColumn,
addEmptyRow,
insertRow,
geodataToggleState,
syncCount,
paginationData,
}
},
)
export { useProvideMapViewStore }
export function useMapViewStoreOrThrow() {
const mapViewStore = useMapViewStore()
if (mapViewStore == null) throw new Error('Please call `useProvideMapViewStore` on the appropriate parent component')
return mapViewStore
}

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

@ -1,12 +1,12 @@
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import type { WatchStopHandle } from 'vue' import type { WatchStopHandle } from 'vue'
import type { TableInfoType, TableType } from 'nocodb-sdk' import type { TableInfoType, TableType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, useNuxtApp, useProject, useState, watch } from '#imports' import { extractSdkResponseErrorMsg, storeToRefs, useNuxtApp, useProject, useState, watch } from '#imports'
export function useMetas() { export function useMetas() {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { tables } = useProject() const { tables } = storeToRefs(useProject())
const metas = useState<{ [idOrTitle: string]: TableType | any }>('metas', () => ({})) const metas = useState<{ [idOrTitle: string]: TableType | any }>('metas', () => ({}))

2
packages/nc-gui/composables/useMultiSelect/index.ts

@ -145,14 +145,12 @@ export function useMultiSelect(
return return
} }
editEnabled.value = false
isMouseDown = true isMouseDown = true
selectedRange.startRange({ row, col }) selectedRange.startRange({ row, col })
} }
const handleCellClick = (event: MouseEvent, row: number, col: number) => { const handleCellClick = (event: MouseEvent, row: number, col: number) => {
isMouseDown = true isMouseDown = true
editEnabled.value = false
selectedRange.startRange({ row, col }) selectedRange.startRange({ row, col })
selectedRange.endRange({ row, col }) selectedRange.endRange({ row, col })
makeActive(row, col) makeActive(row, col)

6
packages/nc-gui/composables/useSelectedCellKeyupListener/index.ts

@ -1,15 +1,15 @@
import { isClient } from '@vueuse/core' import { isClient } from '@vueuse/core'
import type { Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
function useSelectedCellKeyupListener( function useSelectedCellKeyupListener(
selected: Ref<boolean>, selected: Ref<boolean | undefined> | ComputedRef<boolean | undefined>,
handler: (e: KeyboardEvent) => void, handler: (e: KeyboardEvent) => void,
{ immediate = false }: { immediate?: boolean } = {}, { immediate = false }: { immediate?: boolean } = {},
) { ) {
if (isClient) { if (isClient) {
watch( watch(
selected, selected,
(nextVal: boolean, _: boolean, cleanup) => { (nextVal: boolean | undefined, _: boolean | undefined, cleanup) => {
// bind listener when `selected` is truthy // bind listener when `selected` is truthy
if (nextVal) { if (nextVal) {
document.addEventListener('keydown', handler, true) document.addEventListener('keydown', handler, true)

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

@ -12,10 +12,12 @@ import {
message, message,
provide, provide,
ref, ref,
storeToRefs,
useApi, useApi,
useI18n, useI18n,
useInjectionState, useInjectionState,
useMetas, useMetas,
useProject,
useProvideSmartsheetRowStore, useProvideSmartsheetRowStore,
watch, watch,
} from '#imports' } from '#imports'
@ -43,7 +45,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const { metas, setMeta } = useMetas() const { metas, setMeta } = useMetas()
const { project } = useProject() const projectStore = useProject()
const { project } = storeToRefs(projectStore)
const { t } = useI18n() const { t } = useI18n()
@ -88,14 +91,14 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
// if project is not defined then set it with an object containing base // if project is not defined then set it with an object containing base
if (!project.value?.bases) if (!project.value?.bases)
project.value = { projectStore.setProject({
bases: [ bases: [
{ {
id: viewMeta.base_id, id: viewMeta.base_id,
type: viewMeta.client, type: viewMeta.client,
}, },
], ],
} })
const relatedMetas = { ...viewMeta.relatedMetas } const relatedMetas = { ...viewMeta.relatedMetas }

36
packages/nc-gui/composables/useSharedView.ts

@ -1,13 +1,13 @@
import type { ExportTypes, FilterType, KanbanType, PaginatedType, RequestParams, SortType, TableType, ViewType } from 'nocodb-sdk' import type { ExportTypes, FilterType, KanbanType, PaginatedType, RequestParams, SortType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import { computed, useGlobal, useMetas, useNuxtApp, useState } from '#imports' import { computed, storeToRefs, useGlobal, useMetas, useNuxtApp, useState } from '#imports'
export function useSharedView() { export function useSharedView() {
const nestedFilters = ref<(FilterType & { status?: 'update' | 'delete' | 'create'; parentId?: string })[]>([]) const nestedFilters = ref<(FilterType & { status?: 'update' | 'delete' | 'create'; parentId?: string })[]>([])
const { appInfo } = $(useGlobal()) const { appInfo } = $(useGlobal())
const { project } = useProject() const { project } = storeToRefs(useProject())
const appInfoDefaultLimit = appInfo.defaultLimit || 25 const appInfoDefaultLimit = appInfo.defaultLimit || 25
@ -56,7 +56,7 @@ export function useSharedView() {
} }
if (localPassword) password.value = localPassword if (localPassword) password.value = localPassword
sharedView.value = { title: '', ...viewMeta } sharedView.value = { title: '', ...viewMeta } as ViewType
meta.value = { ...viewMeta.model } meta.value = { ...viewMeta.model }
let order = 1 let order = 1
@ -83,29 +83,34 @@ export function useSharedView() {
Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key])) Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key]))
} }
const fetchSharedViewData = async ({ const fetchSharedViewData = async (param: {
sortsArr,
filtersArr,
offset,
}: {
sortsArr: SortType[] sortsArr: SortType[]
filtersArr: FilterType[] filtersArr: FilterType[]
fields?: any[]
sort?: any[]
where?: string
/** Query params for nested data */
nested?: any
offset?: number offset?: number
}) => { }) => {
if (!sharedView.value) return if (!sharedView.value)
return {
list: [],
pageInfo: {},
}
if (!offset) { if (!param.offset) {
const page = paginationData.value.page || 1 const page = paginationData.value.page || 1
const pageSize = paginationData.value.pageSize || appInfoDefaultLimit const pageSize = paginationData.value.pageSize || appInfoDefaultLimit
offset = (page - 1) * pageSize param.offset = (page - 1) * pageSize
} }
const { data } = await $api.public.dataList( return await $api.public.dataList(
sharedView.value.uuid!, sharedView.value.uuid!,
{ {
offset, ...param,
filterArrJson: JSON.stringify(filtersArr ?? nestedFilters.value), filterArrJson: JSON.stringify(param.filtersArr ?? nestedFilters.value),
sortArrJson: JSON.stringify(sortsArr ?? sorts.value), sortArrJson: JSON.stringify(param.sortsArr ?? sorts.value),
} as any, } as any,
{ {
headers: { headers: {
@ -113,7 +118,6 @@ export function useSharedView() {
}, },
}, },
) )
return data
} }
const fetchSharedViewGroupedData = async ( const fetchSharedViewGroupedData = async (

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

@ -13,6 +13,7 @@ import {
isMm, isMm,
message, message,
ref, ref,
storeToRefs,
unref, unref,
useI18n, useI18n,
useInjectionState, useInjectionState,
@ -28,7 +29,7 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
const { t } = useI18n() const { t } = useI18n()
const { project } = useProject() const { project } = storeToRefs(useProject())
const { metas } = useMetas() const { metas } = useMetas()

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

@ -1,7 +1,17 @@
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk' import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { computed, ref, unref, useEventBus, useFieldQuery, useInjectionState, useNuxtApp, useProject } from '#imports' import {
computed,
ref,
storeToRefs,
unref,
useEventBus,
useFieldQuery,
useInjectionState,
useNuxtApp,
useProject,
} from '#imports'
import type { SmartsheetStoreEvents } from '~/lib' import type { SmartsheetStoreEvents } from '~/lib'
const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState( const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
@ -14,7 +24,9 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
) => { ) => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { sqlUis } = useProject() const projectStore = useProject()
const { sqlUis } = storeToRefs(projectStore)
const sqlUi = ref( const sqlUi = ref(
(meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0], (meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0],
@ -26,13 +38,13 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore')) const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore'))
// getters
const isLocked = computed(() => view.value?.lock_type === 'locked') const isLocked = computed(() => view.value?.lock_type === 'locked')
const isPkAvail = computed(() => (meta.value as TableType)?.columns?.some((c) => c.pk)) const isPkAvail = computed(() => (meta.value as TableType)?.columns?.some((c) => c.pk))
const isGrid = computed(() => view.value?.type === ViewTypes.GRID) const isGrid = computed(() => view.value?.type === ViewTypes.GRID)
const isForm = computed(() => view.value?.type === ViewTypes.FORM) const isForm = computed(() => view.value?.type === ViewTypes.FORM)
const isGallery = computed(() => view.value?.type === ViewTypes.GALLERY) const isGallery = computed(() => view.value?.type === ViewTypes.GALLERY)
const isKanban = computed(() => view.value?.type === ViewTypes.KANBAN) const isKanban = computed(() => view.value?.type === ViewTypes.KANBAN)
const isMap = computed(() => view.value?.type === ViewTypes.MAP)
const isSharedForm = computed(() => isForm.value && shared) const isSharedForm = computed(() => isForm.value && shared)
const xWhere = computed(() => { const xWhere = computed(() => {
let where let where
@ -51,7 +63,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
}) })
const isSqlView = computed(() => (meta.value as TableType)?.type === 'view') const isSqlView = computed(() => (meta.value as TableType)?.type === 'view')
const sorts = ref<Required<SortType>[]>((unref(initialSorts) as Required<SortType>[]) ?? []) const sorts = ref<SortType[]>(unref(initialSorts) ?? [])
const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? []) const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? [])
return { return {
@ -65,6 +77,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
isGrid, isGrid,
isGallery, isGallery,
isKanban, isKanban,
isMap,
cellRefs, cellRefs,
isSharedForm, isSharedForm,
sorts, sorts,

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

@ -7,6 +7,7 @@ import {
generateUniqueTitle as generateTitle, generateUniqueTitle as generateTitle,
message, message,
reactive, reactive,
storeToRefs,
useI18n, useI18n,
useMetas, useMetas,
useNuxtApp, useNuxtApp,
@ -32,7 +33,8 @@ export function useTable(onTableCreate?: (tableMeta: TableType) => void, baseId?
const { loadTables } = useProject() const { loadTables } = useProject()
const { closeTab } = useTabs() const { closeTab } = useTabs()
const { sqlUis, project, tables } = useProject() const projectStore = useProject()
const { sqlUis, project, tables } = storeToRefs(projectStore)
const sqlUi = computed(() => (baseId && sqlUis.value[baseId] ? sqlUis.value[baseId] : Object.values(sqlUis.value)[0])) const sqlUi = computed(() => (baseId && sqlUis.value[baseId] ? sqlUis.value[baseId] : Object.values(sqlUis.value)[0]))

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

@ -1,7 +1,7 @@
import { isSystemColumn } from 'nocodb-sdk' import { ViewTypes, isSystemColumn } from 'nocodb-sdk'
import type { ColumnType, TableType, ViewType } from 'nocodb-sdk' import type { ColumnType, MapType, TableType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import { IsPublicInj, computed, inject, ref, useNuxtApp, useProject, useUIPermission, watch } from '#imports' import { IsPublicInj, computed, inject, ref, storeToRefs, useNuxtApp, useProject, useUIPermission, watch } from '#imports'
import type { Field } from '~/lib' import type { Field } from '~/lib'
export function useViewColumns( export function useViewColumns(
@ -19,22 +19,29 @@ export function useViewColumns(
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { isSharedBase } = useProject() const { isSharedBase } = storeToRefs(useProject())
const isLocalMode = computed( const isLocalMode = computed(
() => isPublic.value || !isUIAllowed('hideAllColumns') || !isUIAllowed('showAllColumns') || isSharedBase.value, () => isPublic.value || !isUIAllowed('hideAllColumns') || !isUIAllowed('showAllColumns') || isSharedBase.value,
) )
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)
// (on the other hand, the logic complexity is still very low atm - might be overkill)
return view.value?.type === ViewTypes.MAP && (view.value?.view as MapType)?.fk_geo_data_col_id === column.id
}
const metaColumnById = computed<Record<string, ColumnType>>(() => { const metaColumnById = computed<Record<string, ColumnType>>(() => {
if (!meta.value?.columns) return {} if (!meta.value?.columns) return {}
return meta.value.columns.reduce( return (meta.value.columns as ColumnType[]).reduce(
(acc, curr) => ({ (acc, curr) => ({
...acc, ...acc,
[curr.id!]: curr, [curr.id!]: curr,
}), }),
{}, {},
) ) as Record<string, ColumnType>
}) })
const loadViewColumns = async () => { const loadViewColumns = async () => {
@ -43,7 +50,7 @@ export function useViewColumns(
let order = 1 let order = 1
if (view.value?.id) { if (view.value?.id) {
const data = (isPublic.value ? meta.value?.columns : await $api.dbViewColumn.list(view.value.id)) as any[] const data = (isPublic.value ? meta.value?.columns : (await $api.dbViewColumn.list(view.value.id)).list) as any[]
const fieldById = data.reduce<Record<string, any>>((acc, curr) => { const fieldById = data.reduce<Record<string, any>>((acc, curr) => {
curr.show = !!curr.show curr.show = !!curr.show
@ -62,8 +69,10 @@ export function useViewColumns(
title: column.title, title: column.title,
fk_column_id: column.id, fk_column_id: column.id,
...currentColumnField, ...currentColumnField,
show: currentColumnField.show || isColumnViewEssential(currentColumnField),
order: currentColumnField.order || order++, order: currentColumnField.order || order++,
system: isSystemColumn(metaColumnById?.value?.[currentColumnField.fk_column_id!]), system: isSystemColumn(metaColumnById?.value?.[currentColumnField.fk_column_id!]),
isViewEssentialField: isColumnViewEssential(column),
} }
}) })
.sort((a: Field, b: Field) => a.order - b.order) .sort((a: Field, b: Field) => a.order - b.order)
@ -98,7 +107,7 @@ export function useViewColumns(
if (isLocalMode.value) { if (isLocalMode.value) {
fields.value = fields.value?.map((field: Field) => ({ fields.value = fields.value?.map((field: Field) => ({
...field, ...field,
show: false, show: !!field.isViewEssentialField,
})) }))
reloadData?.() reloadData?.()
return return
@ -153,7 +162,7 @@ export function useViewColumns(
const showSystemFields = computed({ const showSystemFields = computed({
get() { get() {
return view.value?.show_system_fields || false return (view.value?.show_system_fields as boolean) || false
}, },
set(v: boolean) { set(v: boolean) {
if (view?.value?.id) { if (view?.value?.id) {

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

@ -12,6 +12,7 @@ import {
message, message,
populateInsertObject, populateInsertObject,
ref, ref,
storeToRefs,
until, until,
useApi, useApi,
useGlobal, useGlobal,
@ -26,7 +27,7 @@ import {
} from '#imports' } from '#imports'
import type { Row } from '~/lib' import type { Row } from '~/lib'
const formatData = (list: Row[]) => const formatData = (list: Record<string, any>[]) =>
list.map((row) => ({ list.map((row) => ({
row: { ...row }, row: { ...row },
oldRow: { ...row }, oldRow: { ...row },
@ -58,7 +59,7 @@ export function useViewData(
const _paginationData = ref<PaginatedType>({ page: 1, pageSize: appInfoDefaultLimit }) const _paginationData = ref<PaginatedType>({ page: 1, pageSize: appInfoDefaultLimit })
const aggCommentCount = ref<{ row_id: string; count: number }[]>([]) const aggCommentCount = ref<{ row_id: string; count: string }[]>([])
const galleryData = ref<GalleryType>() const galleryData = ref<GalleryType>()
@ -70,7 +71,7 @@ export function useViewData(
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const { project, isSharedBase } = useProject() const { project, isSharedBase } = storeToRefs(useProject())
const { sharedView, fetchSharedViewData, paginationData: sharedPaginationData } = useSharedView() const { sharedView, fetchSharedViewData, paginationData: sharedPaginationData } = useSharedView()
@ -182,7 +183,7 @@ export function useViewData(
for (const row of formattedData.value) { for (const row of formattedData.value) {
const id = extractPkFromRow(row.row, meta.value?.columns as ColumnType[]) const id = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
row.rowMeta.commentCount = aggCommentCount.value?.find((c: Record<string, any>) => c.row_id === id)?.count || 0 row.rowMeta.commentCount = +(aggCommentCount.value?.find((c: Record<string, any>) => c.row_id === id)?.count || 0)
} }
} }
@ -197,6 +198,7 @@ export function useViewData(
where: where?.value, where: where?.value,
}) })
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value }) : await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value })
formattedData.value = formatData(response.list) formattedData.value = formatData(response.list)
paginationData.value = response.pageInfo paginationData.value = response.pageInfo
@ -477,15 +479,24 @@ export function useViewData(
} }
} }
const navigateToSiblingRow = async (dir: NavigateDir) => { // get current expanded row index
// get current expanded row index function getExpandedRowIndex() {
const expandedRowIndex = formattedData.value.findIndex( return formattedData.value.findIndex(
(row: Row) => routeQuery.rowId === extractPkFromRow(row.row, meta.value?.columns as ColumnType[]), (row: Row) => routeQuery.rowId === extractPkFromRow(row.row, meta.value?.columns as ColumnType[]),
) )
}
const navigateToSiblingRow = async (dir: NavigateDir) => {
const expandedRowIndex = getExpandedRowIndex()
// calculate next row index based on direction // calculate next row index based on direction
let siblingRowIndex = expandedRowIndex + (dir === NavigateDir.NEXT ? 1 : -1) let siblingRowIndex = expandedRowIndex + (dir === NavigateDir.NEXT ? 1 : -1)
// if unsaved row skip it
while (formattedData.value[siblingRowIndex]?.rowMeta?.new) {
siblingRowIndex = siblingRowIndex + (dir === NavigateDir.NEXT ? 1 : -1)
}
const currentPage = paginationData?.value?.page || 1 const currentPage = paginationData?.value?.page || 1
// if next row index is less than 0, go to previous page and point to last element // if next row index is less than 0, go to previous page and point to last element
@ -547,5 +558,6 @@ export function useViewData(
removeRowIfNew, removeRowIfNew,
navigateToSiblingRow, navigateToSiblingRow,
deleteRowById, deleteRowById,
getExpandedRowIndex,
} }
} }

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

@ -11,6 +11,7 @@ import {
inject, inject,
message, message,
ref, ref,
storeToRefs,
useDebounceFn, useDebounceFn,
useMetas, useMetas,
useNuxtApp, useNuxtApp,
@ -35,7 +36,7 @@ export function useViewFilters(
const { nestedFilters } = useSmartsheetStoreOrThrow() const { nestedFilters } = useSmartsheetStoreOrThrow()
const { projectMeta } = useProject() const { projectMeta } = storeToRefs(useProject())
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
@ -140,8 +141,28 @@ export function useViewFilters(
return isNullOrEmptyOp ? projectMeta.value.showNullAndEmptyInFilter : true return isNullOrEmptyOp ? projectMeta.value.showNullAndEmptyInFilter : true
} }
const isComparisonSubOpAllowed = (
filter: FilterType,
compOp: {
text: string
value: string
ignoreVal?: boolean
includedTypes?: UITypes[]
excludedTypes?: UITypes[]
},
) => {
if (compOp.includedTypes) {
// include allowed values only if selected column type matches
return filter.fk_column_id && compOp.includedTypes.includes(types.value[filter.fk_column_id])
} else if (compOp.excludedTypes) {
// include not allowed values only if selected column type not matches
return filter.fk_column_id && !compOp.excludedTypes.includes(types.value[filter.fk_column_id])
}
}
const placeholderFilter = (): Filter => { const placeholderFilter = (): Filter => {
return { return {
// TODO: fix type
comparison_op: comparisonOpList(options.value?.[0].uidt as UITypes).filter((compOp) => comparison_op: comparisonOpList(options.value?.[0].uidt as UITypes).filter((compOp) =>
isComparisonOpAllowed({ fk_column_id: options.value?.[0].id }, compOp), isComparisonOpAllowed({ fk_column_id: options.value?.[0].id }, compOp),
)?.[0].value, )?.[0].value,
@ -161,16 +182,15 @@ export function useViewFilters(
try { try {
if (hookId) { if (hookId) {
if (parentId) { if (parentId) {
filters.value = await $api.dbTableFilter.childrenRead(parentId) filters.value = (await $api.dbTableFilter.childrenRead(parentId)).list as Filter[]
} else { } else {
// todo: return type is incorrect filters.value = (await $api.dbTableWebhookFilter.read(hookId!)).list as Filter[]
filters.value = (await $api.dbTableWebhookFilter.read(hookId!)) as unknown as Filter[]
} }
} else { } else {
if (parentId) { if (parentId) {
filters.value = await $api.dbTableFilter.childrenRead(parentId) filters.value = (await $api.dbTableFilter.childrenRead(parentId)).list as Filter[]
} else { } else {
filters.value = await $api.dbTableFilter.read(view.value!.id!) filters.value = (await $api.dbTableFilter.read(view.value!.id!)).list as Filter[]
} }
} }
} catch (e: any) { } catch (e: any) {
@ -327,5 +347,6 @@ export function useViewFilters(
addFilterGroup, addFilterGroup,
saveOrUpdateDebounced, saveOrUpdateDebounced,
isComparisonOpAllowed, isComparisonOpAllowed,
isComparisonSubOpAllowed,
} }
} }

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

@ -7,6 +7,7 @@ import {
inject, inject,
message, message,
ref, ref,
storeToRefs,
useNuxtApp, useNuxtApp,
useProject, useProject,
useSharedView, useSharedView,
@ -24,7 +25,7 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { isSharedBase } = useProject() const { isSharedBase } = storeToRefs(useProject())
const reloadHook = inject(ReloadViewDataHookInj) const reloadHook = inject(ReloadViewDataHookInj)
@ -49,7 +50,7 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
} }
} }
if (!view?.value) return if (!view?.value) return
sorts.value = (await $api.dbTableSort.list(view.value!.id!)).sorts?.list || [] sorts.value = (await $api.dbTableSort.list(view.value!.id!)).list as SortType[]
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e)
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))

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

@ -74,7 +74,8 @@
"insertBefore": "Insert Before", "insertBefore": "Insert Before",
"hideField": "Hide Field", "hideField": "Hide Field",
"sortAsc": "Sort Ascending", "sortAsc": "Sort Ascending",
"sortDesc": "Sort Descending" "sortDesc": "Sort Descending",
"geoDataField": "GeoData Field"
}, },
"objects": { "objects": {
"project": "مشروع", "project": "مشروع",
@ -98,7 +99,8 @@
"gallery": "معرض صور", "gallery": "معرض صور",
"form": "نموذج", "form": "نموذج",
"kanban": "كانبان", "kanban": "كانبان",
"calendar": "تقويم" "calendar": "تقويم",
"map": "Map"
}, },
"user": "مستخدم", "user": "مستخدم",
"users": "مستخدمين", "users": "مستخدمين",
@ -136,6 +138,7 @@
"Currency": "عملة", "Currency": "عملة",
"Percent": "نسبة مؤية", "Percent": "نسبة مؤية",
"Duration": "مدة", "Duration": "مدة",
"GeoData": "GeoData",
"Rating": "تقييم", "Rating": "تقييم",
"Formula": "معادلة", "Formula": "معادلة",
"Rollup": "جمع البيانات", "Rollup": "جمع البيانات",
@ -232,6 +235,7 @@
"action": "إجراء", "action": "إجراء",
"actions": "إجراءات", "actions": "إجراءات",
"operation": "عملية", "operation": "عملية",
"operationSub": "Sub Operation",
"operationType": "نوع العملية", "operationType": "نوع العملية",
"operationSubType": "نوع العملية الفرعية", "operationSubType": "نوع العملية الفرعية",
"description": "وصف", "description": "وصف",
@ -253,6 +257,9 @@
"barcodeFormat": "Barcode format", "barcodeFormat": "Barcode format",
"qrCodeValueTooLong": "Too many characters for a QR code", "qrCodeValueTooLong": "Too many characters for a QR code",
"barcodeValueTooLong": "Too many characters for a barcode", "barcodeValueTooLong": "Too many characters for a barcode",
"yourLocation": "Your Location",
"lng": "Lng",
"lat": "Lat",
"aggregateFunction": "وظيفة التجميع", "aggregateFunction": "وظيفة التجميع",
"dbCreateIfNotExists": "قاعدة البيانات: إنشاء إذا لم يكن موجودا", "dbCreateIfNotExists": "قاعدة البيانات: إنشاء إذا لم يكن موجودا",
"clientKey": "مفتاح العميل", "clientKey": "مفتاح العميل",
@ -452,6 +459,10 @@
"stackedBy": "Stacked By", "stackedBy": "Stacked By",
"chooseGroupingField": "Choose a Grouping Field", "chooseGroupingField": "Choose a Grouping Field",
"addOrEditStack": "Add / Edit Stack" "addOrEditStack": "Add / Edit Stack"
},
"map": {
"mappedBy": "Mapped By",
"chooseMappingField": "Choose a Mapping Field"
} }
}, },
"tooltip": { "tooltip": {
@ -518,6 +529,11 @@
"orgCreator": "Creator can create new projects and access any invited project.", "orgCreator": "Creator can create new projects and access any invited project.",
"orgViewer": "Viewer is not allowed to create new projects but they can access any invited project." "orgViewer": "Viewer is not allowed to create new projects but they can access any invited project."
}, },
"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."
},
"footerInfo": "الصفوف لكل صفحة", "footerInfo": "الصفوف لكل صفحة",
"upload": "حدد الملف المراد رفعه", "upload": "حدد الملف المراد رفعه",
"upload_sub": "أو سحب وإسقاط الملف", "upload_sub": "أو سحب وإسقاط الملف",
@ -600,6 +616,7 @@
"gallery": "إضافة عرض المعرض", "gallery": "إضافة عرض المعرض",
"form": "إضافة عرض النموذج", "form": "إضافة عرض النموذج",
"kanban": "إضافة عرض كانبان", "kanban": "إضافة عرض كانبان",
"map": "Add Map View",
"calendar": "إضافة طريقة عرض التقويم" "calendar": "إضافة طريقة عرض التقويم"
}, },
"tablesMetadataInSync": "تزامن البيانات الوصفية للجداول", "tablesMetadataInSync": "تزامن البيانات الوصفية للجداول",
@ -690,6 +707,7 @@
"nameShouldStartWithAnAlphabetOr_": "Name should start with an alphabet or _", "nameShouldStartWithAnAlphabetOr_": "Name should start with an alphabet or _",
"followingCharactersAreNotAllowed": "Following characters are not allowed", "followingCharactersAreNotAllowed": "Following characters are not allowed",
"columnNameRequired": "Column name is required", "columnNameRequired": "Column name is required",
"columnNameExceedsCharacters": "The length of column name exceeds the max {value} characters",
"projectNameExceeds50Characters": "Project name exceeds 50 characters", "projectNameExceeds50Characters": "Project name exceeds 50 characters",
"projectNameCannotStartWithSpace": "Project name cannot start with space", "projectNameCannotStartWithSpace": "Project name cannot start with space",
"requiredField": "Required field", "requiredField": "Required field",

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

Loading…
Cancel
Save