Browse Source

Merge pull request #7308 from nocodb/develop

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

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

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

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

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

49
.github/workflows/uffizzi-preview.yml

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

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

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

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

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

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-transparent"
: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(false, $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">

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

@ -78,7 +78,7 @@ 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 outline-none focus:outline-transparent focus:ring-0"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="submitCurrency"
@keydown.down.stop

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

@ -238,6 +238,7 @@ 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 }"
@ -249,6 +250,7 @@ const clickHandler = () => {
:open="isOpen"
@click="clickHandler"
@update:open="updateOpen"
@keydown.enter="open = !open"
>
<template #suffixIcon></template>
</a-date-picker>

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

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

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

@ -1,12 +1,10 @@
<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,
@ -53,14 +51,14 @@ 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))
@ -71,6 +69,8 @@ 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 +180,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 +297,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,6 +328,26 @@ 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>
@ -357,7 +364,7 @@ const selectedOpts = computed(() => {
}"
>
<template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value">
<a-tag class="rounded-tag" :color="selectedOpt.color">
<a-tag class="rounded-tag max-w-full" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -367,7 +374,21 @@ const selectedOpts = computed(() => {
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.title }}
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ selectedOpt.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</template>
@ -389,7 +410,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" />
@ -402,7 +425,7 @@ const selectedOpts = computed(() => {
:class="`nc-select-option-${column.title}-${op.title}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
<a-tag class="rounded-tag max-w-full" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -412,7 +435,21 @@ const selectedOpts = computed(() => {
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ op.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ op.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</a-select-option>
@ -530,3 +567,10 @@ const selectedOpts = computed(() => {
@apply !text-xs;
}
</style>
<style lang="scss">
.ant-select-item-option-content,
.ant-select-item-option-state {
@apply !flex !items-center;
}
</style>

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

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

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

@ -36,14 +36,37 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
vModel.value = +e.key === +vModel.value ? 0 : +e.key
}
})
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`"
@keydown="onKeyPress"
>
<template #character>
<MdiStar v-if="ratingMeta.icon.full === 'mdi-star'" class="text-sm" />

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

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

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

@ -1,12 +1,10 @@
<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,
@ -47,9 +45,11 @@ 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 aselect = ref<typeof AntSelect>()
@ -61,8 +61,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 +75,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 +97,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 +215,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 +230,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 +240,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,12 +257,27 @@ 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 v-if="!(active || isEditable)">
<a-tag v-if="selectedOpt" class="rounded-tag" :color="selectedOpt.color">
<div
class="h-full w-full flex items-center nc-single-select focus:outline-transparent"
:class="{ 'read-only': readOnly }"
@click="toggleMenu"
@keydown.enter.stop.prevent="toggleMenu"
>
<div v-if="!(active || isEditable)" class="w-full">
<a-tag v-if="selectedOpt" class="rounded-tag max-w-full" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -274,12 +287,26 @@ const selectedOpt = computed(() => {
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.title }}
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ selectedOpt.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</div>
<a-select
<NcSelect
v-else
ref="aselect"
v-model:value="vModel"
@ -290,12 +317,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"
@ -305,7 +334,7 @@ const selectedOpt = computed(() => {
:class="`nc-select-option-${column.title}-${op.title}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
<a-tag class="rounded-tag max-w-full" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -315,7 +344,21 @@ const selectedOpt = computed(() => {
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ op.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ op.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</a-select-option>
@ -327,7 +370,7 @@ const selectedOpt = computed(() => {
</div>
</div>
</a-select-option>
</a-select>
</NcSelect>
</div>
</template>
@ -342,6 +385,7 @@ const selectedOpt = computed(() => {
:deep(.ant-select-clear) {
opacity: 1;
border-radius: 100%;
}
.nc-single-select:not(.read-only) {
@ -363,3 +407,9 @@ const selectedOpt = computed(() => {
@apply block;
}
</style>
<style lang="scss">
.ant-select-item-option-content {
@apply !flex !items-center;
}
</style>

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

@ -58,7 +58,9 @@ const isDragging = ref(false)
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLTextAreaElement)?.focus()
const 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 +171,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,
}"
>
@ -198,7 +200,7 @@ 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,
@ -239,8 +241,13 @@ watch(editEnabled, () => {
<NcTooltip
v-if="!isVisible"
placement="bottom"
class="!absolute right-0 bottom-1 nc-text-area-expand-btn"
:class="{ 'right-0 bottom-1': editEnabled, '!bottom-0': !isRichMode }"
class="!absolute right-0 bottom-1 hidden nc-text-area-expand-btn"
:class="{
'right-0 bottom-1': editEnabled,
'!bottom-0': !isRichMode,
'top-1 hidden !group-hover:block': isExpandedFormOpen,
'bottom-1': !isExpandedFormOpen,
}"
>
<template #title>{{ $t('title.expand') }}</template>
<NcButton type="secondary" size="xsmall" data-testid="attachment-cell-file-picker-button" @click.stop="onExpand">
@ -300,6 +307,6 @@ textarea:focus {
<style lang="scss">
.cell:hover .nc-text-area-expand-btn {
@apply !block;
@apply !block cursor-pointer;
}
</style>

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

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

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

@ -113,6 +113,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
<template>
<a-date-picker
v-model:value="localState"
tabindex="0"
picker="year"
:bordered="false"
class="!w-full !px-1 !border-none"
@ -125,6 +126,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>

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

@ -180,7 +180,6 @@ const onImageClick = (item: any) => {
<template>
<div
ref="attachmentCellRef"
tabindex="0"
:style="{
height: isForm || isExpandedForm ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
}"
@ -207,7 +206,9 @@ const onImageClick = (item: any) => {
:class="{ 'sm:(mx-auto px-4) xs:(w-full min-w-8)': !visibleItems.length }"
class="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))

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

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

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

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

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

@ -5,14 +5,14 @@ import {
ProjectRoles,
WorkspaceRolesToProjectRoles,
extractRolesObj,
parseStringDateTime,
timeAgo,
} from 'nocodb-sdk'
import type { WorkspaceUserRoles } from 'nocodb-sdk'
import InfiniteLoading from 'v3-infinite-loading'
import { isEeUI, storeToRefs } 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()
@ -30,7 +30,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)
@ -38,52 +37,32 @@ const accessibleRoles = ref<(typeof ProjectRoles)[keyof typeof ProjectRoles][]>(
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) => {
try {
if (
@ -115,29 +94,6 @@ const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
}
}
watchDebounced(
userSearchText,
async () => {
isSearching.value = true
currentPage.value = 0
totalCollaborators.value = 0
collaborators.value = []
try {
await loadCollaborators()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isSearching.value = false
}
},
{
debounce: 300,
maxWait: 600,
},
)
onMounted(async () => {
isLoading.value = true
try {
@ -156,6 +112,10 @@ onMounted(async () => {
isLoading.value = false
}
})
const filteredCollaborators = computed(() =>
collaborators.value.filter((collab) => collab.email.toLowerCase().includes(userSearchText.value.toLowerCase())),
)
</script>
<template>
@ -177,7 +137,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')" />
@ -186,13 +146,13 @@ onMounted(async () => {
<div class="flex flex-col rounded-lg overflow-hidden border-1 max-w-350 max-h-[calc(100%-8rem)]">
<div class="flex flex-row bg-gray-50 min-h-12 items-center border-b-1">
<div class="text-gray-700 users-email-grid">{{ $t('objects.users') }}</div>
<div class="text-gray-700 date-joined-grid">{{ $t('title.dateJoined') }}</div>
<div class="text-gray-700 user-access-grid">{{ $t('general.access') }}</div>
<div class="text-gray-700 date-joined-grid">{{ $t('title.dateJoined') }}</div>
</div>
<div class="flex flex-col nc-scrollbar-md">
<div
v-for="(collab, i) of collaborators"
v-for="(collab, i) of filteredCollaborators"
:key="i"
class="user-row flex flex-row border-b-1 py-1 min-h-14 items-center"
>
@ -202,7 +162,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
@ -221,20 +180,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>
@ -258,12 +216,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,
() => {

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

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

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" />

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

@ -7,7 +7,16 @@ provide(CurrentCellInj, el)
</script>
<template>
<div ref="el" class="select-none">
<div ref="el" class="select-none nc-data-cell">
<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>

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

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

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

@ -52,7 +52,7 @@ const { allowCSVDownload } = useSharedView()
</template>
<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">

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

@ -34,6 +34,13 @@ const updateCdfValue = (cdf: string | null) => {
onMounted(() => {
updateCdfValue(vModel.value?.cdf ? vModel.value.cdf : null)
})
watch(
() => vModel.value.cdf,
(newValue) => {
cdfValue.value = newValue
},
)
</script>
<template>
@ -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>

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

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

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

@ -90,6 +90,8 @@ const { isUIAllowed } = useRoles()
const readOnly = computed(() => !isUIAllowed('dataEdit') || isPublic.value)
const 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>

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

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

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

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

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

@ -448,7 +448,8 @@ const closeAddColumnDropdown = (scrollToLastCol = false) => {
}
async function openNewRecordHandler() {
const newRow = addEmptyRow()
// skip update row when it is `New record form`
const newRow = addEmptyRow(dataRef.value.length, true)
if (newRow) expandForm?.(newRow, undefined, true)
}
@ -697,8 +698,17 @@ function scrollToRow(row?: number) {
scrollToCell?.()
}
function addEmptyRow(row?: number) {
async function saveEmptyRow(rowObj: Row) {
await updateOrSaveRow?.(rowObj)
}
function addEmptyRow(row?: number, skipUpdate: boolean = false) {
const rowObj = callAddEmptyRow?.(row)
if (!skipUpdate && rowObj) {
saveEmptyRow(rowObj)
}
nextTick().then(() => {
scrollToRow(row ?? dataRef.value.length - 1)
})
@ -781,7 +791,7 @@ onClickOutside(tableBodyEl, (e) => {
// ignore unselecting if clicked inside or on the picker(Date, Time, DateTime, Year)
// 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 &&
@ -995,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(
@ -1027,13 +1043,11 @@ eventBus.on(async (event, payload) => {
addColumnDropdown.value = true
}
if (event === SmartsheetStoreEvents.CLEAR_NEW_ROW) {
const removed = removeRowIfNew?.(payload)
clearSelectedRange()
activeCell.row = null
activeCell.col = null
if (removed) {
clearSelectedRange()
activeCell.row = null
activeCell.col = null
}
removeRowIfNew?.(payload)
}
})
@ -1258,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>
@ -1486,7 +1503,6 @@ onKeyStroke('ArrowDown', onDown)
>
<div class="items-center flex gap-1 min-w-[60px]">
<div
v-if="!readOnly || isMobileMode"
class="nc-row-no sm:min-w-4 text-xs text-gray-500"
:class="{ toggle: !readOnly, hidden: row.rowMeta.selected }"
>
@ -1576,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]"
@ -1698,7 +1714,7 @@ onKeyStroke('ArrowDown', onDown)
</NcMenuItem>
<NcMenuItem
v-if="contextMenuTarget"
v-if="contextMenuTarget && hasEditPermission"
class="nc-base-menu-item"
data-testid="context-menu-item-paste"
:disabled="isSystemColumn(fields[contextMenuTarget.col])"
@ -1721,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">
@ -1734,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">
@ -1741,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))" />

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

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

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)) {

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

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

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 {}
}

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

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

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()

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

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

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

@ -83,6 +83,14 @@ const belongsToColumn = computed(
() =>
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
)
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>

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

@ -2,7 +2,7 @@
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, 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 } }>
@ -11,6 +11,8 @@ const cellValue = inject(CellValueInj)
const { isPg } = useBase()
const { showNull } = useGlobal()
const result = computed(() =>
isPg(column.value.source_id) ? renderValue(handleTZ(cellValue?.value)) : renderValue(cellValue?.value),
)
@ -30,6 +32,8 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
<span>ERR!</span>
</a-tooltip>
<span v-else-if="cellValue === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<div v-else class="py-2" @dblclick="activateShowEditNonEditableFieldWarning">
<div v-if="urls" v-html="urls" />

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

@ -102,6 +102,21 @@ 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>
@ -109,21 +124,30 @@ const openListDlg = () => {
<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>

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

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

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

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

@ -9,6 +9,7 @@ import {
findIndexByPk,
message,
populateInsertObject,
rowDefaultData,
rowPkData,
storeToRefs,
until,
@ -53,9 +54,9 @@ export function useData(args: {
},
})
function addEmptyRow(addAfter = formattedData.value.length) {
function addEmptyRow(addAfter = formattedData.value.length, metaValue = meta.value) {
formattedData.value.splice(addAfter, 0, {
row: {},
row: { ...rowDefaultData(metaValue?.columns) },
oldRow: {},
rowMeta: { new: true },
})
@ -240,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 ')
)
@ -386,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:

22
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 {
@ -718,7 +728,15 @@ export function useMultiSelect(
return message.info(t('msg.info.updateNotAllowedWithoutPK'))
}
if (isTypableInputColumn(columnObj) && makeEditable(rowObj, columnObj) && columnObj.title) {
rowObj.row[columnObj.title] = ''
if (columnObj.uidt === UITypes.LongText) {
if (rowObj.row[columnObj.title] === '<br />') {
rowObj.row[columnObj.title] = e.key
} else {
rowObj.row[columnObj.title] = rowObj.row[columnObj.title] ? rowObj.row[columnObj.title] + e.key : e.key
}
} else {
rowObj.row[columnObj.title] = ''
}
}
// editEnabled = true
}

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: {

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

@ -187,24 +187,32 @@ export function useViewData(
controller.value = CancelToken.source()
isPaginationLoading.value = true
let response
const response = !isPublic.value
? await api.dbViewRow.list(
'noco',
base.value.id!,
metaId.value!,
viewMeta.value!.id!,
{
...queryParams.value,
...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where: where?.value,
} as any,
{ cancelToken: controller.value.token },
)
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value })
try {
response = !isPublic.value
? await api.dbViewRow.list(
'noco',
base.value.id!,
metaId.value!,
viewMeta.value!.id!,
{
...queryParams.value,
...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where: where?.value,
} as any,
{ cancelToken: controller.value.token },
)
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value, where: where?.value })
} catch (error) {
// if the request is canceled, then do nothing
if (error.code === 'ERR_CANCELED') {
return
}
throw error
}
formattedData.value = formatData(response.list)
paginationData.value = response.pageInfo
isPaginationLoading.value = false
@ -311,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})`
}

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

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

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

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

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

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

20
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": "0.202.10",
"nocodb-sdk": "workspace:^",
"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.145",
"@iconify-json/material-symbols": "^1.1.67",
"@iconify-json/mdi": "^1.1.61",
"@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-28372028.a469898",
"@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')))
})
},

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

@ -27,8 +27,6 @@ const scannerIsReady = ref(false)
const showCodeScannerOverlay = ref(false)
const editEnabled = ref<boolean[]>([])
const onLoaded = async () => {
scannerIsReady.value = true
}
@ -168,10 +166,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')))
})
},

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

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

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

@ -172,6 +172,10 @@ export const useBase = defineStore('baseStore', () => {
await loadTables()
await 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) => {

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

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

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

@ -70,6 +70,7 @@ const getLteText = (fieldUiType: UITypes) => {
export const comparisonOpList = (
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,

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"
}
}

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

@ -1,5 +1,8 @@
{
"label": "Records",
"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