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 6 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">
<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 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>
<path
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"
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;
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 type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk'
import type { FormFieldsLimitOptionsType } from '~/lib'
import {
ActiveCellInj,
ColumnInj,
@ -95,7 +96,43 @@ const options = computed<(SelectOptionType & { value?: string })[]>(() => {
for (const op of opts.filter((el: SelectOptionType) => el.order === null)) {
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 []
})
@ -353,154 +390,194 @@ const onFocus = () => {
<template>
<div
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"
>
<div
v-if="!active"
class="flex flex-wrap"
:style="{
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': rowHeight || 1,
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
>
<template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value">
<a-tag class="rounded-tag max-w-full" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ selectedOpt.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</template>
<div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
<a-checkbox-group v-model:value="vModel" class="nc-field-layout-list">
<a-checkbox
v-for="op of options"
:key="op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
:class="`nc-select-option-${column.title}-${op.title}`"
>
<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',
}"
>
<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-checkbox>
</a-checkbox-group>
</div>
<a-select
v-else
ref="aselect"
v-model:value="vModel"
mode="multiple"
class="w-full overflow-hidden"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:bordered="false"
clear-icon
:show-search="!isMobileMode"
:show-arrow="editAllowed && !readOnly"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-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
<template v-else>
<div
v-if="!active"
class="flex flex-wrap"
:style="{
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': rowHeight || 1,
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
>
<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 && !isPublic && !disableOptionCreation && isUIAllowed('fieldEdit')"
:key="searchVal"
:value="searchVal"
<template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value">
<a-tag class="rounded-tag max-w-full" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ selectedOpt.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</template>
</div>
<a-select
v-else
ref="aselect"
v-model:value="vModel"
mode="multiple"
class="w-full overflow-hidden"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:bordered="false"
clear-icon
:show-search="!isMobileMode"
:show-arrow="editAllowed && !readOnly"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-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">
<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"
<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
>
<span
:style="{
'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor
.mostReadable(options.find((el) => el.title === val)?.color || '#ccc', ['#0b1d05', '#fff'])
.toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
<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 && !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>
</a-tag>
</template>
</a-select>
<span
:style="{
'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', {
level: 'AA',
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>
</template>

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

@ -3,6 +3,7 @@ import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk'
import type { FormFieldsLimitOptionsType } from '~/lib'
import {
ActiveCellInj,
ColumnInj,
@ -88,7 +89,44 @@ const options = computed<(SelectOptionType & { value: string })[]>(() => {
for (const op of opts.filter((el: any) => el.order === null)) {
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 []
})
@ -272,82 +310,70 @@ const onFocus = () => {
<template>
<div
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"
@keydown.enter.stop.prevent="toggleMenu"
>
<div v-if="!(active || isEditable)" class="w-full">
<a-tag v-if="selectedOpt" class="rounded-tag max-w-full" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
<div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
<a-radio-group v-model:value="vModel" class="nc-field-layout-list">
<a-radio
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}`"
>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.title }}
</template>
<a-tag class="rounded-tag max-w-full" :color="op.color">
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: 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>
</NcTooltip>
</span>
</a-tag>
</div>
<NcSelect
v-else
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></a-radio
>
</a-radio-group>
<div
v-if="vModel"
class="inline-block px-2 pt-2 cursor-pointer text-xs text-gray-500 hover:text-gray-800"
@click="vModel = ''"
>
<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
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
: tinycolor.mostReadable(selectedOpt.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 }}
{{ selectedOpt.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
@ -357,21 +383,80 @@ const onFocus = () => {
display: 'inline',
}"
>
{{ op.title }}
{{ selectedOpt.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>
<NcSelect
v-else
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">
<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>
</a-select-option>
</NcSelect>
</a-select-option>
</NcSelect>
</template>
</div>
</template>

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

@ -1,8 +1,10 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import tinycolor from 'tinycolor2'
import { Checkbox, CheckboxGroup, Radio, RadioGroup } from 'ant-design-vue'
import type { Select as AntSelect } from 'ant-design-vue'
import type { UserFieldRecordType } from 'nocodb-sdk'
import type { FormFieldsLimitOptionsType } from '~/lib'
import {
ActiveCellInj,
CellClickHookInj,
@ -77,16 +79,59 @@ const searchVal = ref<string | null>()
const { isUIAllowed } = useRoles()
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[] = []
collaborators.push(
...(baseUsers.value?.map((user: any) => ({
id: user.id,
email: user.email,
display_name: user.display_name,
deleted: user.deleted,
})) || []),
)
if (
!isEditColumn.value &&
isForm.value &&
parseProp(column.value.meta)?.isLimitOption &&
(parseProp(column.value.meta)?.limitOptions || []).length
) {
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
})
@ -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) => {
if (!n) searchVal.value = ''
@ -266,87 +319,85 @@ const filterOption = (input: string, option: any) => {
:class="{ 'read-only': readOnly }"
@click="toggleMenu"
>
<div
v-if="!active"
class="flex flex-wrap"
:style="{
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': rowHeight || 1,
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
>
<template v-for="selectedOpt of vModel" :key="selectedOpt.value">
<a-tag class="rounded-tag 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 v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
<component
:is="isMultiple ? CheckboxGroup : RadioGroup"
v-model:value="vModelListLayout"
class="nc-field-layout-list"
@update:value="
(value) => {
vModel = isMultiple ? value : [value]
}
"
>
<template v-for="op of options" :key="op.id || op.email">
<component
:is="isMultiple ? Checkbox : Radio"
v-if="!op.deleted"
:key="op.id || op.email"
:value="op.id"
:data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
:class="`nc-select-option-${column.title}-${op.email}`"
>
<div class="flex-none">
<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>
<a-tag class="rounded-tag max-w-full !pl-0" color="'#ccc'">
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
'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"
>
{{ 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>
</NcTooltip>
</span>
</a-tag>
</template>
</a-tag>
</component>
</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>
<a-select
v-else
ref="aselect"
v-model:value="vModel"
mode="multiple"
class="w-full overflow-hidden"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:bordered="false"
clear-icon
:show-search="!isMobileMode"
:show-arrow="editAllowed && !readOnly"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-user-select-cell !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
>
<template v-else>
<div
v-if="!active"
class="flex flex-wrap"
:style="{
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': rowHeight || 1,
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
>
<template v-for="selectedOpt of vModel" :key="selectedOpt.value">
<a-tag class="rounded-tag max-w-full !pl-0" color="'#ccc'">
<span
:style="{
@ -358,17 +409,17 @@ const filterOption = (input: string, option: any) => {
class="flex items-stretch gap-2"
:class="{ 'text-sm': isKanban }"
>
<div>
<div class="flex-none">
<GeneralUserIcon
size="auto"
:name="op.display_name?.trim() ? op.display_name?.trim() : ''"
:email="op.email"
: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>
{{ op.display_name?.trim() || op.email }}
{{ selectedOpt.label }}
</template>
<span
class="text-ellipsis overflow-hidden"
@ -378,51 +429,121 @@ const filterOption = (input: string, option: any) => {
display: 'inline',
}"
>
{{ op.display_name?.trim() || op.email }}
{{ selectedOpt.label }}
</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"
>
<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 }"
</template>
</div>
<a-select
v-else
ref="aselect"
v-model:value="vModel"
mode="multiple"
class="w-full overflow-hidden"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:bordered="false"
clear-icon
:show-search="!isMobileMode"
:show-arrow="editAllowed && !readOnly"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-user-select-cell !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'">
<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>
<GeneralUserIcon
size="auto"
:name="!label?.includes('@') ? label.trim() : ''"
:email="label"
class="!text-[0.65rem]"
/>
</div>
{{ label }}
</span>
</a-tag>
</template>
</a-select>
<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="!label?.includes('@') ? label.trim() : ''"
:email="label"
class="!text-[0.65rem]"
/>
</div>
{{ label }}
</span>
</a-tag>
</template>
</a-select>
</template>
</div>
</template>

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

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

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

@ -1,10 +1,18 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import Draggable from 'vuedraggable'
import tinycolor from 'tinycolor2'
import { Pane, Splitpanes } from 'splitpanes'
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 {
ActiveViewInj,
@ -151,9 +159,7 @@ const imageCropperData = ref<{
cropFor: 'banner',
})
const focusLabel: VNodeRef = (el) => {
return (el as HTMLInputElement)?.focus()
}
const focusLabel = ref<HTMLTextAreaElement>()
const searchQuery = ref('')
@ -164,6 +170,7 @@ const { betaFeatureToggleState } = useBetaFeatureToggle()
const { open, onChange: onChangeFile } = useFileDialog({
accept: 'image/*',
multiple: false,
reset: true,
})
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()
}
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 = ''
isTabPressed.value = false
})
@ -561,6 +576,12 @@ watch(activeRow, (newValue) => {
}
})
watch([focusLabel, activeRow], () => {
if (activeRow && focusLabel.value) {
focusLabel.value?.focus()
}
})
useEventListener(
formRef,
'focusout',
@ -674,9 +695,9 @@ useEventListener(
@submit="handleOnUploadImage"
></GeneralImageCropper>
<!-- 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" />
<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">
<NcButton
type="secondary"
@ -794,6 +815,9 @@ useEventListener(
{
'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,
},
@ -841,6 +865,9 @@ useEventListener(
{
'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,
},
@ -893,13 +920,13 @@ useEventListener(
:class="[
`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,
},
{
'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,
},
@ -982,7 +1009,7 @@ useEventListener(
<template v-if="activeRow === element.title">
<a-form-item class="my-0 !mb-2">
<a-textarea
:ref="focusLabel"
ref="focusLabel"
v-model:value="element.label"
:rows="1"
auto-size
@ -1069,7 +1096,10 @@ useEventListener(
v-else
v-model="formState[element.title]"
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(' ', '')}`"
:column="element"
:edit-enabled="true"
@ -1080,19 +1110,55 @@ useEventListener(
</div>
<!-- Field Settings -->
<!-- eslint-disable vue/no-constant-condition -->
<div
v-if="activeRow === element.title && false"
class="nc-form-field-settings border-t border-gray-200 p-4 lg:p-6"
v-if="activeRow === element.title"
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,... -->
<div class="flex items-start gap-3 px-3 py-2 border-1 border-gray-200 rounded-lg">
<!-- Layout -->
<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" />
<div>
<div class="font-medium text-gray-800">{{ $t('labels.showOnConditions') }}</div>
<div class="text-gray-500">{{ $t('labels.showFieldOnConditionsMet') }}</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>
</template>
@ -1145,7 +1211,7 @@ useEventListener(
</div>
</div>
<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">
<div class="flex flex-wrap justify-between items-center gap-2">
<div class="flex gap-3">
@ -1298,12 +1364,7 @@ useEventListener(
</template>
</div>
</Pane>
<Pane
v-if="formViewData"
min-size="20"
size="50"
class="nc-form-right-splitpane-item !overflow-y-auto nc-form-scrollbar"
>
<Pane 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">
<!-- Appearance Settings -->
<div class="text-base font-bold text-gray-900">{{ $t('labels.appearanceSettings') }}</div>
@ -1474,7 +1535,13 @@ useEventListener(
}
.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-geodata {
@apply !py-1;
@ -1524,6 +1591,12 @@ useEventListener(
@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) {
@apply p-0;
@ -1581,4 +1654,14 @@ useEventListener(
:deep(.nc-form-input-required + button):focus {
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>

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) => ({
...c,
meta: { ...parseProp(fieldById[c.id].meta), ...parseProp(c.meta) },
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_view_id: viewMeta.value?.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++,
id: fieldById[c.id!] && fieldById[c.id!].id,
}))

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

@ -195,7 +195,9 @@
"restore": "Restore",
"replace": "Replace",
"banner": "Banner",
"logo": "Logo"
"logo": "Logo",
"dropdown":"Dropdown",
"list":"List"
},
"objects": {
"day": "Day",
@ -425,7 +427,9 @@
"setNull": "Set NULL",
"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": {
"selectYear": "Select Year",
@ -692,7 +696,10 @@
"backgroundColor":"Background Color",
"hideNocodbBranding":"Hide NocoDB Branding",
"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": {
"noRange": "Calendar view requires a date range",
@ -1002,7 +1009,8 @@
"noTokenCreated": "No API Tokens created",
"noTokenCreatedLabel": "Looks like you haven’t generated any API tokens yet.",
"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": {
"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'
interface FormFieldsLimitOptionsType {
id: string
order: number
show: boolean
}
export type {
User,
ProjectMetaInfo,
@ -226,4 +232,5 @@ export type {
UsersSortType,
CommandPaletteType,
CalendarRangeType,
FormFieldsLimitOptionsType,
}

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

@ -66,8 +66,16 @@ p {
}
.nc-form-view {
.nc-data-cell {
@apply !border-none rounded-none;
&:focus-within {
@apply !border-none;
}
}
.nc-cell {
@apply bg-white dark:bg-slate-500;
@apply bg-white dark:bg-slate-500 appearance-none;
&.nc-cell-checkbox {
@apply color-transition !border-0;
@ -89,7 +97,18 @@ p {
@apply bg-white dark:bg-slate-500;
&.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 {
@apply w-full;
@ -105,8 +124,7 @@ p {
input,
textarea,
&.nc-virtual-cell,
> div {
&.nc-virtual-cell {
@apply bg-white dark:(bg-slate-500 text-white);
.ant-btn {
@ -117,6 +135,18 @@ p {
@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 {
@apply p-0 h-auto;
@ -132,7 +162,7 @@ p {
}
}
&:not(.nc-cell-longtext) {
@apply px-2 py-2;
@apply p-2;
}
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]"
class="nc-input truncate"
: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"
edit-enabled
/>

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

@ -292,6 +292,7 @@ onMounted(() => {
v-else
v-model="formState[field.title]"
class="nc-input h-auto"
:class="parseProp(field?.meta)?.isList ? 'layout-list' : ''"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
edit-enabled
@ -481,33 +482,6 @@ onMounted(() => {
@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 {
@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
import NcEye from '~icons/nc-icons/eye'
import NcEyeOff from '~icons/nc-icons/eye-off'
import NcStar from '~icons/nc-icons/star'
import NcUnStar from '~icons/nc-icons/star-remove'
import NcSearch from '~icons/nc-icons/search'
@ -417,7 +418,7 @@ export const iconMap = {
calculator: h('span', { class: 'material-symbols' }, 'calculate'),
rollup: h('span', { class: 'material-symbols' }, 'group_work'),
eye: NcEye,
eyeSlash: h('span', { class: 'material-symbols' }, 'visibility_off'),
eyeSlash: NcEyeOff,
expand: h('span', { class: 'material-symbols' }, 'open_in_full'),
shrink: h('span', { class: 'material-symbols' }, 'close_fullscreen'),
check: NcCheck,

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

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

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

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

Loading…
Cancel
Save