Browse Source

Merge pull request #7729 from nocodb/nc-feat/form-view-tbd-2

Nc feat/form view tbd 2
pull/7723/head
Raju Udava 7 months ago committed by GitHub
parent
commit
b48a11cf63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 14
      packages/nc-gui/assets/nc-icons/eye-off.svg
  2. 10
      packages/nc-gui/assets/nc-icons/eye.svg
  3. 36
      packages/nc-gui/assets/style.scss
  4. 361
      packages/nc-gui/components/cell/MultiSelect.vue
  5. 221
      packages/nc-gui/components/cell/SingleSelect.vue
  6. 373
      packages/nc-gui/components/cell/User.vue
  7. 9
      packages/nc-gui/components/general/ImageCropper.vue
  8. 133
      packages/nc-gui/components/smartsheet/Form.vue
  9. 293
      packages/nc-gui/components/smartsheet/form/LimitOptions.vue
  10. 1
      packages/nc-gui/composables/useSharedFormViewStore.ts
  11. 2
      packages/nc-gui/composables/useViewData.ts
  12. 16
      packages/nc-gui/lang/en.json
  13. 7
      packages/nc-gui/lib/types.ts
  14. 40
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index.vue
  15. 5
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  16. 28
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue
  17. 3
      packages/nc-gui/utils/iconUtils.ts
  18. 2
      packages/nc-gui/utils/parseUtils.ts
  19. 7
      packages/nocodb-sdk/src/lib/UITypes.ts
  20. 1
      packages/nocodb-sdk/src/lib/index.ts

14
packages/nc-gui/assets/nc-icons/eye-off.svg

@ -0,0 +1,14 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_453_14719)">
<path
d="M6.59984 2.82676C7.05873 2.71935 7.52855 2.66566 7.99984 2.66676C12.6665 2.66676 15.3332 8.0001 15.3332 8.0001C14.9285 8.75717 14.4459 9.46992 13.8932 10.1268M9.41317 9.41343C9.23007 9.60993 9.00927 9.76754 8.76394 9.87685C8.51861 9.98616 8.25377 10.0449 7.98523 10.0497C7.71669 10.0544 7.44995 10.005 7.20091 9.90443C6.95188 9.80384 6.72565 9.65412 6.53573 9.4642C6.34582 9.27428 6.1961 9.04806 6.09551 8.79902C5.99492 8.54999 5.94552 8.28325 5.95026 8.0147C5.955 7.74616 6.01378 7.48133 6.12309 7.236C6.2324 6.99067 6.39001 6.76986 6.5865 6.58677M11.9598 11.9601C10.8202 12.8288 9.43258 13.31 7.99984 13.3334C3.33317 13.3334 0.666504 8.0001 0.666504 8.0001C1.49576 6.4547 2.64593 5.1045 4.03984 4.0401L11.9598 11.9601Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M0.666504 0.666748L15.3332 15.3334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
</g>
<defs>
<clipPath id="clip0_453_14719">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

10
packages/nc-gui/assets/nc-icons/eye.svg

@ -1,4 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.666656 7.99996C0.666656 7.99996 3.33332 2.66663 7.99999 2.66663C12.6667 2.66663 15.3333 7.99996 15.3333 7.99996C15.3333 7.99996 12.6667 13.3333 7.99999 13.3333C3.33332 13.3333 0.666656 7.99996 0.666656 7.99996Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> <path
<path d="M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> d="M0.666504 8.00008C0.666504 8.00008 3.33317 2.66675 7.99984 2.66675C12.6665 2.66675 15.3332 8.00008 15.3332 8.00008C15.3332 8.00008 12.6665 13.3334 7.99984 13.3334C3.33317 13.3334 0.666504 8.00008 0.666504 8.00008Z"
</svg> stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg>

Before

Width:  |  Height:  |  Size: 634 B

After

Width:  |  Height:  |  Size: 675 B

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

@ -749,3 +749,39 @@ svg.nc-cell-icon, svg.nc-virtual-cell-icon {
@apply w-1em h-1em flex-none; @apply w-1em h-1em flex-none;
font-size: 1rem; font-size: 1rem;
} }
// For select type field list layout
.nc-field-layout-list {
@apply !flex !flex-col !items-start w-full !space-y-0.5 !max-w-full;
.ant-checkbox-wrapper {
@apply !m-0 !h-9 !mr-0 !flex !items-center w-full !max-w-full pl-2 rounded-lg hover:bg-gray-100;
&:hover {
.ant-checkbox-checked:after {
@apply !rounded;
}
}
.ant-checkbox {
@apply !top-0;
& + span {
@apply !flex !pl-4 max-w-[calc(100%_-_16px)];
}
.ant-checkbox-checked:after,
.ant-checkbox-inner {
@apply !rounded;
}
}
}
.ant-radio-wrapper {
@apply !m-0 !h-9 !mr-0 !flex !items-center w-full !max-w-full pl-2 rounded-lg hover:bg-gray-100;
.ant-radio {
@apply !top-0;
}
.ant-radio + span {
@apply !flex !pl-4 max-w-[calc(100%_-_16px)];
}
}
}

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

