Browse Source

Merge branch 'develop' into l10n_develop_2

pull/7846/head
Raju Udava 7 months ago committed by GitHub
parent
commit
a8f8a651cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      packages/nc-gui/components/cmd-k/index.vue
  2. 31
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  3. 2
      packages/nc-gui/components/dlg/ProjectDelete.vue
  4. 1
      packages/nc-gui/components/dlg/ProjectDuplicate.vue
  5. 2
      packages/nc-gui/components/dlg/share-and-collaborate/View.vue
  6. 104
      packages/nc-gui/components/general/BaseIconColorPicker.vue
  7. 78
      packages/nc-gui/components/general/ColorPicker.vue
  8. 61
      packages/nc-gui/components/general/ColorSliderWrapper.vue
  9. 52
      packages/nc-gui/components/general/ProjectIcon.vue
  10. 12
      packages/nc-gui/components/nc/Modal.vue
  11. 20
      packages/nc-gui/components/project/AccessSettings.vue
  12. 296
      packages/nc-gui/components/project/ShareBaseDlg.vue
  13. 4
      packages/nc-gui/components/project/View.vue
  14. 2
      packages/nc-gui/components/roles/Selector.vue
  15. 16
      packages/nc-gui/components/smartsheet/Form.vue
  16. 6
      packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue
  17. 13
      packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue
  18. 13
      packages/nc-gui/components/smartsheet/calendar/MonthView.vue
  19. 4
      packages/nc-gui/components/smartsheet/calendar/SideMenu.vue
  20. 15
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue
  21. 9
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue
  22. 4
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  23. 58
      packages/nc-gui/components/smartsheet/toolbar/RowHeight.vue
  24. 2
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  25. 20
      packages/nc-gui/components/workspace/CreateProjectDlg.vue
  26. 2
      packages/nc-gui/components/workspace/InviteSection.vue
  27. 24
      packages/nc-gui/components/workspace/ProjectList.vue
  28. 2
      packages/nc-gui/composables/useRoles/index.ts
  29. 8
      packages/nc-gui/lang/en.json
  30. 9
      packages/nc-gui/lib/acl.ts
  31. 7
      packages/nc-gui/middleware/01.security.global.ts
  32. 1
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId].vue
  33. 7
      packages/nc-gui/store/bases.ts
  34. 2
      packages/nc-gui/utils/colorsUtils.ts
  35. 21
      packages/noco-docs/docs/070.fields/040.field-types/060.formula/030.string-functions.md
  36. 7
      packages/nocodb-sdk/src/lib/enums.ts
  37. 16
      packages/nocodb-sdk/src/lib/formulaHelpers.ts
  38. 4
      packages/nocodb/src/controllers/view-columns.controller.ts
  39. 12
      packages/nocodb/src/db/BaseModelSqlv2.ts
  40. 11
      packages/nocodb/src/db/functionMappings/commonFns.ts
  41. 1
      packages/nocodb/src/helpers/PagedResponse.ts
  42. 6
      packages/nocodb/src/helpers/extractLimitAndOffset.ts
  43. 1
      packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts
  44. 5
      packages/nocodb/src/models/Base.ts
  45. 41
      packages/nocodb/src/models/BaseUser.ts
  46. 16
      packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts
  47. 4
      packages/nocodb/src/schema/swagger-v2.json
  48. 5
      packages/nocodb/src/schema/swagger.json
  49. 2
      packages/nocodb/src/services/calendar-datas.service.ts
  50. 3
      packages/nocodb/src/services/command-palette.service.ts
  51. 9
      packages/nocodb/src/services/datas.service.ts
  52. 10
      tests/playwright/tests/db/columns/columnFormula.spec.ts

7
packages/nc-gui/components/cmd-k/index.vue

@ -17,6 +17,7 @@ interface CmdAction {
keywords?: string[] keywords?: string[]
section?: string section?: string
is_default?: number | null is_default?: number | null
iconColor?: string
} }
const props = defineProps<{ const props = defineProps<{
@ -385,6 +386,11 @@ defineExpose({
@click="fireAction(act)" @click="fireAction(act)"
> >
<div class="cmdk-action-content w-full"> <div class="cmdk-action-content w-full">
<template v-if="title === 'Bases' || act.icon === 'project'">
<GeneralBaseIconColorPicker :key="act.iconColor" :model-value="act.iconColor" type="database" readonly>
</GeneralBaseIconColorPicker>
</template>
<template v-else>
<component <component
:is="(iconMap as any)[act.icon]" :is="(iconMap as any)[act.icon]"
v-if="act.icon && typeof act.icon === 'string' && (iconMap as any)[act.icon]" v-if="act.icon && typeof act.icon === 'string' && (iconMap as any)[act.icon]"
@ -399,6 +405,7 @@ defineExpose({
<div v-else-if="act.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center"> <div v-else-if="act.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center">
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="act.icon" readonly /> <LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="act.icon" readonly />
</div> </div>
</template>
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg"> <a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title> <template #title>
{{ act.title }} {{ act.title }}

31
packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue

@ -75,6 +75,8 @@ useTabs()
const { meta: metaKey, ctrlKey } = useMagicKeys() const { meta: metaKey, ctrlKey } = useMagicKeys()
const { refreshCommandPalette } = useCommandPalette()
const editMode = ref(false) const editMode = ref(false)
const tempTitle = ref('') const tempTitle = ref('')
@ -172,18 +174,20 @@ defineExpose({
enableEditMode, enableEditMode,
}) })
const setIcon = async (icon: string, base: BaseType) => { const setColor = async (color: string, base: BaseType) => {
try { try {
const meta = { const meta = {
...((base.meta as object) || {}), ...parseProp(base.meta),
icon, iconColor: color,
} }
basesStore.updateProject(base.id!, { meta: JSON.stringify(meta) }) basesStore.updateProject(base.id!, { meta: JSON.stringify(meta) })
$e('a:base:icon:navdraw', { icon }) $e('a:base:icon:color:navdraw', { iconColor: color })
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} finally {
refreshCommandPalette()
} }
} }
@ -420,19 +424,20 @@ const projectDelete = () => {
</NcButton> </NcButton>
<div class="flex items-center mr-1" @click="onProjectClick(base)"> <div class="flex items-center mr-1" @click="onProjectClick(base)">
<div v-e="['c:base:emojiSelect']" class="flex items-center select-none w-6 h-full"> <div class="flex items-center select-none w-6 h-full">
<a-spin v-if="base.isLoading" class="!ml-1.25 !flex !flex-row !items-center !my-0.5 w-8" :indicator="indicator" /> <a-spin v-if="base.isLoading" class="!ml-1.25 !flex !flex-row !items-center !my-0.5 w-8" :indicator="indicator" />
<LazyGeneralEmojiPicker <div v-else>
v-else <GeneralBaseIconColorPicker
:key="base.meta?.icon" :key="`${base.id}_${parseProp(base.meta).iconColor}`"
:emoji="base.meta?.icon" :type="base?.type"
:readonly="true" :model-value="parseProp(base.meta).iconColor"
size="small" size="small"
@emoji-selected="setIcon($event, base)" :readonly="(base?.type && base?.type !== 'database') || !isUIAllowed('baseRename')"
@update:model-value="setColor($event, base)"
> >
<GeneralProjectIcon :type="base.type" /> </GeneralBaseIconColorPicker>
</LazyGeneralEmojiPicker> </div>
</div> </div>
</div> </div>

2
packages/nc-gui/components/dlg/ProjectDelete.vue

@ -52,7 +52,7 @@ const onDelete = async () => {
<GeneralDeleteModal v-model:visible="visible" :entity-name="$t('objects.project')" :on-delete="onDelete"> <GeneralDeleteModal v-model:visible="visible" :entity-name="$t('objects.project')" :on-delete="onDelete">
<template #entity-preview> <template #entity-preview>
<div v-if="base" class="flex flex-row items-center py-2 px-2.25 bg-gray-50 rounded-lg text-gray-700 mb-4"> <div v-if="base" class="flex flex-row items-center py-2 px-2.25 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralProjectIcon :type="base.type" class="nc-view-icon px-1.5 w-10" /> <GeneralProjectIcon :color="parseProp(base.meta).iconColor" :type="base.type" class="nc-view-icon w-6 h-6 mx-1" />
<div <div
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-1.75" class="capitalize text-ellipsis overflow-hidden select-none w-full pl-1.75"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }" :style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"

1
packages/nc-gui/components/dlg/ProjectDuplicate.vue

@ -62,6 +62,7 @@ const _duplicate = async () => {
primaryColor: color, primaryColor: color,
accentColor: complement.toHex8String(), accentColor: complement.toHex8String(),
}, },
iconColor: parseProp(props.base.meta).iconColor,
}), }),
}, },
}) })

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

