Browse Source

Merge pull request #7308 from nocodb/develop

pull/7309/head 0.203.0
github-actions[bot] 8 months ago committed by GitHub
parent
commit
b6d6c406e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .github/ISSUE_TEMPLATE/--bug-report.yaml
  2. 2
      .github/ISSUE_TEMPLATE/--feature-request.yaml
  3. 49
      .github/workflows/uffizzi-preview.yml
  4. 33
      packages/nc-gui/assets/style.scss
  5. 2
      packages/nc-gui/components/account/UserList.vue
  6. 4
      packages/nc-gui/components/account/UsersModal.vue
  7. 18
      packages/nc-gui/components/cell/Checkbox.vue
  8. 2
      packages/nc-gui/components/cell/Currency.vue
  9. 2
      packages/nc-gui/components/cell/DatePicker.vue
  10. 1
      packages/nc-gui/components/cell/DateTimePicker.vue
  11. 94
      packages/nc-gui/components/cell/MultiSelect.vue
  12. 48
      packages/nc-gui/components/cell/Percent.vue
  13. 23
      packages/nc-gui/components/cell/Rating.vue
  14. 18
      packages/nc-gui/components/cell/RichText.vue
  15. 106
      packages/nc-gui/components/cell/SingleSelect.vue
  16. 25
      packages/nc-gui/components/cell/TextArea.vue
  17. 435
      packages/nc-gui/components/cell/User.vue
  18. 2
      packages/nc-gui/components/cell/YearPicker.vue
  19. 3
      packages/nc-gui/components/cell/attachment/index.vue
  20. 2
      packages/nc-gui/components/dlg/share-and-collaborate/Collaborate.vue
  21. 26
      packages/nc-gui/components/monaco/Editor.vue
  22. 1
      packages/nc-gui/components/nc/Button.vue
  23. 113
      packages/nc-gui/components/project/AccessSettings.vue
  24. 35
      packages/nc-gui/components/project/View.vue
  25. 2
      packages/nc-gui/components/smartsheet/ApiSnippet.vue
  26. 4
      packages/nc-gui/components/smartsheet/Cell.vue
  27. 11
      packages/nc-gui/components/smartsheet/DivDataCell.vue
  28. 6
      packages/nc-gui/components/smartsheet/Kanban.vue
  29. 2
      packages/nc-gui/components/smartsheet/Toolbar.vue
  30. 8
      packages/nc-gui/components/smartsheet/column/DecimalOptions.vue
  31. 9
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  32. 20
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  33. 819
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  34. 40
      packages/nc-gui/components/smartsheet/column/RollupOptions.vue
  35. 66
      packages/nc-gui/components/smartsheet/column/UserOptions.vue
  36. 8
      packages/nc-gui/components/smartsheet/details/Api.vue
  37. 32
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  38. 32
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  39. 5
      packages/nc-gui/components/smartsheet/grid/GroupByTable.vue
  40. 67
      packages/nc-gui/components/smartsheet/grid/Table.vue
  41. 3
      packages/nc-gui/components/smartsheet/grid/useColumnDrag.ts
  42. 6
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  43. 13
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  44. 7
      packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue
  45. 2
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  46. 5
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  47. 7
      packages/nc-gui/components/template/Editor.vue
  48. 23
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  49. 6
      packages/nc-gui/components/virtual-cell/Formula.vue
  50. 28
      packages/nc-gui/components/virtual-cell/Links.vue
  51. 15
      packages/nc-gui/components/virtual-cell/Lookup.vue
  52. 17
      packages/nc-gui/components/virtual-cell/QrCode.vue
  53. 16
      packages/nc-gui/components/virtual-cell/barcode/Barcode.vue
  54. 14
      packages/nc-gui/components/webhook/Editor.vue
  55. 21
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  56. 8
      packages/nc-gui/composables/useData.ts
  57. 18
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  58. 22
      packages/nc-gui/composables/useMultiSelect/index.ts
  59. 8
      packages/nc-gui/composables/useSharedView.ts
  60. 44
      packages/nc-gui/composables/useViewData.ts
  61. 11
      packages/nc-gui/composables/useViewGroupBy.ts
  62. 80
      packages/nc-gui/lang/eu.json
  63. 1
      packages/nc-gui/lib/types.ts
  64. 8
      packages/nc-gui/nuxt.config.ts
  65. 20
      packages/nc-gui/package.json
  66. 5
      packages/nc-gui/pages/forgot-password.vue
  67. 7
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  68. 7
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue
  69. 4
      packages/nc-gui/pages/signin.vue
  70. 3
      packages/nc-gui/pages/signup/[[token]].vue
  71. 8
      packages/nc-gui/plugins/poller.ts
  72. 4
      packages/nc-gui/store/base.ts
  73. 35
      packages/nc-gui/store/bases.ts
  74. 3
      packages/nc-gui/store/users.ts
  75. 1
      packages/nc-gui/utils/cell.ts
  76. 4
      packages/nc-gui/utils/columnUtils.ts
  77. 22
      packages/nc-gui/utils/dataUtils.ts
  78. 398
      packages/nc-gui/utils/filterUtils.ts
  79. 623
      packages/nc-gui/utils/formulaUtils.ts
  80. 5
      packages/nc-gui/utils/iconUtils.ts
  81. 5
      packages/noco-docs/docs/020.getting-started/050.self-hosted/_category_.json
  82. 5
      packages/noco-docs/docs/020.getting-started/_category_.json
  83. 5
      packages/noco-docs/docs/030.workspaces/_category_.json
  84. 5
      packages/noco-docs/docs/040.bases/_category_.json
  85. 5
      packages/noco-docs/docs/050.tables/_category_.json
  86. 5
      packages/noco-docs/docs/060.table-operations/_category_.json
  87. 7
      packages/noco-docs/docs/065.table-details/_category_.json
  88. 137
      packages/noco-docs/docs/070.fields/040.field-types/010.text-based/025.rich-text.md
  89. 5
      packages/noco-docs/docs/070.fields/040.field-types/010.text-based/_category_.json
  90. 5
      packages/noco-docs/docs/070.fields/040.field-types/020.numerical/_category_.json
  91. 5
      packages/noco-docs/docs/070.fields/040.field-types/030.select-based/_category_.json
  92. 5
      packages/noco-docs/docs/070.fields/040.field-types/040.links-based/_category_.json
  93. 5
      packages/noco-docs/docs/070.fields/040.field-types/050.custom-types/_category_.json
  94. 5
      packages/noco-docs/docs/070.fields/040.field-types/060.formula/_category_.json
  95. 5
      packages/noco-docs/docs/070.fields/040.field-types/070.date-time-based/_category_.json
  96. 30
      packages/noco-docs/docs/070.fields/040.field-types/080.user-based/010.user.md
  97. 8
      packages/noco-docs/docs/070.fields/040.field-types/080.user-based/_category_.json
  98. 5
      packages/noco-docs/docs/070.fields/040.field-types/_category_.json
  99. 5
      packages/noco-docs/docs/070.fields/_category_.json
  100. 5
      packages/noco-docs/docs/080.records/_category_.json
  101. Some files were not shown because too many files have changed in this diff Show More

2
.github/ISSUE_TEMPLATE/--bug-report.yaml

@ -2,8 +2,6 @@ name: 🐛 Bug Report
description: Create a bug report to help improve NocoDB description: Create a bug report to help improve NocoDB
title: "🐛 Bug: " title: "🐛 Bug: "
labels: [Type : Bug] labels: [Type : Bug]
assignees:
- o1lab
body: body:
- type: markdown - type: markdown
attributes: attributes:

2
.github/ISSUE_TEMPLATE/--feature-request.yaml

@ -2,8 +2,6 @@ name: 🔦 Feature request
description: Suggest a new/missing feature for NocoDB description: Suggest a new/missing feature for NocoDB
title: "🔦 Feature: " title: "🔦 Feature: "
labels: [Type : Feature] labels: [Type : Feature]
assignees:
- o1lab
body: body:
- type: markdown - type: markdown
attributes: attributes:

49
.github/workflows/uffizzi-preview.yml

@ -13,8 +13,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
outputs: outputs:
compose-file-cache-key: ${{ env.COMPOSE_FILE_HASH }} compose-file-cache-key: ${{ steps.hash.outputs.COMPOSE_FILE_HASH }}
pr-number: ${{ env.PR_NUMBER }} git-ref: ${{ steps.event.outputs.GIT_REF }}
pr-number: ${{ steps.event.outputs.PR_NUMBER }}
action: ${{ steps.event.outputs.ACTION }}
steps: steps:
- name: 'Download artifacts' - name: 'Download artifacts'
# Fetch output (zip archive) from the workflow run that triggered this workflow. # Fetch output (zip archive) from the workflow run that triggered this workflow.
@ -29,6 +31,9 @@ jobs:
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name == "preview-spec" return artifact.name == "preview-spec"
})[0]; })[0];
if (matchArtifact === undefined) {
throw TypeError('Build Artifact not found!');
}
let download = await github.rest.actions.downloadArtifact({ let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
@ -37,44 +42,46 @@ jobs:
}); });
let fs = require('fs'); let fs = require('fs');
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data)); fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data));
- name: 'Unzip artifact' - name: 'Accept event from first stage'
run: unzip preview-spec.zip run: unzip preview-spec.zip event.json
- name: Read Event into ENV - name: Read Event into ENV
id: event
run: | run: |
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV echo PR_NUMBER=$(jq '.number | tonumber' < event.json) >> $GITHUB_OUTPUT
cat event.json >> $GITHUB_ENV echo ACTION=$(jq --raw-output '.action | tostring | [scan("\\w+")][0]' < event.json) >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_ENV echo GIT_REF=$(jq --raw-output '.pull_request.head.sha | tostring | [scan("\\w+")][0]' < event.json) >> $GITHUB_OUTPUT
- name: Hash Rendered Compose File - name: Hash Rendered Compose File
id: hash id: hash
# If the previous workflow was triggered by a PR close event, we will not have a compose file artifact. # If the previous workflow was triggered by a PR close event, we will not have a compose file artifact.
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }} if: ${{ steps.event.outputs.ACTION != 'closed' }}
run: echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV run: |
unzip preview-spec.zip docker-compose.rendered.yml
echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_OUTPUT
- name: Cache Rendered Compose File - name: Cache Rendered Compose File
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }} if: ${{ steps.event.outputs.ACTION != 'closed' }}
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: docker-compose.rendered.yml path: docker-compose.rendered.yml
key: ${{ env.COMPOSE_FILE_HASH }} key: ${{ steps.hash.outputs.COMPOSE_FILE_HASH }}
- name: Read PR Number From Event Object
id: pr
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
- name: DEBUG - Print Job Outputs - name: DEBUG - Print Job Outputs
if: ${{ runner.debug }} if: ${{ runner.debug }}
run: | run: |
echo "PR number: ${{ env.PR_NUMBER }}" echo "PR number: ${{ steps.event.outputs.PR_NUMBER }}"
echo "Compose file hash: ${{ env.COMPOSE_FILE_HASH }}" echo "Git Ref: ${{ steps.event.outputs.GIT_REF }}"
echo "Action: ${{ steps.event.outputs.ACTION }}"
echo "Compose file hash: ${{ steps.hash.outputs.COMPOSE_FILE_HASH }}"
cat event.json cat event.json
deploy-uffizzi-preview: deploy-uffizzi-preview:
name: Use Remote Workflow to Preview on Uffizzi name: Use Remote Workflow to Preview on Uffizzi
needs: needs:
- cache-compose-file - cache-compose-file
if: ${{ github.event.workflow_run.conclusion == 'success' }}
uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2 uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2
with: with:
# If this workflow was triggered by a PR close event, cache-key will be an empty string # If this workflow was triggered by a PR close event, cache-key will be an empty string

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