@ -3,6 +3,7 @@ import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue' import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk' import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk'
import type { FormFieldsLimitOptionsType } from '~/lib'
import { import {
ActiveCellInj, ActiveCellInj,
ColumnInj, ColumnInj,
@ -95,7 +96,43 @@ const options = computed<(SelectOptionType & { value?: string })[]>(() => {
for (const op of opts.filter((el: SelectOptionType) => el.order === null)) { for (const op of opts.filter((el: SelectOptionType) => el.order === null)) {
op.title = op.title?.replace(/^'/, '').replace(/'$/, '') op.title = op.title?.replace(/^'/, '').replace(/'$/, '')
} }
return opts.map((o: SelectOptionType) => ({ ...o, value: o.title })) let order = 1
const limitOptionsById =
((parseProp(column.value.meta)?.limitOptions || []).reduce(
(o: Record<string, FormFieldsLimitOptionsType>, f: FormFieldsLimitOptionsType) => {
if (order < (f?.order ?? 0)) {
order = f.order
}
return {
...o,
[f.id]: f,
}
},
{},
) as Record<string, FormFieldsLimitOptionsType>) ?? {}
if (
!isEditColumn.value &&
isForm.value &&
parseProp(column.value.meta)?.isLimitOption &&
(parseProp(column.value.meta)?.limitOptions || []).length
) {
return opts
.filter((o: SelectOptionType) => {
if (limitOptionsById[o.id!]?.show !== undefined) {
return limitOptionsById[o.id!]?.show
}
return false
})
.map((o) => ({
...o,
value: o.title,
order: o.id && limitOptionsById[o.id] ? limitOptionsById[o.id]?.order : order++,
}))
.sort((a, b) => a.order - b.order)
} else {
return opts.map((o: SelectOptionType) => ({ ...o, value: o.title }))
}
} }
return [] return []
}) })
@ -353,154 +390,194 @@ const onFocus = () => {
<template> <template>
<div <div
class="nc-cell-field nc-multi-select h-full w-full flex items-center" class="nc-cell-field nc-multi-select h-full w-full flex items-center"
:class="{ 'read-only': readOnly }" :class="{ 'read-only': readOnly, 'max-w-full': isForm }"
@click="toggleMenu" @click="toggleMenu"
> >
<div <div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
v-if="!active" <a-checkbox-group v-model:value="vModel" class="nc-field-layout-list">
class="flex flex-wrap" <a-checkbox
:style="{ v-for="op of options"
'display': '-webkit-box', :key="op.title"
'max-width': '100%', :value="op.title"
'-webkit-line-clamp': rowHeight || 1, :data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
'-webkit-box-orient': 'vertical', :class="`nc-select-option-${column.title}-${op.title}`"
'overflow': 'hidden', >
}" <a-tag class="rounded-tag max-w-full" :color="op.color">
> <span
<template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value"> :style="{
<a-tag class="rounded-tag max-w-full" :color="selectedOpt.color"> 'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
<span ? '#fff'
:style="{ : tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' }) 'font-size': '13px',
? '#fff' }"
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(), >
'font-size': '13px', <NcTooltip class="truncate max-w-full" show-on-truncate-only>
}" <template #title>
:class="{ 'text-sm': isKanban }" {{ op.title }}
> </template>
<NcTooltip class="truncate max-w-full" show-on-truncate-only> <span
<template #title> class="text-ellipsis overflow-hidden"
{{ selectedOpt.title }} :style="{
</template> wordBreak: 'keep-all',
<span whiteSpace: 'nowrap',
class="text-ellipsis overflow-hidden" display: 'inline',
:style="{ }"
wordBreak: 'keep-all', >
whiteSpace: 'nowrap', {{ op.title }}
display: 'inline', </span>
}" </NcTooltip>
> </span>
{{ selectedOpt.title }} </a-tag>
</span> </a-checkbox>
</NcTooltip> </a-checkbox-group>
</span>
</a-tag>
</template>
</div> </div>
<template v-else>
<a-select <div
v-else v-if="!active"
ref="aselect" class="flex flex-wrap"
v-model:value="vModel" :style="{
mode="multiple" 'display': '-webkit-box',
class="w-full overflow-hidden" 'max-width': '100%',
:placeholder="isEditColumn ? $t('labels.optional') : ''" '-webkit-line-clamp': rowHeight || 1,
:bordered="false" '-webkit-box-orient': 'vertical',
clear-icon 'overflow': 'hidden',
:show-search="!isMobileMode" }"
:show-arrow="editAllowed && !readOnly"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell !min-w-200px ${isOpen ? 'active' : ''}`"
@search="search"
@keydown="onKeyDown"
@focus="onFocus"
@blur="isOpen = false"
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" />
</template>
<a-select-option
v-for="op of options"
:key="op.id || op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
:class="`nc-select-option-${column.title}-${op.title}`"
@click.stop
> >
<a-tag class="rounded-tag max-w-full" :color="op.color"> <template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value">
<span <a-tag class="rounded-tag max-w-full" :color="selectedOpt.color">
:style="{ <span
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' }) :style="{
? '#fff' 'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(), ? '#fff'
'font-size': '13px', : tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
}" 'font-size': '13px',
:class="{ 'text-sm': isKanban }" }"
> :class="{ 'text-sm': isKanban }"
<NcTooltip class="truncate max-w-full" show-on-truncate-only> >
<template #title> <NcTooltip class="truncate max-w-full" show-on-truncate-only>
{{ op.title }} <template #title>
</template> {{ selectedOpt.title }}
<span </template>
class="text-ellipsis overflow-hidden" <span
:style="{ class="text-ellipsis overflow-hidden"
wordBreak: 'keep-all', :style="{
whiteSpace: 'nowrap', wordBreak: 'keep-all',
display: 'inline', whiteSpace: 'nowrap',
}" display: 'inline',
> }"
{{ op.title }} >
</span> {{ selectedOpt.title }}
</NcTooltip> </span>
</span> </NcTooltip>
</a-tag> </span>
</a-select-option> </a-tag>
</template>
<a-select-option </div>
v-if="searchVal && isOptionMissing && !isPublic && !disableOptionCreation && isUIAllowed('fieldEdit')"
:key="searchVal" <a-select
:value="searchVal" 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-multi-select-cell !min-w-200px ${isOpen ? 'active' : ''}`"
@search="search"
@keydown="onKeyDown"
@focus="onFocus"
@blur="isOpen = false"
> >
<div class="flex gap-2 text-gray-500 items-center h-full"> <template #suffixIcon>
<component :is="iconMap.plusThick" class="min-w-4" /> <GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" />
<div class="text-xs whitespace-normal"> </template>
{{ $t('msg.selectOption.createNewOptionNamed') }} <strong>{{ searchVal }}</strong> <a-select-option
</div> v-for="op of options"
</div> :key="op.id || op.title"
</a-select-option> :value="op.title"
:data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
<template #tagRender="{ value: val, onClose }"> :class="`nc-select-option-${column.title}-${op.title}`"
<a-tag @click.stop
v-if="options.find((el) => el.title === val)"
class="rounded-tag nc-selected-option"
:style="{ display: 'flex', alignItems: 'center' }"
:color="options.find((el) => el.title === val)?.color"
:closable="editAllowed && (vModel.length > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@click="onTagClick($event, onClose)"
@close="onClose"
> >
<span <a-tag class="rounded-tag max-w-full" :color="op.color">
:style="{ <span
'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', { :style="{
level: 'AA', 'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
size: 'large', ? '#fff'
}) : tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
? '#fff' 'font-size': '13px',
: tinycolor }"
.mostReadable(options.find((el) => el.title === val)?.color || '#ccc', ['#0b1d05', '#fff']) :class="{ 'text-sm': isKanban }"
.toHex8String(), >
'font-size': '13px', <NcTooltip class="truncate max-w-full" show-on-truncate-only>
}" <template #title>
:class="{ 'text-sm': isKanban }" {{ 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>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && !disableOptionCreation && isUIAllowed('fieldEdit')"
:key="searchVal"
:value="searchVal"
>
<div class="flex gap-2 text-gray-500 items-center h-full">
<component :is="iconMap.plusThick" class="min-w-4" />
<div class="text-xs whitespace-normal">
{{ $t('msg.selectOption.createNewOptionNamed') }} <strong>{{ searchVal }}</strong>
</div>
</div>
</a-select-option>
<template #tagRender="{ value: val, onClose }">
<a-tag
v-if="options.find((el) => el.title === val)"
class="rounded-tag nc-selected-option"
:style="{ display: 'flex', alignItems: 'center' }"
:color="options.find((el) => el.title === val)?.color"
:closable="editAllowed && (vModel.length > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@click="onTagClick($event, onClose)"
@close="onClose"
> >
{{ val }} <span
</span> :style="{
</a-tag> 'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', {
</template> level: 'AA',
</a-select> size: 'large',
})
? '#fff'
: tinycolor
.mostReadable(options.find((el) => el.title === val)?.color || '#ccc', ['#0b1d05', '#fff'])
.toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ val }}
</span>
</a-tag>
</template>
</a-select>
</template>
</div> </div>
</template> </template>

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