@ -154,7 +154,7 @@ watch(showShareModal, (val) => {
</div> </div>
<div class="share-base"> <div class="share-base">
<div class="flex flex-row items-center gap-x-2 px-4 pt-3 pb-3 select-none"> <div class="flex flex-row items-center gap-x-2 px-4 pt-3 pb-3 select-none">
<GeneralProjectIcon :type="base.type" class="nc-view-icon group-hover" /> <GeneralProjectIcon :color="parseProp(base.meta).iconColor" :type="base.type" class="nc-view-icon group-hover" />
<div>{{ $t('activity.shareBase.label') }}</div> <div>{{ $t('activity.shareBase.label') }}</div>
<div <div

104
packages/nc-gui/components/general/BaseIconColorPicker.vue

@ -0,0 +1,104 @@
<script lang="ts" setup>
import tinycolor from 'tinycolor2'
import { NcProjectType, baseIconColors } from '#imports'
const props = withDefaults(
defineProps<{
type?: NcProjectType | string
modelValue?: string
size?: 'small' | 'medium' | 'large' | 'xlarge'
readonly?: boolean
iconClass?: string
}>(),
{
type: NcProjectType.DB,
size: 'small',
},
)
const emit = defineEmits(['update:modelValue'])
const { modelValue } = toRefs(props)
const { size, readonly } = props
const isOpen = ref(false)
const colorRef = ref(tinycolor(modelValue.value).isValid() ? modelValue.value : baseIconColors[0])
const updateIconColor = (color: string) => {
const tcolor = tinycolor(color)
if (tcolor.isValid()) {
colorRef.value = color
}
}
const onClick = (e: Event) => {
if (readonly) return
e.stopPropagation()
isOpen.value = !isOpen.value
}
watch(
isOpen,
(value) => {
if (!value && colorRef.value !== modelValue.value) {
emit('update:modelValue', colorRef.value)
}
},
{
immediate: true,
},
)
</script>
<template>
<div>
<a-dropdown v-model:visible="isOpen" :trigger="['click']" :disabled="readonly">
<div
class="flex flex-row justify-center items-center select-none rounded-md nc-base-icon-picker-trigger"
:class="{
'hover:bg-gray-500 hover:bg-opacity-15 cursor-pointer': !readonly,
'bg-gray-500 bg-opacity-15': isOpen,
'h-6 w-6 text-lg': size === 'small',
'h-8 w-8 text-xl': size === 'medium',
'h-10 w-10 text-2xl': size === 'large',
'h-14 w-16 text-5xl': size === 'xlarge',
}"
@click="onClick"
>
<NcTooltip placement="topLeft" :disabled="readonly">
<template #title> {{ $t('tooltip.changeIconColour') }} </template>
<div>
<GeneralProjectIcon :color="colorRef" :type="type" />
</div>
</NcTooltip>
</div>
<template #overlay>
<div
class="nc-base-icon-color-picker-dropdown relative bg-white rounded-lg border-1 border-gray-200 overflow-hidden max-w-[342px]"
>
<div class="flex justify-start">
<GeneralColorPicker
:model-value="colorRef"
:colors="baseIconColors"
:is-new-design="true"
class="nc-base-icon-color-picker"
@input="updateIconColor"
/>
</div>
</div>
</template>
</a-dropdown>
</div>
</template>
<style lang="scss" scoped>
.nc-base-icon-color-picker-dropdown {
box-shadow: 0px 8px 8px -4px #0000000a, 0px 20px 24px -4px #0000001a;
}
</style>

78
packages/nc-gui/components/general/ColorPicker.vue

@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import tinycolor from 'tinycolor2'
import { computed, enumColor, ref, watch } from '#imports' import { computed, enumColor, ref, watch } from '#imports'
interface Props { interface Props {
@ -7,7 +8,7 @@ interface Props {
rowSize?: number rowSize?: number
advanced?: boolean advanced?: boolean
pickButton?: boolean pickButton?: boolean
borders?: string[] colorBoxBorder?: boolean
isNewDesign?: boolean isNewDesign?: boolean
} }
@ -17,6 +18,7 @@ const props = withDefaults(defineProps<Props>(), {
rowSize: 10, rowSize: 10,
advanced: true, advanced: true,
pickButton: false, pickButton: false,
colorBoxBorder: false,
isNewDesign: false, isNewDesign: false,
}) })
@ -41,7 +43,8 @@ const selectColor = (color: string, closeModal = false) => {
const isPickerOn = ref(false) const isPickerOn = ref(false)
const compare = (colorA: string, colorB: string) => colorA.toLowerCase() === colorB.toLowerCase() const compare = (colorA: string, colorB: string) =>
colorA.toLowerCase() === colorB.toLowerCase() || colorA.toLowerCase() === tinycolor(colorB).toHex8String().toLowerCase()
watch(picked, (n, _o) => { watch(picked, (n, _o) => {
vModel.value = n vModel.value = n
@ -50,21 +53,41 @@ watch(picked, (n, _o) => {
<template> <template>
<div class="color-picker"> <div class="color-picker">
<div v-for="colId in Math.ceil(props.colors.length / props.rowSize)" :key="colId" class="color-picker-row"> <div
<button v-for="colId in Math.ceil(props.colors.length / props.rowSize)"
:key="colId"
class="color-picker-row"
:class="{
'mt-2': colId > 1,
}"
>
<div
v-for="(color, i) of colors.slice((colId - 1) * rowSize, colId * rowSize)" v-for="(color, i) of colors.slice((colId - 1) * rowSize, colId * rowSize)"
:key="`color-${colId}-${i}`" :key="`color-${colId}-${i}`"
class="p-1 rounded-md flex h-8"
:class="{
'hover:bg-gray-200': isNewDesign,
}"
>
<button
class="color-selector" class="color-selector"
:class="{ 'selected': compare(picked, color), 'new-design': isNewDesign }" :class="{ 'selected': compare(picked, color), 'new-design': isNewDesign }"
:style="{ :style="{
'background-color': `${color}`, backgroundColor: `${color}`,
'border': borders?.length && borders[i] ? `1px solid ${borders[i]}` : undefined, border: colorBoxBorder ? `1px solid ${tinycolor(color).darken(30).toString()}` : undefined,
}" }"
@click="selectColor(color, true)" @click="selectColor(color, true)"
> >
{{ compare(picked, color) && !isNewDesign ? '&#10003;' : '' }} {{ compare(picked, color) && !isNewDesign ? '&#10003;' : '' }}
</button> </button>
<button class="h-6 w-6 mt-2.7 ml-1 border-1 border-[grey] rounded-md" @click="isPickerOn = !isPickerOn"> </div>
<div
class="p-1 rounded-md h-8"
:class="{
'hover:bg-gray-200': isNewDesign,
}"
>
<button class="nc-more-colors-trigger h-6 w-6 border-1 border-gray-400 rounded" @click="isPickerOn = !isPickerOn">
<GeneralTooltip> <GeneralTooltip>
<template #title>{{ $t('activity.moreColors') }}</template> <template #title>{{ $t('activity.moreColors') }}</template>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@ -73,8 +96,14 @@ watch(picked, (n, _o) => {
</GeneralTooltip> </GeneralTooltip>
</button> </button>
</div> </div>
</div>
<a-card v-if="props.advanced" class="w-full mt-2" :body-style="{ padding: '0px' }" :bordered="false"> <a-card
v-if="props.advanced"
class="w-full mt-2"
:body-style="{ paddingLeft: '4px !important', paddingRight: '4px !important' }"
:bordered="false"
>
<div v-if="isPickerOn" class="flex justify-center"> <div v-if="isPickerOn" class="flex justify-center">
<LazyGeneralChromeWrapper v-model="picked" class="!w-full !shadow-none" /> <LazyGeneralChromeWrapper v-model="picked" class="!w-full !shadow-none" />
</div> </div>
@ -82,25 +111,15 @@ watch(picked, (n, _o) => {
</div> </div>
</template> </template>
<style scoped> <style lansg="scss" scoped>
.color-picker { .color-picker {
display: flex; @apply flex flex-col items-center justify-center bg-white p-2.5;
align-items: center;
justify-content: center;
flex-direction: column;
background: white;
padding: 10px;
} }
.color-picker-row { .color-picker-row {
display: flex; @apply flex flex-row space-x-1;
flex-direction: row;
} }
.color-selector { .color-selector {
position: relative; @apply h-6 w-6 rounded;
height: 25px;
width: 25px;
margin: 10px 5px;
border-radius: 5px;
-webkit-text-stroke-width: 1px; -webkit-text-stroke-width: 1px;
-webkit-text-stroke-color: white; -webkit-text-stroke-color: white;
} }
@ -108,19 +127,14 @@ watch(picked, (n, _o) => {
filter: brightness(90%); filter: brightness(90%);
-webkit-filter: brightness(90%); -webkit-filter: brightness(90%);
} }
.color-selector:focus,
.color-selector.selected:not(.new-design) { .color-selector.selected,
filter: brightness(90%); .nc-more-colors-trigger:focus {
-webkit-filter: brightness(90%);
}
.color-selector:focus.new-design {
outline: none; outline: none;
box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe; box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe;
} }
.color-selector.selected.new-design {
box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe;
}
:deep(.vc-chrome-toggle-icon) { :deep(.vc-chrome-toggle-icon) {
@apply ml-3!important; @apply !ml-3;
} }
</style> </style>

61
packages/nc-gui/components/general/ColorSliderWrapper.vue

@ -0,0 +1,61 @@
<script lang="ts" setup>
import { Slider } from '@ckpack/vue-color'
import tinycolor from 'tinycolor2'
interface Props {
modelValue?: any
mode?: 'hsl' | 'hsv'
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '#3069FE',
mode: 'hsv',
})
const emit = defineEmits(['update:modelValue', 'input'])
const picked = computed({
get: () => tinycolor(props.modelValue || '#3069FE').toHsv() as any,
set: (val) => {
if (val) {
emit('update:modelValue', val[props.mode] || null)
emit('input', val[props.mode] || null)
}
},
})
</script>
<template>
<Slider
v-model="picked"
class="nc-color-slider-wrapper min-w-[200px]"
:style="{
'--nc-color-slider-pointer': tinycolor(`hsv(${picked.h ?? 199}, 100%, 100%)`).toHexString(),
}"
/>
</template>
<style lang="scss" scoped>
.nc-color-slider-wrapper {
&.vc-slider {
@apply !w-full;
}
:deep(.vc-slider-swatches) {
@apply hidden;
}
:deep(.vc-slider-hue-warp) {
@apply h-1.5;
.vc-hue {
@apply rounded-lg;
}
.vc-hue-pointer {
top: -3px !important;
}
.vc-hue-picker {
background-color: white;
box-shadow: 0 0 0 3px var(--nc-color-slider-pointer) !important;
}
}
}
</style>

52
packages/nc-gui/components/general/ProjectIcon.vue

@ -1,18 +1,62 @@
<script lang="ts" setup> <script lang="ts" setup>
const { hoverable } = defineProps<{ import tinycolor from 'tinycolor2'
import { baseIconColors } from '#imports'
const props = withDefaults(
defineProps<{
type?: string type?: string
hoverable?: boolean hoverable?: boolean
}>() color?: string
}>(),
{
color: baseIconColors[0],
},
)
const { color } = toRefs(props)
const iconColor = computed(() => {
return color.value && tinycolor(color.value).isValid()
? {
tint: baseIconColors.includes(color.value) ? color.value : tinycolor(color.value).lighten(10).toHexString(),
shade: tinycolor(color.value).darken(40).toHexString(),
}
: {
tint: baseIconColors[0],
shade: tinycolor(baseIconColors[0]).darken(40).toHexString(),
}
})
</script> </script>
<template> <template>
<GeneralIcon <svg
icon="project" width="16"
height="16"
viewBox="0 0 1073 1073"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-[#2824FB] base" class="text-[#2824FB] base"
:class="{ :class="{
'nc-base-icon-hoverable': hoverable, 'nc-base-icon-hoverable': hoverable,
}" }"
>
<mask id="mask0_1749_80944" style="mask-type: luminance" maskUnits="userSpaceOnUse" x="94" y="40" width="885" height="993">
<path d="M978.723 40H94V1033H978.723V40Z" fill="white" />
</mask>
<g mask="url(#mask0_1749_80944)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M638.951 291.265L936.342 462.949C966.129 480.145 980.256 502.958 978.723 525.482V774.266C980.256 796.789 966.129 819.602 936.342 836.798L638.951 1008.48C582.292 1041.19 490.431 1041.19 433.773 1008.48L136.381 836.798C106.595 819.602 92.4675 796.789 93.9999 774.266L93.9999 525.482C92.4675 502.957 106.595 480.145 136.381 462.949L433.773 291.265C490.431 258.556 582.292 258.556 638.951 291.265Z"
:fill="iconColor.shade"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M638.951 65.0055L936.342 236.69C966.129 253.886 980.256 276.699 978.723 299.222V548.006C980.256 570.529 966.129 593.343 936.342 610.538L638.951 782.223C582.292 814.931 490.431 814.931 433.773 782.223L136.381 610.538C106.595 593.343 92.4675 570.529 93.9999 548.006L93.9999 299.222C92.4675 276.699 106.595 253.886 136.381 236.69L433.773 65.0055C490.431 32.2968 582.292 32.2968 638.951 65.0055Z"
:fill="iconColor.tint"
/> />
</g>
</svg>
</template> </template>
<style scoped> <style scoped>

12
packages/nc-gui/components/nc/Modal.vue

@ -6,19 +6,21 @@ const props = withDefaults(
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
destroyOnClose?: boolean destroyOnClose?: boolean
maskClosable?: boolean maskClosable?: boolean
showSeparator?: boolean
wrapClassName?: string wrapClassName?: string
}>(), }>(),
{ {
size: 'medium', size: 'medium',
destroyOnClose: true, destroyOnClose: true,
maskClosable: true, maskClosable: true,
showSeparator: true,
wrapClassName: '', wrapClassName: '',
}, },
) )
const emits = defineEmits(['update:visible']) const emits = defineEmits(['update:visible'])
const { width: propWidth, destroyOnClose, maskClosable, wrapClassName: _wrapClassName } = props const { width: propWidth, destroyOnClose, maskClosable, wrapClassName: _wrapClassName, showSeparator } = props
const { isMobileMode } = useGlobal() const { isMobileMode } = useGlobal()
@ -98,7 +100,13 @@ const slots = useSlots()
maxHeight: height, maxHeight: height,
}" }"
> >
<div v-if="slots.header" class="flex pb-2 mb-2 text-lg font-medium border-b-1 border-gray-100"> <div
v-if="slots.header"
:class="{
'border-b-1 border-gray-100': showSeparator,
}"
class="flex pb-2 mb-2 text-lg font-medium"
>
<slot name="header" /> <slot name="header" />
</div> </div>

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