@ -43,10 +43,6 @@ body {
height: var(--topbar-height) !important; height: var(--topbar-height) !important;
} }
.anticon-check-circle {
@apply !relative top-[-1px] left-0;
}
html, html,
body, body,
#__nuxt, #__nuxt,
@ -61,7 +57,7 @@ main {
} }
.mobile { .mobile {
.nc-scrollbar-md, .nc-scrollbar-x-md, .nc-scrollbar-dark-md, .nc-scrollbar-x-md-dark, .nc-scrollbar-x-lg { .nc-scrollbar-md, .nc-scrollbar-lg, .nc-scrollbar-x-md, .nc-scrollbar-dark-md, .nc-scrollbar-x-md-dark, .nc-scrollbar-x-lg {
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 0px; width: 0px;
} }
@ -92,6 +88,30 @@ main {
} }
} }
.nc-scrollbar-lg {
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: thin !important;
&::-webkit-scrollbar {
width: 10px;
height: 10px;
}
&::-webkit-scrollbar-track-piece {
width: 0px;
}
&::-webkit-scrollbar {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
width: 4px;
@apply bg-gray-200;
}
&::-webkit-scrollbar-thumb:hover {
@apply bg-gray-300;
}
}
.nc-scrollbar-x-md { .nc-scrollbar-x-md {
overflow-x: scroll; overflow-x: scroll;
scrollbar-width: thin !important; scrollbar-width: thin !important;
@ -701,4 +721,7 @@ input[type='number'] {
.ant-message-notice-content { .ant-message-notice-content {
@apply !rounded-md; @apply !rounded-md;
.ant-message-custom-content{
@apply flex items-center
}
} }

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

@ -261,7 +261,7 @@ const openDeleteModal = (user: UserType) => {
<div <div
class="flex items-center gap-2" class="flex items-center gap-2"
:class="{ :class="{
'opacity-0': el.roles?.includes('super'), 'opacity-0 pointer-events-none': el.roles?.includes('super'),
}" }"
> >
<NcDropdown :trigger="['click']"> <NcDropdown :trigger="['click']">

4
packages/nc-gui/components/account/UsersModal.vue

@ -34,6 +34,8 @@ const { copy } = useCopy()
const { dashboardUrl } = useDashboard() const { dashboardUrl } = useDashboard()
const { clearBasesUser } = useBases()
const usersData = ref<Users>({ emails: '', role: OrgUserRoles.VIEWER, invitationToken: undefined }) const usersData = ref<Users>({ emails: '', role: OrgUserRoles.VIEWER, invitationToken: undefined })
const formRef = ref() const formRef = ref()
@ -64,6 +66,8 @@ const saveUser = async () => {
// Successfully updated the user details // Successfully updated the user details
message.success(t('msg.success.userAdded')) message.success(t('msg.success.userAdded'))
clearBasesUser()
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e)
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))

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

@ -42,6 +42,8 @@ const readOnly = inject(ReadonlyInj)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false)) const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const rowHeight = inject(RowHeightInj, ref())
const checkboxMeta = computed(() => { const checkboxMeta = computed(() => {
return { return {
icon: { icon: {
@ -82,18 +84,28 @@ useSelectedCellKeyupListener(active, (e) => {
<template> <template>
<div <div
class="flex cursor-pointer w-full h-full items-center" class="flex cursor-pointer w-full h-full items-center focus:outline-transparent"
:class="{ :class="{
'w-full flex-start pl-2': isForm || isGallery || isExpandedFormOpen, 'w-full flex-start pl-2': isForm || isGallery || isExpandedFormOpen,
'w-full justify-center': !isForm && !isGallery && !isExpandedFormOpen, 'w-full justify-center': !isForm && !isGallery && !isExpandedFormOpen,
'nc-cell-hover-show': !vModel && !readOnly, 'nc-cell-hover-show': !vModel && !readOnly,
'opacity-0': readOnly && !vModel, 'opacity-0': readOnly && !vModel,
}" }"
:style="{
height:
isForm || isExpandedFormOpen || isGallery || isEditColumnMenu ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
}"
tabindex="0"
@click="onClick(false, $event)" @click="onClick(false, $event)"
@keydown.enter.stop="onClick(false, $event)"
> >
<div <div
class="items-center" class="flex items-center"
:class="{ 'w-full justify-start': isEditColumnMenu || isGallery || isForm, 'py-2': isEditColumnMenu }" :class="{
'w-full justify-start': isEditColumnMenu || isGallery || isForm,
'justify-center': !isEditColumnMenu && !isGallery && !isForm,
'py-2': isEditColumnMenu,
}"
@click="onClick(true)" @click="onClick(true)"
> >
<Transition name="layout" mode="out-in" :duration="100"> <Transition name="layout" mode="out-in" :duration="100">

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

@ -78,7 +78,7 @@ onMounted(() => {
:ref="focus" :ref="focus"
v-model="vModel" v-model="vModel"
type="number" type="number"
class="w-full h-full text-sm border-none rounded-md outline-none" class="w-full h-full text-sm border-none rounded-md outline-none focus:outline-transparent focus:ring-0"
:placeholder="isEditColumn ? $t('labels.optional') : ''" :placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="submitCurrency" @blur="submitCurrency"
@keydown.down.stop @keydown.down.stop

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

@ -238,6 +238,7 @@ const clickHandler = () => {
<a-date-picker <a-date-picker
v-model:value="localState" v-model:value="localState"
:picker="picker" :picker="picker"
tabindex="0"
:bordered="false" :bordered="false"
class="!w-full !px-1 !border-none" class="!w-full !px-1 !border-none"
:class="{ 'nc-null': modelValue === null && showNull }" :class="{ 'nc-null': modelValue === null && showNull }"
@ -249,6 +250,7 @@ const clickHandler = () => {
:open="isOpen" :open="isOpen"
@click="clickHandler" @click="clickHandler"
@update:open="updateOpen" @update:open="updateOpen"
@keydown.enter="open = !open"
> >
<template #suffixIcon></template> <template #suffixIcon></template>
</a-date-picker> </a-date-picker>

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

@ -303,6 +303,7 @@ const isColDisabled = computed(() => {
:open="isOpen" :open="isOpen"
@click="clickHandler" @click="clickHandler"
@ok="okHandler" @ok="okHandler"
@keydown.enter="open = !open"
> >
<template #suffixIcon></template> <template #suffixIcon></template>
</a-date-picker> </a-date-picker>

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

@ -1,12 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue' import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk' import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk'
import { import {
ActiveCellInj, ActiveCellInj,
CellClickHookInj,
ColumnInj, ColumnInj,
EditColumnInj, EditColumnInj,
EditModeInj, EditModeInj,
@ -53,14 +51,14 @@ const isEditable = inject(EditModeInj, ref(false))
const activeCell = inject(ActiveCellInj, ref(false)) const activeCell = inject(ActiveCellInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
// use both ActiveCellInj or EditModeInj to determine the active state // use both ActiveCellInj or EditModeInj to determine the active state
// since active will be false in case of form view // since active will be false in case of form view
const active = computed(() => activeCell.value || isEditable.value) const active = computed(() => activeCell.value || isEditable.value || isForm.value)
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false)) const isEditColumn = inject(EditColumnInj, ref(false))
const rowHeight = inject(RowHeightInj, ref(undefined)) const rowHeight = inject(RowHeightInj, ref(undefined))
@ -71,6 +69,8 @@ const aselect = ref<typeof AntSelect>()
const isOpen = ref(false) const isOpen = ref(false)
const isFocusing = ref(false)
const isKanban = inject(IsKanbanInj, ref(false)) const isKanban = inject(IsKanbanInj, ref(false))
const searchVal = ref<string | null>() const searchVal = ref<string | null>()
@ -180,9 +180,7 @@ watch(isOpen, (n, _o) => {
if (!n) searchVal.value = '' if (!n) searchVal.value = ''
if (editAllowed.value) { if (editAllowed.value) {
if (!n) { if (n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
aselect.value?.$el?.querySelector('input')?.focus() aselect.value?.$el?.querySelector('input')?.focus()
} }
} }
@ -299,22 +297,11 @@ const onTagClick = (e: Event, onClose: Function) => {
} }
} }
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = () => { const toggleMenu = () => {
if (cellClickHook) return if (isFocusing.value) return
isOpen.value = editAllowed.value && !isOpen.value
}
const cellClickHookHandler = () => {
isOpen.value = editAllowed.value && !isOpen.value isOpen.value = editAllowed.value && !isOpen.value
} }
onMounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
onUnmounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
const handleClose = (e: MouseEvent) => { const handleClose = (e: MouseEvent) => {
// close dropdown if clicked outside of dropdown // close dropdown if clicked outside of dropdown
@ -341,6 +328,26 @@ const selectedOpts = computed(() => {
return selectedOptions return selectedOptions
}, []) }, [])
}) })
const onKeyDown = (e: KeyboardEvent) => {
// Tab
if (e.key === 'Tab') {
isOpen.value = false
return
}
e.stopPropagation()
}
const onFocus = () => {
isFocusing.value = true
setTimeout(() => {
isFocusing.value = false
}, 250)
isOpen.value = true
}
</script> </script>
<template> <template>
@ -357,7 +364,7 @@ const selectedOpts = computed(() => {
}" }"
> >
<template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value"> <template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value">
<a-tag class="rounded-tag" :color="selectedOpt.color"> <a-tag class="rounded-tag max-w-full" :color="selectedOpt.color">
<span <span
:style="{ :style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' }) 'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -367,7 +374,21 @@ const selectedOpts = computed(() => {
}" }"
:class="{ 'text-sm': isKanban }" :class="{ 'text-sm': isKanban }"
> >
{{ selectedOpt.title }} <NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ selectedOpt.title }}
</span>
</NcTooltip>
</span> </span>
</a-tag> </a-tag>
</template> </template>
@ -389,7 +410,9 @@ const selectedOpts = computed(() => {
:class="{ 'caret-transparent': !hasEditRoles }" :class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell !min-w-200px ${isOpen ? 'active' : ''}`" :dropdown-class-name="`nc-dropdown-multi-select-cell !min-w-200px ${isOpen ? 'active' : ''}`"
@search="search" @search="search"
@keydown.stop @keydown="onKeyDown"
@focus="onFocus"
@blur="isOpen = false"
> >
<template #suffixIcon> <template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" /> <GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" />
@ -402,7 +425,7 @@ const selectedOpts = computed(() => {
:class="`nc-select-option-${column.title}-${op.title}`" :class="`nc-select-option-${column.title}-${op.title}`"
@click.stop @click.stop
> >
<a-tag class="rounded-tag" :color="op.color"> <a-tag class="rounded-tag max-w-full" :color="op.color">
<span <span
:style="{ :style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' }) 'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -412,7 +435,21 @@ const selectedOpts = computed(() => {
}" }"
:class="{ 'text-sm': isKanban }" :class="{ 'text-sm': isKanban }"
> >
{{ op.title }} <NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ op.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ op.title }}
</span>
</NcTooltip>
</span> </span>
</a-tag> </a-tag>
</a-select-option> </a-select-option>
@ -530,3 +567,10 @@ const selectedOpts = computed(() => {
@apply !text-xs; @apply !text-xs;
} }
</style> </style>
<style lang="scss">
.ant-select-item-option-content,
.ant-select-item-option-state {
@apply !flex !items-center;
}
</style>

48
packages/nc-gui/components/cell/Percent.vue

@ -20,6 +20,8 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const _vModel = useVModel(props, 'modelValue', emits) const _vModel = useVModel(props, 'modelValue', emits)
const wrapperRef = ref<HTMLElement>()
const vModel = computed({ const vModel = computed({
get: () => _vModel.value, get: () => _vModel.value,
set: (value) => { set: (value) => {
@ -56,6 +58,18 @@ const onBlur = () => {
const onFocus = () => { const onFocus = () => {
cellFocused.value = true cellFocused.value = true
editEnabled.value = true
expandedEditEnabled.value = true
}
const onWrapperFocus = () => {
cellFocused.value = true
editEnabled.value = true
expandedEditEnabled.value = true
nextTick(() => {
wrapperRef.value?.querySelector('input')?.focus()
})
} }
const onMouseover = () => { const onMouseover = () => {
@ -67,10 +81,41 @@ const onMouseleave = () => {
expandedEditEnabled.value = false expandedEditEnabled.value = false
} }
} }
const onTabPress = (e: KeyboardEvent) => {
if (e.shiftKey) {
e.preventDefault()
// Shift + Tab does not work for percent cell
// so we manually focus on the last form item
const focusesNcCellIndex = Array.from(document.querySelectorAll('.nc-expanded-form-row .nc-data-cell')).findIndex((el) => {
return el.querySelector('.nc-filter-value-select') === wrapperRef.value
})
if (focusesNcCellIndex >= 0) {
const nodes = document.querySelectorAll('.nc-expanded-form-row .nc-data-cell')
for (let i = focusesNcCellIndex - 1; i >= 0; i--) {
const lastFormItem = nodes[i].querySelector('[tabindex="0"]') as HTMLElement
if (lastFormItem) {
lastFormItem.focus()
break
}
}
}
}
}
</script> </script>
<template> <template>
<div class="nc-filter-value-select w-full" @mouseover="onMouseover" @mouseleave="onMouseleave"> <div
ref="wrapperRef"
tabindex="0"
class="nc-filter-value-select w-full focus:outline-transparent"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
@focus="onWrapperFocus"
>
<input <input
v-if="(!isExpandedFormOpen && editEnabled) || (isExpandedFormOpen && expandedEditEnabled)" v-if="(!isExpandedFormOpen && editEnabled) || (isExpandedFormOpen && expandedEditEnabled)"
:ref="focus" :ref="focus"
@ -86,6 +131,7 @@ const onMouseleave = () => {
@keydown.right.stop @keydown.right.stop
@keydown.up.stop @keydown.up.stop
@keydown.delete.stop @keydown.delete.stop
@keydown.tab="onTabPress"
@selectstart.capture.stop @selectstart.capture.stop
@mousedown.stop @mousedown.stop
/> />

23
packages/nc-gui/components/cell/Rating.vue

@ -36,14 +36,37 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
vModel.value = +e.key === +vModel.value ? 0 : +e.key vModel.value = +e.key === +vModel.value ? 0 : +e.key
} }
}) })
const onKeyPress = (e: KeyboardEvent) => {
if (/^\d$/.test(e.key)) {
e.stopPropagation()
vModel.value = +e.key === +vModel.value ? 0 : +e.key
}
}
const rateDomRef = ref()
// Remove tabindex from rate inputs set by antd
watch(rateDomRef, () => {
if (!rateDomRef.value) return
const rateInputs = rateDomRef.value.$el.querySelectorAll('div[role="radio"]')
if (!rateInputs) return
for (let i = 0; i < rateInputs.length; i++) {
rateInputs[i].setAttribute('tabindex', '-1')
}
})
</script> </script>
<template> <template>
<a-rate <a-rate
ref="rateDomRef"
v-model:value="vModel" v-model:value="vModel"
:disabled="readonly" :disabled="readonly"
:count="ratingMeta.max" :count="ratingMeta.max"
:style="`color: ${ratingMeta.color}; padding: 0px 5px`" :style="`color: ${ratingMeta.color}; padding: 0px 5px`"
@keydown="onKeyPress"
> >
<template #character> <template #character>
<MdiStar v-if="ratingMeta.icon.full === 'mdi-star'" class="text-sm" /> <MdiStar v-if="ratingMeta.icon.full === 'mdi-star'" class="text-sm" />

18
packages/nc-gui/components/cell/RichText.vue

@ -19,6 +19,8 @@ const props = defineProps<{
const emits = defineEmits(['update:value']) const emits = defineEmits(['update:value'])
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const turndownService = new TurndownService({}) const turndownService = new TurndownService({})
turndownService.addRule('lineBreak', { turndownService.addRule('lineBreak', {
@ -108,7 +110,7 @@ const editor = useEditor({
editable: !props.readonly, editable: !props.readonly,
}) })
const setEditorContent = (contentMd: any) => { const setEditorContent = (contentMd: any, focusEndOfDoc?: boolean) => {
if (!editor.value) return if (!editor.value) return
const selection = editor.value.view.state.selection const selection = editor.value.view.state.selection
@ -120,6 +122,15 @@ const setEditorContent = (contentMd: any) => {
editor.value.chain().setContent(content).setTextSelection(selection.to).run() editor.value.chain().setContent(content).setTextSelection(selection.to).run()
setTimeout(() => { setTimeout(() => {
if (focusEndOfDoc) {
const docSize = editor.value!.state.doc.nodeSize
editor.value
?.chain()
.setTextSelection(docSize - 1)
.run()
}
;(editor.value!.state as any).history$.prevRanges = null ;(editor.value!.state as any).history$.prevRanges = null
;(editor.value!.state as any).history$.done.eventCount = 0 ;(editor.value!.state as any).history$.done.eventCount = 0
}, 100) }, 100)
@ -134,7 +145,7 @@ if (props.syncValueChange) {
watch(editorDom, () => { watch(editorDom, () => {
if (!editorDom.value) return if (!editorDom.value) return
setEditorContent(vModel.value) setEditorContent(vModel.value, true)
// Focus editor after editor is mounted // Focus editor after editor is mounted
setTimeout(() => { setTimeout(() => {
@ -162,7 +173,8 @@ watch(editorDom, () => {
class="flex flex-col nc-textarea-rich-editor w-full" class="flex flex-col nc-textarea-rich-editor w-full"
:class="{ :class="{
'ml-1 mt-2.5 flex-grow': props.fullMode, 'ml-1 mt-2.5 flex-grow': props.fullMode,
'nc-scrollbar-md': !props.fullMode && !props.readonly, 'nc-scrollbar-md': (!props.fullMode && !props.readonly) || isExpandedFormOpen,
'flex-grow': isExpandedFormOpen,
}" }"
/> />
</div> </div>

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

@ -1,12 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue' import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk' import type { SelectOptionType } from 'nocodb-sdk'
import { import {
ActiveCellInj, ActiveCellInj,
CellClickHookInj,
ColumnInj, ColumnInj,
EditColumnInj, EditColumnInj,
EditModeInj, EditModeInj,
@ -47,9 +45,11 @@ const isEditable = inject(EditModeInj, ref(false))
const activeCell = inject(ActiveCellInj, ref(false)) const activeCell = inject(ActiveCellInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
// use both ActiveCellInj or EditModeInj to determine the active state // use both ActiveCellInj or EditModeInj to determine the active state
// since active will be false in case of form view // since active will be false in case of form view
const active = computed(() => activeCell.value || isEditable.value) const active = computed(() => activeCell.value || isEditable.value || isForm.value)
const aselect = ref<typeof AntSelect>() const aselect = ref<typeof AntSelect>()
@ -61,8 +61,6 @@ const isPublic = inject(IsPublicInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false)) const isEditColumn = inject(EditColumnInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const searchVal = ref() const searchVal = ref()
@ -77,6 +75,8 @@ const { isPg, isMysql } = useBase()
// temporary until it's add the option to column meta // temporary until it's add the option to column meta
const tempSelectedOptState = ref<string>() const tempSelectedOptState = ref<string>()
const isFocusing = ref(false)
const isNewOptionCreateEnabled = computed(() => !isPublic.value && !disableOptionCreation && isUIAllowed('fieldEdit')) const isNewOptionCreateEnabled = computed(() => !isPublic.value && !disableOptionCreation && isUIAllowed('fieldEdit'))
const options = computed<(SelectOptionType & { value: string })[]>(() => { const options = computed<(SelectOptionType & { value: string })[]>(() => {
@ -97,7 +97,7 @@ const isOptionMissing = computed(() => {
return (options.value ?? []).every((op) => op.title !== searchVal.value) return (options.value ?? []).every((op) => op.title !== searchVal.value)
}) })
const hasEditRoles = computed(() => isUIAllowed('dataEdit')) const hasEditRoles = computed(() => isUIAllowed('dataEdit') || isForm.value)
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value) const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value)
@ -215,6 +215,14 @@ const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.stopPropagation() e.stopPropagation()
} }
if (e.key === 'Escape') {
isOpen.value = false
setTimeout(() => {
aselect.value?.$el.querySelector('.ant-select-selection-search > input').focus()
}, 100)
}
} }
const onSelect = () => { const onSelect = () => {
@ -222,8 +230,6 @@ const onSelect = () => {
isEditable.value = false isEditable.value = false
} }
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = (e: Event) => { const toggleMenu = (e: Event) => {
// todo: refactor // todo: refactor
// check clicked element is clear icon // check clicked element is clear icon
@ -234,19 +240,11 @@ const toggleMenu = (e: Event) => {
vModel.value = '' vModel.value = ''
return e.stopPropagation() return e.stopPropagation()
} }
if (cellClickHook) return
isOpen.value = editAllowed.value && !isOpen.value
}
const cellClickHookHandler = () => { if (isFocusing.value) return
isOpen.value = editAllowed.value && !isOpen.value isOpen.value = editAllowed.value && !isOpen.value
} }
onMounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
onUnmounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
const handleClose = (e: MouseEvent) => { const handleClose = (e: MouseEvent) => {
if (isOpen.value && aselect.value && !aselect.value.$el.contains(e.target)) { if (isOpen.value && aselect.value && !aselect.value.$el.contains(e.target)) {
@ -259,12 +257,27 @@ useEventListener(document, 'click', handleClose, true)
const selectedOpt = computed(() => { const selectedOpt = computed(() => {
return options.value.find((o) => o.value === vModel.value || o.value === vModel.value?.trim()) return options.value.find((o) => o.value === vModel.value || o.value === vModel.value?.trim())
}) })
const onFocus = () => {
isFocusing.value = true
setTimeout(() => {
isFocusing.value = false
}, 250)
isOpen.value = true
}
</script> </script>
<template> <template>
<div class="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu"> <div
<div v-if="!(active || isEditable)"> class="h-full w-full flex items-center nc-single-select focus:outline-transparent"
<a-tag v-if="selectedOpt" class="rounded-tag" :color="selectedOpt.color"> :class="{ 'read-only': readOnly }"
@click="toggleMenu"
@keydown.enter.stop.prevent="toggleMenu"
>
<div v-if="!(active || isEditable)" class="w-full">
<a-tag v-if="selectedOpt" class="rounded-tag max-w-full" :color="selectedOpt.color">
<span <span
:style="{ :style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' }) 'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -274,12 +287,26 @@ const selectedOpt = computed(() => {
}" }"
:class="{ 'text-sm': isKanban }" :class="{ 'text-sm': isKanban }"
> >
{{ selectedOpt.title }} <NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ selectedOpt.title }}
</span>
</NcTooltip>
</span> </span>
</a-tag> </a-tag>
</div> </div>
<a-select <NcSelect
v-else v-else
ref="aselect" ref="aselect"
v-model:value="vModel" v-model:value="vModel"
@ -290,12 +317,14 @@ const selectedOpt = computed(() => {
:bordered="false" :bordered="false"
:open="isOpen && editAllowed" :open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed" :disabled="readOnly || !editAllowed"
:show-arrow="hasEditRoles && !readOnly && active && vModel === null"
:dropdown-class-name="`nc-dropdown-single-select-cell !min-w-200px ${isOpen && active ? 'active' : ''}`"
:show-search="!isMobileMode && isOpen && active" :show-search="!isMobileMode && isOpen && active"
:show-arrow="hasEditRoles && !readOnly && active && (vModel === null || vModel === undefined)"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && active ? 'active' : ''}`"
@select="onSelect" @select="onSelect"
@keydown="onKeydown($event)" @keydown="onKeydown($event)"
@search="search" @search="search"
@blur="isOpen = false"
@focus="onFocus"
> >
<a-select-option <a-select-option
v-for="op of options" v-for="op of options"
@ -305,7 +334,7 @@ const selectedOpt = computed(() => {
:class="`nc-select-option-${column.title}-${op.title}`" :class="`nc-select-option-${column.title}-${op.title}`"
@click.stop @click.stop
> >
<a-tag class="rounded-tag" :color="op.color"> <a-tag class="rounded-tag max-w-full" :color="op.color">
<span <span
:style="{ :style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' }) 'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -315,7 +344,21 @@ const selectedOpt = computed(() => {
}" }"
:class="{ 'text-sm': isKanban }" :class="{ 'text-sm': isKanban }"
> >
{{ op.title }} <NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ op.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ op.title }}
</span>
</NcTooltip>
</span> </span>
</a-tag> </a-tag>
</a-select-option> </a-select-option>
@ -327,7 +370,7 @@ const selectedOpt = computed(() => {
</div> </div>
</div> </div>
</a-select-option> </a-select-option>
</a-select> </NcSelect>
</div> </div>
</template> </template>
@ -342,6 +385,7 @@ const selectedOpt = computed(() => {
:deep(.ant-select-clear) { :deep(.ant-select-clear) {
opacity: 1; opacity: 1;
border-radius: 100%;
} }
.nc-single-select:not(.read-only) { .nc-single-select:not(.read-only) {
@ -363,3 +407,9 @@ const selectedOpt = computed(() => {
@apply block; @apply block;
} }
</style> </style>
<style lang="scss">
.ant-select-item-option-content {
@apply !flex !items-center;
}
</style>

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

@ -58,7 +58,9 @@ const isDragging = ref(false)
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLTextAreaElement)?.focus() const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLTextAreaElement)?.focus()
const height = computed(() => { const height = computed(() => {
if (!rowHeight.value || rowHeight.value === 1) return 36 if (isExpandedFormOpen.value) return 36 * 4
if (!rowHeight.value || rowHeight.value === 1 || isEditColumn.value) return 36
return rowHeight.value * 36 return rowHeight.value * 36
}) })
@ -169,16 +171,16 @@ watch(editEnabled, () => {
<template> <template>
<NcDropdown <NcDropdown
v-model:visible="isVisible" v-model:visible="isVisible"
class="overflow-visible" class="overflow-hidden group"
:trigger="[]" :trigger="[]"
placement="bottomLeft" placement="bottomLeft"
:overlay-class-name="isVisible ? 'nc-textarea-dropdown-active' : undefined" :overlay-class-name="isVisible ? 'nc-textarea-dropdown-active' : undefined"
> >
<div <div
class="flex flex-row pt-0.5 w-full rich-wrapper" class="flex flex-row pt-0.5 w-full long-text-wrapper"
:class="{ :class="{
'min-h-10': rowHeight !== 1, 'min-h-10': rowHeight !== 1 || isExpandedFormOpen,
'min-h-6.5': rowHeight === 1, 'min-h-9': rowHeight === 1 && !isExpandedFormOpen,
'h-full': isForm, 'h-full': isForm,
}" }"
> >
@ -198,7 +200,7 @@ watch(editEnabled, () => {
:ref="focus" :ref="focus"
v-model="vModel" v-model="vModel"
rows="4" rows="4"
class="h-full w-full outline-none border-none" class="h-full w-full outline-none border-none nc-scrollbar-lg"
:class="{ :class="{
'p-2': editEnabled, 'p-2': editEnabled,
'py-1 h-full': isForm, 'py-1 h-full': isForm,
@ -239,8 +241,13 @@ watch(editEnabled, () => {
<NcTooltip <NcTooltip
v-if="!isVisible" v-if="!isVisible"
placement="bottom" placement="bottom"
class="!absolute right-0 bottom-1 nc-text-area-expand-btn" class="!absolute right-0 bottom-1 hidden nc-text-area-expand-btn"
:class="{ 'right-0 bottom-1': editEnabled, '!bottom-0': !isRichMode }" :class="{
'right-0 bottom-1': editEnabled,
'!bottom-0': !isRichMode,
'top-1 hidden !group-hover:block': isExpandedFormOpen,
'bottom-1': !isExpandedFormOpen,
}"
> >
<template #title>{{ $t('title.expand') }}</template> <template #title>{{ $t('title.expand') }}</template>
<NcButton type="secondary" size="xsmall" data-testid="attachment-cell-file-picker-button" @click.stop="onExpand"> <NcButton type="secondary" size="xsmall" data-testid="attachment-cell-file-picker-button" @click.stop="onExpand">
@ -300,6 +307,6 @@ textarea:focus {
<style lang="scss"> <style lang="scss">
.cell:hover .nc-text-area-expand-btn { .cell:hover .nc-text-area-expand-btn {
@apply !block; @apply !block cursor-pointer;
} }
</style> </style>

435
packages/nc-gui/components/cell/User.vue

@ -0,0 +1,435 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { UserFieldRecordType } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
EditColumnInj,
EditModeInj,
IsKanbanInj,
ReadonlyInj,
RowHeightInj,
computed,
h,
inject,
isDrawerOrModalExist,
onMounted,
ref,
useEventListener,
useRoles,
useSelectedCellKeyupListener,
watch,
} from '#imports'
import MdiCloseCircle from '~icons/mdi/close-circle'
interface Props {
modelValue?: UserFieldRecordType[] | string | null
rowIndex?: number
location?: 'cell' | 'filter'
forceMulti?: boolean
}
const { modelValue, forceMulti } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { isMobileMode } = useGlobal()
const meta = inject(MetaInj)!
const column = inject(ColumnInj)!
const readOnly = inject(ReadonlyInj)!
const isEditable = inject(EditModeInj, ref(false))
const activeCell = inject(ActiveCellInj, ref(false))
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const baseUsers = computed(() => (meta.value.base_id ? basesUser.value.get(meta.value.base_id) || [] : []))
// use both ActiveCellInj or EditModeInj to determine the active state
// since active will be false in case of form view
const active = computed(() => activeCell.value || isEditable.value)
const isForm = inject(IsFormInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isMultiple = computed(() => forceMulti || (column.value.meta as { is_multi: boolean; notify: boolean })?.is_multi)
const rowHeight = inject(RowHeightInj, ref(undefined))
const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const searchVal = ref<string | null>()
const { isUIAllowed } = useRoles()
const options = computed<UserFieldRecordType[]>(() => {
const collaborators: UserFieldRecordType[] = []
collaborators.push(
...(baseUsers.value?.map((user: any) => ({
id: user.id,
email: user.email,
display_name: user.display_name,
deleted: user.deleted,
})) || []),
)
return collaborators
})
const hasEditRoles = computed(() => isUIAllowed('dataEdit'))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value)
const vModel = computed({
get: () => {
let selected: { label: string; value: string }[] = []
if (typeof modelValue === 'string') {
const idsOrMails = modelValue.split(',').map((idOrMail) => idOrMail.trim())
selected = idsOrMails.reduce((acc, idOrMail) => {
const user = options.value.find((u) => u.id === idOrMail || u.email === idOrMail)
if (user) {
acc.push({
label: user?.display_name || user?.email,
value: user.id,
})
}
return acc
}, [] as { label: string; value: string }[])
} else {
selected =
modelValue?.reduce((acc, item) => {
const label = item?.display_name || item?.email
if (label) {
acc.push({
label,
value: item.id,
})
}
return acc
}, [] as { label: string; value: string }[]) || []
}
return selected
},
set: (val) => {
const value: string[] = []
if (val && val.length) {
val.forEach((item) => {
// @ts-expect-error antd select returns string[] instead of { label: string, value: string }[]
const user = options.value.find((u) => u.id === item)
if (user) {
value.push(user.id)
}
})
}
if (isMultiple.value) {
emit('update:modelValue', val?.length ? value.join(',') : null)
} else {
emit('update:modelValue', val?.length ? value[value.length - 1] : null)
isOpen.value = false
}
},
})
watch(isOpen, (n, _o) => {
if (!n) searchVal.value = ''
if (editAllowed.value) {
if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
aselect.value?.$el?.querySelector('input')?.focus()
}
}
})
// set isOpen to false when active cell is changed
watch(active, (n, _o) => {
if (!n) isOpen.value = false
})
useSelectedCellKeyupListener(activeCell, (e) => {
switch (e.key) {
case 'Escape':
isOpen.value = false
break
case 'Enter':
if (editAllowed.value && active.value && !isOpen.value) {
isOpen.value = true
}
break
// skip space bar key press since it's used for expand row
case ' ':
break
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowRight':
case 'ArrowLeft':
case 'Delete':
// skip
break
default:
if (!editAllowed.value) {
e.preventDefault()
break
}
// toggle only if char key pressed
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1 && !isDrawerOrModalExist()) {
e.stopPropagation()
isOpen.value = true
}
break
}
})
// close dropdown list on escape
useSelectedCellKeyupListener(isOpen, (e) => {
if (e.key === 'Escape') isOpen.value = false
})
const search = () => {
searchVal.value = aselect.value?.$el?.querySelector('.ant-select-selection-search-input')?.value
}
const onTagClick = (e: Event, onClose: Function) => {
// check clicked element is remove icon
if (
(e.target as HTMLElement)?.classList.contains('ant-tag-close-icon') ||
(e.target as HTMLElement)?.closest('.ant-tag-close-icon')
) {
e.stopPropagation()
onClose()
}
}
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = () => {
if (cellClickHook) return
isOpen.value = editAllowed.value && !isOpen.value
}
const cellClickHookHandler = () => {
isOpen.value = editAllowed.value && !isOpen.value
}
onMounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
onUnmounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
const handleClose = (e: MouseEvent) => {
// close dropdown if clicked outside of dropdown
if (
isOpen.value &&
aselect.value &&
!aselect.value.$el.contains(e.target) &&
!document.querySelector('.nc-dropdown-user-select-cell.active')?.contains(e.target as Node)
) {
// loose focus when clicked outside
isEditable.value = false
isOpen.value = false
}
}
useEventListener(document, 'click', handleClose, true)
// search with email
const filterOption = (input: string, option: any) => {
const opt = options.value.find((o) => o.id === option.value)
const searchVal = opt?.display_name || opt?.email
if (searchVal) {
return searchVal.toLowerCase().includes(input.toLowerCase())
}
}
</script>
<template>
<div class="nc-user-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div
v-if="!active"
class="flex flex-wrap"
:style="{
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': rowHeight || 1,
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
>
<template v-for="selectedOpt of vModel" :key="selectedOpt.value">
<a-tag class="rounded-tag" color="'#ccc'">
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.label }}
</span>
</a-tag>
</template>
</div>
<a-select
v-else
ref="aselect"
v-model:value="vModel"
mode="multiple"
class="w-full overflow-hidden"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:bordered="false"
clear-icon
:show-search="!isMobileMode"
:show-arrow="editAllowed && !readOnly"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-user-select-cell ${isOpen ? 'active' : ''}`"
:filter-option="filterOption"
@search="search"
@keydown.stop
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" />
</template>
<template v-for="op of options" :key="op.id || op.email">
<a-select-option
v-if="!op.deleted"
:value="op.id"
:data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
:class="`nc-select-option-${column.title}-${op.email}`"
@click.stop
>
<a-tag class="rounded-tag" color="'#ccc'">
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.display_name?.length ? op.display_name : op.email }}
</span>
</a-tag>
</a-select-option>
</template>
<template #tagRender="{ label, value: val, onClose }">
<a-tag
v-if="options.find((el) => el.id === val)"
class="rounded-tag nc-selected-option"
:style="{ display: 'flex', alignItems: 'center' }"
color="'#ccc'"
:closable="editAllowed && ((vModel?.length ?? 0) > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@click="onTagClick($event, onClose)"
@close="onClose"
>
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ label }}
</span>
</a-tag>
</template>
</a-select>
</div>
</template>
<style scoped lang="scss">
.ms-close-icon {
color: rgba(0, 0, 0, 0.25);
cursor: pointer;
display: flex;
font-size: 12px;
font-style: normal;
height: 12px;
line-height: 1;
text-align: center;
text-transform: none;
transition: color 0.3s ease, opacity 0.15s ease;
width: 12px;
z-index: 1;
margin-right: -6px;
margin-left: 3px;
}
.ms-close-icon:before {
display: block;
}
.ms-close-icon:hover {
color: rgba(0, 0, 0, 0.45);
}
.read-only {
.ms-close-icon {
display: none;
}
}
.rounded-tag {
@apply bg-gray-200 py-0 px-[12px] rounded-[12px];
}
:deep(.ant-tag) {
@apply "rounded-tag" my-[2px];
}
:deep(.ant-tag-close-icon) {
@apply "text-slate-500";
}
:deep(.ant-select-selection-overflow-item) {
@apply "flex overflow-hidden";
}
:deep(.ant-select-selection-overflow) {
@apply flex-nowrap overflow-hidden;
}
.nc-user-select:not(.read-only) {
:deep(.ant-select-selector),
:deep(.ant-select-selector input) {
@apply "!cursor-pointer";
}
}
:deep(.ant-select-selector) {
@apply !px-0;
}
:deep(.ant-select-selection-search-input) {
@apply !text-xs;
}
</style>

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

@ -113,6 +113,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
<template> <template>
<a-date-picker <a-date-picker
v-model:value="localState" v-model:value="localState"
tabindex="0"
picker="year" picker="year"
:bordered="false" :bordered="false"
class="!w-full !px-1 !border-none" class="!w-full !px-1 !border-none"
@ -125,6 +126,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
@click="open = (active || editable) && !open" @click="open = (active || editable) && !open"
@change="open = (active || editable) && !open" @change="open = (active || editable) && !open"
@ok="open = !open" @ok="open = !open"
@keydown.enter="open = !open"
> >
<template #suffixIcon></template> <template #suffixIcon></template>
</a-date-picker> </a-date-picker>

3
packages/nc-gui/components/cell/attachment/index.vue

@ -180,7 +180,6 @@ const onImageClick = (item: any) => {
<template> <template>
<div <div
ref="attachmentCellRef" ref="attachmentCellRef"
tabindex="0"
:style="{ :style="{
height: isForm || isExpandedForm ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`, height: isForm || isExpandedForm ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
}" }"
@ -207,7 +206,9 @@ const onImageClick = (item: any) => {
:class="{ 'sm:(mx-auto px-4) xs:(w-full min-w-8)': !visibleItems.length }" :class="{ 'sm:(mx-auto px-4) xs:(w-full min-w-8)': !visibleItems.length }"
class="group cursor-pointer py-1 flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-none shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)" class="group cursor-pointer py-1 flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-none shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
data-testid="attachment-cell-file-picker-button" data-testid="attachment-cell-file-picker-button"
tabindex="0"
@click="open" @click="open"
@keydown.enter="open"
> >
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" /> <component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />

2
packages/nc-gui/components/dlg/share-and-collaborate/Collaborate.vue

@ -13,7 +13,7 @@ const validators = computed(() => {
{ {
validator: (rule: any, value: string, callback: (errMsg?: string) => void) => { validator: (rule: any, value: string, callback: (errMsg?: string) => void) => {
if (!value || value.length === 0) { if (!value || value.length === 0) {
callback(t('msg.error.signUpRules.emailReqd')) callback(t('msg.error.signUpRules.emailRequired'))
return return
} }
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !validateEmail(e)) const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !validateEmail(e))

26
packages/nc-gui/components/monaco/Editor.vue

@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker' import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker&inline'
import TypescriptWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import type { editor as MonacoEditor } from 'monaco-editor' import type { editor as MonacoEditor } from 'monaco-editor'
import { deepCompare, isDrawerOrModalExist, onMounted, ref, watch } from '#imports' import { deepCompare, initWorker, isDrawerOrModalExist, onMounted, ref, watch } from '#imports'
interface Props { interface Props {
modelValue: string | Record<string, any> modelValue: string | Record<string, any>
@ -45,15 +45,17 @@ const isValid = ref(true)
* Adding monaco editor to Vite * Adding monaco editor to Vite
* *
* @ts-expect-error */ * @ts-expect-error */
self.MonacoEnvironment = { self.MonacoEnvironment = window.MonacoEnvironment = {
getWorker(_: any, label: string) { async getWorker(_: any, label: string) {
switch (label) { switch (label) {
case 'json': case 'json': {
return new JsonWorker() const workerBlob = new Blob([JsonWorker], { type: 'text/javascript' })
case 'typescript': return await initWorker(URL.createObjectURL(workerBlob))
return new TypescriptWorker() }
default: default: {
return new EditorWorker() const workerBlob = new Blob([EditorWorker], { type: 'text/javascript' })
return await initWorker(URL.createObjectURL(workerBlob))
}
} }
}, },
} }

1
packages/nc-gui/components/nc/Button.vue

@ -84,6 +84,7 @@ useEventListener(NcButton, 'mousedown', () => {
xxsmall: size === 'xxsmall', xxsmall: size === 'xxsmall',
focused: isFocused, focused: isFocused,
}" }"
:tabindex="props.disabled ? -1 : 0"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
> >

113
packages/nc-gui/components/project/AccessSettings.vue

@ -5,14 +5,14 @@ import {
ProjectRoles, ProjectRoles,
WorkspaceRolesToProjectRoles, WorkspaceRolesToProjectRoles,
extractRolesObj, extractRolesObj,
parseStringDateTime,
timeAgo, timeAgo,
} from 'nocodb-sdk' } from 'nocodb-sdk'
import type { WorkspaceUserRoles } from 'nocodb-sdk' import type { WorkspaceUserRoles } from 'nocodb-sdk'
import InfiniteLoading from 'v3-infinite-loading'
import { isEeUI, storeToRefs } from '#imports' import { isEeUI, storeToRefs } from '#imports'
const basesStore = useBases() const basesStore = useBases()
const { getProjectUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore
const { activeProjectId } = storeToRefs(basesStore) const { activeProjectId } = storeToRefs(basesStore)
const { orgRoles, baseRoles } = useRoles() const { orgRoles, baseRoles } = useRoles()
@ -30,7 +30,6 @@ interface Collaborators {
const collaborators = ref<Collaborators[]>([]) const collaborators = ref<Collaborators[]>([])
const totalCollaborators = ref(0) const totalCollaborators = ref(0)
const userSearchText = ref('') const userSearchText = ref('')
const currentPage = ref(0)
const isLoading = ref(false) const isLoading = ref(false)
const isSearching = ref(false) const isSearching = ref(false)
@ -38,52 +37,32 @@ const accessibleRoles = ref<(typeof ProjectRoles)[keyof typeof ProjectRoles][]>(
const loadCollaborators = async () => { const loadCollaborators = async () => {
try { try {
currentPage.value += 1 const { users, totalRows } = await getBaseUsers({
const { users, totalRows } = await getProjectUsers({
baseId: activeProjectId.value!, baseId: activeProjectId.value!,
page: currentPage.value,
...(!userSearchText.value ? {} : ({ searchText: userSearchText.value } as any)), ...(!userSearchText.value ? {} : ({ searchText: userSearchText.value } as any)),
limit: 20, force: true,
}) })
totalCollaborators.value = totalRows totalCollaborators.value = totalRows
collaborators.value = [ collaborators.value = [
...collaborators.value, ...users
...users.map((user: any) => ({ .filter((u: any) => !u?.deleted)
...user, .map((user: any) => ({
base_roles: user.roles, ...user,
roles: extractRolesObj(user.main_roles)?.[OrgUserRoles.SUPER_ADMIN] base_roles: user.roles,
? OrgUserRoles.SUPER_ADMIN roles: extractRolesObj(user.main_roles)?.[OrgUserRoles.SUPER_ADMIN]
: user.roles ?? ? OrgUserRoles.SUPER_ADMIN
(user.workspace_roles : user.roles ??
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS (user.workspace_roles
: ProjectRoles.NO_ACCESS), ? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS
})), : ProjectRoles.NO_ACCESS),
})),
] ]
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
} }
const loadListData = async ($state: any) => {
const prevUsersCount = collaborators.value?.length || 0
if (collaborators.value?.length === totalCollaborators.value) {
$state.complete()
return
}
$state.loading()
// const oldPagesCount = currentPage.value || 0
await loadCollaborators()
if (prevUsersCount === collaborators.value?.length) {
$state.complete()
return
}
$state.loaded()
}
const updateCollaborator = async (collab: any, roles: ProjectRoles) => { const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
try { try {
if ( if (
@ -115,29 +94,6 @@ const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
} }
} }
watchDebounced(
userSearchText,
async () => {
isSearching.value = true
currentPage.value = 0
totalCollaborators.value = 0
collaborators.value = []
try {
await loadCollaborators()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isSearching.value = false
}
},
{
debounce: 300,
maxWait: 600,
},
)
onMounted(async () => { onMounted(async () => {
isLoading.value = true isLoading.value = true
try { try {
@ -156,6 +112,10 @@ onMounted(async () => {
isLoading.value = false isLoading.value = false
} }
}) })
const filteredCollaborators = computed(() =>
collaborators.value.filter((collab) => collab.email.toLowerCase().includes(userSearchText.value.toLowerCase())),
)
</script> </script>
<template> <template>
@ -177,7 +137,7 @@ onMounted(async () => {
</div> </div>
<div <div
v-else-if="!collaborators?.length" v-else-if="!filteredCollaborators?.length"
class="nc-collaborators-list w-full h-full flex flex-col items-center justify-center mt-36" class="nc-collaborators-list w-full h-full flex flex-col items-center justify-center mt-36"
> >
<Empty description="$t('title.noMembersFound')" /> <Empty description="$t('title.noMembersFound')" />
@ -186,13 +146,13 @@ onMounted(async () => {
<div class="flex flex-col rounded-lg overflow-hidden border-1 max-w-350 max-h-[calc(100%-8rem)]"> <div class="flex flex-col rounded-lg overflow-hidden border-1 max-w-350 max-h-[calc(100%-8rem)]">
<div class="flex flex-row bg-gray-50 min-h-12 items-center border-b-1"> <div class="flex flex-row bg-gray-50 min-h-12 items-center border-b-1">
<div class="text-gray-700 users-email-grid">{{ $t('objects.users') }}</div> <div class="text-gray-700 users-email-grid">{{ $t('objects.users') }}</div>
<div class="text-gray-700 date-joined-grid">{{ $t('title.dateJoined') }}</div>
<div class="text-gray-700 user-access-grid">{{ $t('general.access') }}</div> <div class="text-gray-700 user-access-grid">{{ $t('general.access') }}</div>
<div class="text-gray-700 date-joined-grid">{{ $t('title.dateJoined') }}</div>
</div> </div>
<div class="flex flex-col nc-scrollbar-md"> <div class="flex flex-col nc-scrollbar-md">
<div <div
v-for="(collab, i) of collaborators" v-for="(collab, i) of filteredCollaborators"
:key="i" :key="i"
class="user-row flex flex-row border-b-1 py-1 min-h-14 items-center" class="user-row flex flex-row border-b-1 py-1 min-h-14 items-center"
> >
@ -202,7 +162,6 @@ onMounted(async () => {
{{ collab.email }} {{ collab.email }}
</span> </span>
</div> </div>
<div class="date-joined-grid">{{ timeAgo(collab.created_at) }}</div>
<div class="user-access-grid"> <div class="user-access-grid">
<template v-if="accessibleRoles.includes(collab.roles)"> <template v-if="accessibleRoles.includes(collab.roles)">
<RolesSelector <RolesSelector
@ -221,20 +180,19 @@ onMounted(async () => {
<RolesBadge :role="collab.roles" /> <RolesBadge :role="collab.roles" />
</template> </template>
</div> </div>
<div class="date-joined-grid">
<NcTooltip class="max-w-full">
<template #title>
{{ parseStringDateTime(collab.created_at) }}
</template>
<span>
{{ timeAgo(collab.created_at) }}
</span>
</NcTooltip>
</div>
</div> </div>
</div> </div>
</div> </div>
<InfiniteLoading v-bind="$attrs" @infinite="loadListData">
<template #spinner>
<div class="flex flex-row w-full justify-center mt-2">
<GeneralLoader />
</div>
</template>
<template #complete>
<span></span>
</template>
</InfiniteLoading>
</div> </div>
</template> </template>
</div> </div>
@ -258,12 +216,11 @@ onMounted(async () => {
} }
.date-joined-grid { .date-joined-grid {
@apply flex items-start; @apply w-1/4 flex items-start;
width: calc(50% - 10rem);
} }
.user-access-grid { .user-access-grid {
@apply w-40; @apply w-1/4 flex justify-start;
} }
.user-row { .user-row {

35
packages/nc-gui/components/project/View.vue

@ -5,9 +5,7 @@ import { isEeUI } from '#imports'
const basesStore = useBases() const basesStore = useBases()
const { getProjectUsers } = basesStore const { openedProject, activeProjectId, basesUser } = storeToRefs(basesStore)
const { openedProject, activeProjectId, baseUserCount } = storeToRefs(basesStore)
const { activeTables } = storeToRefs(useTablesStore()) const { activeTables } = storeToRefs(useTablesStore())
const { activeWorkspace, workspaceUserCount } = storeToRefs(useWorkspace()) const { activeWorkspace, workspaceUserCount } = storeToRefs(useWorkspace())
@ -32,24 +30,9 @@ const { isMobileMode } = useGlobal()
const baseSettingsState = ref('') const baseSettingsState = ref('')
const userCount = isEeUI ? workspaceUserCount : baseUserCount const userCount = computed(() =>
isEeUI ? workspaceUserCount : activeProjectId.value ? basesUser.value.get(activeProjectId.value)?.length : 0,
const updateBaseUserCount = async () => { )
if (!baseUserCount || !isUIAllowed('newUser')) return
try {
const { totalRows } = await getProjectUsers({
baseId: activeProjectId.value!,
page: 1,
searchText: undefined,
limit: 20,
})
baseUserCount.value = totalRows
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
watch( watch(
() => route.value.query?.page, () => route.value.query?.page,
@ -82,16 +65,6 @@ watch(projectPageTab, () => {
} }
}) })
watch(
() => route.value.params.baseId,
(newVal, oldVal) => {
if (newVal && oldVal === undefined) {
updateBaseUserCount()
}
},
{ immediate: true },
)
watch( watch(
() => openedProject.value?.title, () => openedProject.value?.title,
() => { () => {

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

@ -84,7 +84,7 @@ const selectedLangName = ref(langs[0].name)
const apiUrl = computed( const apiUrl = computed(
() => () =>
new URL( new URL(
`/api/v1/db/data/noco/${base.value.id}/${meta.value?.title}/views/${view.value?.title}`, `/api/v1/db/data/noco/${base.value.id}/${meta.value?.id}/views/${view.value?.id}`,
(appInfo.value && appInfo.value.ncSiteUrl) || '/', (appInfo.value && appInfo.value.ncSiteUrl) || '/',
).href, ).href,
) )

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

@ -21,7 +21,6 @@ import {
isDate, isDate,
isDateTime, isDateTime,
isDecimal, isDecimal,
isDrawerExist,
isDuration, isDuration,
isEmail, isEmail,
isFloat, isFloat,
@ -40,6 +39,7 @@ import {
isTextArea, isTextArea,
isTime, isTime,
isURL, isURL,
isUser,
isYear, isYear,
provide, provide,
ref, ref,
@ -204,7 +204,6 @@ onUnmounted(() => {
'h-10': isForm && !isSurveyForm && !isAttachment(column) && !props.virtual, 'h-10': isForm && !isSurveyForm && !isAttachment(column) && !props.virtual,
'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu, 'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu,
'!min-h-30 resize-y': isTextArea(column) && (isForm || isSurveyForm), '!min-h-30 resize-y': isTextArea(column) && (isForm || isSurveyForm),
'!border-2 !border-brand-500': props.editEnabled && (isSurveyForm || isForm) && !isDrawerExist(),
}, },
]" ]"
@keydown.enter.exact="navigate(NavigateDir.NEXT, $event)" @keydown.enter.exact="navigate(NavigateDir.NEXT, $event)"
@ -245,6 +244,7 @@ onUnmounted(() => {
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" /> <LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" /> <LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" />
<LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" /> <LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" />
<LazyCellUser v-else-if="isUser(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" /> <LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" /> <LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" /> <LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />

11
packages/nc-gui/components/smartsheet/DivDataCell.vue

@ -7,7 +7,16 @@ provide(CurrentCellInj, el)
</script> </script>
<template> <template>
<div ref="el" class="select-none"> <div ref="el" class="select-none nc-data-cell">
<slot /> <slot />
</div> </div>
</template> </template>
<style lang="scss" scoped>
.nc-data-cell:focus-within {
@apply !border-1 !border-brand-500 !rounded-lg !shadow-none !ring-0;
}
.nc-data-cell {
@apply border-1 border-gray-200 overflow-hidden rounded-lg;
}
</style>

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

@ -402,10 +402,10 @@ const getRowId = (row: RowType) => {
> >
<div <div
ref="kanbanContainerRef" ref="kanbanContainerRef"
class="nc-kanban-container flex mt-4 pb-4 px-4 overflow-y-hidden w-full nc-scrollbar-x-md" class="nc-kanban-container flex mt-4 pb-4 px-4 overflow-y-hidden w-full nc-scrollbar-x-lg"
:style="{ :style="{
minHeight: 'calc(100vh - var(--topbar-height) - 3.5rem)', minHeight: 'calc(100vh - var(--topbar-height) - 4.1rem)',
maxHeight: 'calc(100vh - var(--topbar-height) - 3.5rem)', maxHeight: 'calc(100vh - var(--topbar-height) - 4.1rem)',
}" }"
> >
<div v-if="isViewDataLoading" class="flex flex-row min-h-full gap-x-2"> <div v-if="isViewDataLoading" class="flex flex-row min-h-full gap-x-2">

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

@ -52,7 +52,7 @@ const { allowCSVDownload } = useSharedView()
</template> </template>
<LazySmartsheetToolbarSearchData <LazySmartsheetToolbarSearchData
v-if="(isGrid || isGallery || isKanban) && !isPublic" v-if="isGrid || isGallery || isKanban"
:class="{ :class="{
'shrink': !isMobileMode, 'shrink': !isMobileMode,
'w-full': isMobileMode, 'w-full': isMobileMode,

8
packages/nc-gui/components/smartsheet/column/DecimalOptions.vue

@ -30,6 +30,13 @@ onMounted(() => {
vModel.value.meta.precision = precisionFormats[0] vModel.value.meta.precision = precisionFormats[0]
} }
}) })
// update datatype precision when precision is less than the new value
// avoid downgrading precision if the new value is less than the current precision
// to avoid fractional part data loss(eg. 1.2345 -> 1.23)
const onPrecisionChange = (value: number) => {
vModel.value.dtxs = Math.max(value, vModel.value.dtxs)
}
</script> </script>
<template> <template>
@ -38,6 +45,7 @@ onMounted(() => {
v-if="vModel.meta?.precision" v-if="vModel.meta?.precision"
v-model:value="vModel.meta.precision" v-model:value="vModel.meta.precision"
dropdown-class-name="nc-dropdown-decimal-format" dropdown-class-name="nc-dropdown-decimal-format"
@change="onPrecisionChange"
> >
<a-select-option v-for="(format, i) of precisionFormats" :key="i" :value="format"> <a-select-option v-for="(format, i) of precisionFormats" :key="i" :value="format">
<div class="flex gap-2 w-full justify-between items-center"> <div class="flex gap-2 w-full justify-between items-center">

9
packages/nc-gui/components/smartsheet/column/DefaultValue.vue

@ -34,6 +34,13 @@ const updateCdfValue = (cdf: string | null) => {
onMounted(() => { onMounted(() => {
updateCdfValue(vModel.value?.cdf ? vModel.value.cdf : null) updateCdfValue(vModel.value?.cdf ? vModel.value.cdf : null)
}) })
watch(
() => vModel.value.cdf,
(newValue) => {
cdfValue.value = newValue
},
)
</script> </script>
<template> <template>
@ -58,7 +65,7 @@ onMounted(() => {
:is="iconMap.close" :is="iconMap.close"
v-if="![UITypes.Year, UITypes.SingleSelect, UITypes.MultiSelect].includes(vModel.uidt)" v-if="![UITypes.Year, UITypes.SingleSelect, UITypes.MultiSelect].includes(vModel.uidt)"
class="w-4 h-4 cursor-pointer rounded-full !text-black-500 text-gray-500 cursor-pointer hover:bg-gray-50" class="w-4 h-4 cursor-pointer rounded-full !text-black-500 text-gray-500 cursor-pointer hover:bg-gray-50"
@click="cdfValue = null" @click="updateCdfValue(null)"
/> />
</div> </div>
</div> </div>

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

@ -81,6 +81,10 @@ const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup, UITypes.Links] const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup, UITypes.Links]
// To close column type dropdown on escape and
// close modal only when the type popup is close
const isColumnTypeOpen = ref(false)
const geoDataToggleCondition = (t: { name: UITypes }) => { const geoDataToggleCondition = (t: { name: UITypes }) => {
if (!appInfo.value.ee) return true if (!appInfo.value.ee) return true
@ -199,6 +203,8 @@ onMounted(() => {
}) })
const handleEscape = (event: KeyboardEvent): void => { const handleEscape = (event: KeyboardEvent): void => {
if (isColumnTypeOpen.value) return
if (event.key === 'Escape') emit('cancel') if (event.key === 'Escape') emit('cancel')
} }
@ -206,6 +212,16 @@ const isFieldsTab = computed(() => {
return openedViewsTab.value === 'field' return openedViewsTab.value === 'field'
}) })
const onDropdownChange = (value: boolean) => {
if (value) {
isColumnTypeOpen.value = value
} else {
setTimeout(() => {
isColumnTypeOpen.value = value
}, 300)
}
}
if (props.fromTableExplorer) { if (props.fromTableExplorer) {
watch( watch(
formState, formState,
@ -224,7 +240,7 @@ if (props.fromTableExplorer) {
'bg-white': !props.fromTableExplorer, 'bg-white': !props.fromTableExplorer,
'w-[400px]': !props.embedMode, 'w-[400px]': !props.embedMode,
'!w-146': isTextArea(formState) && formState.meta?.richMode, '!w-146': isTextArea(formState) && formState.meta?.richMode,
'!w-[600px]': formState.uidt === UITypes.Formula && !props.embedMode, '!w-116 overflow-visible': formState.uidt === UITypes.Formula && !props.embedMode,
'!w-[500px]': formState.uidt === UITypes.Attachment && !props.embedMode && !appInfo.ee, '!w-[500px]': formState.uidt === UITypes.Attachment && !props.embedMode && !appInfo.ee,
'shadow-lg border-1 border-gray-200 shadow-gray-300 rounded-xl p-6': !embedMode, 'shadow-lg border-1 border-gray-200 shadow-gray-300 rounded-xl p-6': !embedMode,
}" }"
@ -275,6 +291,7 @@ if (props.fromTableExplorer) {
class="nc-column-type-input !rounded" class="nc-column-type-input !rounded"
:disabled="isKanban || readOnly" :disabled="isKanban || readOnly"
dropdown-class-name="nc-dropdown-column-type border-1 !rounded-md border-gray-200" dropdown-class-name="nc-dropdown-column-type border-1 !rounded-md border-gray-200"
@dropdown-visible-change="onDropdownChange"
@change="onUidtOrIdTypeChange" @change="onUidtOrIdTypeChange"
@dblclick="showDeprecated = !showDeprecated" @dblclick="showDeprecated = !showDeprecated"
> >
@ -323,6 +340,7 @@ if (props.fromTableExplorer) {
<LazySmartsheetColumnLinkOptions v-if="isEdit && formState.uidt === UITypes.Links" v-model:value="formState" /> <LazySmartsheetColumnLinkOptions v-if="isEdit && formState.uidt === UITypes.Links" v-model:value="formState" />
<LazySmartsheetColumnPercentOptions v-if="formState.uidt === UITypes.Percent" v-model:value="formState" /> <LazySmartsheetColumnPercentOptions v-if="formState.uidt === UITypes.Percent" v-model:value="formState" />
<LazySmartsheetColumnSpecificDBTypeOptions v-if="formState.uidt === UITypes.SpecificDBType" /> <LazySmartsheetColumnSpecificDBTypeOptions v-if="formState.uidt === UITypes.SpecificDBType" />
<LazySmartsheetColumnUserOptions v-if="formState.uidt === UITypes.User" v-model:value="formState" :is-edit="isEdit" />
<SmartsheetColumnSelectOptions <SmartsheetColumnSelectOptions
v-if="formState.uidt === UITypes.SingleSelect || formState.uidt === UITypes.MultiSelect" v-if="formState.uidt === UITypes.SingleSelect || formState.uidt === UITypes.MultiSelect"
v-model:value="formState" v-model:value="formState"

819
packages/nc-gui/components/smartsheet/column/FormulaOptions.vue

@ -2,34 +2,28 @@
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { ListItem as AntListItem } from 'ant-design-vue' import type { ListItem as AntListItem } from 'ant-design-vue'
import jsep from 'jsep' import jsep from 'jsep'
import type { ColumnType, FormulaType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { import {
FormulaError,
UITypes, UITypes,
isLinksOrLTAR,
isNumericCol,
isSystemColumn,
jsepCurlyHook, jsepCurlyHook,
substituteColumnIdWithAliasInFormula, substituteColumnIdWithAliasInFormula,
validateDateWithUnknownFormat, validateFormulaAndExtractTreeWithType,
} from 'nocodb-sdk' } from 'nocodb-sdk'
import type { ColumnType, FormulaType } from 'nocodb-sdk'
import { import {
MetaInj, MetaInj,
NcAutocompleteTree, NcAutocompleteTree,
computed, computed,
formulaList, formulaList,
formulaTypes,
formulas, formulas,
getUIDTIcon, getUIDTIcon,
getWordUntilCaret, getWordUntilCaret,
iconMap, iconMap,
inject, inject,
insertAtCursor, insertAtCursor,
isDate,
nextTick, nextTick,
onMounted, onMounted,
ref, ref,
storeToRefs,
useBase,
useColumnCreateStoreOrThrow, useColumnCreateStoreOrThrow,
useDebounceFn, useDebounceFn,
useI18n, useI18n,
@ -52,59 +46,40 @@ const { setAdditionalValidations, validateInfos, sqlUi, column } = useColumnCrea
const { t } = useI18n() const { t } = useI18n()
const baseStore = useBase()
const { tables } = storeToRefs(baseStore)
const { predictFunction: _predictFunction } = useNocoEe() const { predictFunction: _predictFunction } = useNocoEe()
enum JSEPNode {
COMPOUND = 'Compound',
IDENTIFIER = 'Identifier',
MEMBER_EXP = 'MemberExpression',
LITERAL = 'Literal',
THIS_EXP = 'ThisExpression',
CALL_EXP = 'CallExpression',
UNARY_EXP = 'UnaryExpression',
BINARY_EXP = 'BinaryExpression',
ARRAY_EXP = 'ArrayExpression',
}
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const supportedColumns = computed( const supportedColumns = computed(
() => meta?.value?.columns?.filter((col) => !uiTypesNotSupportedInFormulas.includes(col.uidt as UITypes)) || [], () => meta?.value?.columns?.filter((col) => !uiTypesNotSupportedInFormulas.includes(col.uidt as UITypes)) || [],
) )
const { metas } = useMetas() const { getMeta } = useMetas()
const refTables = computed(() => { const suggestionPreviewed = ref<Record<any, string> | undefined>()
if (!tables.value || !tables.value.length || !meta.value || !meta.value.columns) {
return []
}
const _refTables = meta.value.columns
.filter((column) => isLinksOrLTAR(column) && !column.system && column.source_id === meta.value?.source_id)
.map((column) => ({
col: column.colOptions,
column,
...tables.value.find((table) => table.id === (column.colOptions as LinkToAnotherRecordType).fk_related_model_id),
}))
.filter((table) => (table.col as LinkToAnotherRecordType)?.fk_related_model_id === table.id && !table.mm)
return _refTables as Required<TableType & { column: ColumnType; col: Required<LinkToAnotherRecordType> }>[]
})
const validators = { const validators = {
formula_raw: [ formula_raw: [
{ {
validator: (_: any, formula: any) => { validator: (_: any, formula: any) => {
return new Promise<void>((resolve, reject) => { return (async () => {
if (!formula?.trim()) return reject(new Error('Required')) if (!formula?.trim()) throw new Error('Required')
const res = parseAndValidateFormula(formula)
if (res !== true) { try {
return reject(new Error(res)) await validateFormulaAndExtractTreeWithType({
column: column.value,
formula,
columns: supportedColumns.value,
clientOrSqlUi: sqlUi.value,
getMeta,
})
} catch (e: any) {
if (e instanceof FormulaError && e.extra?.key) {
throw new Error(t(e.extra.key, e.extra))
}
throw new Error(e.message)
} }
resolve() })()
})
}, },
}, },
], ],
@ -120,6 +95,8 @@ const formulaRef = ref()
const sugListRef = ref() const sugListRef = ref()
const variableListRef = ref<(typeof AntListItem)[]>([])
const sugOptionsRef = ref<(typeof AntListItem)[]>([]) const sugOptionsRef = ref<(typeof AntListItem)[]>([])
const wordToComplete = ref<string | undefined>('') const wordToComplete = ref<string | undefined>('')
@ -143,6 +120,7 @@ const suggestionsList = computed(() => {
description: formulas[fn].description, description: formulas[fn].description,
syntax: formulas[fn].syntax, syntax: formulas[fn].syntax,
examples: formulas[fn].examples, examples: formulas[fn].examples,
docsUrl: formulas[fn].docsUrl,
})), })),
...supportedColumns.value ...supportedColumns.value
.filter((c) => { .filter((c) => {
@ -176,521 +154,13 @@ const acTree = computed(() => {
return ref return ref
}) })
function parseAndValidateFormula(formula: string) { const suggestedFormulas = computed(() => {
try { return suggestion.value.filter((s) => s && s.type !== 'column')
const parsedTree = jsep(formula) })
const metaErrors = validateAgainstMeta(parsedTree)
if (metaErrors.size) {
return [...metaErrors].join(', ')
}
return true
} catch (e: any) {
return e.message
}
}
function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = new Set()) {
if (parsedTree.type === JSEPNode.CALL_EXP) {
const calleeName = parsedTree.callee.name.toUpperCase()
// validate function name
if (!availableFunctions.includes(calleeName)) {
errors.add(t('msg.formula.functionNotAvailable', { function: calleeName }))
}
// validate arguments
const validation = formulas[calleeName] && formulas[calleeName].validation
if (validation && validation.args) {
if (validation.args.rqd !== undefined && validation.args.rqd !== parsedTree.arguments.length) {
errors.add(t('msg.formula.requiredArgumentsFormula', { requiredArguments: validation.args.rqd, calleeName }))
} else if (validation.args.min !== undefined && validation.args.min > parsedTree.arguments.length) {
errors.add(t('msg.formula.minRequiredArgumentsFormula', { minRequiredArguments: validation.args.min, calleeName }))
} else if (validation.args.max !== undefined && validation.args.max < parsedTree.arguments.length) {
errors.add(t('msg.formula.maxRequiredArgumentsFormula', { maxRequiredArguments: validation.args.max, calleeName }))
}
}
parsedTree.arguments.map((arg: Record<string, any>) => validateAgainstMeta(arg, errors))
// validate data type
if (parsedTree.callee.type === JSEPNode.IDENTIFIER) {
const expectedType = formulas[calleeName.toUpperCase()].type
if (expectedType === formulaTypes.NUMERIC) {
if (calleeName === 'WEEKDAY') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add(t('msg.formula.firstParamWeekDayHaveDate'))
}
},
typeErrors,
)
// parsedTree.arguments[1] = startDayOfWeek (optional)
validateAgainstType(
parsedTree.arguments[1],
formulaTypes.STRING,
(v: any) => {
if (
typeof v !== 'string' ||
!['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].includes(v.toLowerCase())
) {
typeErrors.add(t('msg.formula.secondParamWeekDayHaveDate'))
}
},
typeErrors,
)
} else {
parsedTree.arguments.map((arg: Record<string, any>) => validateAgainstType(arg, expectedType, null, typeErrors))
}
} else if (expectedType === formulaTypes.DATE) {
if (calleeName === 'DATEADD') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add(t('msg.formula.firstParamDateAddHaveDate'))
}
},
typeErrors,
)
// parsedTree.arguments[1] = numeric
validateAgainstType(
parsedTree.arguments[1],
formulaTypes.NUMERIC,
(v: any) => {
if (typeof v !== 'number') {
typeErrors.add(t('msg.formula.secondParamDateAddHaveNumber'))
}
},
typeErrors,
)
// parsedTree.arguments[2] = ["day" | "week" | "month" | "year"]
validateAgainstType(
parsedTree.arguments[2],
formulaTypes.STRING,
(v: any) => {
if (!['day', 'week', 'month', 'year'].includes(v)) {
typeErrors.add(typeErrors.add(t('msg.formula.thirdParamDateAddHaveDate')))
}
},
typeErrors,
)
} else if (calleeName === 'DATETIME_DIFF') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add(t('msg.formula.firstParamDateDiffHaveDate'))
}
},
typeErrors,
)
// parsedTree.arguments[1] = date
validateAgainstType(
parsedTree.arguments[1],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add(t('msg.formula.secondParamDateDiffHaveDate'))
}
},
typeErrors,
)
// parsedTree.arguments[2] = ["milliseconds" | "ms" | "seconds" | "s" | "minutes" | "m" | "hours" | "h" | "days" | "d" | "weeks" | "w" | "months" | "M" | "quarters" | "Q" | "years" | "y"]
validateAgainstType(
parsedTree.arguments[2],
formulaTypes.STRING,
(v: any) => {
if (
![
'milliseconds',
'ms',
'seconds',
's',
'minutes',
'm',
'hours',
'h',
'days',
'd',
'weeks',
'w',
'months',
'M',
'quarters',
'Q',
'years',
'y',
].includes(v)
) {
typeErrors.add(t('msg.formula.thirdParamDateDiffHaveDate'))
}
},
typeErrors,
)
}
}
}
errors = new Set([...errors, ...typeErrors])
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
if (supportedColumns.value.filter((c) => !column || column.value?.id !== c.id).every((c) => c.title !== parsedTree.name)) {
errors.add(
t('msg.formula.columnNotAvailable', {
columnName: parsedTree.name,
}),
)
}
// check circular reference
// e.g. formula1 -> formula2 -> formula1 should return circular reference error
// get all formula columns excluding itself
const formulaPaths = supportedColumns.value
.filter((c) => c.id !== column.value?.id && c.uidt === UITypes.Formula)
.reduce((res: Record<string, any>[], c: Record<string, any>) => {
// in `formula`, get all the (unique) target neighbours
// i.e. all column id (e.g. cl_xxxxxxxxxxxxxx) with formula type
const neighbours = [
...new Set(
(c.colOptions.formula.match(/cl_\w{14}/g) || []).filter(
(colId: string) =>
supportedColumns.value.filter((col: ColumnType) => col.id === colId && col.uidt === UITypes.Formula).length,
),
),
]
if (neighbours.length > 0) {
// e.g. formula column 1 -> [formula column 2, formula column3]
res.push({ [c.id]: neighbours })
}
return res
}, [])
// include target formula column (i.e. the one to be saved if applicable)
const targetFormulaCol = supportedColumns.value.find(
(c: ColumnType) => c.title === parsedTree.name && c.uidt === UITypes.Formula,
)
if (targetFormulaCol && column.value?.id) {
formulaPaths.push({
[column.value?.id as string]: [targetFormulaCol.id],
})
}
const vertices = formulaPaths.length
if (vertices > 0) {
// perform kahn's algo for cycle detection
const adj = new Map()
const inDegrees = new Map()
// init adjacency list & indegree
for (const [_, v] of Object.entries(formulaPaths)) {
const src = Object.keys(v)[0]
const neighbours = v[src]
inDegrees.set(src, inDegrees.get(src) || 0)
for (const neighbour of neighbours) {
adj.set(src, (adj.get(src) || new Set()).add(neighbour))
inDegrees.set(neighbour, (inDegrees.get(neighbour) || 0) + 1)
}
}
const queue: string[] = []
// put all vertices with in-degree = 0 (i.e. no incoming edges) to queue
inDegrees.forEach((inDegree, col) => {
if (inDegree === 0) {
// in-degree = 0 means we start traversing from this node
queue.push(col)
}
})
// init count of visited vertices
let visited = 0
// BFS
while (queue.length !== 0) {
// remove a vertex from the queue
const src = queue.shift()
// if this node has neighbours, increase visited by 1
const neighbours = adj.get(src) || new Set()
if (neighbours.size > 0) {
visited += 1
}
// iterate each neighbouring nodes
neighbours.forEach((neighbour: string) => {
// decrease in-degree of its neighbours by 1
inDegrees.set(neighbour, inDegrees.get(neighbour) - 1)
// if in-degree becomes 0
if (inDegrees.get(neighbour) === 0) {
// then put the neighboring node to the queue
queue.push(neighbour)
}
})
}
// vertices not same as visited = cycle found
if (vertices !== visited) {
errors.add(t('msg.formula.cantSaveCircularReference'))
}
}
} else if (parsedTree.type === JSEPNode.BINARY_EXP) {
if (!availableBinOps.includes(parsedTree.operator)) {
errors.add(t('msg.formula.operationNotAvailable', { operation: parsedTree.operator }))
}
validateAgainstMeta(parsedTree.left, errors)
validateAgainstMeta(parsedTree.right, errors)
} else if (parsedTree.type === JSEPNode.LITERAL || parsedTree.type === JSEPNode.UNARY_EXP) {
// do nothing
} else if (parsedTree.type === JSEPNode.COMPOUND) {
if (parsedTree.body.length) {
errors.add(t('msg.formula.cantSaveFieldFormulaInvalid'))
}
} else {
errors.add(t('msg.formula.cantSaveFieldFormulaInvalid'))
}
return errors
}
function validateAgainstType(parsedTree: any, expectedType: string, func: any, typeErrors = new Set()) {
if (parsedTree === false || typeof parsedTree === 'undefined') {
return typeErrors
}
if (parsedTree.type === JSEPNode.LITERAL) {
if (typeof func === 'function') {
func(parsedTree.value)
} else if (expectedType === formulaTypes.NUMERIC) {
if (typeof parsedTree.value !== 'number') {
typeErrors.add(t('msg.formula.numericTypeIsExpected'))
}
} else if (expectedType === formulaTypes.STRING) {
if (typeof parsedTree.value !== 'string') {
typeErrors.add(t('msg.formula.stringTypeIsExpected'))
}
}
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
const col = supportedColumns.value.find((c) => c.title === parsedTree.name)
if (col === undefined) {
return
}
if (col.uidt === UITypes.Formula) {
const foundType = getRootDataType(jsep(col.colOptions?.formula_raw))
if (foundType === 'N/A') {
typeErrors.add(t('msg.formula.notSupportedToReferenceColumn', { columnName: col.title }))
} else if (expectedType !== foundType) {
typeErrors.add(
t('msg.formula.typeIsExpectedButFound', {
type: expectedType,
found: foundType,
}),
)
}
} else {
switch (col.uidt) {
// string
case UITypes.SingleLineText:
case UITypes.LongText:
case UITypes.MultiSelect:
case UITypes.SingleSelect:
case UITypes.PhoneNumber:
case UITypes.Email:
case UITypes.URL:
if (expectedType !== formulaTypes.STRING) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: formulaTypes.STRING,
expectedType,
}),
)
}
break
// numeric
case UITypes.Year:
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Rating:
case UITypes.Count:
case UITypes.AutoNumber:
case UITypes.Currency:
if (expectedType !== formulaTypes.NUMERIC) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: formulaTypes.NUMERIC,
expectedType,
}),
)
}
break
// date
case UITypes.Date:
case UITypes.DateTime:
case UITypes.CreateTime:
case UITypes.LastModifiedTime:
if (expectedType !== formulaTypes.DATE) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: formulaTypes.DATE,
expectedType,
}),
)
}
break
case UITypes.Rollup: {
const rollupFunction = col.colOptions.rollup_function
if (['count', 'avg', 'sum', 'countDistinct', 'sumDistinct', 'avgDistinct'].includes(rollupFunction)) {
// these functions produce a numeric value, which can be used in numeric functions
if (expectedType !== formulaTypes.NUMERIC) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: formulaTypes.NUMERIC,
expectedType,
}),
)
}
} else {
// the value is based on the foreign rollup column type
const selectedTable = refTables.value.find((t) => t.column.id === col.colOptions.fk_relation_column_id)
const refTableColumns = metas.value[selectedTable.id].columns.filter(
(c: ColumnType) =>
vModel.value.fk_lookup_column_id === c.id ||
(!isSystemColumn(c) && c.id !== vModel.value.id && c.uidt !== UITypes.Links),
)
const childFieldColumn = refTableColumns.find(
(column: ColumnType) => column.id === col.colOptions.fk_rollup_column_id,
)
const abstractType = sqlUi.value.getAbstractType(childFieldColumn)
if (expectedType === formulaTypes.DATE && !isDate(childFieldColumn, sqlUi.value.getAbstractType(childFieldColumn))) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: abstractType,
expectedType,
}),
)
} else if (expectedType === formulaTypes.NUMERIC && !isNumericCol(childFieldColumn)) {
typeErrors.add(
t('msg.formula.columnWithTypeFoundButExpected', {
columnName: parsedTree.name,
columnType: abstractType,
expectedType,
}),
)
}
}
break
}
// not supported
case UITypes.ForeignKey:
case UITypes.Attachment:
case UITypes.ID:
case UITypes.Time:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.Lookup:
case UITypes.Barcode:
case UITypes.Button:
case UITypes.Checkbox:
case UITypes.Collaborator:
case UITypes.QrCode:
default:
typeErrors.add(t('msg.formula.notSupportedToReferenceColumn', { columnName: parsedTree.name }))
break
}
}
} else if (parsedTree.type === JSEPNode.UNARY_EXP || parsedTree.type === JSEPNode.BINARY_EXP) {
if (expectedType !== formulaTypes.NUMERIC) {
// parsedTree.name won't be available here
typeErrors.add(
t('msg.formula.typeIsExpectedButFound', {
type: formulaTypes.NUMERIC,
found: expectedType,
}),
)
}
} else if (parsedTree.type === JSEPNode.CALL_EXP) {
const calleeName = parsedTree.callee.name.toUpperCase()
if (formulas[calleeName]?.type && expectedType !== formulas[calleeName].type) {
typeErrors.add(
t('msg.formula.typeIsExpectedButFound', {
type: expectedType,
found: formulas[calleeName].type,
}),
)
}
}
return typeErrors
}
function getRootDataType(parsedTree: any): any { const variableList = computed(() => {
// given a parse tree, return the data type of it return suggestion.value.filter((s) => s && s.type === 'column')
if (parsedTree.type === JSEPNode.CALL_EXP) { })
return formulas[parsedTree.callee.name.toUpperCase()].type
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
const col = supportedColumns.value.find((c) => c.title === parsedTree.name) as Record<string, any>
if (col?.uidt === UITypes.Formula) {
return getRootDataType(jsep(col?.formula_raw))
} else {
switch (col?.uidt) {
// string
case UITypes.SingleLineText:
case UITypes.LongText:
case UITypes.MultiSelect:
case UITypes.SingleSelect:
case UITypes.PhoneNumber:
case UITypes.Email:
case UITypes.URL:
return formulaTypes.STRING
// numeric
case UITypes.Year:
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Rating:
case UITypes.Count:
case UITypes.AutoNumber:
return formulaTypes.NUMERIC
// date
case UITypes.Date:
case UITypes.DateTime:
case UITypes.CreateTime:
case UITypes.LastModifiedTime:
return formulaTypes.DATE
// not supported
case UITypes.ForeignKey:
case UITypes.Attachment:
case UITypes.ID:
case UITypes.Time:
case UITypes.Currency:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.Rollup:
case UITypes.Lookup:
case UITypes.Barcode:
case UITypes.Button:
case UITypes.Checkbox:
case UITypes.Collaborator:
case UITypes.QrCode:
default:
return 'N/A'
}
}
} else if (parsedTree.type === JSEPNode.BINARY_EXP || parsedTree.type === JSEPNode.UNARY_EXP) {
return formulaTypes.NUMERIC
} else if (parsedTree.type === JSEPNode.LITERAL) {
return typeof parsedTree.value
} else {
return 'N/A'
}
}
function isCurlyBracketBalanced() { function isCurlyBracketBalanced() {
// count number of opening curly brackets and closing curly brackets // count number of opening curly brackets and closing curly brackets
@ -739,6 +209,11 @@ function handleInput() {
suggestion.value = acTree.value suggestion.value = acTree.value
.complete(wordToComplete.value) .complete(wordToComplete.value)
?.sort((x: Record<string, any>, y: Record<string, any>) => sortOrder[x.type] - sortOrder[y.type]) ?.sort((x: Record<string, any>, y: Record<string, any>) => sortOrder[x.type] - sortOrder[y.type])
if (suggestion.value.length > 0 && suggestion.value[0].type !== 'column') {
suggestionPreviewed.value = suggestion.value[0]
}
if (!isCurlyBracketBalanced()) { if (!isCurlyBracketBalanced()) {
suggestion.value = suggestion.value.filter((v) => v.type === 'column') suggestion.value = suggestion.value.filter((v) => v.type === 'column')
} }
@ -746,14 +221,21 @@ function handleInput() {
} }
function selectText() { function selectText() {
if (suggestion.value && selected.value > -1 && selected.value < suggestion.value.length) { if (suggestion.value && selected.value > -1 && selected.value < suggestionsList.value.length) {
appendText(suggestion.value[selected.value]) if (selected.value < suggestedFormulas.value.length) {
appendText(suggestedFormulas.value[selected.value])
} else {
appendText(variableList.value[selected.value + suggestedFormulas.value.length])
}
} }
selected.value = 0
} }
function suggestionListUp() { function suggestionListUp() {
if (suggestion.value) { if (suggestion.value) {
selected.value = --selected.value > -1 ? selected.value : suggestion.value.length - 1 selected.value = --selected.value > -1 ? selected.value : suggestion.value.length - 1
suggestionPreviewed.value = suggestedFormulas.value[selected.value]
scrollToSelectedOption() scrollToSelectedOption()
} }
} }
@ -761,6 +243,8 @@ function suggestionListUp() {
function suggestionListDown() { function suggestionListDown() {
if (suggestion.value) { if (suggestion.value) {
selected.value = ++selected.value % suggestion.value.length selected.value = ++selected.value % suggestion.value.length
suggestionPreviewed.value = suggestedFormulas.value[selected.value]
scrollToSelectedOption() scrollToSelectedOption()
} }
} }
@ -769,9 +253,9 @@ function scrollToSelectedOption() {
nextTick(() => { nextTick(() => {
if (sugOptionsRef.value[selected.value]) { if (sugOptionsRef.value[selected.value]) {
try { try {
sugListRef.value.$el.scrollTo({ sugOptionsRef.value[selected.value].$el.scrollIntoView({
top: sugOptionsRef.value[selected.value].$el.offsetTop, block: 'nearest',
behavior: 'smooth', inline: 'start',
}) })
} catch (e) {} } catch (e) {}
} }
@ -796,15 +280,55 @@ setAdditionalValidations({
onMounted(() => { onMounted(() => {
jsep.plugins.register(jsepCurlyHook) jsep.plugins.register(jsepCurlyHook)
}) })
// const predictFunction = async () => {
// await _predictFunction(formState, meta, supportedColumns, suggestionsList, vModel)
// }
</script> </script>
<template> <template>
<div class="formula-wrapper"> <div class="formula-wrapper relative">
<a-form-item v-bind="validateInfos.formula_raw" :label="$t('datatype.Formula')"> <div
v-if="suggestionPreviewed && suggestionPreviewed.type === 'function'"
class="absolute -left-91 w-84 top-0 bg-white z-10 pl-3 pt-3 border-1 shadow-md rounded-xl"
>
<div class="pr-3">
<div class="flex flex-row w-full justify-between pb-1 border-b-1">
<div class="flex items-center gap-x-1 font-semibold text-base">
<component :is="iconMap.function" class="text-lg" />
{{ suggestionPreviewed.text }}
</div>
<NcButton type="text" size="small" @click="suggestionPreviewed = undefined">
<GeneralIcon icon="close" />
</NcButton>
</div>
</div>
<div class="flex flex-col max-h-120 nc-scrollbar-md pr-2">
<div class="flex mt-3">{{ suggestionPreviewed.description }}</div>
<div class="text-gray-500 uppercase text-xs mt-3 mb-2">Syntax</div>
<div class="bg-white rounded-md py-1 px-2 border-1">{{ suggestionPreviewed.syntax }}</div>
<div class="text-gray-500 uppercase text-xs mt-3 mb-2">Examples</div>
<div
v-for="(example, index) of suggestionPreviewed.examples"
:key="example"
class="bg-gray-100 py-1 px-2"
:class="{
'border-t-1 border-gray-200': index !== 0,
'rounded-b-md': index === suggestionPreviewed.examples.length - 1 && suggestionPreviewed.examples.length !== 1,
'rounded-t-md': index === 0 && suggestionPreviewed.examples.length !== 1,
'rounded-md': suggestionPreviewed.examples.length === 1,
}"
>
{{ example }}
</div>
</div>
<div class="flex flex-row mt-1 mb-3 justify-end pr-3">
<a target="_blank" rel="noopener noreferrer" :href="suggestionPreviewed.docsUrl">
<NcButton type="text" class="!text-gray-400 !hover:text-gray-800 !text-xs"
>View in Docs
<GeneralIcon icon="openInNew" class="ml-1" />
</NcButton>
</a>
</div>
</div>
<a-form-item v-bind="validateInfos.formula_raw" class="!pb-1" :label="$t('datatype.Formula')">
<!-- <GeneralIcon <!-- <GeneralIcon
v-if="isEeUI" v-if="isEeUI"
icon="magic" icon="magic"
@ -815,7 +339,7 @@ onMounted(() => {
<a-textarea <a-textarea
ref="formulaRef" ref="formulaRef"
v-model:value="vModel.formula_raw" v-model:value="vModel.formula_raw"
class="mb-2 nc-formula-input" class="nc-formula-input !rounded-md !my-1"
@keydown.down.prevent="suggestionListDown" @keydown.down.prevent="suggestionListDown"
@keydown.up.prevent="suggestionListUp" @keydown.up.prevent="suggestionListUp"
@keydown.enter.prevent="selectText" @keydown.enter.prevent="selectText"
@ -823,73 +347,90 @@ onMounted(() => {
/> />
</a-form-item> </a-form-item>
<div class="text-gray-600 mt-2 mb-4 prose-sm"> <div ref="sugListRef" class="h-[250px] overflow-auto nc-scrollbar-md">
{{ <template v-if="suggestedFormulas.length > 0">
// As using {} in translation will be treated as placeholder, and this translation contain {} as part of th text <div class="rounded-t-lg border-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs">Formulas</div>
$t('msg.formula.hintStart', {
placeholder1: '{}', <a-list
placeholder2: '{column_name}', :data-source="suggestedFormulas"
}) :locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }"
}} class="border-1 border-t-0 rounded-b-lg !mb-4"
<a >
class="prose-sm" <template #renderItem="{ item, index }">
href="https://docs.nocodb.com/setup-and-usages/formulas#available-formula-features" <a-list-item
target="_blank" :ref="
rel="noopener" (el) => {
> sugOptionsRef[index] = el
{{ $t('msg.formula.hintEnd') }} }
</a> "
</div> class="cursor-pointer !overflow-hidden hover:bg-gray-50"
:class="{
'!bg-gray-100': selected === index,
}"
@click.prevent.stop="appendText(item)"
@mouseenter="suggestionPreviewed = item"
>
<a-list-item-meta>
<template #title>
<div class="flex items-center gap-x-1">
<component :is="iconMap.function" v-if="item.type === 'function'" class="text-lg" />
<component :is="iconMap.calculator" v-if="item.type === 'op'" class="text-lg" />
<component :is="item.icon" v-if="item.type === 'column'" class="text-lg" />
<span class="prose-sm text-gray-600">{{ item.text }}</span>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
<template v-if="variableList.length > 0">
<div class="rounded-t-lg border-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs">Fields</div>
<a-list
ref="variableListRef"
:data-source="variableList"
:locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }"
class="border-1 border-t-0 rounded-b-lg !overflow-hidden"
>
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index + suggestedFormulas.length] = el
}
"
:class="{
'!bg-gray-100': selected === index + suggestedFormulas.length,
}"
class="cursor-pointer hover:bg-gray-50"
@click.prevent.stop="appendText(item)"
>
<a-list-item-meta>
<template #title>
<div class="flex items-center gap-x-1">
<component :is="item.icon" class="text-lg" />
<div class="h-[250px] overflow-auto scrollbar-thin-primary">
<a-list ref="sugListRef" :data-source="suggestion" :locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }">
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index] = el
}
"
class="cursor-pointer"
@click.prevent.stop="appendText(item)"
>
<a-list-item-meta>
<template #title>
<div class="flex">
<a-col :span="6">
<span class="prose-sm text-gray-600">{{ item.text }}</span> <span class="prose-sm text-gray-600">{{ item.text }}</span>
</a-col> </div>
</template>
<a-col :span="18"> </a-list-item-meta>
<div v-if="item.type === 'function'" class="text-xs text-gray-500"> </a-list-item>
{{ item.description }} <br /><br /> </template>
{{ $t('labels.syntax') }}: <br /> </a-list>
{{ item.syntax }} <br /><br /> </template>
{{ $t('labels.examples') }}: <br /> <div v-if="suggestion.length === 0">
<span class="text-gray-500">Empty</span>
<div v-for="(example, idx) of item.examples" :key="idx"> </div>
<div>({{ idx + 1 }}): {{ example }}</div>
</div>
</div>
<div v-if="item.type === 'column'" class="float-right mr-5 -mt-2">
<a-badge-ribbon :text="item.uidt" color="gray" />
</div>
</a-col>
</div>
</template>
<template #avatar>
<component :is="iconMap.function" v-if="item.type === 'function'" class="text-lg" />
<component :is="iconMap.calculator" v-if="item.type === 'op'" class="text-lg" />
<component :is="item.icon" v-if="item.type === 'column'" class="text-lg" />
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
:deep(.ant-list-item) {
@apply !pt-1.75 pb-0.75 !px-2;
}
</style>

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

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from '@vue/runtime-core' import { onMounted } from '@vue/runtime-core'
import type { ColumnType, LinkToAnotherRecordType, TableType, UITypes } from 'nocodb-sdk' import type { ColumnType, LinkToAnotherRecordType, TableType, UITypes } from 'nocodb-sdk'
import { isLinksOrLTAR, isNumericCol, isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { getAvailableRollupForUiType, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from '#imports' import type { Ref } from '#imports'
import { import {
MetaInj, MetaInj,
@ -102,31 +102,27 @@ const cellIcon = (column: ColumnType) =>
const aggFunctionsList: Ref<Record<string, string>[]> = ref([]) const aggFunctionsList: Ref<Record<string, string>[]> = ref([])
const allFunctions = [
{ text: t('datatype.Count'), value: 'count' },
{ text: t('general.min'), value: 'min' },
{ text: t('general.max'), value: 'max' },
{ text: t('general.avg'), value: 'avg' },
{ text: t('general.sum'), value: 'sum' },
{ text: t('general.countDistinct'), value: 'countDistinct' },
{ text: t('general.sumDistinct'), value: 'sumDistinct' },
{ text: t('general.avgDistinct'), value: 'avgDistinct' },
]
watch( watch(
() => vModel.value.fk_rollup_column_id, () => vModel.value.fk_rollup_column_id,
() => { () => {
const childFieldColumn = columns.value?.find((column: ColumnType) => column.id === vModel.value.fk_rollup_column_id) const childFieldColumn = columns.value?.find((column: ColumnType) => column.id === vModel.value.fk_rollup_column_id)
const showNumericFunctions = isNumericCol(childFieldColumn)
const nonNumericFunctions = [ aggFunctionsList.value = allFunctions.filter((func) =>
// functions for non-numeric types, getAvailableRollupForUiType(childFieldColumn?.uidt as UITypes).includes(func.value),
// e.g. count / min / max / countDistinct date field )
{ text: t('datatype.Count'), value: 'count' },
{ text: t('general.min'), value: 'min' }, if (!aggFunctionsList.value.includes(vModel.value.rollup_function)) {
{ text: t('general.max'), value: 'max' },
{ text: t('general.countDistinct'), value: 'countDistinct' },
]
const numericFunctions = showNumericFunctions
? [
{ text: t('general.avg'), value: 'avg' },
{ text: t('general.sum'), value: 'sum' },
{ text: t('general.sumDistinct'), value: 'sumDistinct' },
{ text: t('general.avgDistinct'), value: 'avgDistinct' },
]
: []
aggFunctionsList.value = [...nonNumericFunctions, ...numericFunctions]
if (!showNumericFunctions && ['avg', 'sum', 'sumDistinct', 'avgDistinct'].includes(vModel.value.rollup_function)) {
// when the previous roll up function was numeric type and the current child field is non-numeric // when the previous roll up function was numeric type and the current child field is non-numeric
// reset rollup function with a non-numeric type // reset rollup function with a non-numeric type
vModel.value.rollup_function = aggFunctionsList.value[0].value vModel.value.rollup_function = aggFunctionsList.value[0].value

66
packages/nc-gui/components/smartsheet/column/UserOptions.vue

@ -0,0 +1,66 @@
<script setup lang="ts">
import { useVModel } from '#imports'
const props = defineProps<{
value: any
isEdit: boolean
}>()
const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit)
const future = ref(false)
const initialIsMulti = ref()
const validators = {}
const { setAdditionalValidations } = useColumnCreateStoreOrThrow()
setAdditionalValidations({
...validators,
})
// set default value
vModel.value.meta = {
is_multi: false,
notify: false,
...vModel.value.meta,
}
onMounted(() => {
initialIsMulti.value = vModel.value.meta.is_multi
})
const updateIsMulti = (e) => {
vModel.value.meta.is_multi = e.target.checked
if (!vModel.value.meta.is_multi) {
vModel.value.cdf = vModel.value.cdf?.split(',')[0] || null
}
}
</script>
<template>
<div class="flex flex-col">
<div>
<a-checkbox
v-if="vModel.meta"
:checked="vModel.meta.is_multi"
class="ml-1 mb-1"
data-testid="user-column-allow-multiple"
@change="updateIsMulti"
>
<span class="text-[10px] text-gray-600">Allow adding multiple users</span>
</a-checkbox>
</div>
<div v-if="future">
<a-checkbox v-if="vModel.meta" v-model:checked="vModel.meta.notify" class="ml-1 mb-1">
<span class="text-[10px] text-gray-600">Notify users with base access when they're added</span>
</a-checkbox>
</div>
<div v-if="initialIsMulti && isEdit && !vModel.meta.is_multi" class="text-error text-[10px] mb-1 mt-2">
<span>Changing from multiple mode to single will retain only first user in each cell!!!</span>
</div>
</div>
</template>

8
packages/nc-gui/components/smartsheet/details/Api.vue

@ -84,7 +84,7 @@ const selectedLangName = ref(langs[0].name)
const apiUrl = computed( const apiUrl = computed(
() => () =>
new URL( new URL(
`/api/v1/db/data/noco/${base.value?.id}/${meta.value?.title}/views/${view.value?.title}`, `/api/v1/db/data/noco/${base.value?.id}/${meta.value?.id}/views/${view.value?.id}`,
(appInfo.value && appInfo.value.ncSiteUrl) || '/', (appInfo.value && appInfo.value.ncSiteUrl) || '/',
).href, ).href,
) )
@ -118,9 +118,9 @@ const api = new Api({
api.dbViewRow.list( api.dbViewRow.list(
"noco", "noco",
${JSON.stringify(base.value?.title)}, ${JSON.stringify(base.value?.id)},
${JSON.stringify(meta.value?.title)}, ${JSON.stringify(meta.value?.id)},
${JSON.stringify(view.value?.title)}, ${JSON.stringify(queryParams.value, null, 4)}).then(function (data) { ${JSON.stringify(view.value?.id)}, ${JSON.stringify(queryParams.value, null, 4)}).then(function (data) {
console.log(data); console.log(data);
}).catch(function (error) { }).catch(function (error) {
console.error(error); console.error(error);

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

@ -90,6 +90,8 @@ const { isUIAllowed } = useRoles()
const readOnly = computed(() => !isUIAllowed('dataEdit') || isPublic.value) const readOnly = computed(() => !isUIAllowed('dataEdit') || isPublic.value)
const expandedFormScrollWrapper = ref()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook()) const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const { addOrEditStackRow } = useKanbanViewStoreOrThrow() const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
@ -439,6 +441,13 @@ const preventModalStatus = computed({
}) })
const onIsExpandedUpdate = (v: boolean) => { const onIsExpandedUpdate = (v: boolean) => {
let isDropdownOpen = false
document.querySelectorAll('.ant-select-dropdown').forEach((el) => {
isDropdownOpen = isDropdownOpen || el.checkVisibility()
})
if (isDropdownOpen) return
if (changedColumns.value.size === 0 && !isUnsavedFormExist.value) { if (changedColumns.value.size === 0 && !isUnsavedFormExist.value) {
isExpanded.value = v isExpanded.value = v
} else if (!v) { } else if (!v) {
@ -451,6 +460,22 @@ const onIsExpandedUpdate = (v: boolean) => {
const isReadOnlyVirtualCell = (column: ColumnType) => { const isReadOnlyVirtualCell = (column: ColumnType) => {
return isRollup(column) || isFormula(column) || isBarcode(column) || isLookup(column) || isQrCode(column) return isRollup(column) || isFormula(column) || isBarcode(column) || isLookup(column) || isQrCode(column)
} }
// Small hack. We need to scroll to the bottom of the form after its mounted and back to top.
// So that tab to next row works properly, as otherwise browser will focus to save button
// when we reach to the bottom of the visual scrollable area, not the actual bottom of the form
watch([expandedFormScrollWrapper, isLoading], () => {
if (isMobileMode.value) return
if (expandedFormScrollWrapper.value && !isLoading.value) {
const height = expandedFormScrollWrapper.value.scrollHeight
expandedFormScrollWrapper.value.scrollTop = height
setTimeout(() => {
expandedFormScrollWrapper.value.scrollTop = 0
}, 125)
}
})
</script> </script>
<script lang="ts"> <script lang="ts">
@ -620,6 +645,7 @@ export default {
}" }"
> >
<div <div
ref="expandedFormScrollWrapper"
class="flex flex-col flex-grow mt-2 h-full max-h-full nc-scrollbar-md pb-6 items-center w-full bg-white p-4 xs:p-0" class="flex flex-col flex-grow mt-2 h-full max-h-full nc-scrollbar-md pb-6 items-center w-full bg-white p-4 xs:p-0"
> >
<div <div
@ -658,7 +684,7 @@ export default {
<SmartsheetDivDataCell <SmartsheetDivDataCell
v-if="col.title" v-if="col.title"
:ref="i ? null : (el: any) => (cellWrapperEl = el)" :ref="i ? null : (el: any) => (cellWrapperEl = el)"
class="bg-white rounded-lg w-80 xs:w-full border-1 border-gray-200 overflow-hidden px-1 sm:min-h-[35px] xs:min-h-13 flex items-center relative" class="bg-white w-80 xs:w-full px-1 sm:min-h-[35px] xs:min-h-13 flex items-center relative"
:class="{ :class="{
'!bg-gray-50 !px-0 !select-text': isReadOnlyVirtualCell(col), '!bg-gray-50 !px-0 !select-text': isReadOnlyVirtualCell(col),
}" }"
@ -894,4 +920,8 @@ export default {
:deep(.ant-select-selection-item) { :deep(.ant-select-selection-item) {
@apply !xs:(mt-1.75 ml-1); @apply !xs:(mt-1.75 ml-1);
} }
.nc-data-cell:focus-within {
@apply !border-1 !border-brand-500 !rounded-lg !shadow-none !ring-0;
}
</style> </style>

32
packages/nc-gui/components/smartsheet/grid/GroupBy.vue

@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import { UITypes } from 'nocodb-sdk' import dayjs from 'dayjs'
import { UITypes, dateFormats, timeFormats } from 'nocodb-sdk'
import Table from './Table.vue' import Table from './Table.vue'
import GroupBy from './GroupBy.vue' import GroupBy from './GroupBy.vue'
import GroupByTable from './GroupByTable.vue' import GroupByTable from './GroupByTable.vue'
@ -139,7 +140,7 @@ const onScroll = (e: Event) => {
// a method to parse group key if grouped column type is LTAR or Lookup // a method to parse group key if grouped column type is LTAR or Lookup
// in these 2 scenario it will return json array or `___` separated value // in these 2 scenario it will return json array or `___` separated value
const parseKey = (group) => { const parseKey = (group: Group) => {
let key = group.key.toString() let key = group.key.toString()
// parse json array key if it's a lookup or link to another record // parse json array key if it's a lookup or link to another record
@ -151,11 +152,36 @@ const parseKey = (group) => {
return key.split('___') return key.split('___')
} }
} }
// show the groupBy dateTime field title format as like cell format
if (key && group.column?.uidt === UITypes.DateTime && dayjs(key).isValid()) {
const dateFormat = parseProp(group.column?.meta)?.date_format ?? dateFormats[0]
const timeFormat = parseProp(group.column?.meta)?.time_format ?? timeFormats[0]
const dateTimeFormat = `${dateFormat} ${timeFormat}`
return [dayjs(key).utc().local().format(dateTimeFormat)]
}
// show the groupBy time field title format as like cell format
if (key && group.column?.uidt === UITypes.Time && dayjs(key).isValid()) {
return [dayjs(key).format(timeFormats[0])]
}
if (key && group.column?.uidt === UITypes.User) {
try {
const parsedKey = JSON.parse(key)
return [parsedKey]
} catch {
return null
}
}
return [key] return [key]
} }
const shouldRenderCell = (column) => const shouldRenderCell = (column) =>
[UITypes.Lookup, UITypes.Attachment, UITypes.Barcode, UITypes.QrCode, UITypes.Links].includes(column?.uidt) [UITypes.Lookup, UITypes.Attachment, UITypes.Barcode, UITypes.QrCode, UITypes.Links, UITypes.User].includes(column?.uidt)
</script> </script>
<template> <template>

5
packages/nc-gui/components/smartsheet/grid/GroupByTable.vue

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { UITypes, isLinksOrLTAR } from 'nocodb-sdk' import { UITypes, isLinksOrLTAR } from 'nocodb-sdk'
import Table from './Table.vue' import Table from './Table.vue'
import { IsGroupByInj, computed, ref } from '#imports' import { IsGroupByInj, computed, ref, rowDefaultData } from '#imports'
import type { Group, Row } from '#imports' import type { Group, Row } from '#imports'
const props = defineProps<{ const props = defineProps<{
@ -38,7 +38,7 @@ const view = inject(ActiveViewInj, ref())
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook()) const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
function addEmptyRow(group: Group, addAfter?: number) { function addEmptyRow(group: Group, addAfter?: number, metaValue = meta.value) {
if (group.nested || !group.rows) return if (group.nested || !group.rows) return
addAfter = addAfter ?? group.rows.length addAfter = addAfter ?? group.rows.length
@ -57,6 +57,7 @@ function addEmptyRow(group: Group, addAfter?: number) {
group.rows.splice(addAfter, 0, { group.rows.splice(addAfter, 0, {
row: { row: {
...rowDefaultData(metaValue?.columns),
...setGroup, ...setGroup,
}, },
oldRow: {}, oldRow: {},

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

@ -448,7 +448,8 @@ const closeAddColumnDropdown = (scrollToLastCol = false) => {
} }
async function openNewRecordHandler() { async function openNewRecordHandler() {
const newRow = addEmptyRow() // skip update row when it is `New record form`
const newRow = addEmptyRow(dataRef.value.length, true)
if (newRow) expandForm?.(newRow, undefined, true) if (newRow) expandForm?.(newRow, undefined, true)
} }
@ -697,8 +698,17 @@ function scrollToRow(row?: number) {
scrollToCell?.() scrollToCell?.()
} }
function addEmptyRow(row?: number) { async function saveEmptyRow(rowObj: Row) {
await updateOrSaveRow?.(rowObj)
}
function addEmptyRow(row?: number, skipUpdate: boolean = false) {
const rowObj = callAddEmptyRow?.(row) const rowObj = callAddEmptyRow?.(row)
if (!skipUpdate && rowObj) {
saveEmptyRow(rowObj)
}
nextTick().then(() => { nextTick().then(() => {
scrollToRow(row ?? dataRef.value.length - 1) scrollToRow(row ?? dataRef.value.length - 1)
}) })
@ -781,7 +791,7 @@ onClickOutside(tableBodyEl, (e) => {
// ignore unselecting if clicked inside or on the picker(Date, Time, DateTime, Year) // ignore unselecting if clicked inside or on the picker(Date, Time, DateTime, Year)
// or single/multi select options // or single/multi select options
const activePickerOrDropdownEl = document.querySelector( const activePickerOrDropdownEl = document.querySelector(
'.nc-picker-datetime.active,.nc-dropdown-single-select-cell.active,.nc-dropdown-multi-select-cell.active,.nc-picker-date.active,.nc-picker-year.active,.nc-picker-time.active', '.nc-picker-datetime.active,.nc-dropdown-single-select-cell.active,.nc-dropdown-multi-select-cell.active,.nc-dropdown-user-select-cell.active,.nc-picker-date.active,.nc-picker-year.active,.nc-picker-time.active',
) )
if ( if (
e.target && e.target &&
@ -995,7 +1005,13 @@ const showFillHandle = computed(
!readOnly.value && !readOnly.value &&
!editEnabled.value && !editEnabled.value &&
(!selectedRange.isEmpty() || (activeCell.row !== null && activeCell.col !== null)) && (!selectedRange.isEmpty() || (activeCell.row !== null && activeCell.col !== null)) &&
!dataRef.value[(isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) ?? -1]?.rowMeta?.new, !dataRef.value[(isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) ?? -1]?.rowMeta?.new &&
activeCell.col !== null &&
!(
isLookup(fields.value[activeCell.col]) ||
isRollup(fields.value[activeCell.col]) ||
isFormula(fields.value[activeCell.col])
),
) )
watch( watch(
@ -1027,13 +1043,11 @@ eventBus.on(async (event, payload) => {
addColumnDropdown.value = true addColumnDropdown.value = true
} }
if (event === SmartsheetStoreEvents.CLEAR_NEW_ROW) { if (event === SmartsheetStoreEvents.CLEAR_NEW_ROW) {
const removed = removeRowIfNew?.(payload) clearSelectedRange()
activeCell.row = null
activeCell.col = null
if (removed) { removeRowIfNew?.(payload)
clearSelectedRange()
activeCell.row = null
activeCell.col = null
}
} }
}) })
@ -1258,7 +1272,10 @@ onKeyStroke('ArrowDown', onDown)
></div> ></div>
</div> </div>
<div ref="gridWrapper" class="nc-grid-wrapper min-h-0 flex-1 relative" :class="gridWrapperClass"> <div ref="gridWrapper" class="nc-grid-wrapper min-h-0 flex-1 relative" :class="gridWrapperClass">
<div v-show="isPaginationLoading" class="flex items-center justify-center absolute l-0 t-0 w-full h-full z-10 pb-10"> <div
v-show="isPaginationLoading"
class="flex items-center justify-center absolute l-0 t-0 w-full h-full z-10 pb-10 pointer-events-none"
>
<div class="flex flex-col justify-center gap-2"> <div class="flex flex-col justify-center gap-2">
<GeneralLoader size="xlarge" /> <GeneralLoader size="xlarge" />
<span class="text-center" v-html="loaderText"></span> <span class="text-center" v-html="loaderText"></span>
@ -1486,7 +1503,6 @@ onKeyStroke('ArrowDown', onDown)
> >
<div class="items-center flex gap-1 min-w-[60px]"> <div class="items-center flex gap-1 min-w-[60px]">
<div <div
v-if="!readOnly || isMobileMode"
class="nc-row-no sm:min-w-4 text-xs text-gray-500" class="nc-row-no sm:min-w-4 text-xs text-gray-500"
:class="{ toggle: !readOnly, hidden: row.rowMeta.selected }" :class="{ toggle: !readOnly, hidden: row.rowMeta.selected }"
> >
@ -1576,7 +1592,7 @@ onKeyStroke('ArrowDown', onDown)
@dblclick="makeEditable(row, columnObj)" @dblclick="makeEditable(row, columnObj)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })" @contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
> >
<div v-if="!switchingTab" class="w-full h-full"> <div v-if="!switchingTab" class="w-full">
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-if="isVirtualCol(columnObj) && columnObj.title" v-if="isVirtualCol(columnObj) && columnObj.title"
v-model="row.row[columnObj.title]" v-model="row.row[columnObj.title]"
@ -1698,7 +1714,7 @@ onKeyStroke('ArrowDown', onDown)
</NcMenuItem> </NcMenuItem>
<NcMenuItem <NcMenuItem
v-if="contextMenuTarget" v-if="contextMenuTarget && hasEditPermission"
class="nc-base-menu-item" class="nc-base-menu-item"
data-testid="context-menu-item-paste" data-testid="context-menu-item-paste"
:disabled="isSystemColumn(fields[contextMenuTarget.col])" :disabled="isSystemColumn(fields[contextMenuTarget.col])"
@ -1721,6 +1737,7 @@ onKeyStroke('ArrowDown', onDown)
" "
class="nc-base-menu-item" class="nc-base-menu-item"
:disabled="isSystemColumn(fields[contextMenuTarget.col])" :disabled="isSystemColumn(fields[contextMenuTarget.col])"
data-testid="context-menu-item-clear"
@click="clearCell(contextMenuTarget)" @click="clearCell(contextMenuTarget)"
> >
<div v-e="['a:row:clear']" class="flex gap-2 items-center"> <div v-e="['a:row:clear']" class="flex gap-2 items-center">
@ -1734,6 +1751,7 @@ onKeyStroke('ArrowDown', onDown)
v-else-if="contextMenuTarget && hasEditPermission" v-else-if="contextMenuTarget && hasEditPermission"
class="nc-base-menu-item" class="nc-base-menu-item"
:disabled="isSystemColumn(fields[contextMenuTarget.col])" :disabled="isSystemColumn(fields[contextMenuTarget.col])"
data-testid="context-menu-item-clear"
@click="clearSelectedRangeOfCells()" @click="clearSelectedRangeOfCells()"
> >
<div v-e="['a:row:clear-range']" class="flex gap-2 items-center"> <div v-e="['a:row:clear-range']" class="flex gap-2 items-center">
@ -1741,17 +1759,16 @@ onKeyStroke('ArrowDown', onDown)
{{ $t('general.clear') }} {{ $t('general.clear') }}
</div> </div>
</NcMenuItem> </NcMenuItem>
<NcDivider />
<NcMenuItem <template v-if="contextMenuTarget && selectedRange.isSingleCell() && isUIAllowed('commentEdit') && !isMobileMode">
v-if="contextMenuTarget && selectedRange.isSingleCell() && isUIAllowed('commentEdit') && !isMobileMode" <NcDivider />
class="nc-base-menu-item" <NcMenuItem class="nc-base-menu-item" @click="commentRow(contextMenuTarget.row)">
@click="commentRow(contextMenuTarget.row)" <div v-e="['a:row:comment']" class="flex gap-2 items-center">
> <MdiMessageOutline class="h-4 w-4" />
<div v-e="['a:row:comment']" class="flex gap-2 items-center"> {{ $t('general.comment') }}
<MdiMessageOutline class="h-4 w-4" /> </div>
{{ $t('general.comment') }} </NcMenuItem>
</div> </template>
</NcMenuItem>
<template v-if="hasEditPermission"> <template v-if="hasEditPermission">
<NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" /> <NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" />

3
packages/nc-gui/components/smartsheet/grid/useColumnDrag.ts

@ -34,6 +34,9 @@ export const useColumnDrag = ({
const lastCol = fields.value[fields.value.length - 1] const lastCol = fields.value[fields.value.length - 1]
const lastViewCol = gridViewCols.value[lastCol.id!] const lastViewCol = gridViewCols.value[lastCol.id!]
// if nextToViewCol/toViewCol is null, return
if (nextToViewCol === null || lastViewCol === null) return
const newOrder = nextToViewCol ? toViewCol.order! + (nextToViewCol.order! - toViewCol.order!) / 2 : lastViewCol.order! + 1 const newOrder = nextToViewCol ? toViewCol.order! + (nextToViewCol.order! - toViewCol.order!) / 2 : lastViewCol.order! + 1
const oldOrder = toBeReorderedViewCol.order const oldOrder = toBeReorderedViewCol.order

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

@ -31,6 +31,7 @@ import {
isTextArea, isTextArea,
isTime, isTime,
isURL, isURL,
isUser,
isYear, isYear,
storeToRefs, storeToRefs,
toRef, toRef,
@ -82,6 +83,11 @@ const renderIcon = (column: ColumnType, abstractType: any) => {
return iconMap.percent return iconMap.percent
} else if (isGeometry(column)) { } else if (isGeometry(column)) {
return iconMap.calculator return iconMap.calculator
} else if (isUser(column)) {
if ((column.meta as { is_multi: boolean; notify: boolean }).is_multi) {
return iconMap.phUsers
}
return iconMap.phUser
} else if (isInt(column, abstractType) || isFloat(column, abstractType)) { } else if (isInt(column, abstractType) || isFloat(column, abstractType)) {
return iconMap.number return iconMap.number
} else if (isString(column, abstractType)) { } else if (isString(column, abstractType)) {

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

@ -208,8 +208,17 @@ const filtersCount = computed(() => {
}, 0) }, 0)
}) })
const applyChanges = async (hookId?: string, _nested = false) => { const applyChanges = async (hookId?: string, nested = false, isConditionSupported = true) => {
await sync(hookId, _nested) // if condition is not supported, delete all filters present
// it's used for bulk webhooks with filters since bulk webhooks don't support conditions at the moment
if (!isConditionSupported) {
// iterate in reverse order and delete all filters, reverse order is for getting the correct index
for (let i = filters.value.length - 1; i >= 0; i--) {
await deleteFilter(filters.value[i], i)
}
}
await sync(hookId, nested)
if (!localNestedFilters.value?.length) return if (!localNestedFilters.value?.length) return

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

@ -21,6 +21,7 @@ import {
isSingleSelect, isSingleSelect,
isTextArea, isTextArea,
isTime, isTime,
isUser,
isYear, isYear,
provide, provide,
ref, ref,
@ -42,6 +43,7 @@ import Decimal from '~/components/cell/Decimal.vue'
import Integer from '~/components/cell/Integer.vue' import Integer from '~/components/cell/Integer.vue'
import Float from '~/components/cell/Float.vue' import Float from '~/components/cell/Float.vue'
import Text from '~/components/cell/Text.vue' import Text from '~/components/cell/Text.vue'
import User from '~/components/cell/User.vue'
interface Props { interface Props {
column: ColumnType column: ColumnType
@ -82,6 +84,7 @@ const checkTypeFunctions = {
isFloat, isFloat,
isTextArea, isTextArea,
isLinks: (col: ColumnType) => col.uidt === UITypes.Links, isLinks: (col: ColumnType) => col.uidt === UITypes.Links,
isUser,
} }
type FilterType = keyof typeof checkTypeFunctions type FilterType = keyof typeof checkTypeFunctions
@ -148,6 +151,7 @@ const componentMap: Partial<Record<FilterType, any>> = computed(() => {
isInt: Integer, isInt: Integer,
isFloat: Float, isFloat: Float,
isLinks: Integer, isLinks: Integer,
isUser: User,
} }
}) })
@ -171,6 +175,9 @@ const componentProps = computed(() => {
case 'isDuration': { case 'isDuration': {
return { showValidationError: false } return { showValidationError: false }
} }
case 'isUser': {
return { forceMulti: true }
}
default: { default: {
return {} return {}
} }

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

@ -90,7 +90,7 @@ const openedBaseUrl = computed(() => {
'max-w-none': isSharedBase && !isMobileMode, 'max-w-none': isSharedBase && !isMobileMode,
}" }"
> >
<NcTooltip class="truncate nc-active-table-title max-w-full"> <NcTooltip class="truncate nc-active-table-title max-w-full" show-on-truncate-only>
<template #title> <template #title>
{{ activeTable?.title }} {{ activeTable?.title }}
</template> </template>

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

@ -33,6 +33,8 @@ const { isUIAllowed } = useRoles()
const { dashboardUrl } = useDashboard() const { dashboardUrl } = useDashboard()
const { clearBasesUser } = useBases()
const users = ref<null | User[]>(null) const users = ref<null | User[]>(null)
const selectedUser = ref<null | User>(null) const selectedUser = ref<null | User>(null)
@ -84,6 +86,9 @@ const inviteUser = async (user: User) => {
await api.auth.baseUserAdd(base.value.id, user as ProjectUserReqType) await api.auth.baseUserAdd(base.value.id, user as ProjectUserReqType)
// clear bases user state
clearBasesUser()
// Successfully added user to base // Successfully added user to base
message.success(t('msg.success.userAddedToProject')) message.success(t('msg.success.userAddedToProject'))
await loadUsers() await loadUsers()

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

@ -784,7 +784,10 @@ watch(modelRef, async () => {
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'source_column'"> <template v-if="column.key === 'source_column'">
<span>{{ record.srcCn }}</span> <NcTooltip class="truncate"
><template #title>{{ record.srcCn }}</template
>{{ record.srcCn }}</NcTooltip
>
</template> </template>
<template v-else-if="column.key === 'destination_column'"> <template v-else-if="column.key === 'destination_column'">
@ -1025,7 +1028,7 @@ watch(modelRef, async () => {
} }
:deep(.template-form-row) > td { :deep(.template-form-row) > td {
@apply p-0 mb-0; @apply p-1 mb-0 truncate max-w-50;
.ant-form-item { .ant-form-item {
@apply mb-0; @apply mb-0;
} }

23
packages/nc-gui/components/virtual-cell/BelongsTo.vue

@ -83,6 +83,14 @@ const belongsToColumn = computed(
() => () =>
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined, relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
) )
const plusBtnRef = ref<HTMLElement | null>(null)
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
})
</script> </script>
<template> <template>
@ -101,11 +109,14 @@ const belongsToColumn = computed(
<div <div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup" v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
class="flex justify-end gap-1 min-h-[30px] items-center" ref="plusBtnRef"
class="flex justify-end group gap-1 min-h-[30px] items-center"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
> >
<GeneralIcon <GeneralIcon
:icon="addIcon" :icon="addIcon"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 select-none group-hover:(text-gray-500) nc-plus" class="text-sm nc-action-icon group-focus:visible invisible text-gray-500/50 hover:text-gray-500 select-none group-hover:(text-gray-500) nc-plus"
@click.stop="listItemsDlg = true" @click.stop="listItemsDlg = true"
/> />
</div> </div>
@ -121,7 +132,7 @@ const belongsToColumn = computed(
<style scoped lang="scss"> <style scoped lang="scss">
.nc-action-icon { .nc-action-icon {
@apply hidden cursor-pointer; @apply cursor-pointer;
} }
.chips-wrapper:hover, .chips-wrapper:hover,
@ -130,4 +141,10 @@ const belongsToColumn = computed(
@apply inline-block; @apply inline-block;
} }
} }
.chips-wrapper:hover {
.nc-action-icon {
@apply visible;
}
}
</style> </style>

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

@ -2,7 +2,7 @@
import { handleTZ } from 'nocodb-sdk' import { handleTZ } from 'nocodb-sdk'
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { CellValueInj, ColumnInj, computed, inject, renderValue, replaceUrlsWithLink, useBase } from '#imports' import { CellValueInj, ColumnInj, computed, inject, renderValue, replaceUrlsWithLink, useBase, useGlobal } from '#imports'
// todo: column type doesn't have required property `error` - throws in typecheck // todo: column type doesn't have required property `error` - throws in typecheck
const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }> const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }>
@ -11,6 +11,8 @@ const cellValue = inject(CellValueInj)
const { isPg } = useBase() const { isPg } = useBase()
const { showNull } = useGlobal()
const result = computed(() => const result = computed(() =>
isPg(column.value.source_id) ? renderValue(handleTZ(cellValue?.value)) : renderValue(cellValue?.value), isPg(column.value.source_id) ? renderValue(handleTZ(cellValue?.value)) : renderValue(cellValue?.value),
) )
@ -30,6 +32,8 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
<span>ERR!</span> <span>ERR!</span>
</a-tooltip> </a-tooltip>
<span v-else-if="cellValue === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<div v-else class="py-2" @dblclick="activateShowEditNonEditableFieldWarning"> <div v-else class="py-2" @dblclick="activateShowEditNonEditableFieldWarning">
<div v-if="urls" v-html="urls" /> <div v-if="urls" v-html="urls" />

28
packages/nc-gui/components/virtual-cell/Links.vue

@ -102,6 +102,21 @@ const openListDlg = () => {
listItemsDlg.value = true listItemsDlg.value = true
} }
const plusBtnRef = ref<HTMLElement | null>(null)
const childListDlgRef = ref<HTMLElement | null>(null)
watch([childListDlg], () => {
if (!childListDlg.value) {
childListDlgRef.value?.focus()
}
})
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
})
</script> </script>
<template> <template>
@ -109,21 +124,30 @@ const openListDlg = () => {
<div class="block flex-shrink truncate"> <div class="block flex-shrink truncate">
<component <component
:is="isUnderLookup ? 'span' : 'a'" :is="isUnderLookup ? 'span' : 'a'"
ref="childListDlgRef"
v-e="['c:cell:links:modal:open']" v-e="['c:cell:links:modal:open']"
:title="textVal" :title="textVal"
class="text-center nc-datatype-link underline-transparent" class="text-center nc-datatype-link underline-transparent"
:class="{ '!text-gray-300': !textVal }" :class="{ '!text-gray-300': !textVal }"
tabindex="0"
@click.stop.prevent="openChildList" @click.stop.prevent="openChildList"
@keydown.enter.stop.prevent="openChildList"
> >
{{ textVal }} {{ textVal }}
</component> </component>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div v-if="!isUnderLookup" class="!xs:hidden flex justify-end hidden group-hover:flex items-center"> <div
v-if="!isUnderLookup"
ref="plusBtnRef"
tabindex="0"
class="!xs:hidden flex group justify-end group-hover:flex items-center"
@keydown.enter.stop="openListDlg"
>
<MdiPlus <MdiPlus
v-if="(!readOnly && isUIAllowed('dataEdit')) || isForm" v-if="(!readOnly && isUIAllowed('dataEdit')) || isForm"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus" class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="openListDlg" @click.stop="openListDlg"
/> />
</div> </div>

15
packages/nc-gui/components/virtual-cell/Lookup.vue

@ -99,18 +99,19 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
<template> <template>
<div <div
class="h-full w-full nc-lookup-cell" class="h-full w-full nc-lookup-cell"
tabindex="-1"
:style="{ height: rowHeight && rowHeight !== 1 ? `${rowHeight * 2}rem` : `2.85rem` }" :style="{ height: rowHeight && rowHeight !== 1 ? `${rowHeight * 2}rem` : `2.85rem` }"
@dblclick="activateShowEditNonEditableFieldWarning" @dblclick="activateShowEditNonEditableFieldWarning"
> >
<div <div
class="h-full w-full flex gap-1 p-1" class="h-full w-full flex gap-1"
:class="{ :class="{
'!overflow-x-auto nc-cell-lookup-scroll nc-scrollbar-x-md !overflow-y-hidden': rowHeight === 1, '!overflow-x-auto nc-cell-lookup-scroll nc-scrollbar-x-md !overflow-y-hidden': rowHeight === 1,
}" }"
> >
<template v-if="lookupColumn"> <template v-if="lookupColumn">
<!-- Render virtual cell --> <!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn)" class="flex"> <div v-if="isVirtualCol(lookupColumn)" class="flex h-full">
<!-- If non-belongs-to LTAR column then pass the array value, else iterate and render --> <!-- If non-belongs-to LTAR column then pass the array value, else iterate and render -->
<template <template
v-if=" v-if="
@ -151,7 +152,7 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
}" }"
> >
<div <div
class="flex gap-1.5 w-full" class="flex items-start gap-1.5 w-full h-full py-[3px]"
:class="{ :class="{
'flex-wrap': rowHeight !== 1 && !isAttachment(lookupColumn), 'flex-wrap': rowHeight !== 1 && !isAttachment(lookupColumn),
'!overflow-x-auto nc-cell-lookup-scroll nc-scrollbar-x-md !overflow-y-hidden': '!overflow-x-auto nc-cell-lookup-scroll nc-scrollbar-x-md !overflow-y-hidden':
@ -162,11 +163,12 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
v-for="(v, i) of arrValue" v-for="(v, i) of arrValue"
:key="i" :key="i"
:class="{ :class="{
'bg-gray-100 px-1 rounded-full min-h-7.5': !isAttachment(lookupColumn), 'bg-gray-100 rounded-full': !isAttachment(lookupColumn),
'border-gray-200 rounded border-1 pt-0.75': ![ 'border-gray-200 rounded border-1': ![
UITypes.Attachment, UITypes.Attachment,
UITypes.MultiSelect, UITypes.MultiSelect,
UITypes.SingleSelect, UITypes.SingleSelect,
UITypes.User,
].includes(lookupColumn.uidt), ].includes(lookupColumn.uidt),
'min-h-0 min-w-0': isAttachment(lookupColumn), 'min-h-0 min-w-0': isAttachment(lookupColumn),
}" }"
@ -177,10 +179,9 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
:edit-enabled="false" :edit-enabled="false"
:virtual="true" :virtual="true"
:read-only="true" :read-only="true"
class=""
:class="{ :class="{
'min-h-0 min-w-0': isAttachment(lookupColumn), 'min-h-0 min-w-0': isAttachment(lookupColumn),
'!max-w-40 !min-w-20 !w-auto px-2': !isAttachment(lookupColumn), '!min-w-20 !w-auto pl-2': !isAttachment(lookupColumn),
}" }"
/> />
</div> </div>

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

@ -77,25 +77,26 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning } = us
</template> </template>
<img v-if="showQrCode" :src="qrCodeLarge" :alt="$t('title.qrCode')" /> <img v-if="showQrCode" :src="qrCodeLarge" :alt="$t('title.qrCode')" />
</a-modal> </a-modal>
<div v-if="tooManyCharsForQrCode" class="text-left text-wrap mt-2 text-[#e65100] text-[10px]">
{{ $t('labels.qrCodeValueTooLong') }}
</div>
<div <div
class="pl-2 w-full flex" v-if="showQrCode"
class="w-full flex"
:class="{ :class="{
'flex-start': isExpandedFormOpen, 'flex-start pl-2': isExpandedFormOpen,
'justify-center': !isExpandedFormOpen, 'justify-center': !isExpandedFormOpen,
}" }"
> >
<img <img
v-if="showQrCode && rowHeight" v-if="rowHeight"
:style="{ height: rowHeight ? `${rowHeight * 1.4}rem` : `1.4rem` }" :style="{ height: rowHeight ? `${rowHeight * 1.8}rem` : `1.8rem` }"
:src="qrCode" :src="qrCode"
:alt="$t('title.qrCode')" :alt="$t('title.qrCode')"
class="min-w-[1.4em]" class="min-w-[1.4em]"
@click="showQrModal" @click="showQrModal"
/> />
<img v-else-if="showQrCode" class="mx-auto min-w-[1.4em]" :src="qrCode" :alt="$t('title.qrCode')" @click="showQrModal" /> <img v-else class="mx-auto min-w-[1.4em]" :src="qrCode" :alt="$t('title.qrCode')" @click="showQrModal" />
</div>
<div v-if="tooManyCharsForQrCode" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('labels.qrCodeValueTooLong') }}
</div> </div>
<div v-if="showEditNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs"> <div v-if="showEditNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('msg.warning.nonEditableFields.computedFieldUnableToClear') }} {{ $t('msg.warning.nonEditableFields.computedFieldUnableToClear') }}

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

@ -55,17 +55,18 @@ const rowHeight = inject(RowHeightInj, ref(undefined))
</a-modal> </a-modal>
<div <div
v-if="!tooManyCharsForBarcode" v-if="!tooManyCharsForBarcode"
class="flex ml-2 w-full items-center" class="flex w-full items-center barcode-wrapper"
:class="{ :class="{
'justify-start': isExpandedFormOpen, 'justify-start ml-2': isExpandedFormOpen,
'justify-center': !isExpandedFormOpen, 'justify-center': !isExpandedFormOpen,
}" }"
> >
<JsBarcodeWrapper <JsBarcodeWrapper
v-if="showBarcode && rowHeight" v-if="showBarcode && rowHeight"
:barcode-value="barcodeValue" :barcode-value="barcodeValue"
tabindex="-1"
:barcode-format="barcodeMeta.barcodeFormat" :barcode-format="barcodeMeta.barcodeFormat"
:custom-style="{ height: rowHeight ? `${rowHeight * 1.4}rem` : `1.4rem`, width: 40 }" :custom-style="{ height: rowHeight ? `${rowHeight * 1.8}rem` : `1.8rem` }"
@on-click-barcode="showBarcodeModal" @on-click-barcode="showBarcodeModal"
> >
<template #barcodeRenderError> <template #barcodeRenderError>
@ -76,6 +77,7 @@ const rowHeight = inject(RowHeightInj, ref(undefined))
</JsBarcodeWrapper> </JsBarcodeWrapper>
<JsBarcodeWrapper <JsBarcodeWrapper
v-else-if="showBarcode" v-else-if="showBarcode"
tabindex="-1"
:barcode-value="barcodeValue" :barcode-value="barcodeValue"
:barcode-format="barcodeMeta.barcodeFormat" :barcode-format="barcodeMeta.barcodeFormat"
@on-click-barcode="showBarcodeModal" @on-click-barcode="showBarcodeModal"
@ -98,3 +100,11 @@ const rowHeight = inject(RowHeightInj, ref(undefined))
{{ $t('msg.warning.nonEditableFields.barcodeFieldsCannotBeDirectlyChanged') }} {{ $t('msg.warning.nonEditableFields.barcodeFieldsCannotBeDirectlyChanged') }}
</div> </div>
</template> </template>
<style lang="scss" scoped>
.barcode-wrapper {
& > div {
@apply max-w-8.2rem;
}
}
</style>

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

@ -403,6 +403,10 @@ async function loadPluginList() {
} }
} }
const isConditionSupport = computed(() => {
return hookRef.eventOperation && !hookRef.eventOperation.includes('bulk')
})
async function saveHooks() { async function saveHooks() {
loading.value = true loading.value = true
try { try {
@ -446,7 +450,7 @@ async function saveHooks() {
} }
if (filterRef.value) { if (filterRef.value) {
await filterRef.value.applyChanges(hookRef.id) await filterRef.value.applyChanges(hookRef.id, false, isConditionSupport.value)
} }
// Webhook details updated successfully // Webhook details updated successfully
@ -496,6 +500,9 @@ watch(
if (props.hook) { if (props.hook) {
setHook(props.hook) setHook(props.hook)
onEventChange() onEventChange()
} else {
// Set the default hook title only when creating a new hook.
hookRef.title = getDefaultHookName(hooks.value)
} }
}, },
{ immediate: true }, { immediate: true },
@ -509,7 +516,6 @@ onMounted(async () => {
} else { } else {
hookRef.eventOperation = eventList.value[0].value.join(' ') hookRef.eventOperation = eventList.value[0].value.join(' ')
} }
hookRef.title = getDefaultHookName(hooks.value)
onNotificationTypeChange() onNotificationTypeChange()
@ -770,8 +776,7 @@ onMounted(async () => {
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
<a-row v-show="isConditionSupport" class="mb-5" type="flex">
<a-row class="mb-5" type="flex">
<a-col :span="24"> <a-col :span="24">
<div class="rounded-lg border-1 p-6"> <div class="rounded-lg border-1 p-6">
<a-checkbox <a-checkbox
@ -790,6 +795,7 @@ onMounted(async () => {
:show-loading="false" :show-loading="false"
:hook-id="hookRef.id" :hook-id="hookRef.id"
:web-hook="true" :web-hook="true"
@update:filters-length="hookRef.condition = $event > 0"
/> />
</div> </div>
</a-col> </a-col>

21
packages/nc-gui/components/workspace/CollaboratorsList.vue

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { OrderedWorkspaceRoles, WorkspaceUserRoles, timeAgo } from 'nocodb-sdk' import { OrderedWorkspaceRoles, WorkspaceUserRoles, parseStringDateTime, timeAgo } from 'nocodb-sdk'
import { storeToRefs, useWorkspace } from '#imports' import { storeToRefs, useWorkspace } from '#imports'
const { workspaceRoles, loadRoles } = useRoles() const { workspaceRoles, loadRoles } = useRoles()
@ -59,9 +59,9 @@ onMounted(async () => {
<div v-else class="nc-collaborators-list mt-6 h-full"> <div v-else class="nc-collaborators-list mt-6 h-full">
<div class="flex flex-col rounded-lg overflow-hidden border-1 max-w-350 max-h-[calc(100%-8rem)]"> <div class="flex flex-col rounded-lg overflow-hidden border-1 max-w-350 max-h-[calc(100%-8rem)]">
<div class="flex flex-row bg-gray-50 min-h-12 items-center"> <div class="flex flex-row bg-gray-50 min-h-12 items-center">
<div class="text-gray-700 users-email-grid w-3/8 ml-10">{{ $t('objects.users') }}</div> <div class="text-gray-700 users-email-grid w-3/8 ml-10 mr-3">{{ $t('objects.users') }}</div>
<div class="text-gray-700 date-joined-grid w-2/8 mr-3 pl-1">{{ $t('title.dateJoined') }}</div>
<div class="text-gray-700 user-access-grid w-2/8 mr-3">{{ $t('general.access') }}</div> <div class="text-gray-700 user-access-grid w-2/8 mr-3">{{ $t('general.access') }}</div>
<div class="text-gray-700 date-joined-grid w-2/8 mr-3">{{ $t('title.dateJoined') }}</div>
<div class="text-gray-700 user-access-grid w-1/8">Actions</div> <div class="text-gray-700 user-access-grid w-1/8">Actions</div>
</div> </div>
@ -77,7 +77,6 @@ onMounted(async () => {
{{ collab.email }} {{ collab.email }}
</span> </span>
</div> </div>
<div class="date-joined-grid w-2/8">{{ timeAgo(collab.created_at) }}</div>
<div class="user-access-grid w-2/8"> <div class="user-access-grid w-2/8">
<template v-if="accessibleRoles.includes(collab.roles)"> <template v-if="accessibleRoles.includes(collab.roles)">
<div class="w-[30px]"> <div class="w-[30px]">
@ -85,15 +84,25 @@ onMounted(async () => {
:role="collab.roles" :role="collab.roles"
:roles="accessibleRoles" :roles="accessibleRoles"
:description="false" :description="false"
class="bg-[red]" class="cursor-pointer"
:on-role-change="(role: WorkspaceUserRoles) => updateCollaborator(collab, role)" :on-role-change="(role: WorkspaceUserRoles) => updateCollaborator(collab, role)"
/> />
</div> </div>
</template> </template>
<template v-else> <template v-else>
<RolesBadge :role="collab.roles" /> <RolesBadge :role="collab.roles" class="cursor-default" />
</template> </template>
</div> </div>
<div class="date-joined-grid w-2/8 flex justify-start">
<NcTooltip class="max-w-full">
<template #title>
{{ parseStringDateTime(collab.created_at) }}
</template>
<span>
{{ timeAgo(collab.created_at) }}
</span>
</NcTooltip>
</div>
<div class="w-1/8 pl-6"> <div class="w-1/8 pl-6">
<NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER" :trigger="['click']"> <NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER" :trigger="['click']">
<MdiDotsVertical <MdiDotsVertical

8
packages/nc-gui/composables/useData.ts

@ -9,6 +9,7 @@ import {
findIndexByPk, findIndexByPk,
message, message,
populateInsertObject, populateInsertObject,
rowDefaultData,
rowPkData, rowPkData,
storeToRefs, storeToRefs,
until, until,
@ -53,9 +54,9 @@ export function useData(args: {
}, },
}) })
function addEmptyRow(addAfter = formattedData.value.length) { function addEmptyRow(addAfter = formattedData.value.length, metaValue = meta.value) {
formattedData.value.splice(addAfter, 0, { formattedData.value.splice(addAfter, 0, {
row: {}, row: { ...rowDefaultData(metaValue?.columns) },
oldRow: {}, oldRow: {},
rowMeta: { new: true }, rowMeta: { new: true },
}) })
@ -240,6 +241,7 @@ export function useData(args: {
col.uidt === UITypes.Barcode || col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup || col.uidt === UITypes.Rollup ||
col.uidt === UITypes.Checkbox || col.uidt === UITypes.Checkbox ||
col.uidt === UITypes.User ||
col.au || col.au ||
col.cdf?.includes(' on update ') col.cdf?.includes(' on update ')
) )
@ -386,6 +388,8 @@ export function useData(args: {
col.uidt === UITypes.QrCode || col.uidt === UITypes.QrCode ||
col.uidt === UITypes.Barcode || col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup || col.uidt === UITypes.Rollup ||
col.uidt === UITypes.Checkbox ||
col.uidt === UITypes.User ||
col.au || col.au ||
col.cdf?.includes(' on update ') col.cdf?.includes(' on update ')
) )

18
packages/nc-gui/composables/useMultiSelect/convertCellData.ts

@ -195,6 +195,24 @@ export default function convertCellData(
return validVals.join(',') return validVals.join(',')
} }
case UITypes.User: {
let parsedVal
try {
try {
parsedVal = typeof value === 'string' ? JSON.parse(value) : value
} catch {
parsedVal = value
}
} catch (e) {
if (isMultiple) {
return null
} else {
throw new Error('Invalid user data')
}
}
return parsedVal || value
}
case UITypes.LinkToAnotherRecord: case UITypes.LinkToAnotherRecord:
case UITypes.Lookup: case UITypes.Lookup:
case UITypes.Rollup: case UITypes.Rollup:

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

@ -2,7 +2,7 @@ import { computed } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { MaybeRef } from '@vueuse/core' import type { MaybeRef } from '@vueuse/core'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk' import type { ColumnType, LinkToAnotherRecordType, TableType, UserFieldRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, dateFormats, isDateMonthFormat, isSystemColumn, isVirtualCol, timeFormats } from 'nocodb-sdk' import { RelationTypes, UITypes, dateFormats, isDateMonthFormat, isSystemColumn, isVirtualCol, timeFormats } from 'nocodb-sdk'
import { parse } from 'papaparse' import { parse } from 'papaparse'
import type { Cell } from './cellRange' import type { Cell } from './cellRange'
@ -112,6 +112,16 @@ export function useMultiSelect(
textToCopy = !!textToCopy textToCopy = !!textToCopy
} }
if (columnObj.uidt === UITypes.User) {
if (textToCopy && Array.isArray(textToCopy)) {
textToCopy = textToCopy
.map((user: UserFieldRecordType) => {
return user.email
})
.join(', ')
}
}
if (typeof textToCopy === 'object') { if (typeof textToCopy === 'object') {
textToCopy = JSON.stringify(textToCopy) textToCopy = JSON.stringify(textToCopy)
} else { } else {
@ -718,7 +728,15 @@ export function useMultiSelect(
return message.info(t('msg.info.updateNotAllowedWithoutPK')) return message.info(t('msg.info.updateNotAllowedWithoutPK'))
} }
if (isTypableInputColumn(columnObj) && makeEditable(rowObj, columnObj) && columnObj.title) { if (isTypableInputColumn(columnObj) && makeEditable(rowObj, columnObj) && columnObj.title) {
rowObj.row[columnObj.title] = '' if (columnObj.uidt === UITypes.LongText) {
if (rowObj.row[columnObj.title] === '<br />') {
rowObj.row[columnObj.title] = e.key
} else {
rowObj.row[columnObj.title] = rowObj.row[columnObj.title] ? rowObj.row[columnObj.title] + e.key : e.key
}
} else {
rowObj.row[columnObj.title] = ''
}
} }
// editEnabled = true // editEnabled = true
} }

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

@ -19,6 +19,10 @@ export function useSharedView() {
const baseStore = useBase() const baseStore = useBase()
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const { base } = storeToRefs(baseStore) const { base } = storeToRefs(baseStore)
const appInfoDefaultLimit = appInfo.value.defaultLimit || 25 const appInfoDefaultLimit = appInfo.value.defaultLimit || 25
@ -99,6 +103,10 @@ export function useSharedView() {
const relatedMetas = { ...viewMeta.relatedMetas } const relatedMetas = { ...viewMeta.relatedMetas }
Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key])) Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key]))
if (viewMeta.users) {
basesUser.value.set(viewMeta.base_id, viewMeta.users)
}
} }
const fetchSharedViewData = async (param: { const fetchSharedViewData = async (param: {

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

@ -187,24 +187,32 @@ export function useViewData(
controller.value = CancelToken.source() controller.value = CancelToken.source()
isPaginationLoading.value = true isPaginationLoading.value = true
let response
const response = !isPublic.value try {
? await api.dbViewRow.list( response = !isPublic.value
'noco', ? await api.dbViewRow.list(
base.value.id!, 'noco',
metaId.value!, base.value.id!,
viewMeta.value!.id!, metaId.value!,
{ viewMeta.value!.id!,
...queryParams.value, {
...params, ...queryParams.value,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }), ...params,
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }), ...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
where: where?.value, ...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
} as any, where: where?.value,
{ cancelToken: controller.value.token }, } as any,
) { cancelToken: controller.value.token },
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value }) )
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value, where: where?.value })
} catch (error) {
// if the request is canceled, then do nothing
if (error.code === 'ERR_CANCELED') {
return
}
throw error
}
formattedData.value = formatData(response.list) formattedData.value = formatData(response.list)
paginationData.value = response.pageInfo paginationData.value = response.pageInfo
isPaginationLoading.value = false isPaginationLoading.value = false
@ -311,8 +319,6 @@ export function useViewData(
} }
const navigateToSiblingRow = async (dir: NavigateDir) => { const navigateToSiblingRow = async (dir: NavigateDir) => {
console.log('test')
const expandedRowIndex = getExpandedRowIndex() const expandedRowIndex = getExpandedRowIndex()
// calculate next row index based on direction // calculate next row index based on direction

11
packages/nc-gui/composables/useViewGroupBy.ts

@ -90,6 +90,12 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
return value ? GROUP_BY_VARS.TRUE : GROUP_BY_VARS.FALSE return value ? GROUP_BY_VARS.TRUE : GROUP_BY_VARS.FALSE
} }
if (col.uidt === UITypes.User) {
if (!value) {
return GROUP_BY_VARS.NULL
}
}
// convert to JSON string if non-string value // convert to JSON string if non-string value
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
value = JSON.stringify(value) value = JSON.stringify(value)
@ -155,6 +161,11 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
acc += `${acc.length ? '~and' : ''}(${curr.title},${curr.key === GROUP_BY_VARS.TRUE ? 'checked' : 'notchecked'})` acc += `${acc.length ? '~and' : ''}(${curr.title},${curr.key === GROUP_BY_VARS.TRUE ? 'checked' : 'notchecked'})`
} else if ([UITypes.Date, UITypes.DateTime].includes(curr.column_uidt as UITypes)) { } else if ([UITypes.Date, UITypes.DateTime].includes(curr.column_uidt as UITypes)) {
acc += `${acc.length ? '~and' : ''}(${curr.title},eq,exactDate,${curr.key})` acc += `${acc.length ? '~and' : ''}(${curr.title},eq,exactDate,${curr.key})`
} else if (curr.column_uidt === UITypes.User) {
try {
const value = JSON.parse(curr.key)
acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,${value.map((v: any) => v.id).join(',')})`
} catch (e) {}
} else { } else {
acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,${curr.key})` acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,${curr.key})`
} }

80
packages/nc-gui/lang/eu.json

@ -2,26 +2,26 @@
"dashboards": { "dashboards": {
"create_new_dashboard_project": "Create New Interface", "create_new_dashboard_project": "Create New Interface",
"connect_data_sources": "Connect data sources", "connect_data_sources": "Connect data sources",
"alert": "Alert", "alert": "Alerta",
"alert-message": "No databases have been connected. Connect database bases to build interfaces. Skip this step and add databases from the base home page later.", "alert-message": "No databases have been connected. Connect database bases to build interfaces. Skip this step and add databases from the base home page later.",
"select_database_projects_that_you_want_to_link_to_this_dashboard_projects": "Select Database Bases that you want to link to this Interface.", "select_database_projects_that_you_want_to_link_to_this_dashboard_projects": "Select Database Bases that you want to link to this Interface.",
"create_interface": "Create interface", "create_interface": "Create interface",
"project_name": "Base Name", "project_name": "Base Name",
"connect": "Connect", "connect": "Connect",
"buttonActionTypes": { "buttonActionTypes": {
"open_external_url": "Open external link", "open_external_url": "Ireki kanpoko lotura",
"delete_record": "Delete record", "delete_record": "Delete record",
"update_record": "Update record", "update_record": "Update record",
"open_layout": "Open layout" "open_layout": "Open layout"
}, },
"widgets": { "widgets": {
"static_text": "Text", "static_text": "Testua",
"chart": "Chart", "chart": "Diagrama",
"table": "Table", "table": "Taula",
"image": "Image", "image": "Irudia",
"map": "Map", "map": "Map",
"button": "Button", "button": "Botoia",
"number": "Number", "number": "Zenbakia",
"bar_chart": "Bar Chart", "bar_chart": "Bar Chart",
"line_chart": "Line Chart", "line_chart": "Line Chart",
"area_chart": "Area Chart", "area_chart": "Area Chart",
@ -39,7 +39,7 @@
} }
}, },
"general": { "general": {
"quit": "Quit", "quit": "Irten",
"home": "Hasiera", "home": "Hasiera",
"load": "Kargatu", "load": "Kargatu",
"open": "Ireki", "open": "Ireki",
@ -47,13 +47,13 @@
"yes": "Bai", "yes": "Bai",
"no": "Ez", "no": "Ez",
"ok": "Ados", "ok": "Ados",
"back": "Back", "back": "Itzuli",
"and": "Eta", "and": "Eta",
"or": "Edo", "or": "Edo",
"add": "Gehitu", "add": "Gehitu",
"edit": "Editatu", "edit": "Editatu",
"link": "Link", "link": "Esteka",
"links": "Links", "links": "Estekak",
"remove": "Ezabatu", "remove": "Ezabatu",
"import": "Import", "import": "Import",
"logout": "Log Out", "logout": "Log Out",
@ -61,15 +61,15 @@
"changeIcon": "Change Icon", "changeIcon": "Change Icon",
"save": "Gorde", "save": "Gorde",
"available": "Available", "available": "Available",
"abort": "Abort", "abort": "Utzi",
"saving": "Saving", "saving": "Gordetzen",
"cancel": "Ezeztatu", "cancel": "Ezeztatu",
"null": "Null", "null": "Null",
"escape": "Escape", "escape": "Escape",
"hex": "Hex", "hex": "Hex",
"clear": "Clear", "clear": "Garbitu",
"slack": "Slack", "slack": "Slack",
"comment": "Comment", "comment": "Iruzkina",
"microsoftTeams": "Microsoft Teams", "microsoftTeams": "Microsoft Teams",
"discord": "Discord", "discord": "Discord",
"matterMost": "Mattermost", "matterMost": "Mattermost",
@ -94,17 +94,17 @@
"bulkInsert": "Bulk Insert", "bulkInsert": "Bulk Insert",
"bulkDelete": "Bulk Delete", "bulkDelete": "Bulk Delete",
"bulkUpdate": "Bulk Update", "bulkUpdate": "Bulk Update",
"deleting": "Deleting", "deleting": "Ezabatzen",
"update": "Eguneratu", "update": "Eguneratu",
"rename": "Berrizendatu", "rename": "Berrizendatu",
"reload": "Birkargatu", "reload": "Birkargatu",
"reset": "Berrezarri", "reset": "Berrezarri",
"install": "Instalatu", "install": "Instalatu",
"show": "Erakutsi", "show": "Erakutsi",
"access": "Access", "access": "Sarbidea",
"visibility": "Visibility", "visibility": "Ikusgarritasuna",
"hide": "Ezkutatu", "hide": "Ezkutatu",
"deprecated": "Deprecated", "deprecated": "Zaharkitua",
"showAll": "Dena erakutsi", "showAll": "Dena erakutsi",
"hideAll": "Ezkutatu dena", "hideAll": "Ezkutatu dena",
"notFound": "Not found", "notFound": "Not found",
@ -127,7 +127,7 @@
"upload": "Igo", "upload": "Igo",
"download": "Deskargatu", "download": "Deskargatu",
"default": "Lehenetsia", "default": "Lehenetsia",
"base": "Source", "base": "Iturria",
"datasource": "Data Source", "datasource": "Data Source",
"more": "Gehiago", "more": "Gehiago",
"less": "Gutxiago", "less": "Gutxiago",
@ -170,10 +170,10 @@
"data": "Data", "data": "Data",
"source": "Source", "source": "Source",
"destination": "Destination", "destination": "Destination",
"active": "Active", "active": "Aktibo",
"inactive": "Inactive", "inactive": "Ez aktibo",
"linked": "linked", "linked": "linked",
"finish": "Finish", "finish": "Amaitu",
"min": "Min", "min": "Min",
"max": "Max", "max": "Max",
"avg": "Avg", "avg": "Avg",
@ -183,13 +183,13 @@
"sumDistinct": "Sum Distinct", "sumDistinct": "Sum Distinct",
"avgDistinct": "Avg Distinct", "avgDistinct": "Avg Distinct",
"join": "Join", "join": "Join",
"options": "Options", "options": "Ezarpenak",
"primaryValue": "Primary Value", "primaryValue": "Primary Value",
"useSurveyMode": "Use Survey Mode", "useSurveyMode": "Use Survey Mode",
"shift": "Shift", "shift": "Shift",
"enter": "Enter", "enter": "Sartu",
"seconds": "Seconds", "seconds": "Seconds",
"paste": "Paste" "paste": "Itsatsi"
}, },
"objects": { "objects": {
"workspace": "Workspace", "workspace": "Workspace",
@ -246,7 +246,7 @@
"externalDb": "External Database" "externalDb": "External Database"
}, },
"datatype": { "datatype": {
"ID": "ID", "ID": "IDa",
"ForeignKey": "Foreign Key", "ForeignKey": "Foreign Key",
"SingleLineText": "Single Line Text", "SingleLineText": "Single Line Text",
"LongText": "Testu luzea", "LongText": "Testu luzea",
@ -298,32 +298,32 @@
"isNotNull": "is not null" "isNotNull": "is not null"
}, },
"title": { "title": {
"docs": "Docs", "docs": "Dokumentuak",
"forum": "Forum", "forum": "Forum",
"parameter": "Parameter", "parameter": "Parametroa",
"headers": "Headers", "headers": "Goiburuak",
"parameterName": "Parameter Name", "parameterName": "Parameter Name",
"currencyLocale": "Currency Locale", "currencyLocale": "Currency Locale",
"currencyCode": "Currency Code", "currencyCode": "Currency Code",
"searchMembers": "Search Members", "searchMembers": "Search Members",
"noMembersFound": "No members found", "noMembersFound": "Ez da kiderik aurkitu",
"dateJoined": "Date Joined", "dateJoined": "Date Joined",
"tokenName": "Token name", "tokenName": "Token name",
"inDesktop": "in Desktop", "inDesktop": "in Desktop",
"rowData": "Record data", "rowData": "Record data",
"creator": "Creator", "creator": "Sortzailea",
"qrCode": "QR Code", "qrCode": "QR kodea",
"termsOfService": "Terms of Service", "termsOfService": "Zerbitzuaren baldintzak",
"updateSelectedRows": "Update Selected Records", "updateSelectedRows": "Update Selected Records",
"noFiltersAdded": "No filters added", "noFiltersAdded": "No filters added",
"editCards": "Edit Cards", "editCards": "Edit Cards",
"noFieldsFound": "No fields found", "noFieldsFound": "No fields found",
"displayValue": "Display Value", "displayValue": "Display Value",
"expand": "Expand", "expand": "Expand",
"hideAll": "Hide all", "hideAll": "Ezkutatu dena",
"hideSystemFields": "Hide system fields", "hideSystemFields": "Hide system fields",
"removeFile": "Remove File", "removeFile": "Remove File",
"hasMany": "Has Many", "hasMany": "Hainbat ditu",
"manyToMany": "Many to Many", "manyToMany": "Many to Many",
"virtualRelation": "Virtual Relation", "virtualRelation": "Virtual Relation",
"linkMore": "Link More", "linkMore": "Link More",
@ -377,7 +377,7 @@
"generateToken": "Sortu tokena", "generateToken": "Sortu tokena",
"APIsAndSupport": "APIak & laguntza", "APIsAndSupport": "APIak & laguntza",
"helpCenter": "Laguntza gunea", "helpCenter": "Laguntza gunea",
"noLabels": "No Labels", "noLabels": "Etiketarik ez",
"swaggerDocumentation": "Swagger Documentation", "swaggerDocumentation": "Swagger Documentation",
"quickImportFrom": "Quick Import From", "quickImportFrom": "Quick Import From",
"quickImport": "Quick Import", "quickImport": "Quick Import",
@ -396,10 +396,10 @@
"addNewToken": "Add new token", "addNewToken": "Add new token",
"accountSettings": "Account Settings", "accountSettings": "Account Settings",
"resetPasswordMenu": "Reset Password", "resetPasswordMenu": "Reset Password",
"tokens": "Tokens", "tokens": "Tokenak",
"userManagement": "User Management", "userManagement": "User Management",
"accountManagement": "Account management", "accountManagement": "Account management",
"licence": "Licence", "licence": "Lizentzia",
"allowAllMimeTypes": "Allow All Mime Types", "allowAllMimeTypes": "Allow All Mime Types",
"defaultView": "Default View", "defaultView": "Default View",
"relations": "Relations", "relations": "Relations",

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

@ -126,6 +126,7 @@ type NcProject = BaseType & {
edit?: boolean edit?: boolean
starred?: boolean starred?: boolean
uuid?: string uuid?: string
users?: User[]
} }
interface UndoRedoAction { interface UndoRedoAction {

8
packages/nc-gui/nuxt.config.ts

@ -4,7 +4,6 @@ import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver' import IconsResolver from 'unplugin-icons/resolver'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers' import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
import monacoEditorPlugin from 'vite-plugin-monaco-editor'
import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill' import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill'
import { FileSystemIconLoader } from 'unplugin-icons/loaders' import { FileSystemIconLoader } from 'unplugin-icons/loaders'
@ -180,13 +179,6 @@ export default defineNuxtConfig({
}), }),
], ],
}), }),
monacoEditorPlugin({
languageWorkers: ['json'],
// customWorkers: [{ label: 'sql', entry: 'monaco-sql-languages/out/esm/sql/sql.worker.js' }],
customDistPath: (root: string, buildOutDir: string) => {
return `${buildOutDir}/` + `monacoeditorwork`
},
}),
PurgeIcons({ PurgeIcons({
/* PurgeIcons Options */ /* PurgeIcons Options */
includedCollections: ['emojione'], includedCollections: ['emojione'],

20
packages/nc-gui/package.json

@ -65,7 +65,7 @@
"locale-codes": "^1.3.1", "locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"monaco-sql-languages": "^0.11.0", "monaco-sql-languages": "^0.11.0",
"nocodb-sdk": "0.202.10", "nocodb-sdk": "workspace:^",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"parse-github-url": "^1.0.2", "parse-github-url": "^1.0.2",
"pinia": "^2.1.7", "pinia": "^2.1.7",
@ -106,26 +106,26 @@
"@esbuild-plugins/node-modules-polyfill": "^0.2.2", "@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@iconify-json/ant-design": "^1.1.13", "@iconify-json/ant-design": "^1.1.13",
"@iconify-json/bi": "^1.1.22", "@iconify-json/bi": "^1.1.22",
"@iconify-json/carbon": "^1.1.26", "@iconify-json/carbon": "^1.1.27",
"@iconify-json/cil": "^1.1.8", "@iconify-json/cil": "^1.1.8",
"@iconify-json/clarity": "^1.1.12", "@iconify-json/clarity": "^1.1.12",
"@iconify-json/eva": "^1.1.10", "@iconify-json/eva": "^1.1.10",
"@iconify-json/ic": "^1.1.17", "@iconify-json/ic": "^1.1.17",
"@iconify-json/ion": "^1.1.15", "@iconify-json/ion": "^1.1.15",
"@iconify-json/la": "^1.1.8", "@iconify-json/la": "^1.1.8",
"@iconify-json/logos": "^1.1.41", "@iconify-json/logos": "^1.1.42",
"@iconify-json/lucide": "^1.1.145", "@iconify-json/lucide": "^1.1.149",
"@iconify-json/material-symbols": "^1.1.67", "@iconify-json/material-symbols": "^1.1.68",
"@iconify-json/mdi": "^1.1.61", "@iconify-json/mdi": "^1.1.63",
"@iconify-json/mi": "^1.1.8", "@iconify-json/mi": "^1.1.8",
"@iconify-json/ph": "^1.1.9", "@iconify-json/ph": "^1.1.9",
"@iconify-json/ri": "^1.1.17", "@iconify-json/ri": "^1.1.18",
"@iconify-json/simple-icons": "^1.1.84", "@iconify-json/simple-icons": "^1.1.86",
"@iconify-json/system-uicons": "^1.1.12", "@iconify-json/system-uicons": "^1.1.12",
"@iconify-json/tabler": "^1.1.102", "@iconify-json/tabler": "^1.1.102",
"@iconify-json/vscode-icons": "^1.1.32", "@iconify-json/vscode-icons": "^1.1.32",
"@intlify/unplugin-vue-i18n": "^0.12.3", "@intlify/unplugin-vue-i18n": "^0.12.3",
"@nuxt/image-edge": "1.1.0-28372028.a469898", "@nuxt/image-edge": "1.1.0-28393680.ddd021d",
"@types/d3-scale": "^4.0.8", "@types/d3-scale": "^4.0.8",
"@types/dagre": "^0.7.52", "@types/dagre": "^0.7.52",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
@ -143,7 +143,7 @@
"@types/turndown": "^5.0.4", "@types/turndown": "^5.0.4",
"@unocss/nuxt": "^0.51.13", "@unocss/nuxt": "^0.51.13",
"@vitest/ui": "^0.18.1", "@vitest/ui": "^0.18.1",
"@vue/compiler-sfc": "^3.3.11", "@vue/compiler-sfc": "^3.3.13",
"@vue/test-utils": "^2.0.2", "@vue/test-utils": "^2.0.2",
"@vueuse/nuxt": "^10.2.1", "@vueuse/nuxt": "^10.2.1",
"@windicss/plugin-animations": "^1.0.9", "@windicss/plugin-animations": "^1.0.9",

5
packages/nc-gui/pages/forgot-password.vue

@ -23,12 +23,13 @@ const form = reactive({
const formRules = { const formRules = {
email: [ email: [
// E-mail is required // E-mail is required
{ required: true, message: t('msg.error.signUpRules.emailReqd') }, { required: true, message: t('msg.error.signUpRules.emailRequired') },
// E-mail must be valid format // E-mail must be valid format
{ {
validator: (_: unknown, v: string) => { validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (validateEmail(v)) return resolve() if (!v?.length || validateEmail(v)) return resolve()
reject(new Error(t('msg.error.signUpRules.emailInvalid'))) reject(new Error(t('msg.error.signUpRules.emailInvalid')))
}) })
}, },

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

@ -27,8 +27,6 @@ const scannerIsReady = ref(false)
const showCodeScannerOverlay = ref(false) const showCodeScannerOverlay = ref(false)
const editEnabled = ref<boolean[]>([])
const onLoaded = async () => { const onLoaded = async () => {
scannerIsReady.value = true scannerIsReady.value = true
} }
@ -168,10 +166,7 @@ const onDecode = async (scannedCodeValue: string) => {
:data-testid="`nc-form-input-cell-${field.label || field.title}`" :data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`" :class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field" :column="field"
:edit-enabled="editEnabled[index]" edit-enabled
@click="editEnabled[index] = true"
@cancel="editEnabled[index] = false"
@update:edit-enabled="editEnabled[index] = $event"
/> />
<a-button <a-button
v-if="field.enable_scanner" v-if="field.enable_scanner"

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

@ -47,8 +47,6 @@ const animationTarget = ref<AnimationTarget>(AnimationTarget.ArrowRight)
const isAnimating = ref(false) const isAnimating = ref(false)
const editEnabled = ref<boolean[]>([])
const el = ref<HTMLDivElement>() const el = ref<HTMLDivElement>()
provide(DropZoneRef, el) provide(DropZoneRef, el)
@ -299,10 +297,7 @@ onMounted(() => {
class="nc-input h-auto" class="nc-input h-auto"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`" :data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
:edit-enabled="editEnabled[index]" edit-enabled
@click="editEnabled[index] = true"
@cancel="editEnabled[index] = false"
@update:edit-enabled="editEnabled[index] = $event"
/> />
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1"> <div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1">

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

@ -38,12 +38,12 @@ const form = reactive({
const formRules: Record<string, RuleObject[]> = { const formRules: Record<string, RuleObject[]> = {
email: [ email: [
// E-mail is required // E-mail is required
{ required: true, message: t('msg.error.signUpRules.emailReqd') }, { required: true, message: t('msg.error.signUpRules.emailRequired') },
// E-mail must be valid format // E-mail must be valid format
{ {
validator: (_: unknown, v: string) => { validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (validateEmail(v)) return resolve() if (!v?.length || validateEmail(v)) return resolve()
reject(new Error(t('msg.error.signUpRules.emailInvalid'))) reject(new Error(t('msg.error.signUpRules.emailInvalid')))
}) })

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

@ -44,12 +44,13 @@ const form = reactive({
const formRules = { const formRules = {
email: [ email: [
// E-mail is required // E-mail is required
{ required: true, message: t('msg.error.signUpRules.emailReqd') }, { required: true, message: t('msg.error.signUpRules.emailRequired') },
// E-mail must be valid format // E-mail must be valid format
{ {
validator: (_: unknown, v: string) => { validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!v?.length || validateEmail(v)) return resolve() if (!v?.length || validateEmail(v)) return resolve()
reject(new Error(t('msg.error.signUpRules.emailInvalid'))) reject(new Error(t('msg.error.signUpRules.emailInvalid')))
}) })
}, },

8
packages/nc-gui/plugins/poller.ts

@ -1,7 +1,7 @@
import type { Api as BaseAPI } from 'nocodb-sdk' import type { Api as BaseAPI } from 'nocodb-sdk'
import { defineNuxtPlugin } from '#imports' import { defineNuxtPlugin } from '#imports'
export default defineNuxtPlugin(async (nuxtApp) => { const pollPlugin = async (nuxtApp) => {
const api: BaseAPI<any> = nuxtApp.$api as any const api: BaseAPI<any> = nuxtApp.$api as any
// unsubscribe all if signed out // unsubscribe all if signed out
@ -89,4 +89,10 @@ export default defineNuxtPlugin(async (nuxtApp) => {
} }
nuxtApp.provide('poller', poller) nuxtApp.provide('poller', poller)
}
export default defineNuxtPlugin(async function (nuxtApp) {
if (!isEeUI) return await pollPlugin(nuxtApp)
}) })
export { pollPlugin }

4
packages/nc-gui/store/base.ts

@ -172,6 +172,10 @@ export const useBase = defineStore('baseStore', () => {
await loadTables() await loadTables()
await basesStore.getBaseUsers({
baseId: base.value.id || baseId.value,
})
// if (withTheme) setTheme(baseMeta.value?.theme) // if (withTheme) setTheme(baseMeta.value?.theme)
return baseLoadedHook.trigger(base.value) return baseLoadedHook.trigger(base.value)

35
packages/nc-gui/store/bases.ts

@ -12,7 +12,7 @@ export const useBases = defineStore('basesStore', () => {
const bases = ref<Map<string, NcProject>>(new Map()) const bases = ref<Map<string, NcProject>>(new Map())
const basesList = computed<NcProject[]>(() => Array.from(bases.value.values()).sort((a, b) => a.updated_at - b.updated_at)) const basesList = computed<NcProject[]>(() => Array.from(bases.value.values()).sort((a, b) => a.updated_at - b.updated_at))
const baseUserCount = ref<number | undefined>(undefined) const basesUser = ref<Map<string, User[]>>(new Map())
const router = useRouter() const router = useRouter()
const route = router.currentRoute const route = router.currentRoute
@ -48,33 +48,35 @@ export const useBases = defineStore('basesStore', () => {
const isProjectsLoading = ref(false) const isProjectsLoading = ref(false)
async function getProjectUsers({ async function getBaseUsers({ baseId, searchText, force = false }: { baseId: string; searchText?: string; force?: boolean }) {
baseId, if (!force && basesUser.value.has(baseId)) {
limit, const users = basesUser.value.get(baseId)
page, return {
searchText, users,
}: { totalRows: users?.length ?? 0,
baseId: string }
limit: number }
page: number
searchText: string | undefined
}) {
const response: any = await api.auth.baseUserList(baseId, { const response: any = await api.auth.baseUserList(baseId, {
query: { query: {
limit,
offset: (page - 1) * limit,
query: searchText, query: searchText,
}, },
} as RequestParams) } as RequestParams)
const totalRows = response.users.pageInfo.totalRows ?? 0 const totalRows = response.users.pageInfo.totalRows ?? 0
basesUser.value.set(baseId, response.users.list)
return { return {
users: response.users.list, users: response.users.list,
totalRows, totalRows,
} }
} }
const clearBasesUser = () => {
basesUser.value.clear()
}
const createProjectUser = async (baseId: string, user: User) => { const createProjectUser = async (baseId: string, user: User) => {
await api.auth.baseUserAdd(baseId, user as ProjectUserReqType) await api.auth.baseUserAdd(baseId, user as ProjectUserReqType)
} }
@ -295,7 +297,6 @@ export const useBases = defineStore('basesStore', () => {
return { return {
bases, bases,
basesList, basesList,
baseUserCount,
loadProjects, loadProjects,
loadProject, loadProject,
getSqlUi, getSqlUi,
@ -312,13 +313,15 @@ export const useBases = defineStore('basesStore', () => {
activeProjectId, activeProjectId,
openedProject, openedProject,
openedProjectBasesMap, openedProjectBasesMap,
getProjectUsers, getBaseUsers,
createProjectUser, createProjectUser,
updateProjectUser, updateProjectUser,
navigateToProject, navigateToProject,
removeProjectUser, removeProjectUser,
navigateToFirstProjectOrHome, navigateToFirstProjectOrHome,
toggleStarred, toggleStarred,
basesUser,
clearBasesUser,
} }
}) })

3
packages/nc-gui/store/users.ts

@ -4,6 +4,7 @@ export const useUsers = defineStore('userStore', () => {
const { api } = useApi() const { api } = useApi()
const { user } = useGlobal() const { user } = useGlobal()
const { loadRoles } = useRoles() const { loadRoles } = useRoles()
const basesStore = useBases()
const updateUserProfile = async ({ const updateUserProfile = async ({
attrs, attrs,
@ -20,6 +21,8 @@ export const useUsers = defineStore('userStore', () => {
...user.value, ...user.value,
...attrs, ...attrs,
} }
basesStore.clearBasesUser()
} }
const loadCurrentUser = loadRoles const loadCurrentUser = loadRoles

1
packages/nc-gui/utils/cell.ts

@ -32,6 +32,7 @@ export const isGeoData = (column: ColumnType) => column.uidt === UITypes.GeoData
export const isPercent = (column: ColumnType) => column.uidt === UITypes.Percent export const isPercent = (column: ColumnType) => column.uidt === UITypes.Percent
export const isSpecificDBType = (column: ColumnType) => column.uidt === UITypes.SpecificDBType export const isSpecificDBType = (column: ColumnType) => column.uidt === UITypes.SpecificDBType
export const isGeometry = (column: ColumnType) => column.uidt === UITypes.Geometry export const isGeometry = (column: ColumnType) => column.uidt === UITypes.Geometry
export const isUser = (column: ColumnType) => column.uidt === UITypes.User
export const isAutoSaved = (column: ColumnType) => export const isAutoSaved = (column: ColumnType) =>
[ [
UITypes.SingleLineText, UITypes.SingleLineText,

4
packages/nc-gui/utils/columnUtils.ts

@ -134,6 +134,10 @@ const uiTypes = [
name: UITypes.SpecificDBType, name: UITypes.SpecificDBType,
icon: iconMap.specificDbType, icon: iconMap.specificDbType,
}, },
{
name: UITypes.User,
icon: iconMap.account,
},
] ]
const getUIDTIcon = (uidt: UITypes | string) => { const getUIDTIcon = (uidt: UITypes | string) => {

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

@ -1,4 +1,4 @@
import { RelationTypes, UITypes } from 'nocodb-sdk' import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk' import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import type { Row } from 'lib' import type { Row } from 'lib'
import { isColumnRequiredAndNull } from './columnUtils' import { isColumnRequiredAndNull } from './columnUtils'
@ -86,3 +86,23 @@ export async function populateInsertObject({
return { missingRequiredColumns, insertObj } return { missingRequiredColumns, insertObj }
} }
// a function to get default values of row
export const rowDefaultData = (columns: ColumnType[] = []) => {
const defaultData: Record<string, string> = columns.reduce<Record<string, any>>((acc: Record<string, any>, col: ColumnType) => {
// avoid setting default value for system col, virtual col, rollup, formula, barcode, qrcode, links, ltar
if (
!isSystemColumn(col) &&
!isVirtualCol(col) &&
!isLinksOrLTAR({ uidt: col.uidt! }) &&
![UITypes.Rollup, UITypes.Lookup, UITypes.Formula, UITypes.Barcode, UITypes.QrCode].includes(col.uidt) &&
col?.cdf
) {
const defaultValue = col.cdf
acc[col.title!] = typeof defaultValue === 'string' ? defaultValue.replace(/^'/, '').replace(/'$/, '') : defaultValue
}
return acc
}, {} as Record<string, any>)
return defaultData
}

398
packages/nc-gui/utils/filterUtils.ts

@ -70,6 +70,7 @@ const getLteText = (fieldUiType: UITypes) => {
export const comparisonOpList = ( export const comparisonOpList = (
fieldUiType: UITypes, fieldUiType: UITypes,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
dateFormat?: string, dateFormat?: string,
): { ): {
text: string text: string
@ -77,203 +78,206 @@ export const comparisonOpList = (
ignoreVal: boolean ignoreVal: boolean
includedTypes?: UITypes[] includedTypes?: UITypes[]
excludedTypes?: UITypes[] excludedTypes?: UITypes[]
}[] => { }[] => [
const isDateMonth = dateFormat && isDateMonthFormat(dateFormat) {
return [ text: 'is checked',
{ value: 'checked',
text: 'is checked', ignoreVal: true,
value: 'checked', includedTypes: [UITypes.Checkbox],
ignoreVal: true, },
includedTypes: [UITypes.Checkbox], {
}, text: 'is not checked',
{ value: 'notchecked',
text: 'is not checked', ignoreVal: true,
value: 'notchecked', includedTypes: [UITypes.Checkbox],
ignoreVal: true, },
includedTypes: [UITypes.Checkbox], {
}, text: getEqText(fieldUiType),
{ value: 'eq',
text: getEqText(fieldUiType), ignoreVal: false,
value: 'eq', excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.Attachment, UITypes.User],
ignoreVal: false, },
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.Attachment], {
}, text: getNeqText(fieldUiType),
{ value: 'neq',
text: getNeqText(fieldUiType), ignoreVal: false,
value: 'neq', excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.Attachment, UITypes.User],
ignoreVal: false, },
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.Attachment], {
}, text: getLikeText(fieldUiType),
{ value: 'like',
text: getLikeText(fieldUiType), ignoreVal: false,
value: 'like', excludedTypes: [
ignoreVal: false, UITypes.Checkbox,
excludedTypes: [ UITypes.SingleSelect,
UITypes.Checkbox, UITypes.MultiSelect,
UITypes.SingleSelect, UITypes.User,
UITypes.MultiSelect, UITypes.Collaborator,
UITypes.Collaborator, UITypes.Date,
UITypes.Date, UITypes.DateTime,
UITypes.DateTime, UITypes.Time,
UITypes.Time, ...numericUITypes,
...numericUITypes, ],
], },
}, {
{ text: getNotLikeText(fieldUiType),
text: getNotLikeText(fieldUiType), value: 'nlike',
value: 'nlike', ignoreVal: false,
ignoreVal: false, excludedTypes: [
excludedTypes: [ UITypes.Checkbox,
UITypes.Checkbox, UITypes.SingleSelect,
UITypes.SingleSelect, UITypes.MultiSelect,
UITypes.MultiSelect, UITypes.User,
UITypes.Collaborator, UITypes.Collaborator,
UITypes.Date, UITypes.Date,
UITypes.DateTime, UITypes.DateTime,
UITypes.Time, UITypes.Time,
...numericUITypes, ...numericUITypes,
], ],
}, },
{ {
text: 'is empty', text: 'is empty',
value: 'empty', value: 'empty',
ignoreVal: true, ignoreVal: true,
excludedTypes: [ excludedTypes: [
UITypes.Checkbox, UITypes.Checkbox,
UITypes.SingleSelect, UITypes.SingleSelect,
UITypes.MultiSelect, UITypes.MultiSelect,
UITypes.Collaborator, UITypes.User,
UITypes.Attachment, UITypes.Collaborator,
UITypes.LinkToAnotherRecord, UITypes.Attachment,
UITypes.Lookup, UITypes.LinkToAnotherRecord,
UITypes.Date, UITypes.Lookup,
UITypes.DateTime, UITypes.Date,
UITypes.Time, UITypes.DateTime,
...numericUITypes, UITypes.Time,
], ...numericUITypes,
}, ],
{ },
text: 'is not empty', {
value: 'notempty', text: 'is not empty',
ignoreVal: true, value: 'notempty',
excludedTypes: [ ignoreVal: true,
UITypes.Checkbox, excludedTypes: [
UITypes.SingleSelect, UITypes.Checkbox,
UITypes.MultiSelect, UITypes.SingleSelect,
UITypes.Collaborator, UITypes.MultiSelect,
UITypes.Attachment, UITypes.User,
UITypes.LinkToAnotherRecord, UITypes.Collaborator,
UITypes.Lookup, UITypes.Attachment,
UITypes.Date, UITypes.LinkToAnotherRecord,
UITypes.DateTime, UITypes.Lookup,
UITypes.Time, UITypes.Date,
...numericUITypes, UITypes.DateTime,
], UITypes.Time,
}, ...numericUITypes,
{ ],
text: 'is null', },
value: 'null', {
ignoreVal: true, text: 'is null',
excludedTypes: [ value: 'null',
...numericUITypes, ignoreVal: true,
UITypes.Checkbox, excludedTypes: [
UITypes.SingleSelect, ...numericUITypes,
UITypes.MultiSelect, UITypes.Checkbox,
UITypes.Collaborator, UITypes.SingleSelect,
UITypes.Attachment, UITypes.MultiSelect,
UITypes.LinkToAnotherRecord, UITypes.User,
UITypes.Lookup, UITypes.Collaborator,
UITypes.Date, UITypes.Attachment,
UITypes.DateTime, UITypes.LinkToAnotherRecord,
UITypes.Time, UITypes.Lookup,
], UITypes.Date,
}, UITypes.DateTime,
{ UITypes.Time,
text: 'is not null', ],
value: 'notnull', },
ignoreVal: true, {
excludedTypes: [ text: 'is not null',
...numericUITypes, value: 'notnull',
UITypes.Checkbox, ignoreVal: true,
UITypes.SingleSelect, excludedTypes: [
UITypes.MultiSelect, ...numericUITypes,
UITypes.Collaborator, UITypes.Checkbox,
UITypes.Attachment, UITypes.SingleSelect,
UITypes.LinkToAnotherRecord, UITypes.MultiSelect,
UITypes.Lookup, UITypes.User,
UITypes.Date, UITypes.Collaborator,
UITypes.DateTime, UITypes.Attachment,
UITypes.Time, UITypes.LinkToAnotherRecord,
], UITypes.Lookup,
}, UITypes.Date,
{ UITypes.DateTime,
text: 'contains all of', UITypes.Time,
value: 'allof', ],
ignoreVal: false, },
includedTypes: [UITypes.MultiSelect], {
}, text: 'contains all of',
{ value: 'allof',
text: 'contains any of', ignoreVal: false,
value: 'anyof', includedTypes: [UITypes.MultiSelect, UITypes.User],
ignoreVal: false, },
includedTypes: [UITypes.MultiSelect, UITypes.SingleSelect], {
}, text: 'contains any of',
{ value: 'anyof',
text: 'does not contain all of', ignoreVal: false,
value: 'nallof', includedTypes: [UITypes.MultiSelect, UITypes.SingleSelect, UITypes.User],
ignoreVal: false, },
includedTypes: [UITypes.MultiSelect], {
}, text: 'does not contain all of',
{ value: 'nallof',
text: 'does not contain any of', ignoreVal: false,
value: 'nanyof', includedTypes: [UITypes.MultiSelect, UITypes.User],
ignoreVal: false, },
includedTypes: [UITypes.MultiSelect, UITypes.SingleSelect], {
}, text: 'does not contain any of',
{ value: 'nanyof',
text: getGtText(fieldUiType), ignoreVal: false,
value: 'gt', includedTypes: [UITypes.MultiSelect, UITypes.SingleSelect, UITypes.User],
ignoreVal: false, },
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time], {
}, text: getGtText(fieldUiType),
{ value: 'gt',
text: getLtText(fieldUiType), ignoreVal: false,
value: 'lt', includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
ignoreVal: false, },
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time], {
}, text: getLtText(fieldUiType),
{ value: 'lt',
text: getGteText(fieldUiType), ignoreVal: false,
value: 'gte', includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
ignoreVal: false, },
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time], {
}, text: getGteText(fieldUiType),
{ value: 'gte',
text: getLteText(fieldUiType), ignoreVal: false,
value: 'lte', includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
ignoreVal: false, },
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time], {
}, text: getLteText(fieldUiType),
{ value: 'lte',
text: 'is within', ignoreVal: false,
value: 'isWithin', includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
ignoreVal: true, },
includedTypes: [...(isDateMonth ? [] : [UITypes.Date, UITypes.DateTime])], {
}, text: 'is within',
{ value: 'isWithin',
text: 'is blank', ignoreVal: true,
value: 'blank', includedTypes: [UITypes.Date, UITypes.DateTime],
ignoreVal: true, },
excludedTypes: [UITypes.Checkbox, UITypes.Links, UITypes.Rollup], {
}, text: 'is blank',
{ value: 'blank',
text: 'is not blank', ignoreVal: true,
value: 'notblank', excludedTypes: [UITypes.Checkbox, UITypes.Links, UITypes.Rollup],
ignoreVal: true, },
excludedTypes: [UITypes.Checkbox, UITypes.Links, UITypes.Rollup], {
}, text: 'is not blank',
] value: 'notblank',
} ignoreVal: true,
excludedTypes: [UITypes.Checkbox, UITypes.Links, UITypes.Rollup],
},
]
export const comparisonSubOpList = ( export const comparisonSubOpList = (
// TODO: type // TODO: type

623
packages/nc-gui/utils/formulaUtils.ts

@ -1,624 +1,5 @@
import type { Input as AntInput } from 'ant-design-vue' import type { Input as AntInput } from 'ant-design-vue'
import { formulas } from 'nocodb-sdk'
const formulaTypes = {
NUMERIC: 'numeric',
STRING: 'string',
DATE: 'date',
LOGICAL: 'logical',
COND_EXP: 'conditional_expression',
}
const formulas: Record<string, any> = {
AVG: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Average of input parameters',
syntax: 'AVG(value1, [value2, ...])',
examples: ['AVG(10, 5) => 7.5', 'AVG({column1}, {column2})', 'AVG({column1}, {column2}, {column3})'],
},
ADD: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Sum of input parameters',
syntax: 'ADD(value1, [value2, ...])',
examples: ['ADD(5, 5) => 10', 'ADD({column1}, {column2})', 'ADD({column1}, {column2}, {column3})'],
},
DATEADD: {
type: formulaTypes.DATE,
validation: {
args: {
rqd: 3,
},
},
description: 'Adds a "count" units to Datetime.',
syntax: 'DATEADD(date | datetime, value, ["day" | "week" | "month" | "year"])',
examples: [
'DATEADD({column1}, 2, "day")',
'DATEADD({column1}, -2, "day")',
'DATEADD({column1}, 2, "week")',
'DATEADD({column1}, -2, "week")',
'DATEADD({column1}, 2, "month")',
'DATEADD({column1}, -2, "month")',
'DATEADD({column1}, 2, "year")',
'DATEADD({column1}, -2, "year")',
],
},
DATETIME_DIFF: {
type: formulaTypes.DATE,
validation: {
args: {
min: 2,
max: 3,
},
},
description: 'Calculate the difference of two given date / datetime in specified units.',
syntax:
'DATETIME_DIFF(date | datetime, date | datetime, ["milliseconds" | "ms" | "seconds" | "s" | "minutes" | "m" | "hours" | "h" | "days" | "d" | "weeks" | "w" | "months" | "M" | "quarters" | "Q" | "years" | "y"])',
examples: [
'DATEDIFF({column1}, {column2})',
'DATEDIFF({column1}, {column2}, "seconds")',
'DATEDIFF({column1}, {column2}, "s")',
'DATEDIFF({column1}, {column2}, "years")',
'DATEDIFF({column1}, {column2}, "y")',
'DATEDIFF({column1}, {column2}, "minutes")',
'DATEDIFF({column1}, {column2}, "m")',
'DATEDIFF({column1}, {column2}, "days")',
'DATEDIFF({column1}, {column2}, "d")',
],
},
AND: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 1,
},
},
description: 'TRUE if all expr evaluate to TRUE',
syntax: 'AND(expr1, [expr2, ...])',
examples: ['AND(5 > 2, 5 < 10) => 1', 'AND({column1} > 2, {column2} < 10)'],
},
OR: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 1,
},
},
description: 'TRUE if at least one expr evaluates to TRUE',
syntax: 'OR(expr1, [expr2, ...])',
examples: ['OR(5 > 2, 5 < 10) => 1', 'OR({column1} > 2, {column2} < 10)'],
},
CONCAT: {
type: formulaTypes.STRING,
validation: {
args: {
min: 1,
},
},
description: 'Concatenated string of input parameters',
syntax: 'CONCAT(str1, [str2, ...])',
examples: ['CONCAT("AA", "BB", "CC") => "AABBCC"', 'CONCAT({column1}, {column2}, {column3})'],
},
TRIM: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Remove trailing and leading whitespaces from input parameter',
syntax: 'TRIM(str)',
examples: ['TRIM(" HELLO WORLD ") => "HELLO WORLD"', 'TRIM({column1})'],
},
UPPER: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Upper case converted string of input parameter',
syntax: 'UPPER(str)',
examples: ['UPPER("nocodb") => "NOCODB"', 'UPPER({column1})'],
},
LOWER: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Lower case converted string of input parameter',
syntax: 'LOWER(str)',
examples: ['LOWER("NOCODB") => "nocodb"', 'LOWER({column1})'],
},
LEN: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Input parameter character length',
syntax: 'LEN(value)',
examples: ['LEN("NocoDB") => 6', 'LEN({column1})'],
},
MIN: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Minimum value amongst input parameters',
syntax: 'MIN(value1, [value2, ...])',
examples: ['MIN(1000, 2000) => 1000', 'MIN({column1}, {column2})'],
},
MAX: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Maximum value amongst input parameters',
syntax: 'MAX(value1, [value2, ...])',
examples: ['MAX(1000, 2000) => 2000', 'MAX({column1}, {column2})'],
},
CEILING: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Rounded next largest integer value of input parameter',
syntax: 'CEILING(value)',
examples: ['CEILING(1.01) => 2', 'CEILING({column1})'],
},
FLOOR: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Rounded largest integer less than or equal to input parameter',
syntax: 'FLOOR(value)',
examples: ['FLOOR(3.1415) => 3', 'FLOOR({column1})'],
},
ROUND: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
max: 2,
},
},
description: 'Rounded number to a specified number of decimal places or the nearest integer if not specified',
syntax: 'ROUND(value, precision), ROUND(value)',
examples: ['ROUND(3.1415) => 3', 'ROUND(3.1415, 2) => 3.14', 'ROUND({column1}, 3)'],
},
MOD: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 2,
},
},
description: 'Remainder after integer division of input parameters',
syntax: 'MOD(value1, value2)',
examples: ['MOD(1024, 1000) => 24', 'MOD({column}, 2)'],
},
REPEAT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'Specified copies of the input parameter string concatenated together',
syntax: 'REPEAT(str, count)',
examples: ['REPEAT("A", 5) => "AAAAA"', 'REPEAT({column}, 5)'],
},
LOG: {
type: formulaTypes.NUMERIC,
validation: {},
description: 'Logarithm of input parameter to the base (default = e) specified',
syntax: 'LOG([base], value)',
examples: ['LOG(2, 1024) => 10', 'LOG(2, {column1})'],
},
EXP: {
type: formulaTypes.NUMERIC,
validation: {},
description: 'Exponential value of input parameter (e ^ power)',
syntax: 'EXP(power)',
examples: ['EXP(1) => 2.718281828459045', 'EXP({column1})'],
},
POWER: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 2,
},
},
description: 'base to the exponent power, as in base ^ exponent',
syntax: 'POWER(base, exponent)',
examples: ['POWER(2, 10) => 1024', 'POWER({column1}, 10)'],
},
SQRT: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Square root of the input parameter',
syntax: 'SQRT(value)',
examples: ['SQRT(100) => 10', 'SQRT({column1})'],
},
ABS: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Absolute value of the input parameter',
syntax: 'ABS(value)',
examples: ['ABS({column1})'],
},
NOW: {
type: formulaTypes.DATE,
validation: {
args: {
rqd: 0,
},
},
description: 'Returns the current time and day',
syntax: 'NOW()',
examples: ['NOW() => 2022-05-19 17:20:43'],
},
REPLACE: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 3,
},
},
description: 'String, after replacing all occurrences of srchStr with rplcStr',
syntax: 'REPLACE(str, srchStr, rplcStr)',
examples: ['REPLACE("AABBCC", "AA", "BB") => "BBBBCC"', 'REPLACE({column1}, {column2}, {column3})'],
},
SEARCH: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'Index of srchStr specified if found, 0 otherwise',
syntax: 'SEARCH(str, srchStr)',
examples: ['SEARCH("HELLO WORLD", "WORLD") => 7', 'SEARCH({column1}, "abc")'],
},
INT: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Integer value of input parameter',
syntax: 'INT(value)',
examples: ['INT(3.1415) => 3', 'INT({column1})'],
},
RIGHT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'n characters from the end of input parameter',
syntax: 'RIGHT(str, n)',
examples: ['RIGHT("HELLO WORLD", 5) => WORLD', 'RIGHT({column1}, 3)'],
},
LEFT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'n characters from the beginning of input parameter',
syntax: 'LEFT(str, n)',
examples: ['LEFT({column1}, 2)', 'LEFT("ABCD", 2) => "AB"'],
},
SUBSTR: {
type: formulaTypes.STRING,
validation: {
args: {
min: 2,
max: 3,
},
},
description: 'Substring of length n of input string from the postition specified',
syntax: ' SUBTR(str, position, [n])',
examples: ['SUBSTR("HELLO WORLD", 7) => WORLD', 'SUBSTR("HELLO WORLD", 7, 3) => WOR', 'SUBSTR({column1}, 7, 5)'],
},
MID: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 3,
},
},
description: 'Alias for SUBSTR',
syntax: 'MID(str, position, [count])',
examples: ['MID("NocoDB", 3, 2) => "co"', 'MID({column1}, 3, 2)'],
},
IF: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 2,
max: 3,
},
},
description: 'SuccessCase if expr evaluates to TRUE, elseCase otherwise',
syntax: 'IF(expr, successCase, elseCase)',
examples: ['IF(5 > 1, "YES", "NO") => "YES"', 'IF({column} > 1, "YES", "NO")'],
},
SWITCH: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 3,
},
},
description: 'Switch case value based on expr output',
syntax: 'SWITCH(expr, [pattern, value, ..., default])',
examples: [
'SWITCH(1, 1, "One", 2, "Two", "N/A") => "One""',
'SWITCH(2, 1, "One", 2, "Two", "N/A") => "Two"',
'SWITCH(3, 1, "One", 2, "Two", "N/A") => "N/A"',
'SWITCH({column1}, 1, "One", 2, "Two", "N/A")',
],
},
URL: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Convert to a hyperlink if it is a valid URL',
syntax: 'URL(str)',
examples: ['URL("https://github.com/nocodb/nocodb")', 'URL({column1})'],
},
WEEKDAY: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
max: 2,
},
},
description: 'Returns the day of the week as an integer between 0 and 6 inclusive starting from Monday by default',
syntax: 'WEEKDAY(date, [startDayOfWeek])',
examples: ['WEEKDAY("2021-06-09")', 'WEEKDAY(NOW(), "sunday")'],
},
TRUE: {
type: formulaTypes.NUMERIC,
validation: {
args: {
max: 0,
},
},
description: 'Returns 1',
syntax: 'TRUE()',
examples: ['TRUE()'],
},
FALSE: {
type: formulaTypes.NUMERIC,
validation: {
args: {
max: 0,
},
},
description: 'Returns 0',
syntax: 'FALSE()',
examples: ['FALSE()'],
},
REGEX_MATCH: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'Returns 1 if the input text matches a regular expression or 0 if it does not.',
syntax: 'REGEX_MATCH(string, regex)',
examples: ['REGEX_MATCH({title}, "abc.*")'],
},
REGEX_EXTRACT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'Returns the first match of a regular expression in a string.',
syntax: 'REGEX_EXTRACT(string, regex)',
examples: ['REGEX_EXTRACT({title}, "abc.*")'],
},
REGEX_REPLACE: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 3,
},
},
description: 'Replaces all matches of a regular expression in a string with a replacement string',
syntax: 'REGEX_MATCH(string, regex, replacement)',
examples: ['REGEX_EXTRACT({title}, "abc.*", "abcd")'],
},
BLANK: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 0,
},
},
description: 'Returns a blank value(null)',
syntax: 'BLANK()',
examples: ['BLANK()'],
},
XOR: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Returns true if an odd number of arguments are true, and false otherwise.',
syntax: 'XOR(expression, [exp2, ...])',
examples: ['XOR(TRUE(), FALSE(), TRUE())'],
},
EVEN: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Returns the nearest even integer that is greater than or equal to the specified value',
syntax: 'EVEN(value)',
examples: ['EVEN({column})'],
},
ODD: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Returns the nearest odd integer that is greater than or equal to the specified value',
syntax: 'ODD(value)',
examples: ['ODD({column})'],
},
RECORD_ID: {
validation: {
args: {
rqd: 0,
},
},
description: 'Returns the record id of the current record',
syntax: 'RECORD_ID()',
examples: ['RECORD_ID()'],
},
COUNTA: {
validation: {
args: {
min: 1,
},
},
description: 'Counts the number of non-empty arguments',
syntax: 'COUNTA(value1, [value2, ...])',
examples: ['COUNTA({field1}, {field2})'],
},
COUNT: {
validation: {
args: {
min: 1,
},
},
description: 'Count the number of arguments that are numbers',
syntax: 'COUNT(value1, [value2, ...])',
examples: ['COUNT({field1}, {field2})'],
},
COUNTALL: {
validation: {
args: {
min: 1,
},
},
description: 'Counts the number of arguments',
syntax: 'COUNTALL(value1, [value2, ...])',
examples: ['COUNTALL({field1}, {field2})'],
},
ROUNDDOWN: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
max: 2,
},
},
description:
'Round down the value after the decimal point to the number of decimal places given by "precision"(default is 0)',
syntax: 'ROUNDDOWN(value, [precision])',
examples: ['ROUNDDOWN({field1})', 'ROUNDDOWN({field1}, 2)'],
},
ROUNDUP: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
max: 2,
},
},
description: 'Round up the value after the decimal point to the number of decimal places given by "precision"(default is 0)',
syntax: 'ROUNDUP(value, [precision])',
examples: ['ROUNDUP({field1})', 'ROUNDUP({field1}, 2)'],
},
VALUE: {
validation: {
args: {
rqd: 1,
},
},
description:
'Extract the numeric value from a string, if `%` or `-` is present, it will handle it accordingly and return the numeric value',
syntax: 'VALUE(value)',
examples: ['VALUE({field})', 'VALUE("abc10000%")', 'VALUE("$10000")'],
},
// Disabling these functions for now; these act as alias for CreatedAt & UpdatedAt fields;
// Issue: Error noticed if CreatedAt & UpdatedAt fields are removed from the table after creating these formulas
//
// CREATED_TIME: {
// validation: {
// args: {
// rqd: 0,
// },
// },
// description: 'Returns the created time of the current record if it exists',
// syntax: 'CREATED_TIME()',
// examples: ['CREATED_TIME()'],
// },
// LAST_MODIFIED_TIME: {
// validation: {
// args: {
// rqd: 0,
// },
// },
// description: 'Returns the last modified time of the current record if it exists',
// syntax: ' LAST_MODIFIED_TIME()',
// examples: [' LAST_MODIFIED_TIME()'],
// },
}
const formulaList = Object.keys(formulas) const formulaList = Object.keys(formulas)
@ -671,4 +52,4 @@ function GetCaretPosition(ctrl: typeof AntInput) {
return CaretPos return CaretPos
} }
export { formulaList, formulas, formulaTypes, getWordUntilCaret, insertAtCursor } export { formulaList, formulas, getWordUntilCaret, insertAtCursor }

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

@ -91,6 +91,9 @@ import Project from '~icons/nc-icons/project'
import LookupIcon from '~icons/nc-icons/lookup' import LookupIcon from '~icons/nc-icons/lookup'
import FileImageIcon from '~icons/nc-icons/file-image' import FileImageIcon from '~icons/nc-icons/file-image'
import PhUsers from '~icons/ph/users'
import PhUser from '~icons/ph/user'
// Roles // Roles
import SuperAdmin from '~icons/nc-icons/super-admin' import SuperAdmin from '~icons/nc-icons/super-admin'
import Owner from '~icons/nc-icons/owner' import Owner from '~icons/nc-icons/owner'
@ -320,6 +323,8 @@ export const iconMap = {
lock: h('span', { class: 'material-symbols' }, 'lock'), lock: h('span', { class: 'material-symbols' }, 'lock'),
account: h('span', { class: 'material-symbols' }, 'person'), account: h('span', { class: 'material-symbols' }, 'person'),
accountCircle: h('span', { class: 'material-symbols' }, 'account_circle'), accountCircle: h('span', { class: 'material-symbols' }, 'account_circle'),
phUser: PhUser,
phUsers: PhUsers,
users: NcUsers, users: NcUsers,
cloudDownload: h('span', { class: 'material-symbols' }, 'cloud_download'), cloudDownload: h('span', { class: 'material-symbols' }, 'cloud_download'),
download: MsDownloadRounded, download: MsDownloadRounded,

5
packages/noco-docs/docs/020.getting-started/050.self-hosted/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "In Open Source", "label": "In Open Source",
"collapsible": true, "collapsible": true,
"collapsed": false "collapsed": false,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/020.getting-started/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Getting Started", "label": "Getting Started",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/030.workspaces/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Workspaces ☁", "label": "Workspaces ☁",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/040.bases/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Bases", "label": "Bases",
"collapsible": true, "collapsible": true,
"collapsed": false "collapsed": false,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/050.tables/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Tables", "label": "Tables",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/060.table-operations/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Table operations", "label": "Table operations",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

7
packages/noco-docs/docs/065.table-details/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Table Details", "label": "Table details",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

137
packages/noco-docs/docs/070.fields/040.field-types/010.text-based/025.rich-text.md

@ -0,0 +1,137 @@
---
title: 'Rich text'
description: 'This article explains how to create & work with a Rich text field.'
tags: ['Fields', 'Field types', 'Text based types', 'Rich text']
keywords: ['Fields', 'Field types', 'Text based types', 'Rich text', 'Create rich text field']
---
`Rich Text` field is text based field & is extension of `Long text` that allows you to add formatting to the text. You can add text formatting like bold, italic, underline, strikethrough, horizontal rule, ordered list, unordered list, code, quote, etc.
## Create a `Rich Text` field
1. Click on `+` icon to the right of `Fields header`
2. On the dropdown modal, enter the field name (Optional).
3. Select the field type as `Long text` from the dropdown.
4. Enable `Rich Text` toggle field.
5. Set default value for the field (Optional).
6. Click on `Save Field` button.
![image](/img/v2/fields/types/richtext.png)
:::note
- Specify default value without quotes.
- Use `Enter` key to add new line.
:::
### Cell display
`Rich Text` field is displayed as a single line text field in the table view. Click on the expand icon in the cell to view the full text.
![image](/img/v2/fields/long-text-expand.png)
![image](/img/v2/fields/long-text-expand-2.png)
## Formatting options
NocoDB supports markdown syntax for formatting the text. Following are the supported formatting options.
### Heading
To create a heading, prefix `#` symbol preceding your heading text. The number of # symbols employed will dictate the heading's hierarchy level and typeface size. Three levels of headings are supported.
```
# Heading 1
## Heading 2
### Heading 3
```
![image](/img/v2/fields/types/richtext-heading.png)
### Text formatting
You can emphasise text with bold, italic, strikethrough or underline formatting options. Table below shows syntax, keyboard shortcut, example & output for each formatting option.
| Style | Syntax | Keyboard shortcut | Example | Output |
| --- | --- | --- | --- | --- |
| Bold | `**bold text**` | `Ctrl/Cmd + B` | `**This is bold text**` | **This is bold text** |
| Italic | `*italicized text*` | `Ctrl/Cmd + I` | `*This is italicized text*` | *This is italicized text* |
| Strikethrough | `~~strikethrough text~~` | `Ctrl/Cmd + Shift + X` | `~~This is strikethrough text~~` | ~~This is strikethrough text~~ |
| Underline | | `Ctrl/Cmd + U` | `This is underlined text` | <u>This is underlined text</u> |
### Quote block
You can quote text with a `>`
```
normal text
> quoted text
```
normal text
> quoted text
### Code block
Code block can be created by using (3 backticks) before & after the code.
````
```
This is a code block
```
````
```
This is a code block
```
### Link
You can create an inline link by using `Link` menu option in the rich text toolbar
![image](/img/v2/fields/types/richtext-links.png)
### Bullet List
You can create unordered list by using `Bulleted list` menu option in the rich text toolbar or by preceding the text with `-` `+` or `*` symbol.
```
- Item 1
- Item 2
+ Item 1
+ Item 2
* Item 1
* Item 2
```
- Item 1
- Item 2
+ Item 1
+ Item 2
* Item 1
* Item 2
:::note
You can create nested lists by using `tab` key & `shift + tab` key to indent & outdent the list items.
:::
### Numbered List
You can create ordered list by using `Numbered list` menu option in the rich text toolbar or by preceding the text with `1.` symbol.
```
1. Item 1
2. Item 2
```
1. Item 1
2. Item 2
### Task list
You can create task lists by using `Task list` menu option in the rich text toolbar or by preceding the text with `[ ]` symbol. You can mark the task as completed by using `[x]` symbol.
```
[ ] Item 1
[x] Item 2
```
- [ ] Item 1
- [x] Item 2
## Similar text based fields
Following are the other text based fields available in NocoDB, custom-built for specific use cases.
- [Single line text](010.single-line-text.md)
- [URL](050.url.md)
- [Email](030.email.md)
- [Phone](040.phonenumber.md)

5
packages/noco-docs/docs/070.fields/040.field-types/010.text-based/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Text based", "label": "Text based",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/070.fields/040.field-types/020.numerical/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Numerical", "label": "Numerical",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/070.fields/040.field-types/030.select-based/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Select based", "label": "Select based",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/070.fields/040.field-types/040.links-based/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Links based", "label": "Links based",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/070.fields/040.field-types/050.custom-types/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Custom types", "label": "Custom types",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/070.fields/040.field-types/060.formula/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Formula", "label": "Formula",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/070.fields/040.field-types/070.date-time-based/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Date Time based", "label": "Date Time based",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

30
packages/noco-docs/docs/070.fields/040.field-types/080.user-based/010.user.md

@ -0,0 +1,30 @@
---
title: 'User'
description: 'This article explains how to create & work with a User field.'
tags: ['Fields', 'Field types', 'User']
keywords: ['Fields', 'Field types', 'User', 'Create User field']
---
`User` field type allows you to assign a user from your current workspace to a record. For example, you can create a `Task` table with a `User` field type to assign a task to a user. You can also configure the field to allow assigning multiple users to a record.
## Create a User field
1. Click on `+` icon to the right of `Fields header`
2. On the dropdown modal, enter the field name (Optional).
3. Select the field type as `User` from the dropdown.
4. Configure `Allow adding multiple users` toggle field (Optional).
5. Configure default value (Optional)
6. Click on `Save Field` button.
![image](/img/v2/fields/types/user-field.png)
### Cell display
`User` field display is quite identical to `Select` field. It is displayed as a dropdown in the table view. Click on the dropdown to select a user. If `Allow adding multiple users` is enabled, you can select multiple users from the dropdown.
![image](/img/v2/fields/types/user-field-cell.png)
:::note
- If a user is removed from workspace, the user will be removed from the dropdown list. If such user was assigned to a record already, the user will be displayed as is.
- To remove a user from a record, click on the `x` icon next to the user name.
- If display name is not set for a user, the user's email address will be displayed.
:::

8
packages/noco-docs/docs/070.fields/040.field-types/080.user-based/_category_.json

@ -0,0 +1,8 @@
{
"label": "User based",
"collapsible": true,
"collapsed": true,
"link": {
"type": "generated-index"
}
}

5
packages/noco-docs/docs/070.fields/040.field-types/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Field types", "label": "Field types",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/070.fields/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Fields", "label": "Fields",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

5
packages/noco-docs/docs/080.records/_category_.json

@ -1,5 +1,8 @@
{ {
"label": "Records", "label": "Records",
"collapsible": true, "collapsible": true,
"collapsed": true "collapsed": true,
"link": {
"type": "generated-index"
}
} }

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

Loading…
Cancel
Save