Browse Source

Merge branch 'develop' into feat/sort-for-user-management

pull/7276/head
Ramesh Mane 9 months ago committed by GitHub
parent
commit
67bf4f529c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .github/workflows/release-executables.yml
  2. 26
      packages/nc-gui/assets/style.scss
  3. 1
      packages/nc-gui/components.d.ts
  4. 4
      packages/nc-gui/components/account/UsersModal.vue
  5. 18
      packages/nc-gui/components/cell/Checkbox.vue
  6. 34
      packages/nc-gui/components/cell/Currency.vue
  7. 9
      packages/nc-gui/components/cell/DatePicker.vue
  8. 8
      packages/nc-gui/components/cell/DateTimePicker.vue
  9. 10
      packages/nc-gui/components/cell/Decimal.vue
  10. 10
      packages/nc-gui/components/cell/Duration.vue
  11. 9
      packages/nc-gui/components/cell/Email.vue
  12. 7
      packages/nc-gui/components/cell/Float.vue
  13. 19
      packages/nc-gui/components/cell/GeoData.vue
  14. 13
      packages/nc-gui/components/cell/Integer.vue
  15. 60
      packages/nc-gui/components/cell/MultiSelect.vue
  16. 80
      packages/nc-gui/components/cell/Percent.vue
  17. 10
      packages/nc-gui/components/cell/PhoneNumber.vue
  18. 37
      packages/nc-gui/components/cell/Rating.vue
  19. 17
      packages/nc-gui/components/cell/RichText.vue
  20. 64
      packages/nc-gui/components/cell/SingleSelect.vue
  21. 23
      packages/nc-gui/components/cell/Text.vue
  22. 34
      packages/nc-gui/components/cell/TextArea.vue
  23. 7
      packages/nc-gui/components/cell/TimePicker.vue
  24. 9
      packages/nc-gui/components/cell/Url.vue
  25. 442
      packages/nc-gui/components/cell/User.vue
  26. 9
      packages/nc-gui/components/cell/YearPicker.vue
  27. 5
      packages/nc-gui/components/cell/attachment/index.vue
  28. 2
      packages/nc-gui/components/dlg/share-and-collaborate/Collaborate.vue
  29. 2
      packages/nc-gui/components/general/NocoIcon.vue
  30. 1
      packages/nc-gui/components/nc/Button.vue
  31. 114
      packages/nc-gui/components/project/AccessSettings.vue
  32. 35
      packages/nc-gui/components/project/View.vue
  33. 4
      packages/nc-gui/components/smartsheet/Cell.vue
  34. 32
      packages/nc-gui/components/smartsheet/DivDataCell.vue
  35. 17
      packages/nc-gui/components/smartsheet/Form.vue
  36. 2
      packages/nc-gui/components/smartsheet/Toolbar.vue
  37. 8
      packages/nc-gui/components/smartsheet/column/DecimalOptions.vue
  38. 11
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  39. 20
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  40. 819
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  41. 40
      packages/nc-gui/components/smartsheet/column/RollupOptions.vue
  42. 66
      packages/nc-gui/components/smartsheet/column/UserOptions.vue
  43. 31
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  44. 32
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  45. 12
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  46. 40
      packages/nc-gui/components/smartsheet/grid/Table.vue
  47. 6
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  48. 7
      packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue
  49. 5
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  50. 23
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  51. 27
      packages/nc-gui/components/virtual-cell/Formula.vue
  52. 48
      packages/nc-gui/components/virtual-cell/Links.vue
  53. 15
      packages/nc-gui/components/virtual-cell/Lookup.vue
  54. 17
      packages/nc-gui/components/virtual-cell/QrCode.vue
  55. 16
      packages/nc-gui/components/virtual-cell/barcode/Barcode.vue
  56. 21
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  57. 3
      packages/nc-gui/composables/useData.ts
  58. 18
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  59. 12
      packages/nc-gui/composables/useMultiSelect/index.ts
  60. 7
      packages/nc-gui/composables/useSharedFormViewStore.ts
  61. 8
      packages/nc-gui/composables/useSharedView.ts
  62. 4
      packages/nc-gui/composables/useViewData.ts
  63. 11
      packages/nc-gui/composables/useViewGroupBy.ts
  64. 394
      packages/nc-gui/lang/ru.json
  65. 1
      packages/nc-gui/lib/types.ts
  66. 16
      packages/nc-gui/package.json
  67. 5
      packages/nc-gui/pages/forgot-password.vue
  68. 16
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index.vue
  69. 13
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  70. 7
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue
  71. 4
      packages/nc-gui/pages/signin.vue
  72. 3
      packages/nc-gui/pages/signup/[[token]].vue
  73. 4
      packages/nc-gui/store/base.ts
  74. 35
      packages/nc-gui/store/bases.ts
  75. 3
      packages/nc-gui/store/users.ts
  76. 1
      packages/nc-gui/utils/cell.ts
  77. 4
      packages/nc-gui/utils/columnUtils.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. 2
      packages/nc-lib-gui/package.json
  82. 5
      packages/noco-docs/docs/020.getting-started/050.self-hosted/_category_.json
  83. 5
      packages/noco-docs/docs/020.getting-started/_category_.json
  84. 5
      packages/noco-docs/docs/030.workspaces/_category_.json
  85. 5
      packages/noco-docs/docs/040.bases/_category_.json
  86. 5
      packages/noco-docs/docs/050.tables/_category_.json
  87. 5
      packages/noco-docs/docs/060.table-operations/_category_.json
  88. 7
      packages/noco-docs/docs/065.table-details/_category_.json
  89. 137
      packages/noco-docs/docs/070.fields/040.field-types/010.text-based/025.rich-text.md
  90. 5
      packages/noco-docs/docs/070.fields/040.field-types/010.text-based/_category_.json
  91. 5
      packages/noco-docs/docs/070.fields/040.field-types/020.numerical/_category_.json
  92. 5
      packages/noco-docs/docs/070.fields/040.field-types/030.select-based/_category_.json
  93. 5
      packages/noco-docs/docs/070.fields/040.field-types/040.links-based/_category_.json
  94. 5
      packages/noco-docs/docs/070.fields/040.field-types/050.custom-types/_category_.json
  95. 5
      packages/noco-docs/docs/070.fields/040.field-types/060.formula/_category_.json
  96. 5
      packages/noco-docs/docs/070.fields/040.field-types/070.date-time-based/_category_.json
  97. 30
      packages/noco-docs/docs/070.fields/040.field-types/080.user-based/010.user.md
  98. 8
      packages/noco-docs/docs/070.fields/040.field-types/080.user-based/_category_.json
  99. 5
      packages/noco-docs/docs/070.fields/040.field-types/_category_.json
  100. 5
      packages/noco-docs/docs/070.fields/_category_.json
  101. Some files were not shown because too many files have changed in this diff Show More

4
.github/workflows/release-executables.yml

@ -134,7 +134,7 @@ jobs:
- uses: actions/upload-artifact@master
with:
name: ${{ github.event.inputs.tag || inputs.tag }}
name: ${{ format('{0}-signed', github.event.inputs.tag || inputs.tag) }}
path: scripts/pkg-executable/mac-dist
retention-days: 1
@ -145,7 +145,7 @@ jobs:
steps:
- uses: actions/download-artifact@master
with:
name: ${{ github.event.inputs.tag || inputs.tag }}
name: ${{ format('{0}-signed', github.event.inputs.tag || inputs.tag) }}
path: scripts/pkg-executable/mac-dist

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