@ -9,8 +9,8 @@ import {
timeAgo, timeAgo,
} from 'nocodb-sdk' } from 'nocodb-sdk'
import type { Roles, WorkspaceUserRoles } from 'nocodb-sdk' import type { Roles, WorkspaceUserRoles } from 'nocodb-sdk'
import { isEeUI, storeToRefs, useUserSorts } from '#imports'
import type { User } from '#imports' import type { User } from '#imports'
import { isEeUI, storeToRefs, useUserSorts } from '#imports'
const basesStore = useBases() const basesStore = useBases()
const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore
@ -22,6 +22,8 @@ const { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = u
const isSuper = computed(() => orgRoles.value?.[OrgUserRoles.SUPER_ADMIN]) const isSuper = computed(() => orgRoles.value?.[OrgUserRoles.SUPER_ADMIN])
const isInviteModalVisible = ref(false)
interface Collaborators { interface Collaborators {
id: string id: string
email: string email: string
@ -132,20 +134,34 @@ onMounted(async () => {
isLoading.value = false isLoading.value = false
} }
}) })
watch(isInviteModalVisible, () => {
if (!isInviteModalVisible.value) {
loadCollaborators()
}
})
</script> </script>
<template> <template>
<div class="nc-collaborator-table-container mt-4 nc-access-settings-view h-[calc(100vh-8rem)]"> <div class="nc-collaborator-table-container mt-4 nc-access-settings-view h-[calc(100vh-8rem)]">
<LazyProjectShareBaseDlg v-model:model-value="isInviteModalVisible" />
<div v-if="isLoading" class="nc-collaborators-list items-center justify-center"> <div v-if="isLoading" class="nc-collaborators-list items-center justify-center">
<GeneralLoader size="xlarge" /> <GeneralLoader size="xlarge" />
</div> </div>
<template v-else> <template v-else>
<div class="w-full flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25"> <div class="w-full flex flex-row justify-between items-baseline max-w-350 mt-6.5 mb-2 pr-0.25">
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md" :placeholder="$t('title.searchMembers')"> <a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md" :placeholder="$t('title.searchMembers')">
<template #prefix> <template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" /> <PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template> </template>
</a-input> </a-input>
<NcButton size="small" @click="isInviteModalVisible = true">
<div class="flex gap-1">
<component :is="iconMap.plus" class="w-4 h-4" />
{{ $t('activity.addMembers') }}
</div>
</NcButton>
</div> </div>
<div v-if="isSearching" class="nc-collaborators-list items-center justify-center"> <div v-if="isSearching" class="nc-collaborators-list items-center justify-center">

296
packages/nc-gui/components/project/ShareBaseDlg.vue