@ -3,6 +3,7 @@ import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue' import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk' import type { SelectOptionType } from 'nocodb-sdk'
import type { FormFieldsLimitOptionsType } from '~/lib'
import { import {
ActiveCellInj, ActiveCellInj,
ColumnInj, ColumnInj,
@ -88,7 +89,44 @@ const options = computed<(SelectOptionType & { value: string })[]>(() => {
for (const op of opts.filter((el: any) => el.order === null)) { for (const op of opts.filter((el: any) => el.order === null)) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '') op.title = op.title.replace(/^'/, '').replace(/'$/, '')
} }
return opts.map((o: any) => ({ ...o, value: o.title }))
let order = 1
const limitOptionsById =
((parseProp(column.value.meta)?.limitOptions || []).reduce(
(o: Record<string, FormFieldsLimitOptionsType>, f: FormFieldsLimitOptionsType) => {
if (order < (f?.order ?? 0)) {
order = f.order
}
return {
...o,
[f.id]: f,
}
},
{},
) as Record<string, FormFieldsLimitOptionsType>) ?? {}
if (
!isEditColumn.value &&
isForm.value &&
parseProp(column.value.meta)?.isLimitOption &&
(parseProp(column.value.meta)?.limitOptions || []).length
) {
return opts
.filter((o: SelectOptionType & { value: string }) => {
if (limitOptionsById[o.id]?.show !== undefined) {
return limitOptionsById[o.id]?.show
}
return false
})
.map((o: any) => ({
...o,
value: o.title,
order: o.id && limitOptionsById[o.id] ? limitOptionsById[o.id]?.order : order++,
}))
.sort((a, b) => a.order - b.order)
} else {
return opts.map((o: any) => ({ ...o, value: o.title }))
}
} }
return [] return []
}) })
@ -272,82 +310,70 @@ const onFocus = () => {
<template> <template>
<div <div
class="nc-cell-field h-full w-full flex items-center nc-single-select focus:outline-transparent" class="nc-cell-field h-full w-full flex items-center nc-single-select focus:outline-transparent"
:class="{ 'read-only': readOnly }" :class="{ 'read-only': readOnly, 'max-w-full': isForm }"
@click="toggleMenu" @click="toggleMenu"
@keydown.enter.stop.prevent="toggleMenu" @keydown.enter.stop.prevent="toggleMenu"
> >
<div v-if="!(active || isEditable)" class="w-full"> <div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
<a-tag v-if="selectedOpt" class="rounded-tag max-w-full" :color="selectedOpt.color"> <a-radio-group v-model:value="vModel" class="nc-field-layout-list">
<span <a-radio
:style="{ v-for="op of options"
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' }) :key="op.title"
? '#fff' :value="op.title"
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(), :data-testid="`select-option-${column.title}-${rowIndex}`"
'font-size': '13px', :class="`nc-select-option-${column.title}-${op.title}`"
}"
:class="{ 'text-sm': isKanban }"
> >
<NcTooltip class="truncate max-w-full" show-on-truncate-only> <a-tag class="rounded-tag max-w-full" :color="op.color">
<template #title>
{{ selectedOpt.title }}
</template>
<span <span
class="text-ellipsis overflow-hidden"
:style="{ :style="{
wordBreak: 'keep-all', 'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
whiteSpace: 'nowrap', ? '#fff'
display: 'inline', : tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}" }"
> >
{{ selectedOpt.title }} <NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ op.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ op.title }}
</span>
</NcTooltip>
</span> </span>
</NcTooltip> </a-tag></a-radio
</span> >
</a-tag> </a-radio-group>
</div> <div
v-if="vModel"
<NcSelect class="inline-block px-2 pt-2 cursor-pointer text-xs text-gray-500 hover:text-gray-800"
v-else @click="vModel = ''"
ref="aselect"
v-model:value="vModel"
class="w-full overflow-hidden xs:min-h-12"
:class="{ 'caret-transparent': !hasEditRoles }"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:allow-clear="!column.rqd && editAllowed"
:bordered="false"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:show-search="!isMobileMode && isOpen && active"
:show-arrow="hasEditRoles && !readOnly && active && (vModel === null || vModel === undefined)"
:dropdown-class-name="`nc-dropdown-single-select-cell !min-w-200px ${isOpen && active ? 'active' : ''}`"
:dropdown-match-select-width="true"
@select="onSelect"
@keydown="onKeydown($event)"
@search="search"
@blur="isOpen = false"
@focus="onFocus"
>
<a-select-option
v-for="op of options"
:key="op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
:class="`nc-select-option-${column.title}-${op.title}`"
@click.stop
> >
<a-tag class="rounded-tag max-w-full" :color="op.color"> {{ $t('labels.clearSelection') }}
</div>
</div>
<template v-else>
<div v-if="!(active || isEditable)" class="w-full">
<a-tag v-if="selectedOpt" class="rounded-tag max-w-full" :color="selectedOpt.color">
<span <span
:style="{ :style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' }) 'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff' ? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(), : tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px', 'font-size': '13px',
}" }"
:class="{ 'text-sm': isKanban }" :class="{ 'text-sm': isKanban }"
> >
<NcTooltip class="truncate max-w-full" show-on-truncate-only> <NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title> <template #title>
{{ op.title }} {{ selectedOpt.title }}
</template> </template>
<span <span
class="text-ellipsis overflow-hidden" class="text-ellipsis overflow-hidden"
@ -357,21 +383,80 @@ const onFocus = () => {
display: 'inline', display: 'inline',
}" }"
> >
{{ op.title }} {{ selectedOpt.title }}
</span> </span>
</NcTooltip> </NcTooltip>
</span> </span>
</a-tag> </a-tag>
</a-select-option> </div>
<a-select-option v-if="searchVal && isOptionMissing && isNewOptionCreateEnabled" :key="searchVal" :value="searchVal">
<div class="flex gap-2 text-gray-500 items-center h-full"> <NcSelect
<component :is="iconMap.plusThick" class="min-w-4" /> v-else
<div class="text-xs whitespace-normal"> ref="aselect"
{{ $t('msg.selectOption.createNewOptionNamed') }} <strong>{{ searchVal }}</strong> v-model:value="vModel"
class="w-full overflow-hidden xs:min-h-12"
:class="{ 'caret-transparent': !hasEditRoles }"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:allow-clear="!column.rqd && editAllowed"
:bordered="false"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:show-search="!isMobileMode && isOpen && active"
:show-arrow="hasEditRoles && !readOnly && active && (vModel === null || vModel === undefined)"
:dropdown-class-name="`nc-dropdown-single-select-cell !min-w-200px ${isOpen && active ? 'active' : ''}`"
:dropdown-match-select-width="true"
@select="onSelect"
@keydown="onKeydown($event)"
@search="search"
@blur="isOpen = false"
@focus="onFocus"
>
<a-select-option
v-for="op of options"
:key="op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
:class="`nc-select-option-${column.title}-${op.title}`"
@click.stop
>
<a-tag class="rounded-tag max-w-full" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
<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>
<a-select-option v-if="searchVal && isOptionMissing && isNewOptionCreateEnabled" :key="searchVal" :value="searchVal">
<div class="flex gap-2 text-gray-500 items-center h-full">
<component :is="iconMap.plusThick" class="min-w-4" />
<div class="text-xs whitespace-normal">
{{ $t('msg.selectOption.createNewOptionNamed') }} <strong>{{ searchVal }}</strong>
</div>
</div> </div>
</div> </a-select-option>
</a-select-option> </NcSelect>
</NcSelect> </template>
</div> </div>
</template> </template>

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