@ -57,7 +57,7 @@ main {
}
.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 {
width: 0px;
}
@ -88,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 {
overflow-x: scroll;
scrollbar-width: thin !important;

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

@ -11,7 +11,6 @@ declare module '@vue/runtime-core' {
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
ABadgeRibbon: typeof import('ant-design-vue/es')['BadgeRibbon']
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
AButton: typeof import('ant-design-vue/es')['Button']

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

@ -34,6 +34,8 @@ const { copy } = useCopy()
const { dashboardUrl } = useDashboard()
const { clearBasesUser } = useBases()
const usersData = ref<Users>({ emails: '', role: OrgUserRoles.VIEWER, invitationToken: undefined })
const formRef = ref()
@ -64,6 +66,8 @@ const saveUser = async () => {
// Successfully updated the user details
message.success(t('msg.success.userAdded'))
clearBasesUser()
} catch (e: any) {
console.error(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 rowHeight = inject(RowHeightInj, ref())
const checkboxMeta = computed(() => {
return {
icon: {
@ -82,18 +84,28 @@ useSelectedCellKeyupListener(active, (e) => {
<template>
<div
class="flex cursor-pointer w-full h-full items-center"
class="flex cursor-pointer w-full h-full items-center focus:outline-none"
:class="{
'w-full flex-start pl-2': isForm || isGallery || isExpandedFormOpen,
'w-full justify-center': !isForm && !isGallery && !isExpandedFormOpen,
'nc-cell-hover-show': !vModel && !readOnly,
'opacity-0': readOnly && !vModel,
}"
:style="{
height:
isForm || isExpandedFormOpen || isGallery || isEditColumnMenu ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
}"
tabindex="0"
@click="onClick(false, $event)"
@keydown.enter.stop="onClick(true, $event)"
>
<div
class="items-center"
:class="{ 'w-full justify-start': isEditColumnMenu || isGallery || isForm, 'py-2': isEditColumnMenu }"
class="flex items-center"
:class="{
'w-full justify-start': isEditColumnMenu || isGallery || isForm,
'justify-center': !isEditColumnMenu && !isGallery && !isForm,
'py-2': isEditColumnMenu,
}"
@click="onClick(true)"
>
<Transition name="layout" mode="out-in" :duration="100">

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

@ -1,6 +1,16 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import { ColumnInj, EditColumnInj, EditModeInj, IsExpandedFormOpenInj, computed, inject, parseProp, useVModel } from '#imports'
import {
ColumnInj,
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
computed,
inject,
parseProp,
useVModel,
} from '#imports'
interface Props {
modelValue: number | null | undefined
@ -57,7 +67,10 @@ const currency = computed(() => {
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLInputElement)?.focus()
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
const submitCurrency = () => {
if (lastSaved.value !== vModel.value) {
@ -78,7 +91,8 @@ onMounted(() => {
:ref="focus"
v-model="vModel"
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 py-1 outline-none focus:outline-none focus:ring-0"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="submitCurrency"
@keydown.down.stop
@ -99,3 +113,17 @@ onMounted(() => {
<!-- possibly unexpected string / null with showNull == false -->
<span v-else />
</template>
<style lang="scss" scoped>
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
</style>

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

@ -7,6 +7,7 @@ import {
ColumnInj,
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
ReadonlyInj,
computed,
inject,
@ -41,6 +42,8 @@ const readOnly = inject(ReadonlyInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
@ -238,9 +241,10 @@ const clickHandler = () => {
<a-date-picker
v-model:value="localState"
:picker="picker"
:tabindex="0"
:bordered="false"
class="!w-full !px-1 !border-none"
:class="{ 'nc-null': modelValue === null && showNull }"
class="!w-full !py-1 !border-none"
:class="{ 'nc-null': modelValue === null && showNull, '!px-2': isExpandedFormOpen, '!px-0': !isExpandedFormOpen }"
:format="dateFormat"
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"
@ -249,6 +253,7 @@ const clickHandler = () => {
:open="isOpen"
@click="clickHandler"
@update:open="updateOpen"
@keydown.enter="open = !open"
>
<template #suffixIcon></template>
</a-date-picker>

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

@ -6,6 +6,7 @@ import {
CellClickHookInj,
ColumnInj,
EditColumnInj,
IsExpandedFormOpenInj,
ReadonlyInj,
inject,
isDrawerOrModalExist,
@ -39,6 +40,8 @@ const { t } = useI18n()
const isEditColumn = inject(EditColumnInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const column = inject(ColumnInj)!
const isDateInvalid = ref(false)
@ -293,8 +296,8 @@ const isColDisabled = computed(() => {
:disabled="isColDisabled"
:show-time="true"
:bordered="false"
class="!w-full !px-0 !py-1 !border-none"
:class="{ 'nc-null': modelValue === null && showNull }"
class="!w-full !py-1 !border-none"
:class="{ 'nc-null': modelValue === null && showNull, '!px-2': isExpandedFormOpen, '!px-0': !isExpandedFormOpen }"
:format="dateTimeFormat"
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"
@ -303,6 +306,7 @@ const isColDisabled = computed(() => {
:open="isOpen"
@click="clickHandler"
@ok="okHandler"
@keydown.enter="open = !open"
>
<template #suffixIcon></template>
</a-date-picker>

10
packages/nc-gui/components/cell/Decimal.vue

@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, inject, useVModel } from '#imports'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, inject, useVModel } from '#imports'
interface Props {
// when we set a number, then it is number type
@ -63,6 +63,8 @@ const precision = computed(() => {
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
// Handle the arrow keys as its default behavior is to increment/decrement the value
const onKeyDown = (e: any) => {
if (e.key === 'ArrowDown') {
@ -80,7 +82,8 @@ const onKeyDown = (e: any) => {
}
}
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLInputElement)?.focus()
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
watch(isExpandedFormOpen, () => {
if (!isExpandedFormOpen.value) {
@ -94,7 +97,8 @@ watch(isExpandedFormOpen, () => {
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none !py-2 !px-1 border-none rounded-md w-full h-full !text-sm"
class="outline-none py-1 border-none rounded-md w-full h-full !text-sm"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
type="number"
:step="precision"
:placeholder="isEditColumn ? $t('labels.optional') : ''"

10
packages/nc-gui/components/cell/Duration.vue

@ -5,6 +5,7 @@ import {
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
computed,
convertDurationToSeconds,
convertMS2Duration,
@ -83,7 +84,10 @@ const submitDuration = () => {
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLInputElement)?.focus()
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
</script>
<template>
@ -92,8 +96,8 @@ const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value
v-if="editEnabled"
:ref="focus"
v-model="localState"
class="w-full !border-none !outline-none p-0"
:class="{ '!px-2 !py-1': editEnabled }"
class="w-full !border-none !outline-none py-1"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
:placeholder="durationPlaceholder"
@blur="submitDuration"
@keypress="checkDurationFormat($event)"

9
packages/nc-gui/components/cell/Email.vue

@ -4,6 +4,7 @@ import {
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
IsSurveyFormInj,
computed,
inject,
@ -50,7 +51,10 @@ const validEmail = computed(() => vModel.value && validateEmail(vModel.value))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLInputElement)?.focus()
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
watch(
() => editEnabled.value,
@ -70,7 +74,8 @@ watch(
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="w-full outline-none text-sm px-1 py-2"
class="w-full outline-none text-sm py-1"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false"
@keydown.down.stop

7
packages/nc-gui/components/cell/Float.vue

@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, inject, useVModel } from '#imports'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, inject, useVModel } from '#imports'
interface Props {
// when we set a number, then it is number type
@ -40,7 +40,10 @@ const vModel = computed({
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLInputElement)?.focus()
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
</script>
<template>

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

@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { GeoLocationType } from 'nocodb-sdk'
import { Modal as AModal, iconMap, latLongToJoinedString, useVModel } from '#imports'
import { Modal as AModal, IsExpandedFormOpenInj, iconMap, latLongToJoinedString, useVModel } from '#imports'
interface Props {
modelValue?: string | null
@ -16,6 +16,8 @@ const emits = defineEmits<Emits>()
const vModel = useVModel(props, 'modelValue', emits)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const isExpanded = ref(false)
const isLoading = ref(false)
@ -86,7 +88,8 @@ const openInOSM = () => {
<a-dropdown :is="isExpanded ? AModal : 'div'" v-model:visible="isExpanded" :trigger="['click']">
<div
v-if="!isLocationSet"
class="group cursor-pointer flex gap-1 items-center mx-auto max-w-64 justify-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
class="group cursor-pointer flex gap-1 items-center mx-auto max-w-64 justify-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500) my-1"
tabindex="0"
>
<div class="flex items-center gap-2" data-testid="nc-geo-data-set-location-button">
<component
@ -98,7 +101,17 @@ const openInOSM = () => {
</div>
</div>
</div>
<div v-else data-testid="nc-geo-data-lat-long-set">{{ latLongStr }}</div>
<div
v-else
data-testid="nc-geo-data-lat-long-set"
tabindex="0"
class="h-full w-full flex items-center py-1 focus-visible:!outline-none focus:!outline-none"
:class="{
'px-2': isExpandedFormOpen,
}"
>
{{ latLongStr }}
</div>
<template #overlay>
<a-form :model="formState" class="flex flex-col w-max-64 border-1 border-gray-200" @finish="handleFinish">
<a-form-item>

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, inject, useVModel } from '#imports'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, inject, useVModel } from '#imports'
interface Props {
// when we set a number, then it is number type
@ -48,7 +48,10 @@ const vModel = computed({
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLInputElement)?.focus()
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
function onKeyDown(e: any) {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
@ -85,11 +88,9 @@ function onKeyDown(e: any) {
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none py-2 px-1 border-none w-full h-full text-sm"
class="outline-none py-1 border-none w-full h-full text-sm"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
type="number"
:class="{
'pl-2': isExpandedFormOpen,
}"
style="letter-spacing: 0.06rem"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false"

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

@ -1,15 +1,14 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsKanbanInj,
ReadonlyInj,
RowHeightInj,
@ -53,24 +52,28 @@ const isEditable = inject(EditModeInj, ref(false))
const activeCell = inject(ActiveCellInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
// 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 active = computed(() => activeCell.value || isEditable.value || isForm.value)
const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const rowHeight = inject(RowHeightInj, ref(undefined))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const selectedIds = ref<string[]>([])
const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const isFocusing = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const searchVal = ref<string | null>()
@ -180,9 +183,7 @@ watch(isOpen, (n, _o) => {
if (!n) searchVal.value = ''
if (editAllowed.value) {
if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
if (n) {
aselect.value?.$el?.querySelector('input')?.focus()
}
}
@ -299,22 +300,11 @@ const onTagClick = (e: Event, onClose: Function) => {
}
}
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = () => {
if (cellClickHook) return
isOpen.value = editAllowed.value && !isOpen.value
}
if (isFocusing.value) return
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
@ -341,10 +331,34 @@ const selectedOpts = computed(() => {
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>
<template>
<div class="nc-multi-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div
class="nc-multi-select h-full w-full flex items-center"
:class="{ 'read-only': readOnly, 'px-2': isExpandedFormOpen }"
@click="toggleMenu"
>
<div
v-if="!active"
class="flex flex-wrap"
@ -403,7 +417,9 @@ const selectedOpts = computed(() => {
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell !min-w-200px ${isOpen ? 'active' : ''}`"
@search="search"
@keydown.stop
@keydown="onKeyDown"
@focus="onFocus"
@blur="isOpen = false"
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" />

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, inject, useVModel } from '#imports'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, inject, useVModel } from '#imports'
interface Props {
modelValue?: number | string | null
@ -20,6 +20,8 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const _vModel = useVModel(props, 'modelValue', emits)
const wrapperRef = ref<HTMLElement>()
const vModel = computed({
get: () => _vModel.value,
set: (value) => {
@ -33,7 +35,10 @@ const vModel = computed({
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLInputElement)?.focus()
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
const cellFocused = ref(false)
@ -56,6 +61,19 @@ const onBlur = () => {
const onFocus = () => {
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()
wrapperRef.value?.querySelector('input')?.select()
})
}
const onMouseover = () => {
@ -67,16 +85,51 @@ const onMouseleave = () => {
expandedEditEnabled.value = false
}
}
const onTabPress = (e: KeyboardEvent) => {
if (e.shiftKey && (isExpandedFormOpen.value || isForm.value)) {
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(`${isExpandedFormOpen.value ? '.nc-expanded-form-row' : '.nc-form-wrapper'} .nc-data-cell`),
).findIndex((el) => {
return el.querySelector('.nc-filter-value-select') === wrapperRef.value
})
if (focusesNcCellIndex >= 0) {
const nodes = document.querySelectorAll(
`${isExpandedFormOpen.value ? '.nc-expanded-form-row' : '.nc-form-wrapper'} .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>
<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
v-if="(!isExpandedFormOpen && editEnabled) || (isExpandedFormOpen && expandedEditEnabled)"
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="w-full !text-sm !border-none !outline-none focus:ring-0 text-base p-1"
:class="{ '!px-2': editEnabled }"
class="w-full !text-sm !border-none !outline-none focus:ring-0 text-base py-1"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
type="number"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="onBlur"
@ -86,6 +139,7 @@ const onMouseleave = () => {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.tab="onTabPress"
@selectstart.capture.stop
@mousedown.stop
/>
@ -104,3 +158,17 @@ const onMouseleave = () => {
<span v-else>{{ vModel }}&nbsp;</span>
</div>
</template>
<style lang="scss" scoped>
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
</style>

10
packages/nc-gui/components/cell/PhoneNumber.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import isMobilePhone from 'validator/lib/isMobilePhone'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsSurveyFormInj, computed, inject } from '#imports'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, IsSurveyFormInj, computed, inject } from '#imports'
interface Props {
modelValue: string | null | number | undefined
@ -42,7 +42,10 @@ const validEmail = computed(() => vModel.value && isMobilePhone(vModel.value))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLInputElement)?.focus()
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
watch(
() => editEnabled.value,
@ -62,7 +65,8 @@ watch(
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="w-full outline-none text-sm px-1 py-2"
class="w-full outline-none text-sm py-1"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false"
@keydown.down.stop

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

@ -1,5 +1,13 @@
<script setup lang="ts">
import { ActiveCellInj, ColumnInj, computed, inject, parseProp, useSelectedCellKeyupListener } from '#imports'
import {
ActiveCellInj,
ColumnInj,
IsExpandedFormOpenInj,
computed,
inject,
parseProp,
useSelectedCellKeyupListener,
} from '#imports'
interface Props {
modelValue?: number | null | undefined
@ -13,6 +21,8 @@ const column = inject(ColumnInj)!
const readonly = inject(ReadonlyInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const ratingMeta = computed(() => {
return {
icon: {
@ -36,14 +46,37 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
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>
<template>
<a-rate
ref="rateDomRef"
v-model:value="vModel"
:disabled="readonly"
:count="ratingMeta.max"
:style="`color: ${ratingMeta.color}; padding: 0px 5px`"
:style="`color: ${ratingMeta.color}; padding: ${isExpandedFormOpen ? '0px 8px' : '0px 5px'};`"
@keydown="onKeyPress"
>
<template #character>
<MdiStar v-if="ratingMeta.icon.full === 'mdi-star'" class="text-sm" />

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

@ -19,6 +19,8 @@ const props = defineProps<{
const emits = defineEmits(['update:value'])
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const turndownService = new TurndownService({})
turndownService.addRule('lineBreak', {
@ -44,6 +46,15 @@ turndownService.addRule('taskList', {
},
})
turndownService.addRule('strikethrough', {
filter: ['s'],
replacement: (content) => {
return `~${content}~`
},
})
turndownService.keep(['u', 'del'])
const checkListItem = {
name: 'checkListItem',
level: 'block',
@ -154,11 +165,12 @@ watch(editorDom, () => {
<template>
<div
class="h-full"
class="h-full focus:outline-none"
:class="{
'flex flex-col flex-grow nc-rich-text-full': props.fullMode,
'nc-rich-text-embed flex flex-col pl-1 w-full': !props.fullMode,
}"
tabindex="0"
>
<div v-if="props.showMenu" class="absolute top-0 right-0.5">
<CellRichTextSelectedBubbleMenu v-if="editor" :editor="editor" embed-mode />
@ -171,7 +183,8 @@ watch(editorDom, () => {
class="flex flex-col nc-textarea-rich-editor w-full"
:class="{
'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>

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

@ -1,15 +1,14 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
IsKanbanInj,
ReadonlyInj,
@ -47,9 +46,13 @@ const isEditable = inject(EditModeInj, ref(false))
const activeCell = inject(ActiveCellInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
// 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 active = computed(() => activeCell.value || isEditable.value || isForm.value)
const aselect = ref<typeof AntSelect>()
@ -61,8 +64,6 @@ const isPublic = inject(IsPublicInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const { $api } = useNuxtApp()
const searchVal = ref()
@ -77,6 +78,8 @@ const { isPg, isMysql } = useBase()
// temporary until it's add the option to column meta
const tempSelectedOptState = ref<string>()
const isFocusing = ref(false)
const isNewOptionCreateEnabled = computed(() => !isPublic.value && !disableOptionCreation && isUIAllowed('fieldEdit'))
const options = computed<(SelectOptionType & { value: string })[]>(() => {
@ -97,7 +100,7 @@ const isOptionMissing = computed(() => {
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)
@ -215,6 +218,14 @@ const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.stopPropagation()
}
if (e.key === 'Escape') {
isOpen.value = false
setTimeout(() => {
aselect.value?.$el.querySelector('.ant-select-selection-search > input').focus()
}, 100)
}
}
const onSelect = () => {
@ -222,8 +233,6 @@ const onSelect = () => {
isEditable.value = false
}
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = (e: Event) => {
// todo: refactor
// check clicked element is clear icon
@ -234,19 +243,11 @@ const toggleMenu = (e: Event) => {
vModel.value = ''
return e.stopPropagation()
}
if (cellClickHook) return
isOpen.value = editAllowed.value && !isOpen.value
}
const cellClickHookHandler = () => {
if (isFocusing.value) return
isOpen.value = editAllowed.value && !isOpen.value
}
onMounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
onUnmounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
const handleClose = (e: MouseEvent) => {
if (isOpen.value && aselect.value && !aselect.value.$el.contains(e.target)) {
@ -259,10 +260,25 @@ useEventListener(document, 'click', handleClose, true)
const selectedOpt = computed(() => {
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>
<template>
<div class="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div
class="h-full w-full flex items-center nc-single-select focus:outline-transparent"
:class="{ 'read-only': readOnly, 'px-2': isExpandedFormOpen }"
@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
@ -293,7 +309,7 @@ const selectedOpt = computed(() => {
</a-tag>
</div>
<a-select
<NcSelect
v-else
ref="aselect"
v-model:value="vModel"
@ -304,12 +320,14 @@ const selectedOpt = computed(() => {
:bordered="false"
:open="isOpen && 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-arrow="hasEditRoles && !readOnly && active && (vModel === null || vModel === undefined)"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && active ? 'active' : ''}`"
@select="onSelect"
@keydown="onKeydown($event)"
@search="search"
@blur="isOpen = false"
@focus="onFocus"
>
<a-select-option
v-for="op of options"
@ -355,7 +373,7 @@ const selectedOpt = computed(() => {
</div>
</div>
</a-select-option>
</a-select>
</NcSelect>
</div>
</template>

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

@ -1,6 +1,16 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, ReadonlyInj, RowHeightInj, inject, ref, useVModel } from '#imports'
import {
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
ReadonlyInj,
RowHeightInj,
inject,
ref,
useVModel,
} from '#imports'
interface Props {
modelValue?: string | null
@ -24,7 +34,10 @@ const vModel = useVModel(props, 'modelValue', emits)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLInputElement)?.focus()
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
</script>
<template>
@ -32,11 +45,9 @@ const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value
v-if="!readonly && editEnabled"
:ref="focus"
v-model="vModel"
class="h-full w-full outline-none p-2 bg-transparent"
class="h-full w-full outline-none py-1 bg-transparent"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:class="{
'px-1': isExpandedFormOpen,
}"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop

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

@ -38,7 +38,7 @@ const isForm = inject(IsFormInj, ref(false))
const { showNull } = useGlobal()
const vModel = useVModel(props, 'modelValue', emits, { defaultValue: column?.value.cdf ? String(column?.value.cdf) : '' })
const vModel = useVModel(props, 'modelValue', emits)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
@ -55,10 +55,13 @@ const position = ref<
const isDragging = ref(false)
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLTextAreaElement)?.focus()
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && isForm.value && (el as HTMLTextAreaElement)?.focus()
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
})
@ -169,16 +172,16 @@ watch(editEnabled, () => {
<template>
<NcDropdown
v-model:visible="isVisible"
class="overflow-visible"
class="overflow-hidden group"
:trigger="[]"
placement="bottomLeft"
:overlay-class-name="isVisible ? 'nc-textarea-dropdown-active' : undefined"
>
<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="{
'min-h-10': rowHeight !== 1,
'min-h-6.5': rowHeight === 1,
'min-h-10': rowHeight !== 1 || isExpandedFormOpen,
'min-h-9': rowHeight === 1 && !isExpandedFormOpen,
'h-full': isForm,
}"
>
@ -190,6 +193,7 @@ watch(editEnabled, () => {
minHeight: `${height}px !important`,
}"
@dblclick="onExpand"
@keydown.enter="onExpand"
>
<LazyCellRichText v-model:value="vModel" sync-value-change readonly />
</div>
@ -198,11 +202,11 @@ watch(editEnabled, () => {
:ref="focus"
v-model="vModel"
rows="4"
class="h-full w-full outline-none border-none"
class="h-full w-full outline-none border-none nc-scrollbar-lg"
:class="{
'p-2': editEnabled,
'py-1 h-full': isForm,
'px-1': isExpandedFormOpen,
'px-2': isExpandedFormOpen,
}"
:style="{
minHeight: `${height}px`,
@ -239,8 +243,8 @@ watch(editEnabled, () => {
<NcTooltip
v-if="!isVisible"
placement="bottom"
class="!absolute right-0 bottom-1 hidden nc-text-area-expand-btn"
:class="{ 'right-0 bottom-1': editEnabled, '!bottom-0': !isRichMode }"
class="!absolute right-0 hidden nc-text-area-expand-btn group-hover:block"
:class="isExpandedFormOpen || isForm || isRichMode ? 'top-1' : 'bottom-1'"
>
<template #title>{{ $t('title.expand') }}</template>
<NcButton type="secondary" size="xsmall" data-testid="attachment-cell-file-picker-button" @click.stop="onExpand">
@ -300,12 +304,6 @@ textarea:focus {
<style lang="scss">
.cell:hover .nc-text-area-expand-btn {
@apply !block;
}
.rich-wrapper:hover,
.rich-wrapper:active {
:deep(.nc-text-area-expand-btn) {
@apply !block cursor-pointer;
}
@apply !block cursor-pointer;
}
</style>

7
packages/nc-gui/components/cell/TimePicker.vue

@ -3,6 +3,7 @@ import dayjs from 'dayjs'
import {
ActiveCellInj,
EditColumnInj,
IsExpandedFormOpenInj,
ReadonlyInj,
inject,
onClickOutside,
@ -34,6 +35,8 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const column = inject(ColumnInj)!
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isTimeInvalid = ref(false)
const dateFormat = isMysql(column.value.source_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
@ -130,8 +133,8 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
:bordered="false"
use12-hours
format="HH:mm"
class="!w-full !px-1 !border-none"
:class="{ 'nc-null': modelValue === null && showNull }"
class="!w-full !py-1 !border-none"
:class="{ 'nc-null': modelValue === null && showNull, '!px-2': isExpandedFormOpen, '!px-0': !isExpandedFormOpen }"
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"

9
packages/nc-gui/components/cell/Url.vue

@ -6,6 +6,7 @@ import {
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
IsSurveyFormInj,
computed,
inject,
@ -70,7 +71,10 @@ const { cellUrlOptions } = useCellUrlConfig(url)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLInputElement)?.focus()
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && isForm.value && (el as HTMLInputElement)?.focus()
watch(
() => editEnabled.value,
@ -92,7 +96,8 @@ watch(
:ref="focus"
v-model="vModel"
:placeholder="isEditColumn ? $t('labels.enterDefaultUrlOptional') : ''"
class="outline-none text-sm w-full px-2 py-2 bg-transparent h-full"
class="outline-none text-sm w-full py-1 bg-transparent h-full"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop

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

@ -0,0 +1,442 @@
<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,
IsExpandedFormOpenInj,
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 isExpandedFormOpen = inject(IsExpandedFormOpenInj, 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, 'px-2': isExpandedFormOpen }"
@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>

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

@ -3,6 +3,7 @@ import dayjs from 'dayjs'
import {
ActiveCellInj,
EditColumnInj,
IsExpandedFormOpenInj,
ReadonlyInj,
computed,
inject,
@ -31,6 +32,8 @@ const editable = inject(EditModeInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isYearInvalid = ref(false)
const { t } = useI18n()
@ -113,10 +116,11 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
<template>
<a-date-picker
v-model:value="localState"
:tabindex="0"
picker="year"
:bordered="false"
class="!w-full !px-1 !border-none"
:class="{ 'nc-null': modelValue === null && showNull }"
class="!w-full !py-1 !border-none"
:class="{ 'nc-null': modelValue === null && showNull, '!px-2': isExpandedFormOpen, '!px-0': !isExpandedFormOpen }"
:placeholder="placeholder"
:allow-clear="(!readOnly && !localState && !isPk) || isEditColumn"
:input-read-only="true"
@ -125,6 +129,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
@click="open = (active || editable) && !open"
@change="open = (active || editable) && !open"
@ok="open = !open"
@keydown.enter="open = !open"
>
<template #suffixIcon></template>
</a-date-picker>

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

@ -180,12 +180,11 @@ const onImageClick = (item: any) => {
<template>
<div
ref="attachmentCellRef"
tabindex="0"
:style="{
height: isForm || isExpandedForm ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
}"
class="nc-attachment-cell relative flex color-transition flex items-center w-full xs:(min-h-12 max-h-32)"
:class="{ 'justify-center': !active, 'justify-between': active }"
:class="{ 'justify-center': !active, 'justify-between': active, 'px-2': isExpandedForm }"
>
<LazyCellAttachmentCarousel />
@ -207,7 +206,9 @@ const onImageClick = (item: any) => {
: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)"
data-testid="attachment-cell-file-picker-button"
tabindex="0"
@click="open"
@keydown.enter="open"
>
<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) => {
if (!value || value.length === 0) {
callback(t('msg.error.signUpRules.emailReqd'))
callback(t('msg.error.signUpRules.emailRequired'))
return
}
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !validateEmail(e))

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

@ -6,7 +6,7 @@ interface Props {
animate?: boolean
}
const { size = 90, animate = false } = defineProps<Props>()
const { size, animate } = withDefaults(defineProps<Props>(), { size: 90, animate: false })
const ping = autoResetRef(false, 1000)

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

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

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

@ -5,14 +5,14 @@ import {
ProjectRoles,
WorkspaceRolesToProjectRoles,
extractRolesObj,
parseStringDateTime,
timeAgo,
} from 'nocodb-sdk'
import type { Roles, WorkspaceUserRoles } from 'nocodb-sdk'
import InfiniteLoading from 'v3-infinite-loading'
import { isEeUI, storeToRefs, useUserSorts } from '#imports'
const basesStore = useBases()
const { getProjectUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore
const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore
const { activeProjectId } = storeToRefs(basesStore)
const { orgRoles, baseRoles } = useRoles()
@ -33,7 +33,6 @@ interface Collaborators {
const collaborators = ref<Collaborators[]>([])
const totalCollaborators = ref(0)
const userSearchText = ref('')
const currentPage = ref(0)
const isLoading = ref(false)
const isSearching = ref(false)
@ -45,52 +44,32 @@ const sortedCollaborators = computed(() => {
const loadCollaborators = async () => {
try {
currentPage.value += 1
const { users, totalRows } = await getProjectUsers({
const { users, totalRows } = await getBaseUsers({
baseId: activeProjectId.value!,
page: currentPage.value,
...(!userSearchText.value ? {} : ({ searchText: userSearchText.value } as any)),
limit: 20,
force: true,
})
totalCollaborators.value = totalRows
collaborators.value = [
...collaborators.value,
...users.map((user: any) => ({
...user,
base_roles: user.roles,
roles: extractRolesObj(user.main_roles)?.[OrgUserRoles.SUPER_ADMIN]
? OrgUserRoles.SUPER_ADMIN
: user.roles ??
(user.workspace_roles
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS
: ProjectRoles.NO_ACCESS),
})),
...users
.filter((u: any) => !u?.deleted)
.map((user: any) => ({
...user,
base_roles: user.roles,
roles: extractRolesObj(user.main_roles)?.[OrgUserRoles.SUPER_ADMIN]
? OrgUserRoles.SUPER_ADMIN
: user.roles ??
(user.workspace_roles
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS
: ProjectRoles.NO_ACCESS),
})),
]
} catch (e: any) {
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 currentCollaborator = collaborators.value.find((coll) => coll.id === collab.id)!
@ -126,29 +105,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 () => {
isLoading.value = true
try {
@ -168,6 +124,10 @@ onMounted(async () => {
isLoading.value = false
}
})
const filteredCollaborators = computed(() =>
collaborators.value.filter((collab) => collab.email.toLowerCase().includes(userSearchText.value.toLowerCase())),
)
</script>
<template>
@ -189,7 +149,7 @@ onMounted(async () => {
</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"
>
<Empty description="$t('title.noMembersFound')" />
@ -203,18 +163,19 @@ onMounted(async () => {
</span>
<LazyAccountUserMenu :direction="sortDirection.email" field="email" :handle-user-sort="saveOrUpdate" />
</div>
<div class="text-gray-700 date-joined-grid">{{ $t('title.dateJoined') }}</div>
<div class="text-gray-700 user-access-grid flex items-center space-x-2">
<span>
{{ $t('general.access') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div>
<div class="text-gray-700 date-joined-grid">{{ $t('title.dateJoined') }}</div>
</div>
<div class="flex flex-col nc-scrollbar-md">
<div
v-for="(collab, i) of sortedCollaborators"
v-for="(collab, i) of filteredCollaborators"
:key="i"
class="user-row flex flex-row border-b-1 py-1 min-h-14 items-center"
>
@ -224,7 +185,6 @@ onMounted(async () => {
{{ collab.email }}
</span>
</div>
<div class="date-joined-grid">{{ timeAgo(collab.created_at) }}</div>
<div class="user-access-grid">
<template v-if="accessibleRoles.includes(collab.roles)">
<RolesSelector
@ -243,20 +203,19 @@ onMounted(async () => {
<RolesBadge :role="collab.roles" />
</template>
</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>
<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>
</template>
</div>
@ -280,12 +239,11 @@ onMounted(async () => {
}
.date-joined-grid {
@apply flex items-start;
width: calc(50% - 10rem);
@apply w-1/4 flex items-start;
}
.user-access-grid {
@apply w-40;
@apply w-1/4 flex justify-start;
}
.user-row {

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

@ -5,9 +5,7 @@ import { isEeUI } from '#imports'
const basesStore = useBases()
const { getProjectUsers } = basesStore
const { openedProject, activeProjectId, baseUserCount } = storeToRefs(basesStore)
const { openedProject, activeProjectId, basesUser } = storeToRefs(basesStore)
const { activeTables } = storeToRefs(useTablesStore())
const { activeWorkspace, workspaceUserCount } = storeToRefs(useWorkspace())
@ -32,24 +30,9 @@ const { isMobileMode } = useGlobal()
const baseSettingsState = ref('')
const userCount = isEeUI ? workspaceUserCount : baseUserCount
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))
}
}
const userCount = computed(() =>
isEeUI ? workspaceUserCount : activeProjectId.value ? basesUser.value.get(activeProjectId.value)?.length : 0,
)
watch(
() => 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(
() => openedProject.value?.title,
() => {

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

@ -21,7 +21,6 @@ import {
isDate,
isDateTime,
isDecimal,
isDrawerExist,
isDuration,
isEmail,
isFloat,
@ -40,6 +39,7 @@ import {
isTextArea,
isTime,
isURL,
isUser,
isYear,
provide,
ref,
@ -204,7 +204,6 @@ onUnmounted(() => {
'h-10': isForm && !isSurveyForm && !isAttachment(column) && !props.virtual,
'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu,
'!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)"
@ -245,6 +244,7 @@ onUnmounted(() => {
<LazyCellPhoneNumber v-else-if="isPhoneNumber(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')" />
<LazyCellUser v-else-if="isUser(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />

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

@ -4,10 +4,40 @@ import { CurrentCellInj, ref } from '#imports'
const el = ref()
provide(CurrentCellInj, el)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const onTabPress = () => {
if (!isExpandedFormOpen.value && !isForm.value) return
// Find the focused element
const focusedElement = document.activeElement
if (focusedElement) {
// Check if the focused element is a descendant of the wrapper
const closestWrapper = focusedElement.closest('.nc-data-cell')
// Scroll it into view
if (closestWrapper === el.value) {
el.value?.scrollIntoView({ block: 'center' })
}
}
}
</script>
<template>
<div ref="el" class="select-none">
<div ref="el" class="select-none nc-data-cell" @keydown.tab="onTabPress">
<slot />
</div>
</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>

17
packages/nc-gui/components/smartsheet/Form.vue

@ -739,7 +739,7 @@ const onFormItemClick = (element: any) => {
<a-form-item
v-if="isVirtualCol(element)"
:name="element.title"
class="!mb-0 nc-input-required-error"
class="!mb-0 nc-input-required-error nc-form-input-item"
:rules="[
{
required: isRequired(element, element.required),
@ -761,7 +761,7 @@ const onFormItemClick = (element: any) => {
<a-form-item
v-else
:name="element.title"
class="!mb-0 nc-input-required-error"
class="!mb-0 nc-input-required-error nc-form-input-item"
:rules="[
{
required: isRequired(element, element.required),
@ -897,6 +897,11 @@ const onFormItemClick = (element: any) => {
.nc-input {
@apply appearance-none w-full !bg-white rounded px-2 py-2 my-2 border-solid border-1 border-primary border-opacity-50;
&.nc-cell-rating,
&.nc-cell-geodata {
@apply !py-1;
}
:deep(input) {
@apply !px-1;
}
@ -934,4 +939,12 @@ const onFormItemClick = (element: any) => {
}
}
}
.nc-form-input-item .nc-data-cell {
@apply !border-none rounded-none;
&:focus-within {
@apply !border-none;
}
}
</style>

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

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

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

@ -30,6 +30,13 @@ onMounted(() => {
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>
<template>
@ -38,6 +45,7 @@ onMounted(() => {
v-if="vModel.meta?.precision"
v-model:value="vModel.meta.precision"
dropdown-class-name="nc-dropdown-decimal-format"
@change="onPrecisionChange"
>
<a-select-option v-for="(format, i) of precisionFormats" :key="i" :value="format">
<div class="flex gap-2 w-full justify-between items-center">

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

@ -34,13 +34,20 @@ const updateCdfValue = (cdf: string | null) => {
onMounted(() => {
updateCdfValue(vModel.value?.cdf ? vModel.value.cdf : null)
})
watch(
() => vModel.value.cdf,
(newValue) => {
cdfValue.value = newValue
},
)
</script>
<template>
<div class="!my-3 text-xs">{{ $t('placeholder.defaultValue') }}</div>
<div class="flex flex-row gap-2">
<div
class="border-1 flex items-center w-full px-3 my-[-4px] border-gray-300 rounded-md"
class="border-1 flex items-center w-full px-3 my-[-4px] border-gray-300 rounded-md sm:min-h-[32px] xs:min-h-13 flex items-center focus-within:(border-brand-500 shadow-none ring-0)"
:class="{
'!border-brand-500': editEnabled,
}"
@ -58,7 +65,7 @@ onMounted(() => {
:is="iconMap.close"
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"
@click="cdfValue = null"
@click="updateCdfValue(null)"
/>
</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]
// 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 }) => {
if (!appInfo.value.ee) return true
@ -199,6 +203,8 @@ onMounted(() => {
})
const handleEscape = (event: KeyboardEvent): void => {
if (isColumnTypeOpen.value) return
if (event.key === 'Escape') emit('cancel')
}
@ -206,6 +212,16 @@ const isFieldsTab = computed(() => {
return openedViewsTab.value === 'field'
})
const onDropdownChange = (value: boolean) => {
if (value) {
isColumnTypeOpen.value = value
} else {
setTimeout(() => {
isColumnTypeOpen.value = value
}, 300)
}
}
if (props.fromTableExplorer) {
watch(
formState,
@ -224,7 +240,7 @@ if (props.fromTableExplorer) {
'bg-white': !props.fromTableExplorer,
'w-[400px]': !props.embedMode,
'!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,
'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"
:disabled="isKanban || readOnly"
dropdown-class-name="nc-dropdown-column-type border-1 !rounded-md border-gray-200"
@dropdown-visible-change="onDropdownChange"
@change="onUidtOrIdTypeChange"
@dblclick="showDeprecated = !showDeprecated"
>
@ -323,6 +340,7 @@ if (props.fromTableExplorer) {
<LazySmartsheetColumnLinkOptions v-if="isEdit && formState.uidt === UITypes.Links" v-model:value="formState" />
<LazySmartsheetColumnPercentOptions v-if="formState.uidt === UITypes.Percent" v-model:value="formState" />
<LazySmartsheetColumnSpecificDBTypeOptions v-if="formState.uidt === UITypes.SpecificDBType" />
<LazySmartsheetColumnUserOptions v-if="formState.uidt === UITypes.User" v-model:value="formState" :is-edit="isEdit" />
<SmartsheetColumnSelectOptions
v-if="formState.uidt === UITypes.SingleSelect || formState.uidt === UITypes.MultiSelect"
v-model:value="formState"

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

@ -2,34 +2,28 @@
import type { Ref } from 'vue'
import type { ListItem as AntListItem } from 'ant-design-vue'
import jsep from 'jsep'
import type { ColumnType, FormulaType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import {
FormulaError,
UITypes,
isLinksOrLTAR,
isNumericCol,
isSystemColumn,
jsepCurlyHook,
substituteColumnIdWithAliasInFormula,
validateDateWithUnknownFormat,
validateFormulaAndExtractTreeWithType,
} from 'nocodb-sdk'
import type { ColumnType, FormulaType } from 'nocodb-sdk'
import {
MetaInj,
NcAutocompleteTree,
computed,
formulaList,
formulaTypes,
formulas,
getUIDTIcon,
getWordUntilCaret,
iconMap,
inject,
insertAtCursor,
isDate,
nextTick,
onMounted,
ref,
storeToRefs,
useBase,
useColumnCreateStoreOrThrow,
useDebounceFn,
useI18n,
@ -52,59 +46,40 @@ const { setAdditionalValidations, validateInfos, sqlUi, column } = useColumnCrea
const { t } = useI18n()
const baseStore = useBase()
const { tables } = storeToRefs(baseStore)
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 supportedColumns = computed(
() => meta?.value?.columns?.filter((col) => !uiTypesNotSupportedInFormulas.includes(col.uidt as UITypes)) || [],
)
const { metas } = useMetas()
const { getMeta } = useMetas()
const refTables = computed(() => {
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 suggestionPreviewed = ref<Record<any, string> | undefined>()
const validators = {
formula_raw: [
{
validator: (_: any, formula: any) => {
return new Promise<void>((resolve, reject) => {
if (!formula?.trim()) return reject(new Error('Required'))
const res = parseAndValidateFormula(formula)
if (res !== true) {
return reject(new Error(res))
return (async () => {
if (!formula?.trim()) throw new Error('Required')
try {
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 variableListRef = ref<(typeof AntListItem)[]>([])
const sugOptionsRef = ref<(typeof AntListItem)[]>([])
const wordToComplete = ref<string | undefined>('')
@ -143,6 +120,7 @@ const suggestionsList = computed(() => {
description: formulas[fn].description,
syntax: formulas[fn].syntax,
examples: formulas[fn].examples,
docsUrl: formulas[fn].docsUrl,
})),
...supportedColumns.value
.filter((c) => {
@ -176,521 +154,13 @@ const acTree = computed(() => {
return ref
})
function parseAndValidateFormula(formula: string) {
try {
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
}
const suggestedFormulas = computed(() => {
return suggestion.value.filter((s) => s && s.type !== 'column')
})
function getRootDataType(parsedTree: any): any {
// given a parse tree, return the data type of it
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'
}
}
const variableList = computed(() => {
return suggestion.value.filter((s) => s && s.type === 'column')
})
function isCurlyBracketBalanced() {
// count number of opening curly brackets and closing curly brackets
@ -739,6 +209,11 @@ function handleInput() {
suggestion.value = acTree.value
.complete(wordToComplete.value)
?.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()) {
suggestion.value = suggestion.value.filter((v) => v.type === 'column')
}
@ -746,14 +221,21 @@ function handleInput() {
}
function selectText() {
if (suggestion.value && selected.value > -1 && selected.value < suggestion.value.length) {
appendText(suggestion.value[selected.value])
if (suggestion.value && selected.value > -1 && selected.value < suggestionsList.value.length) {
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() {
if (suggestion.value) {
selected.value = --selected.value > -1 ? selected.value : suggestion.value.length - 1
suggestionPreviewed.value = suggestedFormulas.value[selected.value]
scrollToSelectedOption()
}
}
@ -761,6 +243,8 @@ function suggestionListUp() {
function suggestionListDown() {
if (suggestion.value) {
selected.value = ++selected.value % suggestion.value.length
suggestionPreviewed.value = suggestedFormulas.value[selected.value]
scrollToSelectedOption()
}
}
@ -769,9 +253,9 @@ function scrollToSelectedOption() {
nextTick(() => {
if (sugOptionsRef.value[selected.value]) {
try {
sugListRef.value.$el.scrollTo({
top: sugOptionsRef.value[selected.value].$el.offsetTop,
behavior: 'smooth',
sugOptionsRef.value[selected.value].$el.scrollIntoView({
block: 'nearest',
inline: 'start',
})
} catch (e) {}
}
@ -796,15 +280,55 @@ setAdditionalValidations({
onMounted(() => {
jsep.plugins.register(jsepCurlyHook)
})
// const predictFunction = async () => {
// await _predictFunction(formState, meta, supportedColumns, suggestionsList, vModel)
// }
</script>
<template>
<div class="formula-wrapper">
<a-form-item v-bind="validateInfos.formula_raw" :label="$t('datatype.Formula')">
<div class="formula-wrapper relative">
<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
v-if="isEeUI"
icon="magic"
@ -815,7 +339,7 @@ onMounted(() => {
<a-textarea
ref="formulaRef"
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.up.prevent="suggestionListUp"
@keydown.enter.prevent="selectText"
@ -823,73 +347,90 @@ onMounted(() => {
/>
</a-form-item>
<div class="text-gray-600 mt-2 mb-4 prose-sm">
{{
// As using {} in translation will be treated as placeholder, and this translation contain {} as part of th text
$t('msg.formula.hintStart', {
placeholder1: '{}',
placeholder2: '{column_name}',
})
}}
<a
class="prose-sm"
href="https://docs.nocodb.com/setup-and-usages/formulas#available-formula-features"
target="_blank"
rel="noopener"
>
{{ $t('msg.formula.hintEnd') }}
</a>
</div>
<div ref="sugListRef" class="h-[250px] overflow-auto nc-scrollbar-md">
<template v-if="suggestedFormulas.length > 0">
<div class="rounded-t-lg border-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs">Formulas</div>
<a-list
:data-source="suggestedFormulas"
:locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }"
class="border-1 border-t-0 rounded-b-lg !mb-4"
>
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index] = el
}
"
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>
</a-col>
<a-col :span="18">
<div v-if="item.type === 'function'" class="text-xs text-gray-500">
{{ item.description }} <br /><br />
{{ $t('labels.syntax') }}: <br />
{{ item.syntax }} <br /><br />
{{ $t('labels.examples') }}: <br />
<div v-for="(example, idx) of item.examples" :key="idx">
<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>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
<div v-if="suggestion.length === 0">
<span class="text-gray-500">Empty</span>
</div>
</div>
</div>
</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">
import { onMounted } from '@vue/runtime-core'
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 {
MetaInj,
@ -102,31 +102,27 @@ const cellIcon = (column: ColumnType) =>
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(
() => 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 = [
// functions for non-numeric types,
// e.g. count / min / max / countDistinct date field
{ text: t('datatype.Count'), value: 'count' },
{ text: t('general.min'), value: 'min' },
{ 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)) {
aggFunctionsList.value = allFunctions.filter((func) =>
getAvailableRollupForUiType(childFieldColumn?.uidt as UITypes).includes(func.value),
)
if (!aggFunctionsList.value.includes(vModel.value.rollup_function)) {
// 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
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>

31
packages/nc-gui/components/smartsheet/expanded-form/Comments.vue

@ -29,8 +29,19 @@ const editLog = ref<AuditType>()
const isEditing = ref<boolean>(false)
const commentInputDomRef = ref<HTMLInputElement>()
const isCommentMode = ref(false)
const focusCommentInput: VNodeRef = (el) => {
if (!isExpandedFormLoading.value && (isCommentMode.value || isExpandedFormCommentMode.value) && !isEditing.value) {
if (isExpandedFormCommentMode.value) {
setTimeout(() => {
isExpandedFormCommentMode.value = false
}, 400)
}
return (el as HTMLInputElement)?.focus()
}
return el
}
const focusInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
function onKeyDown(event: KeyboardEvent) {
@ -55,6 +66,9 @@ function onKeyEsc(event: KeyboardEvent) {
async function onEditComment() {
if (!isEditing.value || !editLog.value) return
isCommentMode.value = true
await updateComment(editLog.value.id!, {
description: editLog.value.description,
})
@ -106,6 +120,7 @@ const isSaving = ref(false)
const saveComment = async () => {
if (isSaving.value) return
isCommentMode.value = true
isSaving.value = true
try {
@ -128,15 +143,6 @@ const onClickAudit = () => {
tab.value = 'audits'
}
watch(commentInputDomRef, () => {
if (commentInputDomRef.value && isExpandedFormCommentMode.value) {
setTimeout(() => {
commentInputDomRef.value?.focus()
isExpandedFormCommentMode.value = false
}, 400)
}
})
</script>
<template>
@ -254,12 +260,13 @@ watch(commentInputDomRef, () => {
<div class="h-14 flex flex-row w-full bg-white py-2.75 px-1.5 items-center rounded-xl border-1 border-gray-200">
<GeneralUserIcon size="base" class="!w-10" :email="user?.email" :name="user?.display_name" />
<a-input
ref="commentInputDomRef"
:ref="focusCommentInput"
v-model:value="comment"
class="!rounded-lg border-1 bg-white !px-2.5 !py-2 !border-gray-200 nc-comment-box !outline-none"
placeholder="Start typing..."
data-testid="expanded-form-comment-input"
:bordered="false"
:disabled="isSaving"
@keyup.enter.prevent="saveComment"
>
</a-input>

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 expandedFormScrollWrapper = ref()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
@ -439,6 +441,13 @@ const preventModalStatus = computed({
})
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) {
isExpanded.value = v
} else if (!v) {
@ -451,6 +460,22 @@ const onIsExpandedUpdate = (v: boolean) => {
const isReadOnlyVirtualCell = (column: ColumnType) => {
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 lang="ts">
@ -620,6 +645,7 @@ export default {
}"
>
<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"
>
<div
@ -658,7 +684,7 @@ export default {
<SmartsheetDivDataCell
v-if="col.title"
: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="{
'!bg-gray-50 !px-0 !select-text': isReadOnlyVirtualCell(col),
}"
@ -894,4 +920,8 @@ export default {
:deep(.ant-select-selection-item) {
@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>

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

@ -167,11 +167,21 @@ const parseKey = (group: Group) => {
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]
}
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>
<template>

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

@ -791,7 +791,7 @@ onClickOutside(tableBodyEl, (e) => {
// ignore unselecting if clicked inside or on the picker(Date, Time, DateTime, Year)
// or single/multi select options
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 (
e.target &&
@ -1005,7 +1005,13 @@ const showFillHandle = computed(
!readOnly.value &&
!editEnabled.value &&
(!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(
@ -1266,7 +1272,10 @@ onKeyStroke('ArrowDown', onDown)
></div>
</div>
<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">
<GeneralLoader size="xlarge" />
<span class="text-center" v-html="loaderText"></span>
@ -1583,7 +1592,7 @@ onKeyStroke('ArrowDown', onDown)
@dblclick="makeEditable(row, columnObj)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
>
<div v-if="!switchingTab" class="w-full h-full">
<div v-if="!switchingTab" class="w-full">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(columnObj) && columnObj.title"
v-model="row.row[columnObj.title]"
@ -1728,6 +1737,7 @@ onKeyStroke('ArrowDown', onDown)
"
class="nc-base-menu-item"
:disabled="isSystemColumn(fields[contextMenuTarget.col])"
data-testid="context-menu-item-clear"
@click="clearCell(contextMenuTarget)"
>
<div v-e="['a:row:clear']" class="flex gap-2 items-center">
@ -1741,6 +1751,7 @@ onKeyStroke('ArrowDown', onDown)
v-else-if="contextMenuTarget && hasEditPermission"
class="nc-base-menu-item"
:disabled="isSystemColumn(fields[contextMenuTarget.col])"
data-testid="context-menu-item-clear"
@click="clearSelectedRangeOfCells()"
>
<div v-e="['a:row:clear-range']" class="flex gap-2 items-center">
@ -1748,17 +1759,16 @@ onKeyStroke('ArrowDown', onDown)
{{ $t('general.clear') }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem
v-if="contextMenuTarget && selectedRange.isSingleCell() && isUIAllowed('commentEdit') && !isMobileMode"
class="nc-base-menu-item"
@click="commentRow(contextMenuTarget.row)"
>
<div v-e="['a:row:comment']" class="flex gap-2 items-center">
<MdiMessageOutline class="h-4 w-4" />
{{ $t('general.comment') }}
</div>
</NcMenuItem>
<template v-if="contextMenuTarget && selectedRange.isSingleCell() && isUIAllowed('commentEdit') && !isMobileMode">
<NcDivider />
<NcMenuItem class="nc-base-menu-item" @click="commentRow(contextMenuTarget.row)">
<div v-e="['a:row:comment']" class="flex gap-2 items-center">
<MdiMessageOutline class="h-4 w-4" />
{{ $t('general.comment') }}
</div>
</NcMenuItem>
</template>
<template v-if="hasEditPermission">
<NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" />

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

@ -31,6 +31,7 @@ import {
isTextArea,
isTime,
isURL,
isUser,
isYear,
storeToRefs,
toRef,
@ -82,6 +83,11 @@ const renderIcon = (column: ColumnType, abstractType: any) => {
return iconMap.percent
} else if (isGeometry(column)) {
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)) {
return iconMap.number
} else if (isString(column, abstractType)) {

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

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

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

@ -33,6 +33,8 @@ const { isUIAllowed } = useRoles()
const { dashboardUrl } = useDashboard()
const { clearBasesUser } = useBases()
const users = 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)
// clear bases user state
clearBasesUser()
// Successfully added user to base
message.success(t('msg.success.userAddedToProject'))
await loadUsers()

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,
)
const plusBtnRef = ref<HTMLElement | null>(null)
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
})
</script>
<template>
@ -101,11 +109,14 @@ const belongsToColumn = computed(
<div
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
: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"
/>
</div>
@ -121,7 +132,7 @@ const belongsToColumn = computed(
<style scoped lang="scss">
.nc-action-icon {
@apply hidden cursor-pointer;
@apply cursor-pointer;
}
.chips-wrapper:hover,
@ -130,4 +141,10 @@ const belongsToColumn = computed(
@apply inline-block;
}
}
.chips-wrapper:hover {
.nc-action-icon {
@apply visible;
}
}
</style>

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

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

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

@ -3,7 +3,15 @@ import { computed } from '@vue/reactivity'
import type { ColumnType } from 'nocodb-sdk'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { ActiveCellInj, CellValueInj, ColumnInj, IsUnderLookupInj, inject, useSelectedCellKeyupListener } from '#imports'
import {
ActiveCellInj,
CellValueInj,
ColumnInj,
IsExpandedFormOpenInj,
IsUnderLookupInj,
inject,
useSelectedCellKeyupListener,
} from '#imports'
const value = inject(CellValueInj, ref(0))
@ -19,6 +27,8 @@ const readOnly = inject(ReadonlyInj, ref(false))
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const colTitle = computed(() => column.value?.title || '')
const listItemsDlg = ref(false)
@ -102,28 +112,58 @@ const openListDlg = () => {
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>
<template>
<div class="flex w-full group items-center nc-links-wrapper" @dblclick.stop="openChildList">
<div
class="flex w-full group items-center nc-links-wrapper py-1"
:class="{
'px-2': isExpandedFormOpen,
}"
@dblclick.stop="openChildList"
>
<div class="block flex-shrink truncate">
<component
:is="isUnderLookup ? 'span' : 'a'"
ref="childListDlgRef"
v-e="['c:cell:links:modal:open']"
:title="textVal"
class="text-center nc-datatype-link underline-transparent"
:class="{ '!text-gray-300': !textVal }"
tabindex="0"
@click.stop.prevent="openChildList"
@keydown.enter.stop.prevent="openChildList"
>
{{ textVal }}
</component>
</div>
<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
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"
/>
</div>

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

@ -99,18 +99,19 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
<template>
<div
class="h-full w-full nc-lookup-cell"
tabindex="-1"
:style="{ height: rowHeight && rowHeight !== 1 ? `${rowHeight * 2}rem` : `2.85rem` }"
@dblclick="activateShowEditNonEditableFieldWarning"
>
<div
class="h-full w-full flex gap-1 p-1"
class="h-full w-full flex gap-1"
:class="{
'!overflow-x-auto nc-cell-lookup-scroll nc-scrollbar-x-md !overflow-y-hidden': rowHeight === 1,
}"
>
<template v-if="lookupColumn">
<!-- 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 -->
<template
v-if="
@ -151,7 +152,7 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
}"
>
<div
class="flex gap-1.5 w-full"
class="flex items-start gap-1.5 w-full h-full py-[3px]"
:class="{
'flex-wrap': rowHeight !== 1 && !isAttachment(lookupColumn),
'!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"
:key="i"
:class="{
'bg-gray-100 px-1 rounded-full min-h-7.5': !isAttachment(lookupColumn),
'border-gray-200 rounded border-1 pt-0.75': ![
'bg-gray-100 rounded-full': !isAttachment(lookupColumn),
'border-gray-200 rounded border-1': ![
UITypes.Attachment,
UITypes.MultiSelect,
UITypes.SingleSelect,
UITypes.User,
].includes(lookupColumn.uidt),
'min-h-0 min-w-0': isAttachment(lookupColumn),
}"
@ -177,10 +179,9 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
:edit-enabled="false"
:virtual="true"
:read-only="true"
class=""
:class="{
'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>

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

@ -77,25 +77,26 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning } = us
</template>
<img v-if="showQrCode" :src="qrCodeLarge" :alt="$t('title.qrCode')" />
</a-modal>
<div v-if="tooManyCharsForQrCode" class="text-left text-wrap mt-2 text-[#e65100] text-[10px]">
{{ $t('labels.qrCodeValueTooLong') }}
</div>
<div
class="pl-2 w-full flex"
v-if="showQrCode"
class="w-full flex"
:class="{
'flex-start': isExpandedFormOpen,
'flex-start pl-2': isExpandedFormOpen,
'justify-center': !isExpandedFormOpen,
}"
>
<img
v-if="showQrCode && rowHeight"
:style="{ height: rowHeight ? `${rowHeight * 1.4}rem` : `1.4rem` }"
v-if="rowHeight"
:style="{ height: rowHeight ? `${rowHeight * 1.8}rem` : `1.8rem` }"
:src="qrCode"
:alt="$t('title.qrCode')"
class="min-w-[1.4em]"
@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 v-if="showEditNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $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>
<div
v-if="!tooManyCharsForBarcode"
class="flex ml-2 w-full items-center"
class="flex w-full items-center barcode-wrapper"
:class="{
'justify-start': isExpandedFormOpen,
'justify-start ml-2': isExpandedFormOpen,
'justify-center': !isExpandedFormOpen,
}"
>
<JsBarcodeWrapper
v-if="showBarcode && rowHeight"
:barcode-value="barcodeValue"
tabindex="-1"
: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"
>
<template #barcodeRenderError>
@ -76,6 +77,7 @@ const rowHeight = inject(RowHeightInj, ref(undefined))
</JsBarcodeWrapper>
<JsBarcodeWrapper
v-else-if="showBarcode"
tabindex="-1"
:barcode-value="barcodeValue"
:barcode-format="barcodeMeta.barcodeFormat"
@on-click-barcode="showBarcodeModal"
@ -98,3 +100,11 @@ const rowHeight = inject(RowHeightInj, ref(undefined))
{{ $t('msg.warning.nonEditableFields.barcodeFieldsCannotBeDirectlyChanged') }}
</div>
</template>
<style lang="scss" scoped>
.barcode-wrapper {
& > div {
@apply max-w-8.2rem;
}
}
</style>

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

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { OrderedWorkspaceRoles, WorkspaceUserRoles, timeAgo } from 'nocodb-sdk'
import { OrderedWorkspaceRoles, WorkspaceUserRoles, parseStringDateTime, timeAgo } from 'nocodb-sdk'
import { storeToRefs, useWorkspace } from '#imports'
const { workspaceRoles, loadRoles } = useRoles()
@ -59,9 +59,9 @@ onMounted(async () => {
<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-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 date-joined-grid w-2/8 mr-3 pl-1">{{ $t('title.dateJoined') }}</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 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>
@ -77,7 +77,6 @@ onMounted(async () => {
{{ collab.email }}
</span>
</div>
<div class="date-joined-grid w-2/8">{{ timeAgo(collab.created_at) }}</div>
<div class="user-access-grid w-2/8">
<template v-if="accessibleRoles.includes(collab.roles)">
<div class="w-[30px]">
@ -85,15 +84,25 @@ onMounted(async () => {
:role="collab.roles"
:roles="accessibleRoles"
:description="false"
class="bg-[red]"
class="cursor-pointer"
:on-role-change="(role: WorkspaceUserRoles) => updateCollaborator(collab, role)"
/>
</div>
</template>
<template v-else>
<RolesBadge :role="collab.roles" />
<RolesBadge :role="collab.roles" class="cursor-default" />
</template>
</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">
<NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER" :trigger="['click']">
<MdiDotsVertical

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

@ -241,6 +241,7 @@ export function useData(args: {
col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup ||
col.uidt === UITypes.Checkbox ||
col.uidt === UITypes.User ||
col.au ||
col.cdf?.includes(' on update ')
)
@ -387,6 +388,8 @@ export function useData(args: {
col.uidt === UITypes.QrCode ||
col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup ||
col.uidt === UITypes.Checkbox ||
col.uidt === UITypes.User ||
col.au ||
col.cdf?.includes(' on update ')
)

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

@ -195,6 +195,24 @@ export default function convertCellData(
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.Lookup:
case UITypes.Rollup:

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

@ -2,7 +2,7 @@ import { computed } from 'vue'
import dayjs from 'dayjs'
import type { Ref } from 'vue'
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 { parse } from 'papaparse'
import type { Cell } from './cellRange'
@ -112,6 +112,16 @@ export function useMultiSelect(
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') {
textToCopy = JSON.stringify(textToCopy)
} else {

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

@ -60,6 +60,9 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const baseStore = useBase()
const { base } = storeToRefs(baseStore)
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const { t } = useI18n()
const formState = ref<Record<string, any>>({})
@ -127,6 +130,10 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const relatedMetas = { ...viewMeta.relatedMetas }
Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key]))
if (viewMeta.users) {
basesUser.value.set(viewMeta.base_id, viewMeta.users)
}
} catch (e: any) {
if (e.response && e.response.status === 404) {
notFound.value = true

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

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

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

@ -205,7 +205,7 @@ export function useViewData(
} 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') {
@ -319,8 +319,6 @@ export function useViewData(
}
const navigateToSiblingRow = async (dir: NavigateDir) => {
console.log('test')
const expandedRowIndex = getExpandedRowIndex()
// 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
}
if (col.uidt === UITypes.User) {
if (!value) {
return GROUP_BY_VARS.NULL
}
}
// convert to JSON string if non-string value
if (value && typeof value === 'object') {
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'})`
} else if ([UITypes.Date, UITypes.DateTime].includes(curr.column_uidt as UITypes)) {
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 {
acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,${curr.key})`
}

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

@ -1,45 +1,45 @@
{
"dashboards": {
"create_new_dashboard_project": "Create New Interface",
"connect_data_sources": "Connect data sources",
"alert": "Alert",
"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.",
"create_interface": "Create interface",
"project_name": "Base Name",
"connect": "Connect",
"create_new_dashboard_project": "Создать новый интерфейс",
"connect_data_sources": "Подключить источники данных",
"alert": "Оповещение",
"alert-message": "Базы данных не подключены. Подключите базы данных для создания интерфейсов. Пропустите этот шаг и добавьте базы данных с основной домашней страницы позже.",
"select_database_projects_that_you_want_to_link_to_this_dashboard_projects": "Выберите базы данных, которые вы хотите связать с этим интерфейсом.",
"create_interface": "Создать интерфейс",
"project_name": "Основное имя",
"connect": "Подключиться",
"buttonActionTypes": {
"open_external_url": "Open external link",
"delete_record": "Delete record",
"update_record": "Update record",
"open_layout": "Open layout"
"open_external_url": "Открыть внешнюю ссылку",
"delete_record": "Удалить запись",
"update_record": "Обновить запись",
"open_layout": "Открыть макет"
},
"widgets": {
"static_text": "Text",
"chart": "Chart",
"table": "Table",
"image": "Image",
"map": "Map",
"button": "Button",
"number": "Number",
"bar_chart": "Bar Chart",
"line_chart": "Line Chart",
"area_chart": "Area Chart",
"pie_chart": "Pie Chart",
"donut_chart": "Donut Chart",
"scatter_plot": "Scatter Plot",
"bubble_chart": "Bubble Chart",
"radar_chart": "Radar Chart",
"polar_area_chart": "Polar Area Chart",
"radial_bar_chart": "Radial Bar Chart",
"heatmap_chart": "Heatmap Chart",
"treemap_chart": "Treemap Chart",
"box_plot_chart": "Box Plot Chart",
"candlestick_chart": "Candlestick Chart"
"static_text": "Текст",
"chart": "Диаграмма",
"table": "Таблица",
"image": "Изображение",
"map": "Карта",
"button": "Кнопка",
"number": "Число",
"bar_chart": "Гистограмма",
"line_chart": "Линейная диаграмма",
"area_chart": "Диаграмма области",
"pie_chart": "Круговая диаграмма",
"donut_chart": "Кольцевая диаграмма",
"scatter_plot": "Рассеянный участок",
"bubble_chart": "Пузырьковая диаграмма",
"radar_chart": "Радарная диаграмма",
"polar_area_chart": "Полярная диаграмма",
"radial_bar_chart": "Радиальная гистограмма",
"heatmap_chart": "Тепловая карта",
"treemap_chart": "Древовидная карта",
"box_plot_chart": "Диаграмма ячеек",
"candlestick_chart": "Японские свечи"
}
},
"general": {
"quit": "Quit",
"quit": "Выйти",
"home": "На главную",
"load": "Загрузка",
"open": "Открыть",
@ -47,29 +47,29 @@
"yes": "Да",
"no": "Нет",
"ok": "Ок",
"back": "Back",
"back": "Назад",
"and": "И",
"or": "Или",
"add": "Добавить",
"edit": "Изменить",
"link": "Link",
"links": "Links",
"link": "Ссылка",
"links": "Ссылки",
"remove": "Удалить",
"import": "Import",
"logout": "Log Out",
"empty": "Empty",
"changeIcon": "Change Icon",
"import": "Импорт",
"logout": "Выйти",
"empty": "Пусто",
"changeIcon": "Сменить иконку",
"save": "Сохранить",
"available": "Available",
"abort": "Abort",
"saving": "Saving",
"available": "Доступно",
"abort": "Прервать",
"saving": "Сохранение",
"cancel": "Отмена",
"null": "Null",
"escape": "Escape",
"hex": "Hex",
"clear": "Clear",
"null": "Пустой",
"escape": "Выход",
"hex": "Гекс",
"clear": "Очистить",
"slack": "Slack",
"comment": "Comment",
"comment": "Комментарий",
"microsoftTeams": "Microsoft Teams",
"discord": "Discord",
"matterMost": "Mattermost",
@ -183,17 +183,17 @@
"sumDistinct": "Sum Distinct",
"avgDistinct": "Avg Distinct",
"join": "Join",
"options": "Options",
"primaryValue": "Primary Value",
"useSurveyMode": "Use Survey Mode",
"shift": "Shift",
"enter": "Enter",
"seconds": "Seconds",
"paste": "Paste"
"options": "Опции",
"primaryValue": "Первичное значение",
"useSurveyMode": "Использовать режим анкеты",
"shift": "Сдвиг",
"enter": "Вход",
"seconds": "Секунды",
"paste": "Вставить"
},
"objects": {
"workspace": "Workspace",
"workspaces": "Workspaces",
"workspace": "Рабочее пространство",
"workspaces": "Рабочие пространства",
"project": "Проект",
"projects": "Проекты",
"table": "Таблица",
@ -210,7 +210,7 @@
"webhooks": "Вебхуки (Webhooks)",
"view": "Представление",
"views": "Представления",
"sidebar": "Sidebar",
"sidebar": "Боковая панель",
"viewType": {
"grid": "Сетка",
"gallery": "Галерея",
@ -223,27 +223,27 @@
"users": "Пользователи",
"role": "Роль",
"roles": "Роли",
"developer": "Developer",
"developer": "Разработчик",
"roleType": {
"owner": "Владелец",
"creator": "Создатель",
"editor": "Редактор",
"commenter": "Комментатор",
"viewer": "Наблюдатель",
"noaccess": "No Access",
"superAdmin": "Super Admin",
"noaccess": "Нет доступа",
"superAdmin": "Супер админ",
"orgLevelCreator": "Уровень Создатель",
"orgLevelViewer": "Уровень Наблюдатель"
},
"sqlVIew": "Представление SQL",
"rowHeight": "Record Height",
"rowHeight": "Высота записи",
"heightClass": {
"short": "Short",
"medium": "Medium",
"tall": "Tall",
"extra": "Extra"
"short": "Коротко",
"medium": "Среднее",
"tall": "Высокий",
"extra": "Экстра"
},
"externalDb": "External Database"
"externalDb": "Внешняя база данных"
},
"datatype": {
"ID": "ID",
@ -298,37 +298,37 @@
"isNotNull": "не равно Null"
},
"title": {
"docs": "Docs",
"forum": "Forum",
"parameter": "Parameter",
"headers": "Headers",
"parameterName": "Parameter Name",
"currencyLocale": "Currency Locale",
"currencyCode": "Currency Code",
"searchMembers": "Search Members",
"noMembersFound": "No members found",
"dateJoined": "Date Joined",
"tokenName": "Token name",
"inDesktop": "in Desktop",
"rowData": "Record data",
"creator": "Creator",
"qrCode": "QR Code",
"termsOfService": "Terms of Service",
"updateSelectedRows": "Update Selected Records",
"noFiltersAdded": "No filters added",
"editCards": "Edit Cards",
"noFieldsFound": "No fields found",
"displayValue": "Display Value",
"expand": "Expand",
"hideAll": "Hide all",
"hideSystemFields": "Hide system fields",
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",
"downloadFile": "Download File",
"docs": "Документация",
"forum": "Форум",
"parameter": "Параметр",
"headers": "Заголовки",
"parameterName": "Имя параметра",
"currencyLocale": "Валютная локаль",
"currencyCode": "Код валюты",
"searchMembers": "Поиск участников",
"noMembersFound": "Участников не найдено",
"dateJoined": "Дата вступления",
"tokenName": "Имя токена",
"inDesktop": "на рабочем столе",
"rowData": "Запись данных",
"creator": "Создатель",
"qrCode": "QR Код",
"termsOfService": "Пользовательское Соглашение",
"updateSelectedRows": "Обновить выбранные записи",
"noFiltersAdded": "Фильтры не добавлены",
"editCards": "Изменить карты",
"noFieldsFound": "Поля не найдены",
"displayValue": "Отображаемое значение",
"expand": "Развернуть",
"hideAll": "Скрыть все",
"hideSystemFields": "Скрыть системные поля",
"removeFile": "Удалить файл",
"hasMany": "Имеет много",
"manyToMany": "Многие ко многим",
"virtualRelation": "Виртуальные отношения",
"linkMore": "Ссылка Подробнее",
"linkMoreRecords": "Связать больше записей",
"downloadFile": "Скачать файл",
"renameTable": "Rename Table",
"renamingTable": "Renaming Table",
"renamingWs": "Renaming Workspace",
@ -421,56 +421,56 @@
"italic": "Italic",
"underline": "Underline",
"strike": "Strike",
"taskList": "Task List",
"bulletList": "Bullet List",
"numberedList": "Numbered List",
"downloadData": "Download Data",
"blockQuote": "Block Quote",
"noToken": "No Token",
"tokenLimit": "Only one token per user is allowed",
"duplicateAttachment": "File with name {filename} already attached",
"taskList": "Список задач",
"bulletList": "Маркированный список",
"numberedList": "Нумерованный список",
"downloadData": "Скачать данные",
"blockQuote": "Цитата",
"noToken": "Нет токена",
"tokenLimit": "Для каждого пользователя разрешен только один токен",
"duplicateAttachment": "Файл с именем {filename} уже прикреплен",
"viewIdColon": "VIEW ID: {viewId}",
"toAddress": "To Address",
"subject": "Subject",
"body": "Body",
"commaSeparatedMobileNumber": "Comma separated Mobile #",
"headerName": "Header Name",
"icon": "Icon",
"max": "Max",
"enableRichText": "Enable Rich Text",
"toAddress": "Адрес",
"subject": "Тема",
"body": "Тело письма",
"commaSeparatedMobileNumber": "Мобильные # через запятую",
"headerName": "Название заголовка",
"icon": "Иконка",
"max": "Макс",
"enableRichText": "Включить насыщенный текст",
"idColon": "Id:",
"copiedRecordURL": "Copied Record URL",
"copyRecordURL": "Copy Record URL",
"duplicateRecord": "Duplicate record",
"binaryEncodingFormat": "Binary encoding format",
"syntax": "Syntax",
"examples": "Examples",
"durationInfo": "A duration of time in minutes or seconds (e.g. 1:23).",
"addHeader": "Add Header",
"enterDefaultUrlOptional": "Enter default URL (Optional)",
"negative": "Negative",
"discard": "Discard",
"default": "Default",
"defaultNumberPercent": "Default Number (%)",
"durationFormat": "Duration Format",
"dateFormat": "Date Format",
"timeFormat": "Time Format",
"singularLabel": "Singular Label",
"pluralLabel": "Plural Label",
"optional": "(Optional)",
"clickToMake": "Click to make",
"visibleForRole": "visible for role:",
"inUI": "in UI Dashboard",
"projectSettings": "Base Settings",
"clickToHide": "Click to hide",
"clickToDownload": "Click to download",
"forRole": "for role",
"clickToCopyViewID": "Click to copy View ID",
"viewMode": "View Mode",
"searchUsers": "Search Users",
"superAdmin": "Super Admin",
"allTables": "All Tables",
"members": "Members",
"copiedRecordURL": "URL скопированной записи",
"copyRecordURL": "Копировать URL записи",
"duplicateRecord": "Дублировать запись",
"binaryEncodingFormat": "Формат двоичного кодирования",
"syntax": "Синтаксис",
"examples": "Примеры",
"durationInfo": "Длительность времени в минутах или секундах (например, 1:23).",
"addHeader": "Добавить заголовок",
"enterDefaultUrlOptional": "Введите URL по умолчанию (необязательно)",
"negative": "Отрицательный",
"discard": "Сбросить",
"default": "По умолчанию",
"defaultNumberPercent": "Номер по умолчанию (%)",
"durationFormat": "Формат продолжительности",
"dateFormat": "Формат даты",
"timeFormat": "Формат времени",
"singularLabel": "Единственное число",
"pluralLabel": "Множественное число",
"optional": "(Необязательно)",
"clickToMake": "Нажмите, чтобы сделать",
"visibleForRole": "видимый для роли:",
"inUI": "в пользовательском интерфейсе",
"projectSettings": "Базовые настройки",
"clickToHide": "Нажмите, чтобы скрыть",
"clickToDownload": "Нажмите, чтобы загрузить",
"forRole": "для роли",
"clickToCopyViewID": "Нажмите, чтобы скопировать ID вида",
"viewMode": "Режим просмотра",
"searchUsers": "Поиск пользователей",
"superAdmin": "Супер админ",
"allTables": "Все таблицы",
"members": "Участники",
"dataSources": "Data Sources",
"connectDataSource": "Connect a Data Source",
"searchProjects": "Search Bases",
@ -632,56 +632,56 @@
"noAccess": "No access",
"restApis": "Rest APIs",
"apis": "APIs",
"includeData": "Include Data",
"includeView": "Include View",
"includeWebhook": "Include Webhook",
"zoomInToViewColumns": "Zoom in to view columns",
"embedInSite": "Embed this view in your site",
"titleRequired": "title is required.",
"sourceNameRequired": "Source name is required",
"changeWsName": "Change Workspace Name",
"pressEnter": "Press Enter",
"newFormLoaded": "New form will be loaded after",
"webhook": "Webhook"
"includeData": "Включить данные",
"includeView": "Включить вид",
"includeWebhook": "Включить вебхук",
"zoomInToViewColumns": "Увеличить масштаб для просмотра столбцов",
"embedInSite": "Вставить этот вид на ваш сайт",
"titleRequired": "название обязательно.",
"sourceNameRequired": "Имя источника обязательно",
"changeWsName": "Изменить название рабочего пространства",
"pressEnter": "Нажмите Enter",
"newFormLoaded": "Новая форма будет загружена после",
"webhook": "Вебхук"
},
"activity": {
"openInANewTab": "Open in a new tab",
"copyIFrameCode": "Copy IFrame code",
"onCondition": "On Condition",
"bulkDownload": "Bulk Download",
"attachFile": "Attach File",
"viewAttachment": "View Attachments",
"attachmentDrop": "Click or drop a file into cell",
"addFiles": "Add File(s)",
"hideInUI": "Hide in UI",
"addBase": "Add Base",
"addParameter": "Add Parameter",
"submitAnotherForm": "Submit Another Form",
"dragAndDropFieldsHereToAdd": "Drag and drop fields here to add",
"editSource": "Edit Data Source",
"enterText": "Enter text",
"okEditBase": "Ok & Edit Base",
"showInUI": "Show in UI",
"outOfSync": "Out of sync",
"newSource": "New Data Source",
"newWebhook": "New Webhook",
"enablePublicAccess": "Enable Public Access",
"doYouWantToSaveTheChanges": "Do you want to save the changes ?",
"editingAccess": "Editing access",
"openInANewTab": "Открыть в новой вкладке",
"copyIFrameCode": "Копировать код IFrame",
"onCondition": "На Условии",
"bulkDownload": "Массовая загрузка",
"attachFile": "Прикрепить файл",
"viewAttachment": "Просмотр вложений",
"attachmentDrop": "Нажмите или перетащите файл в ячейку",
"addFiles": "Добавить файл(ы)",
"hideInUI": "Скрыть в пользовательском интерфейсе",
"addBase": "Добавить базу",
"addParameter": "Добавить параметр",
"submitAnotherForm": "Отправить другую форму",
"dragAndDropFieldsHereToAdd": "Перетащите поля сюда для добавления",
"editSource": "Изменить источник данных",
"enterText": "Введите текст",
"okEditBase": "Ок и редактировать базу",
"showInUI": "Показать в пользовательском интерфейсе",
"outOfSync": "Не синхронизировано",
"newSource": "Новый источник данных",
"newWebhook": "Новый вебхук",
"enablePublicAccess": "Включить публичный доступ",
"doYouWantToSaveTheChanges": "Вы хотите сохранить изменения?",
"editingAccess": "Редактирование доступа",
"enabledPublicViewing": "Enable public viewing",
"restrictAccessWithPassword": "Restrict access with password",
"manageProjectAccess": "Manage Base Access",
"allowDownload": "Allow Download",
"surveyMode": "Survey Mode",
"rtlOrientation": "RTL Orientation",
"useTheme": "Use Theme",
"copyLink": "Copy Link",
"copiedLink": "Link Copied",
"copyInviteLink": "Copy invite link",
"copiedInviteLink": "Copied invite link",
"restrictAccessWithPassword": "Ограничить доступ паролем",
"manageProjectAccess": "Управление доступом к базе",
"allowDownload": "Разрешить загрузку",
"surveyMode": "Режим опроса",
"rtlOrientation": "Ориентация RTL",
"useTheme": "Использовать тему",
"copyLink": "Скопировать ссылку",
"copiedLink": "Ссылка скопирована",
"copyInviteLink": "Скопируйте ссылку на приглашение",
"copiedInviteLink": "Скопированная ссылка на приглашение",
"copyUrl": "Скопировать URL",
"moreColors": "More Colors",
"moveProject": "Move Base",
"moreColors": "Больше цветов",
"moveProject": "Переместить базу",
"createProject": "Создать проект",
"importProject": "Импорт проекта",
"searchProject": "Найти проект",
@ -692,7 +692,7 @@
"deleteProject": "Удалить проект",
"refreshProject": "Обновить проекты",
"saveProject": "Сохранить проект",
"saveAndQuit": "Save & Quit",
"saveAndQuit": "Сохранить и выйти",
"deleteKanbanStack": "Удалить стек?",
"createProjectExtended": {
"extDB": "Создать подключение к внешней базе данных",
@ -708,7 +708,7 @@
"translate": "Помочь перевести",
"account": {
"authToken": "Скопировать токен авторизации",
"authTokenCopied": "Copied Auth Token",
"authTokenCopied": "Токен аутентификации скопирован",
"swagger": "Swagger: REST APIs",
"projInfo": "Информация о проекте",
"themes": "Темы"
@ -718,8 +718,8 @@
"filter": "Фильтр",
"addFilter": "Добавить фильтр",
"share": "Поделиться",
"groupBy": "Group By",
"addSubGroup": "Add subgroup",
"groupBy": "Группировать по",
"addSubGroup": "Добавить подгруппу",
"shareBase": {
"label": "Share base",
"disable": "Отключить общую базу",

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

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

16
packages/nc-gui/package.json

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

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

@ -23,12 +23,13 @@ const form = reactive({
const formRules = {
email: [
// 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
{
validator: (_: unknown, v: string) => {
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')))
})
},

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

@ -68,6 +68,10 @@ p {
}
.nc-form-view {
.nc-data-cell {
@apply border-solid border-1 !border-gray-300 dark:!border-slate-200;
}
.nc-cell {
@apply bg-white dark:bg-slate-500;
@ -91,7 +95,15 @@ p {
@apply bg-white dark:bg-slate-500;
&.nc-input {
@apply w-full rounded p-2 min-h-[40px] flex items-center border-solid border-1 border-gray-300 dark:border-slate-200;
@apply w-full px-3 min-h-[40px] flex items-center;
&.nc-cell-longtext {
@apply !px-1;
}
&.nc-cell-json {
@apply !h-auto;
}
.duration-cell-wrapper {
@apply w-full;
@ -121,8 +133,6 @@ p {
}
textarea {
@apply px-4 py-2 rounded;
&:focus {
box-shadow: none !important;
}

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

@ -3,7 +3,7 @@ import type { ColumnType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { ref } from 'vue'
import { StreamBarcodeReader } from 'vue-barcode-reader'
import { iconMap, useSharedFormStoreOrThrow } from '#imports'
import { iconMap, useGlobal, useSharedFormStoreOrThrow } from '#imports'
const { sharedFormView, submitForm, v$, formState, notFound, formColumns, submitted, secondsRemain, isLoading } =
useSharedFormStoreOrThrow()
@ -21,14 +21,14 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
return !!(required || (columnObj && columnObj.rqd && !columnObj.cdf))
}
const { isMobileMode } = useGlobal()
const fieldTitleForCurrentScan = ref('')
const scannerIsReady = ref(false)
const showCodeScannerOverlay = ref(false)
const editEnabled = ref<boolean[]>([])
const onLoaded = async () => {
scannerIsReady.value = true
}
@ -70,7 +70,7 @@ const onDecode = async (scannedCodeValue: string) => {
</script>
<template>
<div class="h-full flex flex-col items-center">
<div class="h-full flex flex-col items-center" :class="isMobileMode ? 'mobile' : 'desktop'">
<div
class="color-transition flex flex-col justify-center gap-2 w-full max-w-[max(33%,600px)] m-auto py-4 pb-8 px-16 md:(bg-white dark:bg-slate-700 rounded-lg border-1 border-gray-200 shadow-xl)"
>
@ -168,10 +168,7 @@ const onDecode = async (scannedCodeValue: string) => {
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="editEnabled[index]"
@click="editEnabled[index] = true"
@cancel="editEnabled[index] = false"
@update:edit-enabled="editEnabled[index] = $event"
edit-enabled
/>
<a-button
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 editEnabled = ref<boolean[]>([])
const el = ref<HTMLDivElement>()
provide(DropZoneRef, el)
@ -299,10 +297,7 @@ onMounted(() => {
class="nc-input h-auto"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="editEnabled[index]"
@click="editEnabled[index] = true"
@cancel="editEnabled[index] = false"
@update:edit-enabled="editEnabled[index] = $event"
edit-enabled
/>
<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[]> = {
email: [
// 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
{
validator: (_: unknown, v: string) => {
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')))
})

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

@ -44,12 +44,13 @@ const form = reactive({
const formRules = {
email: [
// 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
{
validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => {
if (!v?.length || validateEmail(v)) return resolve()
reject(new Error(t('msg.error.signUpRules.emailInvalid')))
})
},

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

@ -172,6 +172,10 @@ export const useBase = defineStore('baseStore', () => {
await loadTables()
await basesStore.getBaseUsers({
baseId: base.value.id || baseId.value,
})
// if (withTheme) setTheme(baseMeta.value?.theme)
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 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 route = router.currentRoute
@ -48,33 +48,35 @@ export const useBases = defineStore('basesStore', () => {
const isProjectsLoading = ref(false)
async function getProjectUsers({
baseId,
limit,
page,
searchText,
}: {
baseId: string
limit: number
page: number
searchText: string | undefined
}) {
async function getBaseUsers({ baseId, searchText, force = false }: { baseId: string; searchText?: string; force?: boolean }) {
if (!force && basesUser.value.has(baseId)) {
const users = basesUser.value.get(baseId)
return {
users,
totalRows: users?.length ?? 0,
}
}
const response: any = await api.auth.baseUserList(baseId, {
query: {
limit,
offset: (page - 1) * limit,
query: searchText,
},
} as RequestParams)
const totalRows = response.users.pageInfo.totalRows ?? 0
basesUser.value.set(baseId, response.users.list)
return {
users: response.users.list,
totalRows,
}
}
const clearBasesUser = () => {
basesUser.value.clear()
}
const createProjectUser = async (baseId: string, user: User) => {
await api.auth.baseUserAdd(baseId, user as ProjectUserReqType)
}
@ -295,7 +297,6 @@ export const useBases = defineStore('basesStore', () => {
return {
bases,
basesList,
baseUserCount,
loadProjects,
loadProject,
getSqlUi,
@ -312,13 +313,15 @@ export const useBases = defineStore('basesStore', () => {
activeProjectId,
openedProject,
openedProjectBasesMap,
getProjectUsers,
getBaseUsers,
createProjectUser,
updateProjectUser,
navigateToProject,
removeProjectUser,
navigateToFirstProjectOrHome,
toggleStarred,
basesUser,
clearBasesUser,
}
})

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

@ -4,6 +4,7 @@ export const useUsers = defineStore('userStore', () => {
const { api } = useApi()
const { user } = useGlobal()
const { loadRoles } = useRoles()
const basesStore = useBases()
const updateUserProfile = async ({
attrs,
@ -20,6 +21,8 @@ export const useUsers = defineStore('userStore', () => {
...user.value,
...attrs,
}
basesStore.clearBasesUser()
}
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 isSpecificDBType = (column: ColumnType) => column.uidt === UITypes.SpecificDBType
export const isGeometry = (column: ColumnType) => column.uidt === UITypes.Geometry
export const isUser = (column: ColumnType) => column.uidt === UITypes.User
export const isAutoSaved = (column: ColumnType) =>
[
UITypes.SingleLineText,

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

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

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

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

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

@ -1,624 +1,5 @@
import type { Input as AntInput } from 'ant-design-vue'
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()'],
// },
}
import { formulas } from 'nocodb-sdk'
const formulaList = Object.keys(formulas)
@ -671,4 +52,4 @@ function GetCaretPosition(ctrl: typeof AntInput) {
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 FileImageIcon from '~icons/nc-icons/file-image'
import PhUsers from '~icons/ph/users'
import PhUser from '~icons/ph/user'
// Roles
import SuperAdmin from '~icons/nc-icons/super-admin'
import Owner from '~icons/nc-icons/owner'
@ -320,6 +323,8 @@ export const iconMap = {
lock: h('span', { class: 'material-symbols' }, 'lock'),
account: h('span', { class: 'material-symbols' }, 'person'),
accountCircle: h('span', { class: 'material-symbols' }, 'account_circle'),
phUser: PhUser,
phUsers: PhUsers,
users: NcUsers,
cloudDownload: h('span', { class: 'material-symbols' }, 'cloud_download'),
download: MsDownloadRounded,

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

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

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

@ -1,5 +1,8 @@
{
"label": "In Open Source",
"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",
"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 ☁",
"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",
"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",
"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",
"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,
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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