@ -0,0 +1,296 @@
<script setup lang="ts">
import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk'
import type { User } from '#imports'
const props = defineProps<{
modelValue: boolean
baseId: string
}>()
const emit = defineEmits(['update:modelValue'])
const dialogShow = useVModel(props, 'modelValue', emit)
const inviteData = reactive({
email: '',
roles: ProjectRoles.NO_ACCESS,
})
const { baseRoles } = useRoles()
const basesStore = useBases()
const { activeProjectId } = storeToRefs(basesStore)
const { createProjectUser } = basesStore
const divRef = ref<HTMLDivElement>()
const focusRef = ref<HTMLInputElement>()
const isDivFocused = ref(false)
const emailValidation = reactive({
isError: true,
message: '',
})
const allowedRoles = ref<ProjectRoles[]>([])
onMounted(async () => {
try {
const currentRoleIndex = OrderedProjectRoles.findIndex(
(role) => baseRoles.value && Object.keys(baseRoles.value).includes(role),
)
if (currentRoleIndex !== -1) {
allowedRoles.value = OrderedProjectRoles.slice(currentRoleIndex + 1).filter((r) => r)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
})
const singleEmailValue = ref('')
const emailBadges = ref<Array<string>>([])
const insertOrUpdateString = (str: string) => {
// Check if the string already exists in the array
const index = emailBadges.value.indexOf(str)
if (index !== -1) {
// If the string exists, remove it
emailBadges.value.splice(index, 1)
}
// Add the new string to the array
emailBadges.value.push(str)
}
const emailInputValidation = (input: string): boolean => {
if (!input.length) {
emailValidation.isError = true
emailValidation.message = 'Email should not be empty'
return false
}
if (!validateEmail(input.trim())) {
emailValidation.isError = true
emailValidation.message = 'Invalid Email'
return false
}
return true
}
const isInvitButtonDiabled = computed(() => {
if (!emailBadges.value.length && !singleEmailValue.value.length) {
return true
}
if (emailBadges.value.length && inviteData.email) {
return true
}
})
watch(inviteData, (newVal) => {
// when user only want to enter a single email
// we dont convert that as badge
const isSingleEmailValid = validateEmail(newVal.email)
if (isSingleEmailValid && !emailBadges.value.length) {
singleEmailValue.value = newVal.email
emailValidation.isError = false
return
}
singleEmailValue.value = ''
// when user enters multiple emails comma sepearted or space sepearted
const isNewEmail = newVal.email.charAt(newVal.email.length - 1) === ',' || newVal.email.charAt(newVal.email.length - 1) === ' '
if (isNewEmail && newVal.email.trim().length) {
const emailToAdd = newVal.email.split(',')[0].trim() || newVal.email.split(' ')[0].trim()
if (!validateEmail(emailToAdd)) {
emailValidation.isError = true
emailValidation.message = 'Invalid Email'
return
}
/**
if email is already enterd we delete the already
existing email and add new one
**/
if (emailBadges.value.includes(emailToAdd)) {
insertOrUpdateString(emailToAdd)
inviteData.email = ''
return
}
emailBadges.value.push(emailToAdd)
inviteData.email = ''
singleEmailValue.value = ''
}
if (!newVal.email.length && emailValidation.isError) {
emailValidation.isError = false
}
})
const handleEnter = () => {
const isEmailIsValid = emailInputValidation(inviteData.email)
if (!isEmailIsValid) return
inviteData.email += ' '
emailValidation.isError = false
emailValidation.message = ''
}
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
// remove one email per backspace
onKeyStroke('Backspace', () => {
if (isDivFocused.value && inviteData.email.length < 1) {
emailBadges.value.pop()
}
})
watch(dialogShow, (newVal) => {
if (newVal) {
setTimeout(() => {
focusOnDiv()
}, 100)
}
})
// when bulk email is pasted
const onPaste = (e: ClipboardEvent) => {
const pastedText = e.clipboardData?.getData('text')
const inputArray = pastedText?.split(',') || pastedText?.split(' ')
// if data is pasted to a already existing text in input
// we add existingInput + pasted data
if (inputArray?.length === 1 && inviteData.email.length) {
inputArray[0] = inviteData.email += inputArray[0]
}
inputArray?.forEach((el) => {
const isEmailIsValid = emailInputValidation(el)
if (!isEmailIsValid) return
/**
if email is already enterd we delete the already
existing email and add new one
**/
if (emailBadges.value.includes(el)) {
insertOrUpdateString(el)
return
}
emailBadges.value.push(el)
inviteData.email = ''
})
inviteData.email = ''
}
const inviteProjectCollaborator = async () => {
try {
const payloadData = singleEmailValue.value || emailBadges.value.join(',')
if (!payloadData.includes(',')) {
const validationStatus = validateEmail(payloadData)
if (!validationStatus) {
emailValidation.isError = true
emailValidation.message = 'invalid email'
}
}
await createProjectUser(activeProjectId.value!, {
email: payloadData,
roles: inviteData.roles,
} as unknown as User)
message.success('Invitation sent successfully')
inviteData.email = ''
emailBadges.value = []
dialogShow.value = false
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
singleEmailValue.value = ''
}
}
</script>
<template>
<NcModal
v-model:visible="dialogShow"
:show-separator="false"
:header="$t('activity.createTable')"
size="medium"
@keydown.esc="dialogShow = false"
>
<template #header>
<div class="flex flex-row items-center gap-x-2">
{{ $t('activity.addMember') }}
</div>
</template>
<div class="flex items-center justify-between gap-3 mt-2">
<div class="flex w-full flex-col">
<div class="flex justify-between gap-3 w-full">
<div
ref="divRef"
class="flex items-center border-1 gap-1 w-full overflow-x-scroll nc-scrollbar-x-md items-center h-10 rounded-lg !min-w-96"
tabindex="0"
:class="{
'border-primary/100': isDivFocused,
'p-1': emailBadges?.length > 1,
}"
@click="focusOnDiv"
@blur="isDivFocused = false"
>
<span
v-for="(email, index) in emailBadges"
:key="email"
class="border-1 text-gray-800 bg-gray-100 rounded-md flex items-center px-2 py-1"
>
{{ email }}
<component
:is="iconMap.close"
class="ml-0.5 hover:cursor-pointer mt-0.5 w-4 h-4"
@click="emailBadges.splice(index, 1)"
/>
</span>
<input
id="email"
ref="focusRef"
v-model="inviteData.email"
:placeholder="$t('activity.enterEmail')"
class="w-full min-w-36 outline-none px-2"
data-testid="email-input"
@keyup.enter="handleEnter"
@blur="isDivFocused = false"
@paste.prevent="onPaste"
/>
</div>
<RolesSelector
size="lg"
class="nc-invite-role-selector"
:role="inviteData.roles"
:roles="allowedRoles"
:on-role-change="(role: ProjectRoles) => (inviteData.roles = role)"
:description="false"
/>
</div>
<span v-if="emailValidation.isError && emailValidation.message" class="ml-2 text-red-500 text-[10px] mt-1.5">{{
emailValidation.message
}}</span>
</div>
</div>
<div class="flex mt-8 justify-end">
<div class="flex gap-2">
<NcButton type="secondary" @click="dialogShow = false"> {{ $t('labels.cancel') }} </NcButton>
<NcButton
type="primary"
size="medium"
:disabled="isInvitButtonDiabled || emailValidation.isError"
@click="inviteProjectCollaborator"
>
{{ $t('activity.inviteToBase') }}
</NcButton>
</div>
</div>
</NcModal>
</template>

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

@ -82,7 +82,7 @@ watch(
<div class="flex flex-row items-center gap-x-3"> <div class="flex flex-row items-center gap-x-3">
<GeneralOpenLeftSidebarBtn /> <GeneralOpenLeftSidebarBtn />
<div class="flex flex-row items-center h-full gap-x-2.5"> <div class="flex flex-row items-center h-full gap-x-2.5">
<GeneralProjectIcon :type="openedProject?.type" /> <GeneralProjectIcon :type="openedProject?.type" :color="parseProp(openedProject?.meta).iconColor" />
<NcTooltip class="flex font-medium text-sm capitalize truncate max-w-150" show-on-truncate-only> <NcTooltip class="flex font-medium text-sm capitalize truncate max-w-150" show-on-truncate-only>
<template #title> {{ openedProject?.title }}</template> <template #title> {{ openedProject?.title }}</template>
<span class="truncate"> <span class="truncate">
@ -140,7 +140,7 @@ watch(
</template> </template>
<ProjectAccessSettings /> <ProjectAccessSettings />
</a-tab-pane> </a-tab-pane>
<a-tab-pane v-if="isUIAllowed('baseCreate')" key="data-source"> <a-tab-pane v-if="isUIAllowed('sourceCreate')" key="data-source">
<template #tab> <template #tab>
<div class="tab-title" data-testid="proj-view-tab__data-sources"> <div class="tab-title" data-testid="proj-view-tab__data-sources">
<GeneralIcon icon="database" /> <GeneralIcon icon="database" />

2
packages/nc-gui/components/roles/Selector.vue

@ -11,7 +11,7 @@ const props = withDefaults(
description?: boolean description?: boolean
inherit?: string inherit?: string
onRoleChange: (role: keyof typeof RoleLabels) => void onRoleChange: (role: keyof typeof RoleLabels) => void
size: 'sm' | 'md' size: 'sm' | 'md' | 'lg'
}>(), }>(),
{ {
description: true, description: true,

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

@ -1541,19 +1541,9 @@ useEventListener(
'#E5D4F5', '#E5D4F5',
'#FFCFE6', '#FFCFE6',
]" ]"
:borders="[ color-box-border
'#6A7184', is-new-design
'#FF4A3F', class="nc-form-theme-color-picker !pb-0 !pl-0 -ml-1"
'#FA8231',
'#FCBE3A',
'#27D665',
'#36BFFF',
'#FC3AC6',
'#7D26CD',
'#B33771',
]"
:is-new-design="true"
class="nc-form-theme-color-picker !p-0 !-ml-1"
@input="handleChangeBackground" @input="handleChangeBackground"
/> />
</div> </div>

6
packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue

@ -4,7 +4,7 @@ import type { ColumnType } from 'nocodb-sdk'
import { type Row, computed, isPrimary, ref, useViewColumnsOrThrow } from '#imports' import { type Row, computed, isPrimary, ref, useViewColumnsOrThrow } from '#imports'
import { isRowEmpty } from '~/utils' import { isRowEmpty } from '~/utils'
const emit = defineEmits(['expand-record', 'new-record']) const emit = defineEmits(['expandRecord', 'newRecord'])
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
@ -192,7 +192,7 @@ const newRecord = () => {
[calendarRange.value[0].fk_from_col!.title!]: selectedDate.value.format('YYYY-MM-DD HH:mm:ssZ'), [calendarRange.value[0].fk_from_col!.title!]: selectedDate.value.format('YYYY-MM-DD HH:mm:ssZ'),
}, },
} }
emit('new-record', record) emit('newRecord', record)
} }
</script> </script>
@ -221,7 +221,7 @@ const newRecord = () => {
:resize="false" :resize="false"
color="blue" color="blue"
size="small" size="small"
@click="emit('expand-record', record)" @click="emit('expandRecord', record)"
> >
<template v-if="!isRowEmpty(record, displayField)"> <template v-if="!isRowEmpty(record, displayField)">
<LazySmartsheetCalendarCell <LazySmartsheetCalendarCell

13
packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue

@ -4,7 +4,7 @@ import type { ColumnType } from 'nocodb-sdk'
import { type Row, computed, isPrimary, ref, useViewColumnsOrThrow } from '#imports' import { type Row, computed, isPrimary, ref, useViewColumnsOrThrow } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils' import { generateRandomNumber, isRowEmpty } from '~/utils'
const emit = defineEmits(['expandRecord', 'new-record']) const emit = defineEmits(['expandRecord', 'newRecord'])
const { const {
// activeCalendarView, // activeCalendarView,
@ -847,7 +847,7 @@ const newRecord = (hour: dayjs.Dayjs) => {
[calendarRange.value[0].fk_from_col!.title!]: hour.format('YYYY-MM-DD HH:mm:ssZ'), [calendarRange.value[0].fk_from_col!.title!]: hour.format('YYYY-MM-DD HH:mm:ssZ'),
}, },
} }
emit('new-record', record) emit('newRecord', record)
} }
</script> </script>
@ -909,7 +909,7 @@ const newRecord = (hour: dayjs.Dayjs) => {
}, },
} }
} }
emit('new-record', record) emit('newRecord', record)
} }
" "
> >
@ -946,7 +946,7 @@ const newRecord = (hour: dayjs.Dayjs) => {
}, },
} }
} }
emit('new-record', record) emit('newRecord', record)
} }
" "
> >
@ -969,9 +969,9 @@ const newRecord = (hour: dayjs.Dayjs) => {
</div> </div>
<div class="absolute inset-0 pointer-events-none"> <div class="absolute inset-0 pointer-events-none">
<div class="relative !ml-[60px]" data-testid="nc-calendar-day-record-container"> <div class="relative !ml-[60px]" data-testid="nc-calendar-day-record-container">
<template v-for="(record, rowIndex) in recordsAcrossAllRange.record" :key="rowIndex">
<div <div
v-for="(record, rowIndex) in recordsAcrossAllRange.record" v-if="record.rowMeta.style?.display !== 'none'"
:key="rowIndex"
:data-testid="`nc-calendar-day-record-${record.row[displayField!.title!]}`" :data-testid="`nc-calendar-day-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id" :data-unique-id="record.rowMeta.id"
:style="record.rowMeta.style" :style="record.rowMeta.style"
@ -1014,6 +1014,7 @@ const newRecord = (hour: dayjs.Dayjs) => {
</LazySmartsheetCalendarVRecordCard> </LazySmartsheetCalendarVRecordCard>
</LazySmartsheetRow> </LazySmartsheetRow>
</div> </div>
</template>
</div> </div>
</div> </div>
</div> </div>

13
packages/nc-gui/components/smartsheet/calendar/MonthView.vue

@ -4,7 +4,7 @@ import type { ColumnType } from 'nocodb-sdk'
import { type Row, computed, isPrimary, ref, useViewColumnsOrThrow } from '#imports' import { type Row, computed, isPrimary, ref, useViewColumnsOrThrow } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils' import { generateRandomNumber, isRowEmpty } from '~/utils'
const emit = defineEmits(['new-record', 'expandRecord']) const emit = defineEmits(['newRecord', 'expandRecord'])
const { const {
selectedDate, selectedDate,
@ -635,7 +635,7 @@ const addRecord = (date: dayjs.Dayjs) => {
[fromCol.title!]: date.format('YYYY-MM-DD HH:mm:ssZ'), [fromCol.title!]: date.format('YYYY-MM-DD HH:mm:ssZ'),
}, },
} }
emit('new-record', newRecord) emit('newRecord', newRecord)
} }
</script> </script>
@ -710,7 +710,7 @@ const addRecord = (date: dayjs.Dayjs) => {
[range.fk_from_col!.title!]: dayjs(day).format('YYYY-MM-DD HH:mm:ssZ'), [range.fk_from_col!.title!]: dayjs(day).format('YYYY-MM-DD HH:mm:ssZ'),
}, },
} }
emit('new-record', record) emit('newRecord', record)
} }
" "
> >
@ -738,7 +738,7 @@ const addRecord = (date: dayjs.Dayjs) => {
[calendarRange[0].fk_from_col!.title!]: (day).format('YYYY-MM-DD HH:mm:ssZ'), [calendarRange[0].fk_from_col!.title!]: (day).format('YYYY-MM-DD HH:mm:ssZ'),
}, },
} }
emit('new-record', record) emit('newRecord', record)
} }
" "
> >
@ -772,9 +772,9 @@ const addRecord = (date: dayjs.Dayjs) => {
</div> </div>
</div> </div>
<div class="absolute inset-0 pointer-events-none mt-8 pb-7.5" data-testid="nc-calendar-month-record-container"> <div class="absolute inset-0 pointer-events-none mt-8 pb-7.5" data-testid="nc-calendar-month-record-container">
<template v-for="(record, recordIndex) in recordsToDisplay.records" :key="recordIndex">
<div <div
v-for="(record, recordIndex) in recordsToDisplay.records" v-if="record.rowMeta.style?.display !== 'none'"
:key="recordIndex"
:data-testid="`nc-calendar-month-record-${record.row[displayField!.title!]}`" :data-testid="`nc-calendar-month-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id" :data-unique-id="record.rowMeta.id"
:style="{ :style="{
@ -818,6 +818,7 @@ const addRecord = (date: dayjs.Dayjs) => {
</LazySmartsheetCalendarRecordCard> </LazySmartsheetCalendarRecordCard>
</LazySmartsheetRow> </LazySmartsheetRow>
</div> </div>
</template>
</div> </div>
</div> </div>
</template> </template>

4
packages/nc-gui/components/smartsheet/calendar/SideMenu.vue

@ -8,7 +8,7 @@ const props = defineProps<{
visible: boolean visible: boolean
}>() }>()
const emit = defineEmits(['expand-record', 'newRecord']) const emit = defineEmits(['expandRecord', 'newRecord'])
const INFINITY_SCROLL_THRESHOLD = 100 const INFINITY_SCROLL_THRESHOLD = 100
@ -428,7 +428,7 @@ onUnmounted(() => {
" "
color="blue" color="blue"
data-testid="nc-sidebar-record-card" data-testid="nc-sidebar-record-card"
@click="emit('expand-record', record)" @click="emit('expandRecord', record)"
@dragstart="dragStart($event, record)" @dragstart="dragStart($event, record)"
@dragover.prevent @dragover.prevent
> >

15
packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue

@ -5,7 +5,7 @@ import type { Row } from '~/lib'
import { computed, isPrimary, ref, useViewColumnsOrThrow } from '#imports' import { computed, isPrimary, ref, useViewColumnsOrThrow } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils' import { generateRandomNumber, isRowEmpty } from '~/utils'
const emits = defineEmits(['expandRecord']) const emits = defineEmits(['expandRecord', 'newRecord'])
const { selectedDateRange, formattedData, formattedSideBarData, calendarRange, selectedDate, displayField, updateRowProperty } = const { selectedDateRange, formattedData, formattedSideBarData, calendarRange, selectedDate, displayField, updateRowProperty } =
useCalendarViewStoreOrThrow() useCalendarViewStoreOrThrow()
@ -529,7 +529,7 @@ const addRecord = (date: dayjs.Dayjs) => {
[fromCol.title!]: date.format('YYYY-MM-DD HH:mm:ssZ'), [fromCol.title!]: date.format('YYYY-MM-DD HH:mm:ssZ'),
}, },
} }
emits('new-record', newRecord) emits('newRecord', newRecord)
} }
</script> </script>
@ -565,28 +565,28 @@ const addRecord = (date: dayjs.Dayjs) => {
class="absolute nc-scrollbar-md overflow-y-auto mt-9 pointer-events-none inset-0" class="absolute nc-scrollbar-md overflow-y-auto mt-9 pointer-events-none inset-0"
data-testid="nc-calendar-week-record-container" data-testid="nc-calendar-week-record-container"
> >
<template v-for="(record, id) in calendarData" :key="id">
<div <div
v-for="(record, id) in calendarData" v-if="record.rowMeta.style?.display !== 'none'"
:key="id"
:data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`" :data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id" :data-unique-id="record.rowMeta.id"
:style="{ :style="{
...record.rowMeta.style, ...record.rowMeta.style,
}" }"
class="absolute group draggable-record pointer-events-auto nc-calendar-week-record-card" class="absolute group draggable-record pointer-events-auto nc-calendar-week-record-card"
@mousedown.stop="dragStart($event, record)"
@mouseleave="hoverRecord = null" @mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id" @mouseover="hoverRecord = record.rowMeta.id"
@mousedown.stop="dragStart($event, record)"
> >
<LazySmartsheetRow :row="record"> <LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard <LazySmartsheetCalendarRecordCard
:hover="hoverRecord === record.rowMeta.id || record.rowMeta.id === dragRecord?.rowMeta?.id" :hover="hoverRecord === record.rowMeta.id || record.rowMeta.id === dragRecord?.rowMeta?.id"
:position="record.rowMeta.position" :position="record.rowMeta.position"
:record="record" :record="record"
:selected="dragRecord?.rowMeta?.id === record.rowMeta.id"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')" :resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
:selected="dragRecord?.rowMeta?.id === record.rowMeta.id"
color="blue" color="blue"
@dblclick.stop="emits('expand-record', record)" @dblclick.stop="emits('expandRecord', record)"
@resize-start="onResizeStart" @resize-start="onResizeStart"
> >
<template v-if="!isRowEmpty(record, displayField)"> <template v-if="!isRowEmpty(record, displayField)">
@ -611,6 +611,7 @@ const addRecord = (date: dayjs.Dayjs) => {
</LazySmartsheetCalendarRecordCard> </LazySmartsheetCalendarRecordCard>
</LazySmartsheetRow> </LazySmartsheetRow>
</div> </div>
</template>
</div> </div>
</div> </div>
</template> </template>

9
packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue

@ -5,7 +5,7 @@ import type { Row } from '~/lib'
import { computed, isPrimary, ref, useViewColumnsOrThrow } from '#imports' import { computed, isPrimary, ref, useViewColumnsOrThrow } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils' import { generateRandomNumber, isRowEmpty } from '~/utils'
const emits = defineEmits(['expandRecord']) const emits = defineEmits(['expandRecord', 'newRecord'])
const { const {
selectedDateRange, selectedDateRange,
@ -733,7 +733,7 @@ const addRecord = (date: dayjs.Dayjs) => {
[fromCol.title!]: date.format('YYYY-MM-DD HH:mm:ssZ'), [fromCol.title!]: date.format('YYYY-MM-DD HH:mm:ssZ'),
}, },
} }
emits('new-record', newRecord) emits('newRecord', newRecord)
} }
</script> </script>
@ -805,9 +805,9 @@ const addRecord = (date: dayjs.Dayjs) => {
class="absolute pointer-events-none inset-0 overflow-hidden !mt-[29px]" class="absolute pointer-events-none inset-0 overflow-hidden !mt-[29px]"
data-testid="nc-calendar-week-record-container" data-testid="nc-calendar-week-record-container"
> >
<template v-for="(record, rowIndex) in recordsAcrossAllRange.records" :key="rowIndex">
<div <div
v-for="(record, rowIndex) in recordsAcrossAllRange.records" v-if="record.rowMeta.style?.display !== 'none'"
:key="rowIndex"
:data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`" :data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta!.id" :data-unique-id="record.rowMeta!.id"
:style="record.rowMeta!.style " :style="record.rowMeta!.style "
@ -850,6 +850,7 @@ const addRecord = (date: dayjs.Dayjs) => {
</LazySmartsheetCalendarVRecordCard> </LazySmartsheetCalendarVRecordCard>
</LazySmartsheetRow> </LazySmartsheetRow>
</div> </div>
</template>
</div> </div>
</div> </div>
</div> </div>

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

@ -83,6 +83,7 @@ const gridDisplayValueField = computed(() => {
}) })
const onMove = async (_event: { moved: { newIndex: number; oldIndex: number } }, undo = false) => { const onMove = async (_event: { moved: { newIndex: number; oldIndex: number } }, undo = false) => {
try {
// todo : sync with server // todo : sync with server
if (!fields.value) return if (!fields.value) return
@ -135,6 +136,9 @@ const onMove = async (_event: { moved: { newIndex: number; oldIndex: number } },
reloadViewDataHook?.trigger() reloadViewDataHook?.trigger()
$e('a:fields:reorder') $e('a:fields:reorder')
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
} }
const coverOptions = computed<SelectProps['options']>(() => { const coverOptions = computed<SelectProps['options']>(() => {

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

@ -3,6 +3,25 @@ import { OrgUserRoles, ProjectRoles, extractRolesObj } from 'nocodb-sdk'
import type { GridType } from 'nocodb-sdk' import type { GridType } from 'nocodb-sdk'
import { ActiveViewInj, IsLockedInj, iconMap, inject, ref, storeToRefs, useMenuCloseOnEsc, useUndoRedo } from '#imports' import { ActiveViewInj, IsLockedInj, iconMap, inject, ref, storeToRefs, useMenuCloseOnEsc, useUndoRedo } from '#imports'
const rowHeightOptions: { icon: keyof typeof iconMap; heightClass: string }[] = [
{
icon: 'heightShort',
heightClass: 'short',
},
{
icon: 'heightMedium',
heightClass: 'medium',
},
{
icon: 'heightTall',
heightClass: 'tall',
},
{
icon: 'heightExtra',
heightClass: 'extra',
},
]
const { isSharedBase } = storeToRefs(useBase()) const { isSharedBase } = storeToRefs(useBase())
const view = inject(ActiveViewInj, ref()) const view = inject(ActiveViewInj, ref())
@ -75,38 +94,21 @@ useMenuCloseOnEsc(open)
</div> </div>
<template #overlay> <template #overlay>
<div <div
class="w-full bg-white shadow-lg p-1.5 menu-filter-dropdown border-1 border-gray-200 rounded-md overflow-hidden" class="w-full bg-white shadow-lg p-1.5 menu-filter-dropdown border-1 border-gray-200 rounded-md overflow-hidden w-[160px]"
data-testid="nc-height-menu" data-testid="nc-height-menu"
> >
<div class="flex flex-col w-full text-sm" @click.stop> <div class="flex flex-col w-full text-sm" @click.stop>
<div class="text-xs text-gray-500 px-3 pt-2 pb-1 select-none">{{ $t('objects.rowHeight') }}</div> <div class="text-xs text-gray-500 px-3 pt-2 pb-1 select-none">{{ $t('objects.rowHeight') }}</div>
<div class="nc-row-height-option" @click="updateRowHeight(0)"> <div v-for="(item, i) of rowHeightOptions" class="nc-row-height-option" @click="updateRowHeight(i)">
<GeneralIcon icon="heightShort" class="nc-row-height-icon" /> <div class="flex items-center gap-2">
{{ $t('objects.heightClass.short') }} <GeneralIcon :icon="item.icon" class="nc-row-height-icon" />
<component :is="iconMap.check" v-if="!(view?.view as GridType).row_height" class="text-primary w-4 h-4" /> {{ $t(`objects.heightClass.${item.heightClass}`) }}
</div>
<div class="nc-row-height-option" @click="updateRowHeight(1)">
<GeneralIcon icon="heightMedium" class="nc-row-height-icon" />
{{ $t('objects.heightClass.medium') }}
<component :is="iconMap.check" v-if=" (view?.view as GridType).row_height === 1" class="text-primary w-4 h-4" />
</div> </div>
<div <component
class="nc-row-height-option" :is="iconMap.check"
:class="{'active': (view?.view as GridType).row_height === 2}" v-if="i === 0 ? !(view?.view as GridType).row_height: (view?.view as GridType).row_height === i"
@click="updateRowHeight(2)" class="text-primary w-4 h-4"
> />
<GeneralIcon icon="heightTall" class="nc-row-height-icon" />
{{ $t('objects.heightClass.tall') }}
<component :is="iconMap.check" v-if=" (view?.view as GridType).row_height === 2" class="text-primary w-4 h-4" />
</div>
<div
class="nc-row-height-option"
:class="{'active': (view?.view as GridType).row_height === 3}"
@click="updateRowHeight(3)"
>
<GeneralIcon icon="heightExtra" class="nc-row-height-icon" />
{{ $t('objects.heightClass.extra') }}
<component :is="iconMap.check" v-if=" (view?.view as GridType).row_height === 3" class="text-primary w-4 h-4" />
</div> </div>
</div> </div>
</div> </div>
@ -116,7 +118,7 @@ useMenuCloseOnEsc(open)
<style scoped> <style scoped>
.nc-row-height-option { .nc-row-height-option {
@apply flex items-center gap-2 p-2 justify-start hover:bg-gray-100 rounded-md cursor-pointer text-gray-600; @apply flex items-center gap-2 p-2 justify-between hover:bg-gray-100 rounded-md cursor-pointer text-gray-600;
} }
.nc-row-height-icon { .nc-row-height-icon {

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

@ -50,7 +50,7 @@ const openedBaseUrl = computed(() => {
</template> </template>
<div class="flex flex-row items-center gap-x-1.5"> <div class="flex flex-row items-center gap-x-1.5">
<GeneralProjectIcon <GeneralProjectIcon
:meta="{ type: base?.type }" :type="base?.type"
class="!grayscale min-w-4" class="!grayscale min-w-4"
:style="{ :style="{
filter: 'grayscale(100%) brightness(115%)', filter: 'grayscale(100%) brightness(115%)',

20
packages/nc-gui/components/workspace/CreateProjectDlg.vue

@ -3,7 +3,16 @@ import type { RuleObject } from 'ant-design-vue/es/form'
import type { Form, Input } from 'ant-design-vue' import type { Form, Input } from 'ant-design-vue'
import type { VNodeRef } from '@vue/runtime-core' import type { VNodeRef } from '@vue/runtime-core'
import { computed } from '@vue/reactivity' import { computed } from '@vue/reactivity'
import { NcProjectType, baseTitleValidator, extractSdkResponseErrorMsg, ref, useGlobal, useI18n, useVModel } from '#imports' import {
NcProjectType,
baseIconColors,
baseTitleValidator,
extractSdkResponseErrorMsg,
ref,
useGlobal,
useI18n,
useVModel,
} from '#imports'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@ -38,6 +47,9 @@ const form = ref<typeof Form>()
const formState = ref({ const formState = ref({
title: '', title: '',
meta: {
iconColor: baseIconColors[Math.floor(Math.random() * 1000) % baseIconColors.length],
},
}) })
const creating = ref(false) const creating = ref(false)
@ -48,6 +60,7 @@ const createProject = async () => {
const base = await _createProject({ const base = await _createProject({
type: baseType.value, type: baseType.value,
title: formState.value.title, title: formState.value.title,
meta: formState.value.meta,
}) })
navigateToProject({ navigateToProject({
@ -77,6 +90,9 @@ watch(dialogShow, async (n, o) => {
formState.value = { formState.value = {
title: 'Base', title: 'Base',
meta: {
iconColor: baseIconColors[Math.floor(Math.random() * 1000) % baseIconColors.length],
},
} }
await nextTick() await nextTick()
@ -100,7 +116,7 @@ const typeLabel = computed(() => {
<template #header> <template #header>
<!-- Create A New Table --> <!-- Create A New Table -->
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">
<GeneralProjectIcon :type="baseType" class="mr-2.5 !text-lg !h-4" /> <GeneralProjectIcon :color="formState.meta.iconColor" :type="baseType" class="mr-2.5 !text-lg !h-4" />
{{ {{
$t('general.createEntity', { $t('general.createEntity', {
entity: typeLabel, entity: typeLabel,

2
packages/nc-gui/components/workspace/InviteSection.vue

@ -6,7 +6,7 @@ import { validateEmail } from '~/utils/validation'
const inviteData = reactive({ const inviteData = reactive({
email: '', email: '',
roles: WorkspaceUserRoles.VIEWER, roles: WorkspaceUserRoles.NO_ACCESS,
}) })
const focusRef = ref<HTMLInputElement>() const focusRef = ref<HTMLInputElement>()

24
packages/nc-gui/components/workspace/ProjectList.vue

@ -10,6 +10,7 @@ import {
isEeUI, isEeUI,
message, message,
navigateTo, navigateTo,
parseProp,
ref, ref,
storeToRefs, storeToRefs,
useBases, useBases,
@ -181,16 +182,16 @@ function onProjectTitleClick(index: number) {
} }
} }
const setIcon = async (icon: string, base: BaseType) => { const setColor = async (color: string, base: BaseType) => {
try { try {
const meta = { const meta = {
...((base.meta as object) || {}), ...parseProp(base.meta),
icon, iconColor: color,
} }
basesStore.updateProject(base.id!, { meta: JSON.stringify(meta) }) basesStore.updateProject(base.id!, { meta: JSON.stringify(meta) })
$e('a:base:icon:navdraw', { icon }) $e('a:base:icon:color:navdraw', { iconColor: color })
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
@ -250,15 +251,14 @@ const setIcon = async (icon: string, base: BaseType) => {
<template v-if="column.dataIndex === 'title'"> <template v-if="column.dataIndex === 'title'">
<div class="flex items-center nc-base-title gap-2.5 max-w-full -ml-1.5"> <div class="flex items-center nc-base-title gap-2.5 max-w-full -ml-1.5">
<div class="flex items-center gap-2 text-center"> <div class="flex items-center gap-2 text-center">
<LazyGeneralEmojiPicker <GeneralBaseIconColorPicker
:key="record.id" :key="`${record.id}_${parseProp(record.meta).iconColor}`"
:emoji="record.meta?.icon" :type="record?.type"
size="small" :model-value="parseProp(record.meta).iconColor"
readonly :readonly="(record?.type && record?.type !== 'database') || !isUIAllowed('baseRename')"
@emoji-selected="setIcon($event, record)" @update:model-value="setColor($event, record)"
> >
<GeneralProjectIcon :type="record.type" /> </GeneralBaseIconColorPicker>
</LazyGeneralEmojiPicker>
<!-- todo: replace with switch --> <!-- todo: replace with switch -->
</div> </div>

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

@ -1,8 +1,8 @@
import { isString } from '@vue/shared' import { isString } from '@vue/shared'
import type { Roles, RolesObj, WorkspaceUserRoles } from 'nocodb-sdk' import type { Roles, RolesObj, WorkspaceUserRoles } from 'nocodb-sdk'
import { extractRolesObj } from 'nocodb-sdk' import { extractRolesObj } from 'nocodb-sdk'
import { computed, createSharedComposable, rolePermissions, useApi, useGlobal } from '#imports'
import type { Permission } from '#imports' import type { Permission } from '#imports'
import { computed, createSharedComposable, rolePermissions, useApi, useGlobal } from '#imports'
const hasPermission = (role: Exclude<Roles, WorkspaceUserRoles>, hasRole: boolean, permission: Permission | string) => { const hasPermission = (role: Exclude<Roles, WorkspaceUserRoles>, hasRole: boolean, permission: Permission | string) => {
const rolePermission = rolePermissions[role] const rolePermission = rolePermissions[role]

8
packages/nc-gui/lang/en.json

@ -702,6 +702,10 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
"goToToday": "Go to Today", "goToToday": "Go to Today",
"toggleSidebar": "Toggle Sidebar", "toggleSidebar": "Toggle Sidebar",
@ -966,8 +970,8 @@
"clientKey": "Select .key file", "clientKey": "Select .key file",
"clientCert": "Select .cert file", "clientCert": "Select .cert file",
"clientCA": "Select CA file", "clientCA": "Select CA file",
"changeIconColour": "Change Icon Colour", "changeIconColour": "Change icon colour",
"preFillFormInfo": "To get a prefilled link, make sure you’ve filled the necessary fields in the form view builder.", "preFillFormInfo": "Generate share form URL with pre-filled field data. To get a pre-filled link, make sure you’ve filled the necessary fields in the form view builder.",
"surveyFormInfo": "Form mode with one field per page" "surveyFormInfo": "Form mode with one field per page"
}, },
"placeholder": { "placeholder": {

9
packages/nc-gui/lib/acl.ts

@ -2,7 +2,14 @@ import { OrgUserRoles, ProjectRoles } from 'nocodb-sdk'
const roleScopes = { const roleScopes = {
org: [OrgUserRoles.VIEWER, OrgUserRoles.CREATOR], org: [OrgUserRoles.VIEWER, OrgUserRoles.CREATOR],
base: [ProjectRoles.VIEWER, ProjectRoles.COMMENTER, ProjectRoles.EDITOR, ProjectRoles.CREATOR, ProjectRoles.OWNER], base: [
ProjectRoles.NO_ACCESS,
ProjectRoles.VIEWER,
ProjectRoles.COMMENTER,
ProjectRoles.EDITOR,
ProjectRoles.CREATOR,
ProjectRoles.OWNER,
],
} }
interface Perm { interface Perm {

7
packages/nc-gui/middleware/01.security.global.ts

@ -6,11 +6,16 @@ export default defineNuxtRouteMiddleware(async (to) => {
return return
} }
// allow for shared views // allow for shared views based on page layout
if (to.meta?.layout === 'shared-view') { if (to.meta?.layout === 'shared-view') {
return return
} }
// allow for shared views based on pageType meta prop
if (to.meta?.pageType === 'shared-view') {
return
}
// throw for all other pages // throw for all other pages
throw createError({ statusCode: 403, message: 'Not allowed' }) throw createError({ statusCode: 403, message: 'Not allowed' })
} }

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

@ -22,6 +22,7 @@ import {
definePageMeta({ definePageMeta({
public: true, public: true,
pageType: 'shared-view',
}) })
useSidebar('nc-left-sidebar', { hasSidebar: false }) useSidebar('nc-left-sidebar', { hasSidebar: false })

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

@ -201,6 +201,7 @@ export const useBases = defineStore('basesStore', () => {
isExpanded: route.value.params.baseId === baseId || existingProject.isExpanded, isExpanded: route.value.params.baseId === baseId || existingProject.isExpanded,
// isLoading is managed by Sidebar // isLoading is managed by Sidebar
isLoading: existingProject.isLoading, isLoading: existingProject.isLoading,
meta: { ...parseProp(existingProject.meta), ...parseProp(_project.meta) },
} }
bases.value.set(baseId, base) bases.value.set(baseId, base)
@ -229,7 +230,7 @@ export const useBases = defineStore('basesStore', () => {
...baseUpdatePayload, ...baseUpdatePayload,
} }
bases.value.set(baseId, base) bases.value.set(baseId, { ...base, meta: parseProp(base.meta) })
await api.base.update(baseId, baseUpdatePayload) await api.base.update(baseId, baseUpdatePayload)
@ -241,11 +242,15 @@ export const useBases = defineStore('basesStore', () => {
workspaceId?: string workspaceId?: string
type: string type: string
linkedDbProjectIds?: string[] linkedDbProjectIds?: string[]
meta?: Record<string, unknown>
}) => { }) => {
const result = await api.base.create( const result = await api.base.create(
{ {
title: basePayload.title, title: basePayload.title,
linked_db_project_ids: basePayload.linkedDbProjectIds, linked_db_project_ids: basePayload.linkedDbProjectIds,
meta: JSON.stringify({
...(basePayload.meta || {}),
}),
}, },
{ {
baseURL: getBaseUrl('nc'), baseURL: getBaseUrl('nc'),

2
packages/nc-gui/utils/colorsUtils.ts

@ -111,6 +111,8 @@ export const baseThemeColors = [
'#333333', '#333333',
] ]
export const baseIconColors = ['#36BFFF', '#FA8231', '#FCBE3A', '#27D665', '#6A7184', '#FF4A3F', '#FC3AC6', '#7D26CD']
const designSystem = { const designSystem = {
light: [ light: [
// '#EBF0FF', // '#EBF0FF',

21
packages/noco-docs/docs/070.fields/040.field-types/060.formula/030.string-functions.md

@ -216,6 +216,27 @@ URL(text)
URL('https://www.example.com') => a clickable link for https://www.example.com URL('https://www.example.com') => a clickable link for https://www.example.com
``` ```
## URLENCODE
The URLENCODE function percent-encodes special characters in a string so it can
be substituted as a query parameter into a URL.
It is similar to JavaScript `encodeURIComponent()` function, except it encodes
only characters that have a special meaning according to RFC 3986 section 2.2
and also percent signs and spaces; other characters such as letters from
non-Latin alphabets will not be encoded. Like `encodeURIComponent()`, it should
be used only for encoding URL components, not whole URLs.
#### Syntax
```plaintext
URLENCODE(text)
```
#### Sample
```plaintext
'https://example.com/q?param=' & URLENCODE('Hello, world')
=> 'https://example.com/q?param=Hello%2C%20world'
```
## Related Articles ## Related Articles
- [Numeric and Logical Operators](015.operators.md) - [Numeric and Logical Operators](015.operators.md)

7
packages/nocodb-sdk/src/lib/enums.ts

@ -19,6 +19,7 @@ export enum WorkspaceUserRoles {
VIEWER = 'workspace-level-viewer', VIEWER = 'workspace-level-viewer',
EDITOR = 'workspace-level-editor', EDITOR = 'workspace-level-editor',
COMMENTER = 'workspace-level-commenter', COMMENTER = 'workspace-level-commenter',
NO_ACCESS = 'workspace-level-no-access',
} }
export enum AppEvents { export enum AppEvents {
@ -167,6 +168,7 @@ export const RoleLabels = {
[WorkspaceUserRoles.EDITOR]: 'editor', [WorkspaceUserRoles.EDITOR]: 'editor',
[WorkspaceUserRoles.COMMENTER]: 'commenter', [WorkspaceUserRoles.COMMENTER]: 'commenter',
[WorkspaceUserRoles.VIEWER]: 'viewer', [WorkspaceUserRoles.VIEWER]: 'viewer',
[WorkspaceUserRoles.NO_ACCESS]: 'noaccess',
[ProjectRoles.OWNER]: 'owner', [ProjectRoles.OWNER]: 'owner',
[ProjectRoles.CREATOR]: 'creator', [ProjectRoles.CREATOR]: 'creator',
[ProjectRoles.EDITOR]: 'editor', [ProjectRoles.EDITOR]: 'editor',
@ -184,6 +186,7 @@ export const RoleColors = {
[WorkspaceUserRoles.EDITOR]: 'green', [WorkspaceUserRoles.EDITOR]: 'green',
[WorkspaceUserRoles.COMMENTER]: 'orange', [WorkspaceUserRoles.COMMENTER]: 'orange',
[WorkspaceUserRoles.VIEWER]: 'yellow', [WorkspaceUserRoles.VIEWER]: 'yellow',
[WorkspaceUserRoles.NO_ACCESS]: 'red',
[ProjectRoles.OWNER]: 'purple', [ProjectRoles.OWNER]: 'purple',
[ProjectRoles.CREATOR]: 'blue', [ProjectRoles.CREATOR]: 'blue',
[ProjectRoles.EDITOR]: 'green', [ProjectRoles.EDITOR]: 'green',
@ -203,6 +206,7 @@ export const RoleDescriptions = {
[WorkspaceUserRoles.COMMENTER]: [WorkspaceUserRoles.COMMENTER]:
'Can view and comment data in workspace bases', 'Can view and comment data in workspace bases',
[WorkspaceUserRoles.VIEWER]: 'Can view data in workspace bases', [WorkspaceUserRoles.VIEWER]: 'Can view data in workspace bases',
[WorkspaceUserRoles.NO_ACCESS]: 'Cannot access this workspace',
[ProjectRoles.OWNER]: 'Full access to base', [ProjectRoles.OWNER]: 'Full access to base',
[ProjectRoles.CREATOR]: [ProjectRoles.CREATOR]:
'Can create tables, views, setup webhook, invite collaborators and more', 'Can create tables, views, setup webhook, invite collaborators and more',
@ -222,6 +226,7 @@ export const RoleIcons = {
[WorkspaceUserRoles.EDITOR]: 'role_editor', [WorkspaceUserRoles.EDITOR]: 'role_editor',
[WorkspaceUserRoles.COMMENTER]: 'role_commenter', [WorkspaceUserRoles.COMMENTER]: 'role_commenter',
[WorkspaceUserRoles.VIEWER]: 'role_viewer', [WorkspaceUserRoles.VIEWER]: 'role_viewer',
[WorkspaceUserRoles.NO_ACCESS]: 'role_no_access',
[ProjectRoles.OWNER]: 'role_owner', [ProjectRoles.OWNER]: 'role_owner',
[ProjectRoles.CREATOR]: 'role_creator', [ProjectRoles.CREATOR]: 'role_creator',
[ProjectRoles.EDITOR]: 'role_editor', [ProjectRoles.EDITOR]: 'role_editor',
@ -239,6 +244,7 @@ export const WorkspaceRolesToProjectRoles = {
[WorkspaceUserRoles.EDITOR]: ProjectRoles.EDITOR, [WorkspaceUserRoles.EDITOR]: ProjectRoles.EDITOR,
[WorkspaceUserRoles.COMMENTER]: ProjectRoles.COMMENTER, [WorkspaceUserRoles.COMMENTER]: ProjectRoles.COMMENTER,
[WorkspaceUserRoles.VIEWER]: ProjectRoles.VIEWER, [WorkspaceUserRoles.VIEWER]: ProjectRoles.VIEWER,
[WorkspaceUserRoles.NO_ACCESS]: ProjectRoles.NO_ACCESS,
}; };
export const OrderedWorkspaceRoles = [ export const OrderedWorkspaceRoles = [
@ -247,6 +253,7 @@ export const OrderedWorkspaceRoles = [
WorkspaceUserRoles.EDITOR, WorkspaceUserRoles.EDITOR,
WorkspaceUserRoles.COMMENTER, WorkspaceUserRoles.COMMENTER,
WorkspaceUserRoles.VIEWER, WorkspaceUserRoles.VIEWER,
WorkspaceUserRoles.NO_ACCESS,
]; ];
export const OrderedOrgRoles = [ export const OrderedOrgRoles = [

16
packages/nocodb-sdk/src/lib/formulaHelpers.ts

@ -1056,6 +1056,22 @@ export const formulas: Record<string, FormulaMeta> = {
examples: ['URL("https://github.com/nocodb/nocodb")', 'URL({column1})'], examples: ['URL("https://github.com/nocodb/nocodb")', 'URL({column1})'],
returnType: FormulaDataTypes.STRING, returnType: FormulaDataTypes.STRING,
}, },
URLENCODE: {
docsUrl:
'https://docs.nocodb.com/fields/field-types/formula/string-functions#urlencode',
validation: {
args: {
rqd: 1,
type: FormulaDataTypes.STRING,
},
},
description:
'Percent-encode the input parameter for use in URLs',
syntax: 'URLENCODE(str)',
examples: ['URLENCODE("Hello, world") => "Hello%2C%20world"', 'URLENCODE({column1})'],
returnType: FormulaDataTypes.STRING,
},
WEEKDAY: { WEEKDAY: {
docsUrl: docsUrl:
'https://docs.nocodb.com/fields/field-types/formula/date-functions#weekday', 'https://docs.nocodb.com/fields/field-types/formula/date-functions#weekday',

4
packages/nocodb/src/controllers/view-columns.controller.ts

@ -58,8 +58,8 @@ export class ViewColumnsController {
'/api/v1/db/meta/views/:viewId/columns/:columnId', '/api/v1/db/meta/views/:viewId/columns/:columnId',
'/api/v2/meta/views/:viewId/columns/:columnId', '/api/v2/meta/views/:viewId/columns/:columnId',
]) ])
@Acl('columnUpdate') @Acl('viewColumnUpdate')
async columnUpdate( async viewColumnUpdate(
@Param('viewId') viewId: string, @Param('viewId') viewId: string,
@Param('columnId') columnId: string, @Param('columnId') columnId: string,
@Body() body: ViewColumnReqType, @Body() body: ViewColumnReqType,

12
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -318,14 +318,14 @@ class BaseModelSqlv2 {
sortArr?: Sort[]; sortArr?: Sort[];
sort?: string | string[]; sort?: string | string[];
fieldsSet?: Set<string>; fieldsSet?: Set<string>;
calendarLimitOverride?: number; limitOverride?: number;
} = {}, } = {},
options: { options: {
ignoreViewFilterAndSort?: boolean; ignoreViewFilterAndSort?: boolean;
ignorePagination?: boolean; ignorePagination?: boolean;
validateFormula?: boolean; validateFormula?: boolean;
throwErrorIfInvalidParams?: boolean; throwErrorIfInvalidParams?: boolean;
calendarLimitOverride?: number; limitOverride?: number;
} = {}, } = {},
): Promise<any> { ): Promise<any> {
const { const {
@ -333,7 +333,7 @@ class BaseModelSqlv2 {
ignorePagination = false, ignorePagination = false,
validateFormula = false, validateFormula = false,
throwErrorIfInvalidParams = false, throwErrorIfInvalidParams = false,
calendarLimitOverride, limitOverride,
} = options; } = options;
const { where, fields, ...rest } = this._getListArgs(args as any); const { where, fields, ...rest } = this._getListArgs(args as any);
@ -439,12 +439,12 @@ class BaseModelSqlv2 {
}); });
} }
// For calendar View, if calendarLimitOverride is provided, use it as limit for the query // if limitOverride is provided, use it as limit for the query (for internal usage eg. calendar, export)
if (!ignorePagination) { if (!ignorePagination) {
if (!calendarLimitOverride) { if (!limitOverride) {
applyPaginate(qb, rest); applyPaginate(qb, rest);
} else { } else {
applyPaginate(qb, { ...rest, limit: calendarLimitOverride }); applyPaginate(qb, { ...rest, limit: limitOverride });
} }
} }
const proto = await this.getProto(); const proto = await this.getProto();

11
packages/nocodb/src/db/functionMappings/commonFns.ts

@ -384,4 +384,15 @@ export default {
builder: knex.raw(`(${valueBuilder} IS NOT NULL)${colAlias}`), builder: knex.raw(`(${valueBuilder} IS NOT NULL)${colAlias}`),
}; };
}, },
URLENCODE: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const specialCharacters = '% :/?#[]@$&+,;=';
let str = (await fn(pt.arguments[0])).builder;
// Pass the characters as bound parameters to avoid problems with ? sign.
for (const c of specialCharacters) {
str = `REPLACE(${str}, ?, '${encodeURIComponent(c)}')`;
}
return {
builder: knex.raw(`${str} ${colAlias}`, specialCharacters.split('')),
};
},
}; };

1
packages/nocodb/src/helpers/PagedResponse.ts

@ -11,6 +11,7 @@ export class PagedResponseImpl<T> {
count?: number | string; count?: number | string;
l?: number; l?: number;
o?: number; o?: number;
limitOverride?: number;
} = {}, } = {},
additionalProps?: Record<string, any>, additionalProps?: Record<string, any>,
) { ) {

6
packages/nocodb/src/helpers/extractLimitAndOffset.ts

@ -15,6 +15,7 @@ export function extractLimitAndOffset(
offset?: number | string; offset?: number | string;
l?: number | string; l?: number | string;
o?: number | string; o?: number | string;
limitOverride?: number;
} = {}, } = {},
) { ) {
const obj: { const obj: {
@ -40,5 +41,10 @@ export function extractLimitAndOffset(
const offset = +(args.offset || args.o) || 0; const offset = +(args.offset || args.o) || 0;
obj.offset = Math.max(Number.isInteger(offset) ? offset : 0, 0); obj.offset = Math.max(Number.isInteger(offset) ? offset : 0, 0);
// override limit if provided
if (args.limitOverride) {
obj.limit = +args.limitOverride;
}
return obj; return obj;
} }

1
packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts

@ -35,6 +35,7 @@ export const rolesLabel = {
[ProjectRoles.VIEWER]: 'Base Viewer', [ProjectRoles.VIEWER]: 'Base Viewer',
[ProjectRoles.EDITOR]: 'Base Editor', [ProjectRoles.EDITOR]: 'Base Editor',
[ProjectRoles.COMMENTER]: 'Base Commenter', [ProjectRoles.COMMENTER]: 'Base Commenter',
[ProjectRoles.NO_ACCESS]: 'No Access',
}; };
export function getRolesLabels( export function getRolesLabels(

5
packages/nocodb/src/models/Base.ts

@ -62,6 +62,11 @@ export default class Base implements BaseType {
insertObj.order = await ncMeta.metaGetNextOrder(MetaTable.PROJECT, {}); insertObj.order = await ncMeta.metaGetNextOrder(MetaTable.PROJECT, {});
} }
// stringify meta
if (insertObj.meta) {
insertObj.meta = stringifyMetaProp(insertObj);
}
const { id: baseId } = await ncMeta.metaInsert2( const { id: baseId } = await ncMeta.metaInsert2(
null, null,
null, null,

41
packages/nocodb/src/models/BaseUser.ts

@ -26,6 +26,47 @@ export default class BaseUser {
return baseUser && new BaseUser(baseUser); return baseUser && new BaseUser(baseUser);
} }
public static async bulkInsert(
baseUsers: Partial<BaseUser>[],
ncMeta = Noco.ncMeta,
) {
const insertObj = baseUsers.map((baseUser) =>
extractProps(baseUser, ['fk_user_id', 'base_id', 'roles']),
);
const bulkData = await ncMeta.bulkMetaInsert(
null,
null,
MetaTable.PROJECT_USERS,
insertObj,
true,
);
const uniqueFks: string[] = [
...new Set(bulkData.map((d) => d.base_id)),
] as string[];
for (const fk of uniqueFks) {
await NocoCache.deepDel(
`${CacheScope.BASE_USER}:${fk}:list`,
CacheDelDirection.PARENT_TO_CHILD,
);
}
for (const d of bulkData) {
await NocoCache.set(
`${CacheScope.BASE_USER}:${d.base_id}:${d.fk_user_id}`,
d,
);
await NocoCache.appendToList(
CacheScope.BASE_USER,
[d.base_id],
`${CacheScope.BASE_USER}:${d.base_id}:${d.fk_user_id}`,
);
}
}
public static async insert( public static async insert(
baseUser: Partial<BaseUser>, baseUser: Partial<BaseUser>,
ncMeta = Noco.ncMeta, ncMeta = Noco.ncMeta,

16
packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts

@ -13,6 +13,7 @@ import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
import { NcError } from '~/helpers/catchError'; import { NcError } from '~/helpers/catchError';
import { DatasService } from '~/services/datas.service'; import { DatasService } from '~/services/datas.service';
import { Base, Hook, Model, Source } from '~/models'; import { Base, Hook, Model, Source } from '~/models';
import { parseMetaProp } from '~/utils/modelUtils';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
@ -220,10 +221,7 @@ export class ExportService {
break; break;
case 'meta': case 'meta':
if (view.type === ViewTypes.KANBAN) { if (view.type === ViewTypes.KANBAN) {
const meta = JSON.parse(view.view.meta as string) as Record< const meta = parseMetaProp(view.view) as Record<string, any>;
string,
any
>;
for (const [k, v] of Object.entries(meta)) { for (const [k, v] of Object.entries(meta)) {
const colId = idMap.get(k as string); const colId = idMap.get(k as string);
for (const op of v) { for (const op of v) {
@ -613,6 +611,7 @@ export class ExportService {
query: { limit, offset, fields }, query: { limit, offset, fields },
baseModel, baseModel,
ignoreViewFilterAndSort: true, ignoreViewFilterAndSort: true,
limitOverride: limit,
}) })
.then((result) => { .then((result) => {
try { try {
@ -634,7 +633,9 @@ export class ExportService {
offset + limit, offset + limit,
limit, limit,
fields, fields,
).then(resolve); )
.then(resolve)
.catch(reject);
} }
} catch (e) { } catch (e) {
reject(e); reject(e);
@ -663,6 +664,7 @@ export class ExportService {
query: { limit, offset, fields }, query: { limit, offset, fields },
baseModel, baseModel,
ignoreViewFilterAndSort: true, ignoreViewFilterAndSort: true,
limitOverride: limit,
}) })
.then((result) => { .then((result) => {
try { try {
@ -683,7 +685,9 @@ export class ExportService {
offset + limit, offset + limit,
limit, limit,
fields, fields,
).then(resolve); )
.then(resolve)
.catch(reject);
} }
} catch (e) { } catch (e) {
reject(e); reject(e);

4
packages/nocodb/src/schema/swagger-v2.json

@ -16352,6 +16352,10 @@
"items": { "items": {
"type": "string" "type": "string"
} }
},
"meta": {
"$ref": "#/components/schemas/Meta",
"description": "Base Meta"
} }
}, },
"required": [ "required": [

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

@ -22522,6 +22522,10 @@
"items": { "items": {
"type": "string" "type": "string"
} }
},
"meta": {
"$ref": "#/components/schemas/Meta",
"description": "Base Meta"
} }
}, },
"required": [ "required": [
@ -22601,7 +22605,6 @@
"type": "object", "type": "object",
"properties": { "properties": {
"email": { "email": {
"format": "email",
"type": "string", "type": "string",
"description": "Base User Email" "description": "Base User Email"
}, },

2
packages/nocodb/src/services/calendar-datas.service.ts

@ -56,7 +56,7 @@ export class CalendarDatasService {
viewName: view.id, viewName: view.id,
baseName: model.base_id, baseName: model.base_id,
tableName: model.id, tableName: model.id,
calendarLimitOverride: 3000, // TODO: make this configurable in env limitOverride: 3000, // TODO: make this configurable in env
}); });
} }

3
packages/nocodb/src/services/command-palette.service.ts

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { type UserType, ViewTypes } from 'nocodb-sdk'; import { type UserType, ViewTypes } from 'nocodb-sdk';
import { Base } from '~/models'; import { Base } from '~/models';
import { TablesService } from '~/services/tables.service'; import { TablesService } from '~/services/tables.service';
import { deserializeJSON } from '~/utils/serialize';
const viewTypeAlias: Record<number, string> = { const viewTypeAlias: Record<number, string> = {
[ViewTypes.GRID]: 'grid', [ViewTypes.GRID]: 'grid',
@ -28,6 +29,7 @@ export class CommandPaletteService {
id: `p-${base.id}`, id: `p-${base.id}`,
title: base.title, title: base.title,
icon: 'project', icon: 'project',
iconColor: deserializeJSON(base.meta)?.iconColor,
section: 'Bases', section: 'Bases',
scopePayload: { scopePayload: {
scope: `p-${base.id}`, scope: `p-${base.id}`,
@ -70,6 +72,7 @@ export class CommandPaletteService {
id: `p-${b.id}`, id: `p-${b.id}`,
title: b.title, title: b.title,
icon: 'project', icon: 'project',
iconColor: deserializeJSON(b.meta)?.iconColor,
section: 'Bases', section: 'Bases',
}); });
} }

9
packages/nocodb/src/services/datas.service.ts

@ -23,7 +23,7 @@ export class DatasService {
query: any; query: any;
disableOptimization?: boolean; disableOptimization?: boolean;
ignorePagination?: boolean; ignorePagination?: boolean;
calendarLimitOverride?: number; limitOverride?: number;
throwErrorIfInvalidParams?: boolean; throwErrorIfInvalidParams?: boolean;
}, },
) { ) {
@ -43,7 +43,7 @@ export class DatasService {
query: param.query, query: param.query,
throwErrorIfInvalidParams: true, throwErrorIfInvalidParams: true,
ignorePagination: param.ignorePagination, ignorePagination: param.ignorePagination,
calendarLimitOverride: param.calendarLimitOverride, limitOverride: param.limitOverride,
}); });
} }
@ -153,7 +153,7 @@ export class DatasService {
throwErrorIfInvalidParams?: boolean; throwErrorIfInvalidParams?: boolean;
ignoreViewFilterAndSort?: boolean; ignoreViewFilterAndSort?: boolean;
ignorePagination?: boolean; ignorePagination?: boolean;
calendarLimitOverride?: number; limitOverride?: number;
}) { }) {
const { model, view, query = {}, ignoreViewFilterAndSort = false } = param; const { model, view, query = {}, ignoreViewFilterAndSort = false } = param;
@ -193,7 +193,7 @@ export class DatasService {
ignoreViewFilterAndSort, ignoreViewFilterAndSort,
throwErrorIfInvalidParams: param.throwErrorIfInvalidParams, throwErrorIfInvalidParams: param.throwErrorIfInvalidParams,
ignorePagination: param.ignorePagination, ignorePagination: param.ignorePagination,
calendarLimitOverride: param.calendarLimitOverride, limitOverride: param.limitOverride,
}), }),
{}, {},
listArgs, listArgs,
@ -210,6 +210,7 @@ export class DatasService {
]); ]);
return new PagedResponseImpl(data, { return new PagedResponseImpl(data, {
...query, ...query,
...(param.limitOverride ? { limitOverride: param.limitOverride } : {}),
count, count,
}); });
} }

10
tests/playwright/tests/db/columns/columnFormula.spec.ts

@ -143,6 +143,16 @@ const formulaDataByDbType = (context: NcContext, index: number) => {
result: ['A Corua (La Corua)', 'Abha', 'Abu Dhabi', 'Acua', 'Ad...'], result: ['A Corua (La Corua)', 'Abha', 'Abu Dhabi', 'Acua', 'Ad...'],
unSupDbType: ['sqlite3'], unSupDbType: ['sqlite3'],
}, },
{
formula: 'URLENCODE({City})',
result: [
'A%20Corua%20(La%20Corua)',
'Abha',
'Abu%20Dhabi',
'Acua',
'Adana',
],
},
]; ];
else else
return [ return [

Loading…
Cancel
Save