@ -1,8 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core' import { onUnmounted } from '@vue/runtime-core'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import { Checkbox, CheckboxGroup, Radio, RadioGroup } from 'ant-design-vue'
import type { Select as AntSelect } from 'ant-design-vue' import type { Select as AntSelect } from 'ant-design-vue'
import type { UserFieldRecordType } from 'nocodb-sdk' import type { UserFieldRecordType } from 'nocodb-sdk'
import type { FormFieldsLimitOptionsType } from '~/lib'
import { import {
ActiveCellInj, ActiveCellInj,
CellClickHookInj, CellClickHookInj,
@ -77,16 +79,59 @@ const searchVal = ref<string | null>()
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const options = computed<UserFieldRecordType[]>(() => { const options = computed<UserFieldRecordType[]>(() => {
let order = 1
const limitOptionsById =
((parseProp(column.value.meta)?.limitOptions || []).reduce(
(o: Record<string, FormFieldsLimitOptionsType>, f: FormFieldsLimitOptionsType) => {
if (order < (f?.order ?? 0)) {
order = f.order
}
return {
...o,
[f.id]: f,
}
},
{},
) as Record<string, FormFieldsLimitOptionsType>) ?? {}
const collaborators: UserFieldRecordType[] = [] const collaborators: UserFieldRecordType[] = []
collaborators.push( if (
...(baseUsers.value?.map((user: any) => ({ !isEditColumn.value &&
id: user.id, isForm.value &&
email: user.email, parseProp(column.value.meta)?.isLimitOption &&
display_name: user.display_name, (parseProp(column.value.meta)?.limitOptions || []).length
deleted: user.deleted, ) {
})) || []), collaborators.push(
) ...(baseUsers.value || [])
.filter((user) => {
if (limitOptionsById[user.id]?.show !== undefined) {
return limitOptionsById[user.id]?.show
}
return false
})
.map((user: any) => ({
id: user.id,
email: user.email,
display_name: user.display_name,
deleted: user.deleted,
order: user.id && limitOptionsById[user.id] ? limitOptionsById[user.id]?.order ?? user.order : order++,
}))
.sort((a, b) => a.order - b.order),
)
} else {
collaborators.push(
...(baseUsers.value || [])
.map((user: any) => ({
id: user.id,
email: user.email,
display_name: user.display_name,
deleted: user.deleted,
order: order++,
}))
.sort((a, b) => a.order - b.order),
)
}
return collaborators return collaborators
}) })
@ -146,6 +191,14 @@ const vModel = computed({
}, },
}) })
const vModelListLayout = computed(() => {
if (isMultiple.value) {
return (vModel.value || []).map((item) => item.value)
} else {
return (vModel.value || [])?.[0]?.value || ''
}
})
watch(isOpen, (n, _o) => { watch(isOpen, (n, _o) => {
if (!n) searchVal.value = '' if (!n) searchVal.value = ''
@ -266,87 +319,85 @@ const filterOption = (input: string, option: any) => {
:class="{ 'read-only': readOnly }" :class="{ 'read-only': readOnly }"
@click="toggleMenu" @click="toggleMenu"
> >
<div <div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
v-if="!active" <component
class="flex flex-wrap" :is="isMultiple ? CheckboxGroup : RadioGroup"
:style="{ v-model:value="vModelListLayout"
'display': '-webkit-box', class="nc-field-layout-list"
'max-width': '100%', @update:value="
'-webkit-line-clamp': rowHeight || 1, (value) => {
'-webkit-box-orient': 'vertical', vModel = isMultiple ? value : [value]
'overflow': 'hidden', }
}" "
> >
<template v-for="selectedOpt of vModel" :key="selectedOpt.value"> <template v-for="op of options" :key="op.id || op.email">
<a-tag class="rounded-tag max-w-full !pl-0" color="'#ccc'"> <component
<span :is="isMultiple ? Checkbox : Radio"
:style="{ v-if="!op.deleted"
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' }) :key="op.id || op.email"
? '#fff' :value="op.id"
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(), :data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
'font-size': '13px', :class="`nc-select-option-${column.title}-${op.email}`"
}"
class="flex items-stretch gap-2"
:class="{ 'text-sm': isKanban }"
> >
<div class="flex-none"> <a-tag class="rounded-tag max-w-full !pl-0" color="'#ccc'">
<GeneralUserIcon
size="auto"
:name="!selectedOpt.label?.includes('@') ? selectedOpt.label.trim() : ''"
:email="selectedOpt.label"
class="!text-[0.65rem]"
/>
</div>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.label }}
</template>
<span <span
class="text-ellipsis overflow-hidden"
:style="{ :style="{
wordBreak: 'keep-all', 'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
whiteSpace: 'nowrap', ? '#fff'
display: 'inline', : tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}" }"
class="flex items-stretch gap-2"
> >
{{ selectedOpt.label }} <div>
<GeneralUserIcon
size="auto"
:name="op.display_name?.trim() ? op.display_name?.trim() : ''"
:email="op.email"
class="!text-[0.65rem]"
/>
</div>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ op.display_name?.trim() || op.email }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ op.display_name?.trim() || op.email }}
</span>
</NcTooltip>
</span> </span>
</NcTooltip> </a-tag>
</span> </component>
</a-tag> </template>
</template> </component>
<div
v-if="!isMultiple && vModel.length"
class="inline-block px-2 pt-2 cursor-pointer text-xs text-gray-500 hover:text-gray-800"
@click="vModel = []"
>
{{ $t('labels.clearSelection') }}
</div>
</div> </div>
<template v-else>
<a-select <div
v-else v-if="!active"
ref="aselect" class="flex flex-wrap"
v-model:value="vModel" :style="{
mode="multiple" 'display': '-webkit-box',
class="w-full overflow-hidden" 'max-width': '100%',
:placeholder="isEditColumn ? $t('labels.optional') : ''" '-webkit-line-clamp': rowHeight || 1,
:bordered="false" '-webkit-box-orient': 'vertical',
clear-icon 'overflow': 'hidden',
:show-search="!isMobileMode" }"
:show-arrow="editAllowed && !readOnly" >
:open="isOpen && editAllowed" <template v-for="selectedOpt of vModel" :key="selectedOpt.value">
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-user-select-cell !min-w-200px ${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 max-w-full !pl-0" color="'#ccc'"> <a-tag class="rounded-tag max-w-full !pl-0" color="'#ccc'">
<span <span
:style="{ :style="{
@ -358,17 +409,17 @@ const filterOption = (input: string, option: any) => {
class="flex items-stretch gap-2" class="flex items-stretch gap-2"
:class="{ 'text-sm': isKanban }" :class="{ 'text-sm': isKanban }"
> >
<div> <div class="flex-none">
<GeneralUserIcon <GeneralUserIcon
size="auto" size="auto"
:name="op.display_name?.trim() ? op.display_name?.trim() : ''" :name="!selectedOpt.label?.includes('@') ? selectedOpt.label.trim() : ''"
:email="op.email" :email="selectedOpt.label"
class="!text-[0.65rem]" class="!text-[0.65rem]"
/> />
</div> </div>
<NcTooltip class="truncate max-w-full" show-on-truncate-only> <NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title> <template #title>
{{ op.display_name?.trim() || op.email }} {{ selectedOpt.label }}
</template> </template>
<span <span
class="text-ellipsis overflow-hidden" class="text-ellipsis overflow-hidden"
@ -378,51 +429,121 @@ const filterOption = (input: string, option: any) => {
display: 'inline', display: 'inline',
}" }"
> >
{{ op.display_name?.trim() || op.email }} {{ selectedOpt.label }}
</span> </span>
</NcTooltip> </NcTooltip>
</span> </span>
</a-tag> </a-tag>
</a-select-option> </template>
</template> </div>
<template #tagRender="{ label, value: val, onClose }"> <a-select
<a-tag v-else
v-if="options.find((el) => el.id === val)" ref="aselect"
class="rounded-tag nc-selected-option !pl-0" v-model:value="vModel"
:style="{ display: 'flex', alignItems: 'center' }" mode="multiple"
color="'#ccc'" class="w-full overflow-hidden"
:closable="editAllowed && ((vModel?.length ?? 0) > 1 || !column?.rqd)" :placeholder="isEditColumn ? $t('labels.optional') : ''"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })" :bordered="false"
@click="onTagClick($event, onClose)" clear-icon
@close="onClose" :show-search="!isMobileMode"
> :show-arrow="editAllowed && !readOnly"
<span :open="isOpen && editAllowed"
:style="{ :disabled="readOnly || !editAllowed"
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { :class="{ 'caret-transparent': !hasEditRoles }"
level: 'AA', :dropdown-class-name="`nc-dropdown-user-select-cell !min-w-200px ${isOpen ? 'active' : ''}`"
size: 'large', :filter-option="filterOption"
}) @search="search"
? '#fff' @keydown.stop
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(), >
'font-size': '13px', <template #suffixIcon>
}" <GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" />
class="flex items-stretch gap-2" </template>
:class="{ 'text-sm': isKanban }" <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 max-w-full !pl-0" 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="flex items-stretch gap-2"
:class="{ 'text-sm': isKanban }"
>
<div>
<GeneralUserIcon
size="auto"
:name="op.display_name?.trim() ? op.display_name?.trim() : ''"
:email="op.email"
class="!text-[0.65rem]"
/>
</div>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ op.display_name?.trim() || op.email }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ op.display_name?.trim() || op.email }}
</span>
</NcTooltip>
</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 !pl-0"
: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"
> >
<div> <span
<GeneralUserIcon :style="{
size="auto" 'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', {
:name="!label?.includes('@') ? label.trim() : ''" level: 'AA',
:email="label" size: 'large',
class="!text-[0.65rem]" })
/> ? '#fff'
</div> : tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
{{ label }} 'font-size': '13px',
</span> }"
</a-tag> class="flex items-stretch gap-2"
</template> :class="{ 'text-sm': isKanban }"
</a-select> >
<div>
<GeneralUserIcon
size="auto"
:name="!label?.includes('@') ? label.trim() : ''"
:email="label"
class="!text-[0.65rem]"
/>
</div>
{{ label }}
</span>
</a-tag>
</template>
</a-select>
</template>
</div> </div>
</template> </template>

9
packages/nc-gui/components/general/ImageCropper.vue

@ -103,14 +103,7 @@ watch(showCropper, () => {
class="nc-cropper relative" class="nc-cropper relative"
:src="imageConfig.src" :src="imageConfig.src"
:auto-zoom="true" :auto-zoom="true"
:stencil-props=" :stencil-props="cropperConfig?.aspectRatio ? { aspectRatio: cropperConfig.aspectRatio } : {}"
cropperConfig?.aspectRatio
? {
aspectRatio: cropperConfig.aspectRatio,
}
: {}
"
image-restriction="none"
/> />
<div v-if="previewImage.src" class="result_preview"> <div v-if="previewImage.src" class="result_preview">
<img :src="previewImage.src" alt="Preview Image" /> <img :src="previewImage.src" alt="Preview Image" />

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

@ -1,10 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import { Pane, Splitpanes } from 'splitpanes' import { Pane, Splitpanes } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css' import 'splitpanes/dist/splitpanes.css'
import { ProjectRoles, RelationTypes, UITypes, ViewTypes, getSystemColumns, isLinksOrLTAR, isVirtualCol } from 'nocodb-sdk' import {
ProjectRoles,
RelationTypes,
UITypes,
ViewTypes,
getSystemColumns,
isLinksOrLTAR,
isSelectTypeCol,
isVirtualCol,
} from 'nocodb-sdk'
import type { Permission } from '#imports' import type { Permission } from '#imports'
import { import {
ActiveViewInj, ActiveViewInj,
@ -151,9 +159,7 @@ const imageCropperData = ref<{
cropFor: 'banner', cropFor: 'banner',
}) })
const focusLabel: VNodeRef = (el) => { const focusLabel = ref<HTMLTextAreaElement>()
return (el as HTMLInputElement)?.focus()
}
const searchQuery = ref('') const searchQuery = ref('')
@ -164,6 +170,7 @@ const { betaFeatureToggleState } = useBetaFeatureToggle()
const { open, onChange: onChangeFile } = useFileDialog({ const { open, onChange: onChangeFile } = useFileDialog({
accept: 'image/*', accept: 'image/*',
multiple: false, multiple: false,
reset: true,
}) })
const visibleColumns = computed(() => localColumns.value.filter((f) => f.show).sort((a, b) => a.order - b.order)) const visibleColumns = computed(() => localColumns.value.filter((f) => f.show).sort((a, b) => a.order - b.order))
@ -505,7 +512,15 @@ const handleOnUploadImage = (data: Record<string, any> = {}) => {
updateView() updateView()
} }
onClickOutside(draggableRef, () => { onClickOutside(draggableRef, (e) => {
if (
(e.target as HTMLElement)?.closest(
'.nc-dropdown-single-select-cell, .nc-dropdown-multi-select-cell, .nc-dropdown-user-select-cell',
)
) {
return
}
activeRow.value = '' activeRow.value = ''
isTabPressed.value = false isTabPressed.value = false
}) })
@ -561,6 +576,12 @@ watch(activeRow, (newValue) => {
} }
}) })
watch([focusLabel, activeRow], () => {
if (activeRow && focusLabel.value) {
focusLabel.value?.focus()
}
})
useEventListener( useEventListener(
formRef, formRef,
'focusout', 'focusout',
@ -674,9 +695,9 @@ useEventListener(
@submit="handleOnUploadImage" @submit="handleOnUploadImage"
></GeneralImageCropper> ></GeneralImageCropper>
<!-- cover image --> <!-- cover image -->
<div class="relative max-w-[max(33%,688px)] mx-auto"> <div class="group relative max-w-[max(33%,688px)] mx-auto">
<GeneralFormBanner :banner-image-url="formViewData.banner_image_url" /> <GeneralFormBanner :banner-image-url="formViewData.banner_image_url" />
<div class="absolute bottom-0 right-0"> <div class="absolute bottom-0 right-0 hidden group-hover:block">
<div class="flex items-center space-x-1 m-2"> <div class="flex items-center space-x-1 m-2">
<NcButton <NcButton
type="secondary" type="secondary"
@ -794,6 +815,9 @@ useEventListener(
{ {
'hover:bg-gray-50': activeRow !== 'nc-form-heading' && isEditable, 'hover:bg-gray-50': activeRow !== 'nc-form-heading' && isEditable,
}, },
{
'bg-gray-50': activeRow === 'nc-form-heading' && isEditable,
},
{ {
'!hover:bg-white !ring-0 !cursor-auto': isLocked, '!hover:bg-white !ring-0 !cursor-auto': isLocked,
}, },
@ -841,6 +865,9 @@ useEventListener(
{ {
'hover:bg-gray-50': activeRow !== 'nc-form-sub-heading' && isEditable, 'hover:bg-gray-50': activeRow !== 'nc-form-sub-heading' && isEditable,
}, },
{
'bg-gray-50': activeRow === 'nc-form-sub-heading' && isEditable,
},
{ {
'!hover:bg-white !ring-0 !cursor-auto': isLocked, '!hover:bg-white !ring-0 !cursor-auto': isLocked,
}, },
@ -893,13 +920,13 @@ useEventListener(
:class="[ :class="[
`nc-form-drag-${element.title.replaceAll(' ', '')}`, `nc-form-drag-${element.title.replaceAll(' ', '')}`,
{ {
'rounded-2xl overflow-hidden border-2 cursor-pointer my-1': isEditable, 'rounded-2xl overflow-hidden border-2 my-1': isEditable,
}, },
{ {
'p-4 lg:p-6 border-transparent my-0': !isEditable, 'p-4 lg:p-6 border-transparent my-0': !isEditable,
}, },
{ {
'nc-form-field-drag-handler border-transparent hover:(bg-gray-50) p-4 lg:p-6 ': 'nc-form-field-drag-handler border-transparent hover:(bg-gray-50) p-4 lg:p-6 cursor-pointer':
activeRow !== element.title && isEditable, activeRow !== element.title && isEditable,
}, },
@ -982,7 +1009,7 @@ useEventListener(
<template v-if="activeRow === element.title"> <template v-if="activeRow === element.title">
<a-form-item class="my-0 !mb-2"> <a-form-item class="my-0 !mb-2">
<a-textarea <a-textarea
:ref="focusLabel" ref="focusLabel"
v-model:value="element.label" v-model:value="element.label"
:rows="1" :rows="1"
auto-size auto-size
@ -1069,7 +1096,10 @@ useEventListener(
v-else v-else
v-model="formState[element.title]" v-model="formState[element.title]"
class="nc-input truncate" class="nc-input truncate"
:class="`nc-form-input-${element.title.replaceAll(' ', '')}`" :class="[
`nc-form-input-${element.title.replaceAll(' ', '')}`,
{ 'layout-list': element.meta.isList },
]"
:data-testid="`nc-form-input-${element.title.replaceAll(' ', '')}`" :data-testid="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:column="element" :column="element"
:edit-enabled="true" :edit-enabled="true"
@ -1080,19 +1110,55 @@ useEventListener(
</div> </div>
<!-- Field Settings --> <!-- Field Settings -->
<!-- eslint-disable vue/no-constant-condition -->
<div <div
v-if="activeRow === element.title && false" v-if="activeRow === element.title"
class="nc-form-field-settings border-t border-gray-200 p-4 lg:p-6" class="nc-form-field-settings border-t border-gray-200 p-4 lg:p-6 flex flex-col gap-3"
> >
<!-- Todo: Show on conditions, options limit,... --> <!-- Layout -->
<div class="flex items-start gap-3 px-3 py-2 border-1 border-gray-200 rounded-lg"> <div v-if="isSelectTypeCol(element.uidt)">
<div>Layout</div>
<a-radio-group
v-model:value="element.meta.isList"
class="nc-form-field-layout !mt-2"
@change="updateColMeta(element)"
>
<a-radio :value="false">{{ $t('general.dropdown') }}</a-radio>
<a-radio :value="true">{{ $t('general.list') }}</a-radio>
</a-radio-group>
</div>
<!-- Todo: Show on conditions,... -->
<!-- eslint-disable vue/no-constant-condition -->
<div v-if="false" class="flex items-start gap-3 px-3 py-2 border-1 border-gray-200 rounded-lg">
<a-switch v-e="['a:form-view:field:show-on-condition']" size="small" /> <a-switch v-e="['a:form-view:field:show-on-condition']" size="small" />
<div> <div>
<div class="font-medium text-gray-800">{{ $t('labels.showOnConditions') }}</div> <div class="font-medium text-gray-800">{{ $t('labels.showOnConditions') }}</div>
<div class="text-gray-500">{{ $t('labels.showFieldOnConditionsMet') }}</div> <div class="text-gray-500">{{ $t('labels.showFieldOnConditionsMet') }}</div>
</div> </div>
</div> </div>
<!-- Limit options -->
<div v-if="isSelectTypeCol(element.uidt)" class="px-3 py-2 border-1 border-gray-200 rounded-lg">
<div class="flex items-center gap-3">
<a-switch
v-model:checked="element.meta.isLimitOption"
v-e="['a:form-view:field:limit-options']"
size="small"
@change="updateColMeta(element)"
/>
<div class="font-medium text-gray-800">{{ $t('labels.limitOptions') }}</div>
</div>
<div class="pl-10 mt-2 flex-1 max-w-[calc(100%_-_40px)]">
<div class="text-gray-500">{{ $t('labels.limitOptionsSubtext') }}.</div>
<div v-if="element.meta.isLimitOption" class="mt-5 max-w-[80%]">
<LazySmartsheetFormLimitOptions
v-model:model-value="element.meta.limitOptions"
:column="element"
@update:model-value="updateColMeta(element)"
></LazySmartsheetFormLimitOptions>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -1145,7 +1211,7 @@ useEventListener(
</div> </div>
</div> </div>
<div class="h-full flex-1 max-w-[384px] nc-form-left-drawer border-l border-gray-200"> <div class="h-full flex-1 max-w-[384px] nc-form-left-drawer border-l border-gray-200">
<Splitpanes horizontal class="w-full nc-form-right-splitpane"> <Splitpanes v-if="formViewData" horizontal class="w-full nc-form-right-splitpane">
<Pane min-size="30" size="50" class="nc-form-right-splitpane-item p-4 flex flex-col space-y-4 !min-h-200px"> <Pane min-size="30" size="50" class="nc-form-right-splitpane-item p-4 flex flex-col space-y-4 !min-h-200px">
<div class="flex flex-wrap justify-between items-center gap-2"> <div class="flex flex-wrap justify-between items-center gap-2">
<div class="flex gap-3"> <div class="flex gap-3">
@ -1298,12 +1364,7 @@ useEventListener(
</template> </template>
</div> </div>
</Pane> </Pane>
<Pane <Pane min-size="20" size="50" class="nc-form-right-splitpane-item !overflow-y-auto nc-form-scrollbar">
v-if="formViewData"
min-size="20"
size="50"
class="nc-form-right-splitpane-item !overflow-y-auto nc-form-scrollbar"
>
<div class="p-4 flex flex-col space-y-4 border-b border-gray-200"> <div class="p-4 flex flex-col space-y-4 border-b border-gray-200">
<!-- Appearance Settings --> <!-- Appearance Settings -->
<div class="text-base font-bold text-gray-900">{{ $t('labels.appearanceSettings') }}</div> <div class="text-base font-bold text-gray-900">{{ $t('labels.appearanceSettings') }}</div>
@ -1474,7 +1535,13 @@ useEventListener(
} }
.nc-input { .nc-input {
@apply appearance-none w-full !bg-white rounded-lg border-solid border-1 border-gray-200 focus-within:border-brand-500; @apply appearance-none w-full;
&:not(.layout-list) {
@apply !bg-white rounded-lg border-solid border-1 border-gray-200 focus-within:border-brand-500;
}
&.layout-list {
@apply h-auto !pl-0 !py-1;
}
&.nc-cell-rating, &.nc-cell-rating,
&.nc-cell-geodata { &.nc-cell-geodata {
@apply !py-1; @apply !py-1;
@ -1524,6 +1591,12 @@ useEventListener(
@apply mt-2; @apply mt-2;
} }
} }
:deep(.ant-form-item-has-error .ant-select:not(.ant-select-disabled) .ant-select-selector) {
border: none !important;
}
:deep(.ant-form-item-has-success .ant-select:not(.ant-select-disabled) .ant-select-selector) {
border: none !important;
}
:deep(.nc-cell-attachment) { :deep(.nc-cell-attachment) {
@apply p-0; @apply p-0;
@ -1581,4 +1654,14 @@ useEventListener(
:deep(.nc-form-input-required + button):focus { :deep(.nc-form-input-required + button):focus {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff; box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff;
} }
.nc-form-field-layout {
@apply !flex !items-center w-full space-x-3;
:deep(.ant-radio-wrapper) {
@apply border-1 border-gray-200 rounded-lg !py-2 !px-3 basis-full !mr-0 !items-center;
.ant-radio {
@apply !top-0;
}
}
}
</style> </style>

293
packages/nc-gui/components/smartsheet/form/LimitOptions.vue

@ -0,0 +1,293 @@
<script setup lang="ts">
import Draggable from 'vuedraggable'
import tinycolor from 'tinycolor2'
import type { ColumnType, SelectOptionType, SelectOptionsType, UserFieldRecordType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { FormFieldsLimitOptionsType } from '~/lib'
import { MetaInj, iconMap } from '#imports'
const props = defineProps<{
modelValue: FormFieldsLimitOptionsType[]
column: ColumnType
}>()
const emit = defineEmits(['update:modelValue'])
const meta = inject(MetaInj)!
const column = toRef(props, 'column')
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const baseUsers = computed(() => (meta.value.base_id ? basesUser.value.get(meta.value.base_id) || [] : []))
const searchQuery = ref('')
const drag = ref(false)
const vModel = computed({
get: () => {
let order = 1
const limitOptionsById =
(props.modelValue || []).reduce((o: Record<string, FormFieldsLimitOptionsType>, f: FormFieldsLimitOptionsType) => {
if (order < (f?.order ?? 0)) {
order = f.order
}
return {
...o,
[f.id]: f,
}
}, {} as Record<string, FormFieldsLimitOptionsType>) ?? {}
if (UITypes.User === column.value.uidt) {
const collaborators = ((baseUsers.value || []) as UserFieldRecordType[])
.filter((user) => !user?.deleted)
.map((user: any) => ({
id: user.id,
email: user.email,
display_name: user.display_name,
order: user.id && limitOptionsById[user.id] ? limitOptionsById[user.id]?.order ?? user.order : order++,
show: user.id && limitOptionsById[user.id] ? limitOptionsById[user.id]?.show : !(props.modelValue || []).length,
}))
.sort((a, b) => a.order - b.order)
if ((props.modelValue || []).length !== collaborators.length) {
emit(
'update:modelValue',
collaborators.map((o) => ({ id: o.id, order: o.order, show: o.show })),
)
}
return collaborators
} else if ([UITypes.SingleSelect, UITypes.MultiSelect].includes(column.value.uidt as UITypes)) {
const updateModelValue = ((column.value.colOptions as SelectOptionsType)?.options || [])
.map((c) => {
return {
...c,
order: c.id && limitOptionsById[c.id] ? limitOptionsById[c.id]?.order ?? c.order : order++,
show: c.id && limitOptionsById[c.id] ? limitOptionsById[c.id]?.show : !(props.modelValue || []).length,
} as SelectOptionType & { show?: boolean }
})
.sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order
}
return 0
})
if ((props.modelValue || []).length !== ((column.value.colOptions as SelectOptionsType)?.options || []).length) {
emit(
'update:modelValue',
updateModelValue.map((o) => ({ id: o.id, order: o.order, show: o.show })),
)
}
return updateModelValue
}
return []
},
set: (val) => {
emit(
'update:modelValue',
val.map((o) => ({ id: o.id, order: o.order, show: o.show })),
)
},
})
async function onMove(_event: { moved: { newIndex: number; oldIndex: number; element: any } }) {
const {
moved: { newIndex = 0, oldIndex = 0, element },
} = _event
let nextOrder: number
// set new order value based on the new order of the items
if (!vModel.value.length || vModel.value.length === 1) {
nextOrder = 1
} else if (vModel.value.length - 1 === newIndex) {
// If moving to the end, set nextOrder greater than the maximum order in the list
nextOrder = Math.max(...vModel.value.map((item) => item?.order ?? 0)) + 1
} else if (newIndex === 0) {
// If moving to the beginning, set nextOrder smaller than the minimum order in the list
nextOrder = Math.min(...vModel.value.map((item) => item?.order ?? 0)) / 2
} else {
nextOrder =
(parseFloat(String(vModel.value[newIndex - 1]?.order ?? 0)) + parseFloat(String(vModel.value[newIndex + 1]?.order ?? 0))) /
2
}
const _nextOrder = !isNaN(Number(nextOrder)) ? nextOrder : oldIndex
element.order = _nextOrder
vModel.value = [...vModel.value]
}
</script>
<template>
<div class="w-full h-full nc-col-select-option nc-form-scrollbar">
<div v-if="vModel.length > 12">
<a-input
v-model:value="searchQuery"
class="!h-9 !px-3 !py-1 !rounded-lg mb-2"
:placeholder="`${$t('placeholder.searchOptions')}...`"
name="nc-form-field-limit-option-search-input"
data-testid="nc-form-field-limit-option-search-input"
>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
<template #suffix>
<GeneralIcon
v-if="searchQuery.length > 0"
icon="close"
class="ml-2 h-4 w-4 text-gray-500 group-hover:text-black"
data-testid="nc-form-field-clear-search"
@click="searchQuery = ''"
/>
</template>
</a-input>
</div>
<Draggable
v-if="vModel.length"
:model-value="vModel"
item-key="id"
handle=".nc-child-draggable-icon"
ghost-class="nc-form-field-limit-option-ghost"
class="rounded-lg border-1 border-gray-200 !max-h-[224px] overflow-y-auto nc-form-scrollbar"
@change="onMove($event)"
@start="drag = true"
@end="drag = false"
>
<template #item="{ element }">
<div
v-if="
column.uidt === UITypes.User
? (element?.display_name?.trim() || element?.email)?.toLowerCase().includes(searchQuery.toLowerCase())
: element.title?.toLowerCase().includes(searchQuery.toLowerCase())
"
:key="element.id"
class="w-full h-10 px-2 py-1.5 flex flex-row items-center gap-3 border-b-1 last:border-none border-gray-200"
:class="[
`nc-form-field-${column.title?.replaceAll(' ', '')}-limit-option-${element.title?.replaceAll(' ', '')}`,
`${element.show ? 'hover:bg-gray-50' : 'bg-gray-100'}`,
]"
:data-testid="`nc-form-field-${column.title?.replaceAll(' ', '')}-limit-option-${element.title?.replaceAll(' ', '')}`"
>
<component :is="iconMap.drag" class="nc-child-draggable-icon flex-none cursor-move !h-4 !w-4 text-gray-600" />
<div
@click="
() => {
element.show = !element.show
vModel = [...vModel]
}
"
>
<component
:is="element.show ? iconMap.eye : iconMap.eyeSlash"
class="flex-none cursor-pointer !h-4 !w-4 text-gray-600"
/>
</div>
<a-tag v-if="column.uidt === UITypes.User" class="rounded-tag max-w-[calc(100%_-_70px)] !pl-0" 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="flex items-stretch gap-2"
>
<div>
<GeneralUserIcon
size="auto"
:name="element.display_name?.trim() ? element.display_name?.trim() : ''"
:email="element.email"
class="!text-[0.65rem]"
/>
</div>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ element.display_name?.trim() || element?.email }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ element.display_name?.trim() || element?.email }}
</span>
</NcTooltip>
</span>
</a-tag>
<a-tag v-else class="rounded-tag max-w-[calc(100%_-_70px)]" :color="element.color">
<span
:style="{
'color': tinycolor.isReadable(element.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(element.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ element.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ element.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</div>
</template>
<template v-if="!vModel.length" #footer
><div class="px-0.5 py-2 text-gray-500 text-center">{{ $t('title.noOptionsFound') }}</div></template
>
<template
v-else-if="
vModel.length &&
searchQuery &&
!vModel?.filter((element) => {
return column.uidt === UITypes.User
? (element?.display_name?.trim() || element?.email)?.toLowerCase().includes(searchQuery.toLowerCase())
: element.title?.toLowerCase().includes(searchQuery.toLowerCase())
})?.length
"
#footer
>
<div class="px-0.5 py-2 text-gray-500 text-center">{{ $t('title.noOptionsFound') }} with title `{{ searchQuery }}`</div>
</template>
</Draggable>
</div>
</template>
<style scoped lang="scss">
.nc-form-scrollbar {
@apply scrollbar scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent;
&::-webkit-scrollbar-thumb:hover {
@apply !scrollbar-thumb-gray-300;
}
}
.rounded-tag {
@apply py-0 px-[12px] rounded-[12px];
}
:deep(.ant-tag) {
@apply rounded-tag my-[2px];
}
.nc-form-field-limit-option-ghost {
@apply bg-gray-50;
}
</style>

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

@ -109,6 +109,7 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
columns.value = viewMeta.model?.columns?.map((c) => ({ columns.value = viewMeta.model?.columns?.map((c) => ({
...c, ...c,
meta: { ...parseProp(fieldById[c.id].meta), ...parseProp(c.meta) },
description: fieldById[c.id].description, description: fieldById[c.id].description,
})) }))

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

@ -297,7 +297,7 @@ export function useViewData(
fk_column_id: c.id, fk_column_id: c.id,
fk_view_id: viewMeta.value?.id, fk_view_id: viewMeta.value?.id,
...(fieldById[c.id!] ? fieldById[c.id!] : {}), ...(fieldById[c.id!] ? fieldById[c.id!] : {}),
meta: { ...parseProp(c.meta), ...parseProp(fieldById[c.id!]?.meta) }, // TODO: discuss with @pranav meta: { ...parseProp(fieldById[c.id!]?.meta), ...parseProp(c.meta) }, // TODO: discuss with @pranav
order: (fieldById[c.id!] && fieldById[c.id!].order) || order++, order: (fieldById[c.id!] && fieldById[c.id!].order) || order++,
id: fieldById[c.id!] && fieldById[c.id!].id, id: fieldById[c.id!] && fieldById[c.id!].id,
})) }))

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

@ -195,7 +195,9 @@
"restore": "Restore", "restore": "Restore",
"replace": "Replace", "replace": "Replace",
"banner": "Banner", "banner": "Banner",
"logo": "Logo" "logo": "Logo",
"dropdown":"Dropdown",
"list":"List"
}, },
"objects": { "objects": {
"day": "Day", "day": "Day",
@ -425,7 +427,9 @@
"setNull": "Set NULL", "setNull": "Set NULL",
"setDefault": "Set Default" "setDefault": "Set Default"
}, },
"selectFieldsFromRightPannelToAddHere": "Select fields from right panel to add here" "selectFieldsFromRightPannelToAddHere": "Select fields from right panel to add here",
"noOptionsFound": "No options found"
}, },
"labels": { "labels": {
"selectYear": "Select Year", "selectYear": "Select Year",
@ -692,7 +696,10 @@
"backgroundColor":"Background Color", "backgroundColor":"Background Color",
"hideNocodbBranding":"Hide NocoDB Branding", "hideNocodbBranding":"Hide NocoDB Branding",
"showOnConditions": "Show on condtions", "showOnConditions": "Show on condtions",
"showFieldOnConditionsMet":"Shows field only when conditions are met" "showFieldOnConditionsMet":"Shows field only when conditions are met",
"limitOptions": "Limit options",
"limitOptionsSubtext": "Limit options visible to users by selecting available options",
"clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
@ -1002,7 +1009,8 @@
"noTokenCreated": "No API Tokens created", "noTokenCreated": "No API Tokens created",
"noTokenCreatedLabel": "Looks like you haven’t generated any API tokens yet.", "noTokenCreatedLabel": "Looks like you haven’t generated any API tokens yet.",
"inviteYourTeam": "Invite your team", "inviteYourTeam": "Invite your team",
"inviteYourTeamLabel": "Fast track your projects by collaborating on them with your team!" "inviteYourTeamLabel": "Fast track your projects by collaborating on them with your team!",
"searchOptions": "Search options"
}, },
"msg": { "msg": {
"clickToCopyFieldId": "Click to copy Field Id", "clickToCopyFieldId": "Click to copy Field Id",

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

@ -198,6 +198,12 @@ interface UsersSortType {
type CommandPaletteType = 'cmd-k' | 'cmd-j' | 'cmd-l' type CommandPaletteType = 'cmd-k' | 'cmd-j' | 'cmd-l'
interface FormFieldsLimitOptionsType {
id: string
order: number
show: boolean
}
export type { export type {
User, User,
ProjectMetaInfo, ProjectMetaInfo,
@ -226,4 +232,5 @@ export type {
UsersSortType, UsersSortType,
CommandPaletteType, CommandPaletteType,
CalendarRangeType, CalendarRangeType,
FormFieldsLimitOptionsType,
} }

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

@ -66,8 +66,16 @@ p {
} }
.nc-form-view { .nc-form-view {
.nc-data-cell {
@apply !border-none rounded-none;
&:focus-within {
@apply !border-none;
}
}
.nc-cell { .nc-cell {
@apply bg-white dark:bg-slate-500; @apply bg-white dark:bg-slate-500 appearance-none;
&.nc-cell-checkbox { &.nc-cell-checkbox {
@apply color-transition !border-0; @apply color-transition !border-0;
@ -89,7 +97,18 @@ p {
@apply bg-white dark:bg-slate-500; @apply bg-white dark:bg-slate-500;
&.nc-input { &.nc-input {
@apply w-full rounded p-2 min-h-[40px] flex items-center; @apply w-full;
&:not(.layout-list) {
@apply rounded-lg border-solid border-1 border-gray-200 focus-within:border-brand-500;
& > div {
@apply !bg-transparent;
}
}
&.layout-list {
@apply h-auto !pl-0 !py-1 !bg-transparent !dark:bg-none;
}
.duration-cell-wrapper { .duration-cell-wrapper {
@apply w-full; @apply w-full;
@ -105,8 +124,7 @@ p {
input, input,
textarea, textarea,
&.nc-virtual-cell, &.nc-virtual-cell {
> div {
@apply bg-white dark:(bg-slate-500 text-white); @apply bg-white dark:(bg-slate-500 text-white);
.ant-btn { .ant-btn {
@ -117,6 +135,18 @@ p {
@apply dark:(bg-slate-700 text-white); @apply dark:(bg-slate-700 text-white);
} }
} }
&:not(.layout-list) > div {
@apply bg-white dark:(bg-slate-500 text-white);
}
&.layout-list > div {
.ant-btn {
@apply dark:(bg-slate-300);
}
.chip {
@apply dark:(bg-slate-700 text-white);
}
}
&.nc-cell-longtext { &.nc-cell-longtext {
@apply p-0 h-auto; @apply p-0 h-auto;
@ -132,7 +162,7 @@ p {
} }
} }
&:not(.nc-cell-longtext) { &:not(.nc-cell-longtext) {
@apply px-2 py-2; @apply p-2;
} }
textarea { textarea {

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

@ -176,7 +176,10 @@ const onDecode = async (scannedCodeValue: string) => {
v-model="formState[field.title]" v-model="formState[field.title]"
class="nc-input truncate" class="nc-input truncate"
:data-testid="`nc-form-input-cell-${field.label || field.title}`" :data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`" :class="[
`nc-form-input-${field.title?.replaceAll(' ', '')}`,
{ 'layout-list': parseProp(field?.meta)?.isList },
]"
:column="field" :column="field"
edit-enabled edit-enabled
/> />

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

@ -292,6 +292,7 @@ onMounted(() => {
v-else v-else
v-model="formState[field.title]" v-model="formState[field.title]"
class="nc-input h-auto" class="nc-input h-auto"
:class="parseProp(field?.meta)?.isList ? 'layout-list' : ''"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`" :data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
edit-enabled edit-enabled
@ -481,33 +482,6 @@ onMounted(() => {
@apply flex items-center justify-center gap-2 text-left; @apply flex items-center justify-center gap-2 text-left;
} }
.nc-input {
@apply appearance-none w-full !bg-white !rounded-lg border-solid border-1 border-gray-200 focus-within:border-brand-500;
&.nc-cell-rating,
&.nc-cell-geodata {
@apply !py-1;
}
:deep(input) {
@apply !px-1;
}
&.nc-cell-longtext {
@apply p-0 h-auto overflow-hidden;
}
&:not(.nc-cell-longtext) {
@apply px-2 py-2;
:deep(textarea) {
@apply !p-2;
}
}
&.nc-cell-checkbox {
> * {
@apply justify-center flex items-center;
}
}
}
.nc-form-data-cell.nc-data-cell { .nc-form-data-cell.nc-data-cell {
@apply !border-none rounded-none; @apply !border-none rounded-none;

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

@ -92,6 +92,7 @@ import Sort from '~icons/nc-icons/sort'
// NocoDB Icons // NocoDB Icons
import NcEye from '~icons/nc-icons/eye' import NcEye from '~icons/nc-icons/eye'
import NcEyeOff from '~icons/nc-icons/eye-off'
import NcStar from '~icons/nc-icons/star' import NcStar from '~icons/nc-icons/star'
import NcUnStar from '~icons/nc-icons/star-remove' import NcUnStar from '~icons/nc-icons/star-remove'
import NcSearch from '~icons/nc-icons/search' import NcSearch from '~icons/nc-icons/search'
@ -417,7 +418,7 @@ export const iconMap = {
calculator: h('span', { class: 'material-symbols' }, 'calculate'), calculator: h('span', { class: 'material-symbols' }, 'calculate'),
rollup: h('span', { class: 'material-symbols' }, 'group_work'), rollup: h('span', { class: 'material-symbols' }, 'group_work'),
eye: NcEye, eye: NcEye,
eyeSlash: h('span', { class: 'material-symbols' }, 'visibility_off'), eyeSlash: NcEyeOff,
expand: h('span', { class: 'material-symbols' }, 'open_in_full'), expand: h('span', { class: 'material-symbols' }, 'open_in_full'),
shrink: h('span', { class: 'material-symbols' }, 'close_fullscreen'), shrink: h('span', { class: 'material-symbols' }, 'close_fullscreen'),
check: NcCheck, check: NcCheck,

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

@ -1,7 +1,7 @@
export function parseProp(v: any): any { export function parseProp(v: any): any {
if (!v) return {} if (!v) return {}
try { try {
return typeof v === 'string' ? JSON.parse(v) : v return typeof v === 'string' ? JSON.parse(v) ?? {} : v
} catch { } catch {
return {} return {}
} }

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

@ -202,4 +202,11 @@ export const getEquivalentUIType = ({
} }
}; };
export const isSelectTypeCol = (
colOrUidt: ColumnType | { uidt: UITypes | string } | UITypes | string
) => {
return [UITypes.SingleSelect, UITypes.MultiSelect, UITypes.User].includes(
<UITypes>(typeof colOrUidt === 'object' ? colOrUidt?.uidt : colOrUidt)
);
};
export default UITypes; export default UITypes;

1
packages/nocodb-sdk/src/lib/index.ts

@ -18,6 +18,7 @@ export {
isCreatedOrLastModifiedByCol, isCreatedOrLastModifiedByCol,
isHiddenCol, isHiddenCol,
getEquivalentUIType, getEquivalentUIType,
isSelectTypeCol,
} from '~/lib/UITypes'; } from '~/lib/UITypes';
export { default as CustomAPI, FileType } from '~/lib/CustomAPI'; export { default as CustomAPI, FileType } from '~/lib/CustomAPI';
export { default as TemplateGenerator } from '~/lib/TemplateGenerator'; export { default as TemplateGenerator } from '~/lib/TemplateGenerator';

Loading…
Cancel
Save