Browse Source

Nc Refactor: Kanban view (#8689)

* fix(nc-gui): update kanban view stack ui

* feat(nc-gui): add collapse all stack option

* fix(nc-gui): add empty stack placeholder

* fix(nc-gui): add loading state support for ncSwitch

* fix(nc-gui): swap edit card and stacked by toolbar menu

* fix(nc-gui): update stacked by toolbar menu

* fix(nc-gui): update kanban view height

* fix(nc-gui): add stack bg color

* feat(nc-gui): add support to hide empty stack

* fix(nc-gui): stack loader issue

* fix(nc-gui): checkbox alignment in kanban view

* fix(nc-gui): update stack drag handler and hide it if user does not have permission

* fix(nc-gui): stack title overflow issue

* fix(nc-gui): allow inline rename stack

* fix(nc-gui): advance color picker tab warnings

* fix(nc-gui): rename stack option issues

* fix(nc-gui): small changes

* fix(nc-gui): review changes

* feat(nc-gui): add new stack support

* fix(nc-gui): small changes

* fix(nc-gui): add loading state for rename & add new stack

* fix(nc-gui): reduce width of stack

* fix(nc-gui): make ncSwitch placement prop optional

* fix(nc-gui): some review changes

* fix(nc-gui): remove only from test

* fix(nc-gui): add error handling part in kanban stack update

* fix(nc-gui): update localstate while updating kanban stack meta

* fix(nc-gui): some review changes

* fix(nc-gui): add expand all stack option

* fix(nc-gui): add condition to append new stack obj

* fix(nc-gui): update card field label style

* fix(nc-gui): remove top & bottom padding from stack

* fix(nc-gui): drag stack test update

* fix(nc-gui): console warning issues

* text(nc-gui): update kanban view test

* fix(nc-gui): remove last added empty row from stack if it is not saved

* fix(nc-gui): duplicate column insert issue on rename stack

* fix(nc-gui): update field menu

* fix(nc-gui): add new stack duplicate issue

* feat(nc-gui): add expand record option in context menu of gallery

* fix(nc-gui): delete record fail issue #3111

* fix(nc-gui): hide grouping field by default in kanban view

* chore(nc-gui): lint

* fix(nc-gui): ui review changes

* fix(nc-gui): select option focus issue in edit state

* fix(nc-gui): add bottom border for stack

* fix(nc-gui): ui review changes

* fix(nc-gui): update color picker btn text from select option

* fix(nc-gui): delete default value stack #8212

* fix(nc-gui): stack data offset an drag card issue

* chore(nc-gui): lint
pull/8714/head
Ramesh Mane 6 months ago committed by GitHub
parent
commit
7dc4319dc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 20
      packages/nc-gui/assets/nc-icons/drag.svg
  2. 5
      packages/nc-gui/assets/nc-icons/maximize-all.svg
  3. 12
      packages/nc-gui/assets/nc-icons/maximize.svg
  4. 5
      packages/nc-gui/assets/nc-icons/minimize-all.svg
  5. 10
      packages/nc-gui/assets/nc-icons/minimize.svg
  6. 17
      packages/nc-gui/assets/style.scss
  7. 6
      packages/nc-gui/components/cell/Checkbox.vue
  8. 1
      packages/nc-gui/components/cell/DatePicker.vue
  9. 2
      packages/nc-gui/components/cell/DateTimePicker.vue
  10. 2
      packages/nc-gui/components/cell/Json.vue
  11. 4
      packages/nc-gui/components/cell/ReadOnlyUser.vue
  12. 1
      packages/nc-gui/components/cell/TimePicker.vue
  13. 6
      packages/nc-gui/components/cell/YearPicker.vue
  14. 12
      packages/nc-gui/components/general/AdvanceColorPicker.vue
  15. 20
      packages/nc-gui/components/general/TruncateText.vue
  16. 2
      packages/nc-gui/components/nc/Button.vue
  17. 34
      packages/nc-gui/components/nc/Switch.vue
  18. 44
      packages/nc-gui/components/smartsheet/Gallery.vue
  19. 594
      packages/nc-gui/components/smartsheet/Kanban.vue
  20. 4
      packages/nc-gui/components/smartsheet/Toolbar.vue
  21. 1
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  22. 238
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  23. 3
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  24. 3
      packages/nc-gui/components/smartsheet/header/Cell.vue
  25. 59
      packages/nc-gui/components/smartsheet/kanban/EditOrAddStack.vue
  26. 8
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  27. 135
      packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue
  28. 6
      packages/nc-gui/composables/useAttachment.ts
  29. 9
      packages/nc-gui/composables/useColumnCreateStore.ts
  30. 35
      packages/nc-gui/composables/useKanbanViewStore.ts
  31. 19
      packages/nc-gui/lang/en.json
  32. 11
      packages/nc-gui/utils/iconUtils.ts
  33. 6
      packages/nc-gui/utils/parseUtils.ts
  34. 9
      packages/nocodb/src/helpers/getAst.ts
  35. 2
      packages/nocodb/src/models/View.ts
  36. 86
      tests/playwright/pages/Dashboard/Kanban/index.ts
  37. 3
      tests/playwright/tests/db/views/viewKanban.spec.ts

20
packages/nc-gui/assets/nc-icons/drag.svg

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M5.00016 13.3333C5.36835 13.3333 5.66683 13.0349 5.66683 12.6667C5.66683 12.2985 5.36835 12 5.00016 12C4.63197 12 4.3335 12.2985 4.3335 12.6667C4.3335 13.0349 4.63197 13.3333 5.00016 13.3333Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M5.00016 8.66683C5.36835 8.66683 5.66683 8.36835 5.66683 8.00016C5.66683 7.63197 5.36835 7.3335 5.00016 7.3335C4.63197 7.3335 4.3335 7.63197 4.3335 8.00016C4.3335 8.36835 4.63197 8.66683 5.00016 8.66683Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M5.00016 3.99984C5.36835 3.99984 5.66683 3.70136 5.66683 3.33317C5.66683 2.96498 5.36835 2.6665 5.00016 2.6665C4.63197 2.6665 4.3335 2.96498 4.3335 3.33317C4.3335 3.70136 4.63197 3.99984 5.00016 3.99984Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M11.0002 13.3333C11.3684 13.3333 11.6668 13.0349 11.6668 12.6667C11.6668 12.2985 11.3684 12 11.0002 12C10.632 12 10.3335 12.2985 10.3335 12.6667C10.3335 13.0349 10.632 13.3333 11.0002 13.3333Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M11.0002 8.66683C11.3684 8.66683 11.6668 8.36835 11.6668 8.00016C11.6668 7.63197 11.3684 7.3335 11.0002 7.3335C10.632 7.3335 10.3335 7.63197 10.3335 8.00016C10.3335 8.36835 10.632 8.66683 11.0002 8.66683Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M11.0002 3.99984C11.3684 3.99984 11.6668 3.70136 11.6668 3.33317C11.6668 2.96498 11.3684 2.6665 11.0002 2.6665C10.632 2.6665 10.3335 2.96498 10.3335 3.33317C10.3335 3.70136 10.632 3.99984 11.0002 3.99984Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

5
packages/nc-gui/assets/nc-icons/maximize-all.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M5.33333 2H3.33333C2.97971 2 2.64057 2.14048 2.39052 2.39052C2.14048 2.64057 2 2.97971 2 3.33333V5.33333M14 5.33333V3.33333C14 2.97971 13.8595 2.64057 13.6095 2.39052C13.3594 2.14048 13.0203 2 12.6667 2H10.6667M10.6667 14H12.6667C13.0203 14 13.3594 13.8595 13.6095 13.6095C13.8595 13.3594 14 13.0203 14 12.6667V10.6667M2 10.6667V12.6667C2 13.0203 2.14048 13.3594 2.39052 13.6095C2.64057 13.8595 2.97971 14 3.33333 14H5.33333"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 652 B

12
packages/nc-gui/assets/nc-icons/maximize.svg

@ -1,6 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 14H2V10" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> <path d="M6 14H2V10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M2 14L6.66667 9.33337" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> <path d="M2 14L6.66667 9.33331" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
<path d="M10 2H14V6" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> stroke-linejoin="round" />
<path d="M13.9999 2L9.33325 6.66667" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> <path d="M10 2H14V6" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 2L9.33331 6.66667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 570 B

After

Width:  |  Height:  |  Size: 620 B

5
packages/nc-gui/assets/nc-icons/minimize-all.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M5.33333 2V4C5.33333 4.35362 5.19286 4.69276 4.94281 4.94281C4.69276 5.19286 4.35362 5.33333 4 5.33333H2M14 5.33333H12C11.6464 5.33333 11.3072 5.19286 11.0572 4.94281C10.8071 4.69276 10.6667 4.35362 10.6667 4V2M10.6667 14V12C10.6667 11.6464 10.8071 11.3072 11.0572 11.0572C11.3072 10.8071 11.6464 10.6667 12 10.6667H14M2 10.6667H4C4.35362 10.6667 4.69276 10.8071 4.94281 11.0572C5.19286 11.3072 5.33333 11.6464 5.33333 12V14"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 652 B

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

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2.6665 9.3335H6.6665V13.3335" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M2 14.0002L6.66667 9.3335" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M13.3335 6.6665H9.3335V2.6665" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M9.3335 6.66667L14.0002 2" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 682 B

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

@ -249,6 +249,23 @@ a {
@apply !rounded-md; @apply !rounded-md;
} }
} }
.nc-select-shadow {
&.ant-select {
&:not(.ant-select-disabled):not(:hover):not(.ant-select-focused) .ant-select-selector,
&:not(.ant-select-disabled):hover.ant-select-disabled .ant-select-selector {
@apply shadow-default;
}
&:hover:not(.ant-select-focused):not(.ant-select-disabled) .ant-select-selector {
@apply border-gray-300 shadow-hover;
}
&.ant-select-disabled .ant-select-selector {
box-shadow: none;
}
}
}
// select dropdown border style // select dropdown border style
.ant-select-dropdown { .ant-select-dropdown {
@apply border-1 border-gray-200 rounded-lg; @apply border-1 border-gray-200 rounded-lg;

6
packages/nc-gui/components/cell/Checkbox.vue

@ -25,6 +25,8 @@ const isEditColumnMenu = inject(EditColumnInj, ref(false))
const isGallery = inject(IsGalleryInj, ref(false)) const isGallery = inject(IsGalleryInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const readOnly = inject(ReadonlyInj) const readOnly = inject(ReadonlyInj)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false)) const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
@ -110,8 +112,8 @@ useSelectedCellKeyupListener(active, (e) => {
<div <div
class="flex items-center" class="flex items-center"
:class="{ :class="{
'w-full justify-start': isEditColumnMenu || isGallery || isForm, 'w-full justify-start': isEditColumnMenu || isGallery || isKanban || isForm,
'justify-center': !isEditColumnMenu && !isGallery && !isForm, 'justify-center': !isEditColumnMenu && !isGallery && !isKanban && !isForm,
'py-2': isEditColumnMenu, 'py-2': isEditColumnMenu,
}" }"
@click.stop="onClick(true)" @click.stop="onClick(true)"

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

@ -299,6 +299,7 @@ function handleSelectDate(value?: dayjs.Dayjs) {
:overlay-class-name="`${randomClass} nc-picker-date ${open ? 'active' : ''} !min-w-[260px]`" :overlay-class-name="`${randomClass} nc-picker-date ${open ? 'active' : ''} !min-w-[260px]`"
> >
<div <div
v-bind="$attrs"
:title="localState?.format(dateFormat)" :title="localState?.format(dateFormat)"
class="nc-date-picker h-full flex items-center justify-between ant-picker-input relative" class="nc-date-picker h-full flex items-center justify-between ant-picker-input relative"
> >

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

@ -428,7 +428,7 @@ const cellValue = computed(
</script> </script>
<template> <template>
<div class="nc-cell-field relative"> <div v-bind="$attrs" class="nc-cell-field relative">
<NcDropdown <NcDropdown
:visible="isOpen" :visible="isOpen"
:placement="isDatePicker ? 'bottomLeft' : 'bottomRight'" :placement="isDatePicker ? 'bottomLeft' : 'bottomRight'"

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

@ -214,7 +214,7 @@ watch(inputWrapperRef, () => {
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span> <span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<LazyCellClampedText v-else :value="vModel" :lines="rowHeight" class="nc-cell-field" /> <LazyCellClampedText v-else :value="vModel ? stringifyProp(vModel) : ''" :lines="rowHeight" class="nc-cell-field" />
</component> </component>
</template> </template>

4
packages/nc-gui/components/cell/ReadOnlyUser.vue

@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UserFieldRecordType } from 'nocodb-sdk'
interface Props { interface Props {
modelValue?: string | null modelValue?: UserFieldRecordType[] | UserFieldRecordType | string | null
} }
defineProps<Props>() defineProps<Props>()

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

@ -309,6 +309,7 @@ const cellValue = computed(() => localState.value?.format(parseProp(column.value
:overlay-class-name="`${randomClass} nc-picker-time ${isOpen ? 'active' : ''} !min-w-[0]`" :overlay-class-name="`${randomClass} nc-picker-time ${isOpen ? 'active' : ''} !min-w-[0]`"
> >
<div <div
v-bind="$attrs"
:title="localState?.format('HH:mm')" :title="localState?.format('HH:mm')"
class="nc-time-picker h-full flex items-center justify-between ant-picker-input relative" class="nc-time-picker h-full flex items-center justify-between ant-picker-input relative"
> >

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

@ -264,7 +264,11 @@ function handleSelectDate(value?: dayjs.Dayjs) {
:class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]" :class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]"
:overlay-class-name="`${randomClass} nc-picker-year ${open ? 'active' : ''} !min-w-[260px]`" :overlay-class-name="`${randomClass} nc-picker-year ${open ? 'active' : ''} !min-w-[260px]`"
> >
<div :title="localState?.format('YYYY')" class="nc-year-picker flex items-center justify-between ant-picker-input relative"> <div
v-bind="$attrs"
:title="localState?.format('YYYY')"
class="nc-year-picker flex items-center justify-between ant-picker-input relative"
>
<input <input
ref="datePickerRef" ref="datePickerRef"
type="text" type="text"

12
packages/nc-gui/components/general/AdvanceColorPicker.vue

@ -52,22 +52,22 @@ const defaultColors = computed<string[][]>(() => {
return allColors return allColors
}) })
const localIsDefaultColorTab = ref(true) const localIsDefaultColorTab = ref<'true' | 'false'>('true')
const isDefaultColorTab = computed({ const isDefaultColorTab = computed({
get: () => { get: () => {
if (showActiveColorTab.value && vModel.value) { if (showActiveColorTab.value && vModel.value) {
for (const colorGrp of defaultColors.value) { for (const colorGrp of defaultColors.value) {
if (colorGrp.includes(vModel.value)) { if (colorGrp.includes(vModel.value)) {
return true return 'true'
} }
} }
return false return 'false'
} }
return localIsDefaultColorTab.value return localIsDefaultColorTab.value
}, },
set: (val: boolean) => { set: (val: 'true' | 'false') => {
localIsDefaultColorTab.value = val localIsDefaultColorTab.value = val
if (showActiveColorTab.value) { if (showActiveColorTab.value) {
@ -110,7 +110,7 @@ watch(
<template> <template>
<div class="nc-advance-color-picker w-[336px] pt-2" click.stop> <div class="nc-advance-color-picker w-[336px] pt-2" click.stop>
<NcTabs v-model:activeKey="isDefaultColorTab" class="nc-advance-color-picker-tab w-full"> <NcTabs v-model:activeKey="isDefaultColorTab" class="nc-advance-color-picker-tab w-full">
<a-tab-pane :key="true"> <a-tab-pane key="true">
<template #tab> <template #tab>
<div class="tab" data-testid="nc-default-colors-tab">Default colors</div> <div class="tab" data-testid="nc-default-colors-tab">Default colors</div>
</template> </template>
@ -131,7 +131,7 @@ watch(
</div> </div>
</div> </div>
</a-tab-pane> </a-tab-pane>
<a-tab-pane :key="false"> <a-tab-pane key="false">
<template #tab> <template #tab>
<div class="tab" data-testid="nc-custom-colors-tab"> <div class="tab" data-testid="nc-custom-colors-tab">
<div>Custom colours</div> <div>Custom colours</div>

20
packages/nc-gui/components/general/TruncateText.vue

@ -1,18 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { TooltipPlacement } from 'ant-design-vue/es/tooltip'
interface Props { interface Props {
placement?: placement?: TooltipPlacement
| 'top'
| 'left'
| 'right'
| 'bottom'
| 'topLeft'
| 'topRight'
| 'bottomLeft'
| 'bottomRight'
| 'leftTop'
| 'leftBottom'
| 'rightTop'
| 'rightBottom'
length?: number length?: number
} }
@ -30,12 +20,12 @@ const shortName = computed(() =>
</script> </script>
<template> <template>
<a-tooltip v-if="enableTooltip" :placement="placement"> <NcTooltip v-if="enableTooltip" :placement="placement">
<template #title> <template #title>
<slot /> <slot />
</template> </template>
<div class="w-full">{{ shortName }}</div> <div class="w-full">{{ shortName }}</div>
</a-tooltip> </NcTooltip>
<div v-else class="w-full" data-testid="truncate-label"> <div v-else class="w-full" data-testid="truncate-label">
<slot /> <slot />
</div> </div>

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

@ -186,7 +186,7 @@ useEventListener(NcButton, 'mousedown', () => {
.nc-button.ant-btn[disabled], .nc-button.ant-btn[disabled],
.ant-btn-text.nc-button.ant-btn[disabled] { .ant-btn-text.nc-button.ant-btn[disabled] {
box-shadow: none !important; box-shadow: none !important;
@apply bg-gray-50 border-0 text-gray-300 cursor-not-allowed md:(hover:bg-gray-50); @apply bg-gray-50 border-0 text-gray-300 !cursor-not-allowed md:(hover:bg-gray-50);
} }
.nc-button.ant-btn-text.ant-btn[disabled] { .nc-button.ant-btn-text.ant-btn[disabled] {

34
packages/nc-gui/components/nc/Switch.vue

@ -1,9 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
const props = withDefaults( const props = withDefaults(
defineProps<{ checked: boolean; disabled?: boolean; size?: 'default' | 'small' | 'xsmall'; placement: 'left' | 'right' }>(), defineProps<{
checked: boolean
disabled?: boolean
size?: 'default' | 'small' | 'xsmall'
placement?: 'left' | 'right'
loading?: boolean
}>(),
{ {
size: 'small', size: 'small',
placement: 'left', placement: 'left',
loading: false,
}, },
) )
@ -11,9 +18,13 @@ const emit = defineEmits(['change', 'update:checked'])
const checked = useVModel(props, 'checked', emit) const checked = useVModel(props, 'checked', emit)
const { loading } = toRefs(props)
const switchSize = computed(() => (['default', 'small'].includes(props.size) ? props.size : undefined)) const switchSize = computed(() => (['default', 'small'].includes(props.size) ? props.size : undefined))
const onChange = (e: boolean, updateValue = false) => { const onChange = (e: boolean, updateValue = false) => {
if (loading.value) return
if (updateValue) { if (updateValue) {
checked.value = e checked.value = e
} }
@ -23,7 +34,15 @@ const onChange = (e: boolean, updateValue = false) => {
</script> </script>
<template> <template>
<span v-if="placement === 'right' && $slots.default" class="cursor-pointer pr-2" @click="onChange(!checked, true)"> <span
v-if="placement === 'right' && $slots.default"
class="pr-2"
:class="{
'cursor-not-allowed': disabled,
'cursor-pointer': !disabled,
}"
@click="onChange(!checked, true)"
>
<slot /> <slot />
</span> </span>
<a-switch <a-switch
@ -33,12 +52,21 @@ const onChange = (e: boolean, updateValue = false) => {
:class="{ :class="{
'size-xsmall': size === 'xsmall', 'size-xsmall': size === 'xsmall',
}" }"
:loading="loading"
v-bind="$attrs" v-bind="$attrs"
:size="switchSize" :size="switchSize"
@change="onChange" @change="onChange"
> >
</a-switch> </a-switch>
<span v-if="placement === 'left' && $slots.default" class="cursor-pointer pl-2" @click="onChange(!checked, true)"> <span
v-if="placement === 'left' && $slots.default"
class="pl-2"
:class="{
'cursor-not-allowed': disabled,
'cursor-pointer': !disabled,
}"
@click="onChange(!checked, true)"
>
<slot /> <slot />
</span> </span>
</template> </template>

44
packages/nc-gui/components/smartsheet/Gallery.vue

@ -77,9 +77,9 @@ const contextMenu = computed({
} }
}, },
}) })
const contextMenuTarget = ref<{ row: number } | null>(null) const contextMenuTarget = ref<{ row: RowType; index: number } | null>(null)
const showContextMenu = (e: MouseEvent, target?: { row: number }) => { const showContextMenu = (e: MouseEvent, target?: { row: RowType; index: number }) => {
if (isSqlView.value) return if (isSqlView.value) return
e.preventDefault() e.preventDefault()
if (target) { if (target) {
@ -183,27 +183,41 @@ watch(
</script> </script>
<template> <template>
<a-dropdown <NcDropdown
v-model:visible="contextMenu" v-model:visible="contextMenu"
:trigger="isSqlView ? [] : ['contextmenu']" :trigger="isSqlView ? [] : ['contextmenu']"
overlay-class-name="nc-dropdown-grid-context-menu" overlay-class-name="nc-dropdown-grid-context-menu"
> >
<template #overlay> <template #overlay>
<a-menu class="shadow !rounded !py-0" @click="contextMenu = false"> <NcMenu @click="contextMenu = false">
<a-menu-item v-if="contextMenuTarget" @click="deleteRow(contextMenuTarget.row)"> <NcMenuItem v-if="contextMenuTarget" @click="expandForm(contextMenuTarget.row)">
<div v-e="['a:row:delete']" class="nc-base-menu-item"> <div v-e="['a:row:expand-record']" class="flex items-center gap-2">
<component :is="iconMap.expand" class="flex" />
<!-- Expand Record -->
{{ $t('activity.expandRecord') }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem
v-if="contextMenuTarget?.index !== undefined"
class="!text-red-600 !hover:bg-red-50"
@click="deleteRow(contextMenuTarget.index)"
>
<div v-e="['a:row:delete']" class="flex items-center gap-2">
<component :is="iconMap.delete" class="flex" />
<!-- Delete Row --> <!-- Delete Row -->
{{ $t('activity.deleteRow') }} {{ $t('activity.deleteRow') }}
</div> </div>
</a-menu-item> </NcMenuItem>
<!-- <a-menu-item v-if="contextMenuTarget" @click="openNewRecordFormHook.trigger()"> --> <!-- <NcMenuItem v-if="contextMenuTarget" @click="openNewRecordFormHook.trigger()"> -->
<!-- <div v-e="['a:row:insert']" class="nc-base-menu-item"> --> <!-- <div v-e="['a:row:insert']" class="flex items-center gap-2"> -->
<!-- &lt;!&ndash; Insert New Row &ndash;&gt; --> <!-- &lt;!&ndash; Insert New Row &ndash;&gt; -->
<!-- {{ $t('activity.insertRow') }} --> <!-- {{ $t('activity.insertRow') }} -->
<!-- </div> --> <!-- </div> -->
<!-- </a-menu-item> --> <!-- </NcMenuItem> -->
</a-menu> </NcMenu>
</template> </template>
<div <div
@ -227,7 +241,7 @@ watch(
:body-style="{ padding: '16px !important' }" :body-style="{ padding: '16px !important' }"
:data-testid="`nc-gallery-card-${record.row.id}`" :data-testid="`nc-gallery-card-${record.row.id}`"
@click="expandFormClick($event, record)" @click="expandFormClick($event, record)"
@contextmenu="showContextMenu($event, { row: rowIndex })" @contextmenu="showContextMenu($event, { row: record, index: rowIndex })"
> >
<template v-if="galleryData?.fk_cover_image_col_id" #cover> <template v-if="galleryData?.fk_cover_image_col_id" #cover>
<a-carousel <a-carousel
@ -308,7 +322,7 @@ watch(
<div v-for="col in fieldsWithoutDisplay" :key="`record-${record.row.id}-${col.id}`"> <div v-for="col in fieldsWithoutDisplay" :key="`record-${record.row.id}-${col.id}`">
<div class="flex flex-col rounded-lg w-full"> <div class="flex flex-col rounded-lg w-full">
<div class="flex flex-row w-full justify-start"> <div class="flex flex-row w-full justify-start">
<div class="nc-card-col-header w-full text-gray-500 uppercase"> <div class="nc-card-col-header w-full !children:text-gray-500">
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" :hide-menu="true" /> <LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" :hide-menu="true" />
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="true" /> <LazySmartsheetHeaderCell v-else :column="col" :hide-menu="true" />
@ -349,7 +363,7 @@ watch(
</template> </template>
</div> </div>
</div> </div>
</a-dropdown> </NcDropdown>
<LazySmartsheetPagination <LazySmartsheetPagination
v-model:pagination-data="paginationData" v-model:pagination-data="paginationData"
@ -454,7 +468,7 @@ watch(
.nc-card-col-header { .nc-card-col-header {
:deep(.nc-cell-icon), :deep(.nc-cell-icon),
:deep(.nc-virtual-cell-icon) { :deep(.nc-virtual-cell-icon) {
@apply ml-0; @apply ml-0 !w-3.5 !h-3.5;
} }
} }

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

@ -1,5 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import tinycolor from 'tinycolor2'
import { ViewTypes, isVirtualCol } from 'nocodb-sdk' import { ViewTypes, isVirtualCol } from 'nocodb-sdk'
import type { Row as RowType } from '#imports' import type { Row as RowType } from '#imports'
@ -9,8 +11,6 @@ interface Attachment {
const INFINITY_SCROLL_THRESHOLD = 100 const INFINITY_SCROLL_THRESHOLD = 100
const emptyPagination = ref()
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref()) const view = inject(ActiveViewInj, ref())
@ -47,6 +47,8 @@ const route = router.currentRoute
const { getPossibleAttachmentSrc } = useAttachment() const { getPossibleAttachmentSrc } = useAttachment()
const { metaColumnById } = useViewColumnsOrThrow(view, meta)
const { const {
loadKanbanData, loadKanbanData,
loadMoreKanbanData, loadMoreKanbanData,
@ -59,11 +61,14 @@ const {
groupingFieldColOptions, groupingFieldColOptions,
updateKanbanStackMeta, updateKanbanStackMeta,
groupingField, groupingField,
groupingFieldColumn,
countByStack, countByStack,
deleteStack, deleteStack,
shouldScrollToRight, shouldScrollToRight,
deleteRow, deleteRow,
moveHistory, moveHistory,
addNewStackId,
removeRowFromUncategorizedStack,
} = useKanbanViewStoreOrThrow() } = useKanbanViewStoreOrThrow()
const { isViewDataLoading } = storeToRefs(useViewsStore()) const { isViewDataLoading } = storeToRefs(useViewsStore())
@ -108,6 +113,7 @@ const kanbanContainerRef = ref()
const selectedStackTitle = ref('') const selectedStackTitle = ref('')
reloadViewDataHook?.on(async () => { reloadViewDataHook?.on(async () => {
console.log('load')
await loadKanbanMeta() await loadKanbanMeta()
await loadKanbanData() await loadKanbanData()
}) })
@ -271,15 +277,21 @@ const kanbanListScrollHandler = useDebounceFn(async (e: any) => {
if (stack && (countByStack.value.get(stackTitle) === undefined || stack.length < countByStack.value.get(stackTitle)!)) { if (stack && (countByStack.value.get(stackTitle) === undefined || stack.length < countByStack.value.get(stackTitle)!)) {
const page = Math.ceil(stack.length / pageSize) const page = Math.ceil(stack.length / pageSize)
await loadMoreKanbanData(stackTitle, { offset: page * pageSize })
await loadMoreKanbanData(stackTitle, {
offset:
page * pageSize > countByStack.value.get(stackTitle)! || page * pageSize > stack.length
? (page - 1) * pageSize
: page * pageSize,
})
} }
} }
}) })
const kanbanListRef = (kanbanListElement: HTMLElement) => { const kanbanListRef: VNodeRef = (kanbanListElement) => {
if (kanbanListElement) { if (kanbanListElement) {
kanbanListElement.removeEventListener('scroll', kanbanListScrollHandler) ;(kanbanListElement as HTMLElement).removeEventListener('scroll', kanbanListScrollHandler)
kanbanListElement.addEventListener('scroll', kanbanListScrollHandler) ;(kanbanListElement as HTMLElement).addEventListener('scroll', kanbanListScrollHandler)
} }
} }
@ -300,6 +312,28 @@ const handleCollapseStack = async (stackIdx: number) => {
await updateKanbanStackMeta() await updateKanbanStackMeta()
} }
} }
const handleCollapseAllStack = async () => {
groupingFieldColOptions.value.forEach((stack) => {
if (stack.id !== addNewStackId && !stack.collapsed) {
stack.collapsed = true
}
})
if (!isPublic.value) {
await updateKanbanStackMeta()
}
}
const handleExpandAllStack = async () => {
groupingFieldColOptions.value.forEach((stack) => {
if (stack.id !== addNewStackId && stack.collapsed) {
stack.collapsed = false
}
})
if (!isPublic.value) {
await updateKanbanStackMeta()
}
}
const openNewRecordFormHookHandler = async () => { const openNewRecordFormHookHandler = async () => {
const newRow = await addEmptyRow() const newRow = await addEmptyRow()
@ -368,11 +402,39 @@ const getRowId = (row: RowType) => {
const pk = extractPkFromRow(row.row, meta.value!.columns!) const pk = extractPkFromRow(row.row, meta.value!.columns!)
return pk ? `row-${pk}` : '' return pk ? `row-${pk}` : ''
} }
const hideEmptyStack = computed<boolean>(() => parseProp(kanbanMetaData.value?.meta).hide_empty_stack || false)
const addNewStackObj = {
id: addNewStackId,
}
const isRenameOrNewStack = ref(null)
const compareStack = (stack: any, stack2?: any) => stack?.id && stack2?.id && stack.id === stack2.id
const isSavingStack = ref(null)
const handleSubmitRenameOrNewStack = async (loadMeta: boolean, stack?: any, stackIdx?: number) => {
isSavingStack.value = isRenameOrNewStack.value
isRenameOrNewStack.value = null
if (stack && stack?.title && stack?.color && stackIdx !== undefined) {
groupingFieldColOptions.value[stackIdx].title = stack.title
groupingFieldColOptions.value[stackIdx].color = stack.color
}
if (loadMeta) {
await loadKanbanMeta()
}
isSavingStack.value = null
}
</script> </script>
<template> <template>
<div <div
class="flex flex-col w-full bg-white h-full" class="flex flex-col w-full bg-gray-50 h-full"
data-testid="nc-kanban-wrapper" data-testid="nc-kanban-wrapper"
:style="{ :style="{
minHeight: 'calc(100% - var(--topbar-height))', minHeight: 'calc(100% - var(--topbar-height))',
@ -380,28 +442,30 @@ const getRowId = (row: RowType) => {
> >
<div <div
ref="kanbanContainerRef" ref="kanbanContainerRef"
class="nc-kanban-container flex mt-4 pb-4 px-4 overflow-y-hidden w-full nc-scrollbar-x-lg" class="nc-kanban-container flex p-3 overflow-y-hidden w-full nc-scrollbar-x-lg"
:style="{ :style="{
minHeight: isMobileMode ? 'calc(100% - 2rem)' : 'calc(100vh - var(--topbar-height) - 4.1rem)', minHeight: isMobileMode ? 'calc(100% - 2rem)' : 'calc(100vh - var(--topbar-height) - var(--toolbar-height) - 0.4rem)',
maxHeight: isMobileMode ? 'calc(100% - 2rem)' : 'calc(100vh - var(--topbar-height) - 4.1rem)', maxHeight: isMobileMode ? 'calc(100% - 2rem)' : 'calc(100vh - var(--topbar-height) - var(--toolbar-height) - 0.4rem)',
}" }"
> >
<div v-if="isViewDataLoading" class="flex flex-row min-h-full gap-x-2"> <div v-if="isViewDataLoading" class="flex flex-row min-h-full gap-x-2">
<a-skeleton-input v-for="index of Array(20)" :key="index" class="!min-w-80 !min-h-full !rounded-xl overflow-hidden" /> <a-skeleton-input v-for="index of Array(20)" :key="index" class="!min-w-80 !min-h-full !rounded-xl overflow-hidden" />
</div> </div>
<a-dropdown <NcDropdown
v-else v-else
v-model:visible="contextMenu" v-model:visible="contextMenu"
:trigger="['contextmenu']" :trigger="['contextmenu']"
overlay-class-name="nc-dropdown-kanban-context-menu" overlay-class-name="nc-dropdown-kanban-context-menu"
> >
<div class="flex gap-3">
<!-- Draggable Stack --> <!-- Draggable Stack -->
<Draggable <Draggable
v-model="groupingFieldColOptions" v-model="groupingFieldColOptions"
class="flex gap-4" class="flex gap-3"
item-key="id" item-key="id"
group="kanban-stack" group="kanban-stack"
draggable=".nc-kanban-stack" draggable=".nc-kanban-stack"
handle=".nc-kanban-stack-drag-handler"
filter=".not-draggable" filter=".not-draggable"
:move="onMoveCallback" :move="onMoveCallback"
@start="(e) => e.target.classList.add('grabbing')" @start="(e) => e.target.classList.add('grabbing')"
@ -409,53 +473,143 @@ const getRowId = (row: RowType) => {
@change="onMoveStack($event)" @change="onMoveStack($event)"
> >
<template #item="{ element: stack, index: stackIdx }"> <template #item="{ element: stack, index: stackIdx }">
<div class="nc-kanban-stack" :class="{ 'w-[50px]': stack.collapsed }"> <div
class="nc-kanban-stack"
:class="{
'w-[44px]': stack.collapsed,
'hidden': hideEmptyStack && !formattedData.get(stack.title)?.length,
}"
:data-testid="`nc-kanban-stack-${stack.title}`"
>
<!-- Non Collapsed Stacks --> <!-- Non Collapsed Stacks -->
<a-card <a-card
v-if="!stack.collapsed" v-if="!stack.collapsed"
:key="`${stack.id}-${stackIdx}`" :key="`${stack.id}-${stackIdx}`"
class="mx-4 !bg-gray-100 flex flex-col w-80 h-full !rounded-xl overflow-y-hidden" class="flex flex-col w-68.5 h-full !rounded-xl overflow-y-hidden !shadow-none !hover:shadow-none !border-gray-200"
:class="{ :class="{
'not-draggable': stack.title === null || isLocked || isPublic || !hasEditPermission, 'not-draggable': stack.title === null || isLocked || isPublic || !hasEditPermission,
'!cursor-default': isLocked || !hasEditPermission, '!cursor-default': isLocked || !hasEditPermission,
}" }"
:head-style="{ paddingBottom: '0px' }" :head-style="{ paddingBottom: '0px' }"
:body-style="{ padding: '0px', height: '100%', borderRadius: '0.75rem !important', paddingBottom: '0rem' }" :body-style="{
padding: '0px !important',
height: '100%',
borderRadius: '0.75rem !important',
paddingBottom: '0rem !important',
}"
> >
<!-- Header Color Bar -->
<div
:style="`background-color: ${stack.color}`"
class="nc-kanban-stack-head-color h-[10px] mt-3 mx-3 rounded-full"
></div>
<!-- Skeleton --> <!-- Skeleton -->
<div v-if="!formattedData.get(stack.title) || !countByStack" class="mt-2.5 px-3 !w-full"> <div v-if="!formattedData.get(stack.title) || !countByStack" class="mt-2.5 px-3 !w-full">
<a-skeleton-input :active="true" class="!w-full !h-9.75 !rounded-lg overflow-hidden" /> <a-skeleton-input :active="true" class="!w-full !h-9.75 !rounded-lg overflow-hidden" />
</div> </div>
<!-- Stack --> <!-- Stack -->
<a-layout v-else class="!bg-gray-100"> <a-layout v-else>
<a-layout-header> <a-layout-header class="border-b-1 border-gray-100 min-h-[49px]">
<div class="nc-kanban-stack-head font-medium flex items-center"> <div
<a-dropdown class="nc-kanban-stack-head w-full flex gap-1"
:trigger="['click']" :class="{
overlay-class-name="nc-dropdown-kanban-stack-context-menu" 'items-start': compareStack(stack, isRenameOrNewStack),
class="bg-white !rounded-lg" 'items-center': !compareStack(stack, isRenameOrNewStack),
}"
> >
<div <div
class="flex items-center w-full mx-2 px-3 py-1" class="flex-1 flex gap-1 max-w-[calc(100%_-_32px)]"
:class="{ 'capitalize': stack.title === null, 'cursor-pointer': !isLocked }" :class="{
'items-start': compareStack(stack, isRenameOrNewStack),
'items-center': !compareStack(stack, isRenameOrNewStack),
}"
> >
<LazyGeneralTruncateText>{{ stack.title ?? 'uncategorized' }}</LazyGeneralTruncateText> <NcButton
<span v-if="!isLocked" class="w-full flex w-[15px]"> v-if="!(isLocked || isPublic || !hasEditPermission)"
<component :is="iconMap.arrowDown" class="text-grey text-lg ml-auto" /> :disabled="
!stack.title || compareStack(stack, isSavingStack) || compareStack(stack, isRenameOrNewStack)
"
type="text"
size="xs"
class="nc-kanban-stack-drag-handler !px-1.5 !cursor-move !:disabled:cursor-not-allowed mt-0.5"
>
<GeneralLoader v-if="compareStack(stack, isSavingStack)" size="regular" class="stack-rename-loader" />
<GeneralIcon v-else icon="ncDrag" class="!font-weight-800 flex-none" />
</NcButton>
<div
class="flex-1 flex max-w-[calc(100%_-_28px)]"
:class="{
'-ml-1': compareStack(stack, isRenameOrNewStack),
}"
>
<template
v-if="compareStack(stack, isRenameOrNewStack) && metaColumnById[isRenameOrNewStack?.fk_column_id]"
>
<SmartsheetKanbanEditOrAddStack
:column="metaColumnById[isRenameOrNewStack?.fk_column_id]"
:option-id="isRenameOrNewStack.id"
@submit="(loadMeta, payload) => handleSubmitRenameOrNewStack(loadMeta, payload, stackIdx)"
/>
</template>
<a-tag
v-else
class="max-w-full !rounded-full !px-2 !py-1 h-7 !m-0 !border-none !mt-0.5"
:color="stack.color"
@dblclick="
() => {
if (stack.title !== null && hasEditPermission && !isPublic && !isLocked) {
isRenameOrNewStack = stack
}
}
"
>
<span
:style="{
color: tinycolor.isReadable(stack.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(stack.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
}"
class="text-sm font-semibold"
>
<NcTooltip class="truncate max-w-full" placement="bottom" show-on-truncate-only>
<template #title>
{{ stack.title ?? 'Uncategorized' }}
</template>
<span
data-testid="nc-kanban-stack-title"
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ stack.title ?? 'Uncategorized' }}
</span>
</NcTooltip>
</span> </span>
</a-tag>
</div> </div>
<template v-if="!isLocked" #overlay> </div>
<a-menu class="ml-6 !text-sm !px-0 !py-2 !rounded"> <NcDropdown
<a-menu-item v-if="!isLocked"
placement="bottomRight"
overlay-class-name="nc-dropdown-kanban-stack-context-menu"
class="bg-white !rounded-lg"
>
<NcButton
:disabled="compareStack(stack, isSavingStack)"
type="text"
size="xs"
class="!px-1.5 mt-0.5"
data-testid="nc-kanban-stack-context-menu"
>
<GeneralIcon icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu class="!text-sm">
<NcMenuItem
v-if="hasEditPermission && !isPublic && !isLocked" v-if="hasEditPermission && !isPublic && !isLocked"
v-e="['c:kanban:add-new-record']" v-e="['c:kanban:add-new-record']"
data-testid="nc-kanban-context-menu-add-new-record"
@click=" @click="
() => { () => {
selectedStackTitle = stack.title selectedStackTitle = stack.title
@ -463,56 +617,116 @@ const getRowId = (row: RowType) => {
} }
" "
> >
<div class="py-2 flex gap-2 items-center"> <div class="flex gap-2 items-center">
<component :is="iconMap.plus" class="text-gray-500" /> <component :is="iconMap.plus" class="flex-none w-4 h-4" />
{{ $t('activity.addNewRecord') }} {{ $t('activity.addNewRecord') }}
</div> </div>
</a-menu-item> </NcMenuItem>
<a-menu-item v-e="['c:kanban:collapse-stack']" @click="handleCollapseStack(stackIdx)"> <NcMenuItem
<div class="py-2 flex gap-1.8 items-center"> v-if="stack.title !== null && hasEditPermission && !isPublic && !isLocked"
<component :is="iconMap.arrowCollapse" class="text-gray-500" /> v-e="['c:kanban:rename-stack']"
data-testid="nc-kanban-context-menu-rename-stack"
@click="
() => {
isRenameOrNewStack = stack
}
"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.ncEdit" class="flex-none w-4 h-4" />
{{ $t('activity.kanban.renameStack') }}
</div>
</NcMenuItem>
<NcMenuItem
v-e="['c:kanban:collapse-stack']"
data-testid="nc-kanban-context-menu-collapse-stack"
@click="handleCollapseStack(stackIdx)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.minimize" class="flex-none w-4 h-4" />
{{ $t('activity.kanban.collapseStack') }} {{ $t('activity.kanban.collapseStack') }}
</div> </div>
</a-menu-item> </NcMenuItem>
<a-menu-item
v-if="stack.title !== null && !isPublic && hasEditPermission" <NcMenuItem
v-e="['c:kanban:collapse-all-stack']"
data-testid="nc-kanban-context-menu-collapse-all-stack"
@click="handleCollapseAllStack"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.minimizeAll" class="flex-none w-4 h-4" />
{{ $t('activity.kanban.collapseAll') }}
</div>
</NcMenuItem>
<NcMenuItem
v-e="['c:kanban:expand-all-stack']"
data-testid="nc-kanban-context-menu-expand-all-stack"
@click="handleExpandAllStack"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.maximizeAll" class="flex-none w-4 h-4" />
{{ $t('activity.kanban.expandAll') }}
</div>
</NcMenuItem>
<template v-if="stack.title !== null && !isPublic && hasEditPermission">
<NcDivider />
<NcMenuItem
v-e="['c:kanban:delete-stack']" v-e="['c:kanban:delete-stack']"
class="!text-red-600 !hover:bg-red-50"
data-testid="nc-kanban-context-menu-delete-stack"
@click="handleDeleteStackClick(stack.title, stackIdx)" @click="handleDeleteStackClick(stack.title, stackIdx)"
> >
<div class="py-2 flex gap-2 items-center"> <div class="flex gap-2 items-center">
<component :is="iconMap.delete" class="text-gray-500" /> <component :is="iconMap.delete" class="flex-none w-4 h-4" />
{{ $t('activity.kanban.deleteStack') }} {{ $t('activity.kanban.deleteStack') }}
</div> </div>
</a-menu-item> </NcMenuItem>
</a-menu> </template>
</NcMenu>
</template> </template>
</a-dropdown> </NcDropdown>
</div> </div>
</a-layout-header> </a-layout-header>
<a-layout-content <a-layout-content
class="overflow-y-hidden mt-1" class="overflow-y-hidden"
:style="{ maxHeight: isUIAllowed('dataInsert') ? 'calc(100% - 11rem)' : 'calc(100% - 8rem)' }" :style="{
backgroundColor: tinycolor
.mix(
stack.color || '#ccc',
'#ffffff',
tinycolor(stack.color || '#ccc').isLight()
? 70
: tinycolor(stack.color || '#ccc').getBrightness() <= 100
? 80
: 90,
)
.toString(),
}"
>
<div
:ref="kanbanListRef"
class="nc-kanban-list h-full px-2 nc-scrollbar-thin"
:data-stack-title="stack.title"
> >
<div :ref="kanbanListRef" class="nc-kanban-list h-full nc-scrollbar-dark-md" :data-stack-title="stack.title">
<!-- Draggable Record Card --> <!-- Draggable Record Card -->
<Draggable <Draggable
:list="formattedData.get(stack.title)" :list="formattedData.get(stack.title)"
item-key="row.Id" item-key="row.Id"
draggable=".nc-kanban-item" draggable=".nc-kanban-item"
group="kanban-card" group="kanban-card"
class="h-full" class="flex flex-col h-full mb-2"
filter=".not-draggable" filter=".not-draggable"
@start="(e) => e.target.classList.add('grabbing')" @start="(e) => e.target.classList.add('grabbing')"
@end="(e) => e.target.classList.remove('grabbing')" @end="(e) => e.target.classList.remove('grabbing')"
@change="onMove($event, stack.title)" @change="onMove($event, stack.title)"
> >
<template #item="{ element: record, index }"> <template #item="{ element: record, index }">
<div class="nc-kanban-item py-2 pl-3 pr-2"> <div class="nc-kanban-item py-1 first:pt-2 last:pb-2">
<LazySmartsheetRow :row="record"> <LazySmartsheetRow :row="record">
<a-card <a-card
:key="`${getRowId(record)}-${index}`" :key="`${getRowId(record)}-${index}`"
class="!rounded-xl h-full border-gray-200 border-1 group overflow-hidden break-all max-w-[450px] cursor-pointer" class="!rounded-lg h-full border-gray-200 border-1 group overflow-hidden break-all max-w-[450px] cursor-pointer"
:body-style="{ padding: '16px !important' }" :body-style="{ padding: '16px !important' }"
:data-stack="stack.title" :data-stack="stack.title"
:data-testid="`nc-gallery-card-${record.row.id}`" :data-testid="`nc-gallery-card-${record.row.id}`"
@ -606,7 +820,7 @@ const getRowId = (row: RowType) => {
<div v-for="col in fieldsWithoutDisplay" :key="`record-${record.row.id}-${col.id}`"> <div v-for="col in fieldsWithoutDisplay" :key="`record-${record.row.id}-${col.id}`">
<div class="flex flex-col rounded-lg w-full"> <div class="flex flex-col rounded-lg w-full">
<div class="flex flex-row w-full justify-start"> <div class="flex flex-row w-full justify-start">
<div class="nc-card-col-header w-full text-gray-500 uppercase"> <div class="nc-card-col-header w-full !children:text-gray-500">
<LazySmartsheetHeaderVirtualCell <LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)" v-if="isVirtualCol(col)"
:column="col" :column="col"
@ -646,23 +860,44 @@ const getRowId = (row: RowType) => {
</LazySmartsheetRow> </LazySmartsheetRow>
</div> </div>
</template> </template>
</Draggable> <template v-if="!formattedData.get(stack.title)?.length" #footer>
<div class="h-full w-full flex flex-col gap-4 items-center justify-center">
<div class="flex flex-col items-center gap-2 text-gray-600 text-center">
<span class="text-sm font-semibold">
{{ $t('general.empty') }} {{ $t('general.stack').toLowerCase() }}
</span>
<span class="text-xs font-weight-500">
{{ $t('title.looksLikeThisStackIsEmpty') }}
</span>
</div> </div>
</a-layout-content> <NcButton
v-if="isUIAllowed('dataInsert')"
<div class="!rounded-lg !px-3 pt-3"> size="xs"
<div v-if="formattedData.get(stack.title)" class="text-center"> type="secondary"
<!-- Stack Title --> @click="
() => {
selectedStackTitle = stack.title
openNewRecordFormHook.trigger(stack.title)
}
"
>
<div class="flex items-center gap-2">
<component :is="iconMap.plus" v-if="!isPublic && !isLocked" />
<!-- Record Count --> {{ $t('activity.newRecord') }}
<div class="nc-kanban-data-count text-gray-500">
{{ formattedData.get(stack.title)!.length }} / {{ countByStack.get(stack.title) ?? 0 }}
{{ countByStack.get(stack.title) !== 1 ? $t('objects.records') : $t('objects.record') }}
</div> </div>
</NcButton>
<div </div>
</template>
</Draggable>
</div>
</a-layout-content>
<a-layout-footer v-if="formattedData.get(stack.title)" class="border-t-1 border-gray-100">
<div class="flex items-center justify-between">
<NcButton
v-if="isUIAllowed('dataInsert')" v-if="isUIAllowed('dataInsert')"
class="flex flex-row w-full mt-3 justify-between items-center cursor-pointer bg-white px-4 py-2 rounded-lg border-gray-100 border-1 shadow-sm shadow-gray-100" size="xs"
type="secondary"
@click=" @click="
() => { () => {
selectedStackTitle = stack.title selectedStackTitle = stack.title
@ -670,11 +905,21 @@ const getRowId = (row: RowType) => {
} }
" "
> >
Add Record <div class="flex items-center gap-2">
<component :is="iconMap.plus" v-if="!isPublic && !isLocked" class="" /> <component :is="iconMap.plus" v-if="!isPublic && !isLocked" class="" />
{{ $t('activity.newRecord') }}
</div> </div>
</NcButton>
<div v-else>&nbsp;</div>
<!-- Record Count -->
<div class="nc-kanban-data-count text-gray-500 font-weight-500 px-1">
{{ formattedData.get(stack.title)!.length }}/{{ countByStack.get(stack.title) ?? 0 }}
{{ countByStack.get(stack.title) !== 1 ? $t('objects.records') : $t('objects.record') }}
</div> </div>
</div> </div>
</a-layout-footer>
</a-layout> </a-layout>
</a-card> </a-card>
@ -682,64 +927,190 @@ const getRowId = (row: RowType) => {
<a-card <a-card
v-else v-else
:key="`${stack.id}-collapsed`" :key="`${stack.id}-collapsed`"
:style="`background-color: ${stack.color} !important`" class="nc-kanban-collapsed-stack flex items-center w-68.5 h-[44px] !rounded-xl cursor-pointer h-full !p-2 overflow-hidden !shadow-none !hover:shadow-none !border-gray-200"
class="nc-kanban-collapsed-stack mx-4 flex items-center w-[300px] h-[50px] !rounded-xl cursor-pointer h-full !pr-[10px] overflow-hidden"
:class="{ :class="{
'not-draggable': stack.title === null || isLocked || isPublic || !hasEditPermission, 'not-draggable': stack.title === null || isLocked || isPublic || !hasEditPermission,
}" }"
:body-style="{ padding: '0px', height: '100%', width: '100%', background: '#f0f2f5 !important' }" :body-style="{
padding: '0px !important',
height: '100%',
width: '100%',
borderRadius: '0.75rem !important',
paddingBottom: '0rem !important',
}"
>
<div class="h-full flex items-center justify-between" @click="handleCollapseStack(stackIdx)">
<div
v-if="!formattedData.get(stack.title) || !countByStack"
class="!w-full !h-full flex items-center justify-center"
> >
<div class="items-center justify-between" @click="handleCollapseStack(stackIdx)">
<div v-if="!formattedData.get(stack.title) || !countByStack" class="mt-4 px-3 !w-full">
<a-skeleton-input :active="true" class="!w-full !h-4 !rounded-lg overflow-hidden" /> <a-skeleton-input :active="true" class="!w-full !h-4 !rounded-lg overflow-hidden" />
</div> </div>
<div v-else class="nc-kanban-data-count mt-[12px] mx-[10px]"> <div v-else class="nc-kanban-stack-head w-full flex items-center justify-between gap-2">
<!-- Stack title --> <div class="flex items-center gap-1">
<div <NcButton
class="float-right flex gap-2 items-center cursor-pointer font-bold" v-if="!(isLocked || isPublic || !hasEditPermission)"
:class="{ capitalize: stack.title === null }" :disabled="!stack.title"
type="text"
size="xs"
class="nc-kanban-stack-drag-handler !px-1.5 !cursor-move"
@click.stop
> >
<LazyGeneralTruncateText>{{ stack.title ?? 'uncategorized' }}</LazyGeneralTruncateText> <GeneralIcon icon="ncDrag" class="font-weight-800 flex-none" />
<component :is="iconMap.arrowDown" class="text-grey text-lg" /> </NcButton>
<div class="flex-1 flex max-w-[115px]">
<a-tag class="max-w-full !rounded-full !px-2 !py-1 h-7 !m-0 !border-none" :color="stack.color">
<span
:style="{
color: tinycolor.isReadable(stack.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(stack.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
}"
class="text-sm font-semibold"
>
<NcTooltip class="truncate max-w-full" placement="left" show-on-truncate-only>
<template #title>
{{ stack.title ?? 'Uncategorized' }}
</template>
<span
data-testid="nc-kanban-stack-title"
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ stack.title ?? 'Uncategorized' }}
</span>
</NcTooltip>
</span>
</a-tag>
</div> </div>
</div>
<div class="flex items-center gap-3">
<div
class="nc-kanban-data-count px-1 rounded bg-gray-200 text-gray-800 text-sm font-weight-500"
:style="{ 'word-break': 'keep-all', 'white-space': 'nowrap' }"
>
<!-- Record Count --> <!-- Record Count -->
{{ formattedData.get(stack.title)!.length }} / {{ countByStack.get(stack.title) }} {{ formattedData.get(stack.title)!.length }}
{{ countByStack.get(stack.title) !== 1 ? $t('objects.records') : $t('objects.record') }} {{ countByStack.get(stack.title) !== 1 ? $t('objects.records') : $t('objects.record') }}
</div> </div>
<NcButton type="text" size="xs" class="!px-1.5">
<component :is="iconMap.arrowDown" class="text-grey h-4 w-4 flex-none" />
</NcButton>
</div>
</div>
</div> </div>
</a-card> </a-card>
</div> </div>
</template> </template>
</Draggable> </Draggable>
<div v-if="hasEditPermission && !isPublic && !isLocked && groupingFieldColumn?.id" class="nc-kanban-add-new-stack">
<!-- Add New Stack -->
<a-card
class="flex flex-col w-68.5 !rounded-xl overflow-y-hidden !shadow-none !hover:shadow-none border-gray-200"
:class="{
'!cursor-default': isLocked || !hasEditPermission,
'!border-none': !compareStack(addNewStackObj, isRenameOrNewStack),
}"
:head-style="{ paddingBottom: '0px' }"
:body-style="{
padding: '0px !important',
height: '100%',
borderRadius: '0.75rem !important',
paddingBottom: '0rem !important',
}"
>
<!-- Skeleton -->
<div v-if="!formattedData.get(null) || !countByStack" class="mt-2.5 px-3 !w-full">
<a-skeleton-input :active="true" class="!w-full !h-9.75 !rounded-lg overflow-hidden" />
</div>
<!-- Stack -->
<a-layout v-else>
<a-layout-header
:class="{
'!p-0 overflow-hidden': !compareStack(addNewStackObj, isRenameOrNewStack),
}"
>
<div
class="w-full flex"
:class="{
'items-start': compareStack(addNewStackObj, isRenameOrNewStack),
'cursor-pointer': !compareStack(addNewStackObj, isRenameOrNewStack),
}"
@click="
() => {
if (!compareStack(addNewStackObj, isRenameOrNewStack)) {
isRenameOrNewStack = addNewStackObj
}
}
"
>
<NcButton
v-if="!compareStack(addNewStackObj, isRenameOrNewStack)"
type="secondary"
class="add-new-stack-btn w-full !rounded-xl min-h-11"
>
<div class="flex items-center gap-2">
<component :is="iconMap.plus" v-if="!isPublic && !isLocked" class="" />
{{ $t('general.new') }} {{ $t('general.stack').toLowerCase() }}
</div>
</NcButton>
<div
v-else
class="flex-1 flex"
:class="{
'-ml-1': compareStack(addNewStackObj, isRenameOrNewStack),
}"
@click.stop
>
<template
v-if="compareStack(addNewStackObj, isRenameOrNewStack) && metaColumnById[groupingFieldColumn?.id]"
>
<SmartsheetKanbanEditOrAddStack
:column="metaColumnById[groupingFieldColumn?.id]"
is-new-stack
@submit="(loadMeta) => handleSubmitRenameOrNewStack(loadMeta, undefined)"
/>
</template>
</div>
</div>
</a-layout-header>
</a-layout>
</a-card>
</div>
</div>
<!-- Drop down Menu --> <!-- Drop down Menu -->
<template v-if="!isLocked && !isPublic && hasEditPermission" #overlay> <template v-if="!isLocked && !isPublic && hasEditPermission" #overlay>
<a-menu class="shadow !rounded !py-0" @click="contextMenu = false"> <NcMenu @click="contextMenu = false">
<a-menu-item v-if="contextMenuTarget" @click="expandForm(contextMenuTarget)"> <NcMenuItem v-if="contextMenuTarget" @click="expandForm(contextMenuTarget)">
<div v-e="['a:kanban:expand-record']" class="nc-base-menu-item nc-kanban-context-menu-item"> <div v-e="['a:kanban:expand-record']" class="flex items-center gap-2 nc-kanban-context-menu-item">
<component :is="iconMap.expand" class="flex" /> <component :is="iconMap.expand" class="flex" />
<!-- Expand Record --> <!-- Expand Record -->
{{ $t('activity.expandRecord') }} {{ $t('activity.expandRecord') }}
</div> </div>
</a-menu-item> </NcMenuItem>
<a-divider class="!m-0 !p-0" /> <NcDivider />
<a-menu-item v-if="contextMenuTarget" @click="deleteRow(contextMenuTarget)"> <NcMenuItem v-if="contextMenuTarget" class="!text-red-600 !hover:bg-red-50" @click="deleteRow(contextMenuTarget)">
<div v-e="['a:kanban:delete-record']" class="nc-base-menu-item nc-kanban-context-menu-item"> <div v-e="['a:kanban:delete-record']" class="flex items-center gap-2 nc-kanban-context-menu-item">
<component :is="iconMap.delete" class="flex" /> <component :is="iconMap.delete" class="flex" />
<!-- Delete Record --> <!-- Delete Record -->
{{ $t('activity.deleteRecord') }} {{ $t('activity.deleteRecord') }}
</div> </div>
</a-menu-item> </NcMenuItem>
</a-menu> </NcMenu>
</template> </template>
</a-dropdown> </NcDropdown>
</div> </div>
<LazySmartsheetPagination
v-model:pagination-data="emptyPagination"
align-count-on-right
hide-pagination
class="!py-4 h-10 !xs:py-0"
>
</LazySmartsheetPagination>
</div> </div>
<Suspense> <Suspense>
@ -751,6 +1122,7 @@ const getRowId = (row: RowType) => {
:meta="meta" :meta="meta"
:load-row="!isPublic" :load-row="!isPublic"
:view="view" :view="view"
@cancel="removeRowFromUncategorizedStack"
/> />
</Suspense> </Suspense>
@ -785,12 +1157,16 @@ const getRowId = (row: RowType) => {
// override ant design style // override ant design style
.a-layout, .a-layout,
.ant-layout-header, .ant-layout-header,
.ant-layout-footer {
@apply !bg-white;
}
.ant-layout-content { .ant-layout-content {
@apply !bg-gray-100; background-color: unset;
} }
.ant-layout-header,
.ant-layout-header { .ant-layout-footer {
@apply !h-[30px] !leading-[30px] !px-[5px] !my-[10px]; @apply p-2 text-sm;
height: unset !important;
} }
.nc-kanban-collapsed-stack { .nc-kanban-collapsed-stack {
@ -846,17 +1222,17 @@ const getRowId = (row: RowType) => {
} }
.nc-card-display-value-wrapper { .nc-card-display-value-wrapper {
@apply my-0 text-xl leading-8 text-gray-600; @apply my-0 text-base leading-8 text-gray-800;
.nc-cell, .nc-cell,
.nc-virtual-cell { .nc-virtual-cell {
@apply text-xl leading-8; @apply text-base leading-6;
:deep(.nc-cell-field), :deep(.nc-cell-field),
:deep(input), :deep(input),
:deep(textarea), :deep(textarea),
:deep(.nc-cell-field-link) { :deep(.nc-cell-field-link) {
@apply !text-xl leading-8 text-gray-600; @apply !text-base leading-6 text-gray-800;
} }
} }
} }
@ -864,7 +1240,7 @@ const getRowId = (row: RowType) => {
.nc-card-col-header { .nc-card-col-header {
:deep(.nc-cell-icon), :deep(.nc-cell-icon),
:deep(.nc-virtual-cell-icon) { :deep(.nc-virtual-cell-icon) {
@apply ml-0; @apply ml-0 !w-3.5 !h-3.5;
} }
} }

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

@ -43,10 +43,10 @@ const isTab = computed(() => {
<LazySmartsheetToolbarCalendarRange v-if="isCalendar" /> <LazySmartsheetToolbarCalendarRange v-if="isCalendar" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban || isMap" :show-system-fields="false" />
<LazySmartsheetToolbarStackedBy v-if="isKanban" /> <LazySmartsheetToolbarStackedBy v-if="isKanban" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban || isMap" :show-system-fields="false" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap" /> <LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap" />
<LazySmartsheetToolbarGroupByMenu v-if="isGrid" /> <LazySmartsheetToolbarGroupByMenu v-if="isGrid" />

1
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -2,7 +2,6 @@
import type { ColumnReqType, ColumnType } from 'nocodb-sdk' import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { UITypes, UITypesName, isLinksOrLTAR, isSelfReferencingTableColumn, isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { UITypes, UITypesName, isLinksOrLTAR, isSelfReferencingTableColumn, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import Icon from '../header/Icon.vue'
import MdiPlusIcon from '~icons/mdi/plus-circle-outline' import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline' import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
import MdiIdentifierIcon from '~icons/mdi/identifier' import MdiIdentifierIcon from '~icons/mdi/identifier'

238
packages/nc-gui/components/smartsheet/column/SelectOptions.vue

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { UITypes } from 'nocodb-sdk' import tinycolor from 'tinycolor2'
import { type SelectOptionsType, UITypes } from 'nocodb-sdk'
import InfiniteLoading from 'v3-infinite-loading' import InfiniteLoading from 'v3-infinite-loading'
interface Option { interface Option {
@ -9,20 +10,25 @@ interface Option {
id?: string id?: string
fk_colum_id?: string fk_colum_id?: string
order?: number order?: number
status?: 'remove' status?: 'remove' | 'new'
index?: number index?: number
} }
const props = defineProps<{ const props = defineProps<{
value: any value: any
fromTableExplorer?: boolean fromTableExplorer?: boolean
isKanbanStack?: boolean
optionId?: string
isNewStack?: boolean
}>() }>()
const emit = defineEmits(['update:value']) const emit = defineEmits(['update:value', 'saveChanges'])
const vModel = useVModel(props, 'value', emit) const vModel = useVModel(props, 'value', emit)
const { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow() const { isKanbanStack, optionId, isNewStack } = toRefs(props)
const { setAdditionalValidations, validateInfos, column } = useColumnCreateStoreOrThrow()
// const { base } = storeToRefs(useBase()) // const { base } = storeToRefs(useBase())
@ -61,7 +67,7 @@ const validators = {
validator: (_: any, _opt: any) => { validator: (_: any, _opt: any) => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
for (const opt of options.value) { for (const opt of options.value) {
if ((opt as any).status === 'remove') continue if ((opt as any).status === 'remove' || (opt as any).status === 'new') continue
if (!opt.title.length) { if (!opt.title.length) {
return reject(new Error(t('msg.selectOption.cantBeNull'))) return reject(new Error(t('msg.selectOption.cantBeNull')))
@ -87,43 +93,13 @@ setAdditionalValidations({
...validators, ...validators,
} as any) } as any)
onMounted(() => { const kanbanStackOption = computed(() => {
if (!vModel.value.colOptions?.options) { if (isNewStack.value) {
vModel.value.colOptions = { return renderedOptions.value[renderedOptions.value.length - 1]
options: [], } else if (optionId.value) {
} return renderedOptions.value.find((o) => o.id === optionId.value)
}
isReverseLazyLoad.value = false
options.value = vModel.value.colOptions.options
let indexCounter = 0
options.value.map((el) => {
el.index = indexCounter++
return el
})
loadedOptionAnchor.value = Math.min(loadedOptionAnchor.value, options.value.length)
renderedOptions.value = [...options.value].slice(0, loadedOptionAnchor.value)
// Support for older options
for (const op of options.value.filter((el) => el.order === null)) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '')
}
if (vModel.value.cdf && typeof vModel.value.cdf === 'string') {
const fndDefaultOption = options.value.filter((el) => el.title === vModel.value.cdf)
if (!fndDefaultOption.length) {
vModel.value.cdf = vModel.value.cdf.replace(/^'/, '').replace(/'$/, '')
}
}
const fndDefaultOption = options.value.filter((el) => el.title === vModel.value.cdf)
if (fndDefaultOption.length) {
defaultOption.value = vModel.value.uidt === UITypes.SingleSelect ? [fndDefaultOption[0]] : fndDefaultOption
} }
return null
}) })
const getNextColor = () => { const getNextColor = () => {
@ -142,15 +118,20 @@ const addNewOption = () => {
title: '', title: '',
color: getNextColor(), color: getNextColor(),
index: options.value.length, index: options.value.length,
...(isKanbanStack.value ? { status: 'new' } : {}),
} }
options.value.push(tempOption) options.value.push(tempOption as Option)
if (isKanbanStack.value) {
renderedOptions.value = options.value
} else {
isReverseLazyLoad.value = true isReverseLazyLoad.value = true
loadedOptionAnchor.value = options.value.length - OPTIONS_PAGE_COUNT loadedOptionAnchor.value = options.value.length - OPTIONS_PAGE_COUNT
loadedOptionAnchor.value = Math.max(loadedOptionAnchor.value, 0) loadedOptionAnchor.value = Math.max(loadedOptionAnchor.value, 0)
renderedOptions.value = options.value.slice(loadedOptionAnchor.value, options.value.length) renderedOptions.value = options.value.slice(loadedOptionAnchor.value, options.value.length)
}
optionsWrapperDomRef.value!.scrollTop = optionsWrapperDomRef.value!.scrollHeight optionsWrapperDomRef.value!.scrollTop = optionsWrapperDomRef.value!.scrollHeight
@ -174,7 +155,7 @@ const addNewOption = () => {
// await _optionsMagic(base, formState, getNextColor, options.value, renderedOptions.value) // await _optionsMagic(base, formState, getNextColor, options.value, renderedOptions.value)
// } // }
const syncOptions = () => { const syncOptions = (saveChanges: boolean = false, submit: boolean = false, payload?: Option) => {
// set initial colOptions if not set // set initial colOptions if not set
vModel.value.colOptions = vModel.value.colOptions || {} vModel.value.colOptions = vModel.value.colOptions || {}
vModel.value.colOptions.options = options.value vModel.value.colOptions.options = options.value
@ -189,6 +170,10 @@ const syncOptions = () => {
const { status: _s, ...rest } = op const { status: _s, ...rest } = op
return rest return rest
}) })
if (saveChanges) {
emit('saveChanges', submit, true, payload)
}
} }
const removeRenderedOption = (index: number) => { const removeRenderedOption = (index: number) => {
@ -220,7 +205,7 @@ const removeRenderedOption = (index: number) => {
} }
} }
const optionChanged = (changedElement: Option) => { const optionChanged = (changedElement: Option, saveChanges: boolean = false) => {
const changedDefaultOptionIndex = defaultOption.value.findIndex((o) => { const changedDefaultOptionIndex = defaultOption.value.findIndex((o) => {
if (o.id !== undefined && changedElement.id !== undefined) { if (o.id !== undefined && changedElement.id !== undefined) {
return o.id === changedElement.id return o.id === changedElement.id
@ -238,7 +223,7 @@ const optionChanged = (changedElement: Option) => {
vModel.value.cdf = defaultOption.value.map((o) => o.title).join(',') vModel.value.cdf = defaultOption.value.map((o) => o.title).join(',')
} }
} }
syncOptions() syncOptions(saveChanges)
} }
const undoRemoveRenderedOption = (index: number) => { const undoRemoveRenderedOption = (index: number) => {
@ -344,20 +329,153 @@ const loadListData = async ($state: any) => {
$state.loaded() $state.loaded()
} }
onMounted(() => {
if (!vModel.value.colOptions?.options) {
vModel.value.colOptions = {
options: [],
}
}
isReverseLazyLoad.value = false
options.value = vModel.value.colOptions.options
let indexCounter = 0
options.value.map((el) => {
el.index = indexCounter++
return el
})
if (isKanbanStack.value) {
renderedOptions.value = options.value
} else {
loadedOptionAnchor.value = Math.min(loadedOptionAnchor.value, options.value.length)
renderedOptions.value = [...options.value].slice(0, loadedOptionAnchor.value)
}
// Support for older options
for (const op of options.value.filter((el) => el.order === null)) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '')
}
if (vModel.value.cdf && typeof vModel.value.cdf === 'string') {
const fndDefaultOption = options.value.filter((el) => el.title === vModel.value.cdf)
if (!fndDefaultOption.length) {
vModel.value.cdf = vModel.value.cdf.replace(/^'/, '').replace(/'$/, '')
}
}
const fndDefaultOption = options.value.filter((el) => el.title === vModel.value.cdf)
if (fndDefaultOption.length) {
defaultOption.value = vModel.value.uidt === UITypes.SingleSelect ? [fndDefaultOption[0]] : fndDefaultOption
}
if (isKanbanStack.value && isNewStack.value) {
addNewOption()
} else if (isKanbanStack.value) {
nextTick(() => {
setTimeout(() => {
const doms = document.querySelectorAll(`.nc-col-option-select-option .nc-select-col-option-select-option`)
const dom = doms[doms.length - 1] as HTMLInputElement
if (dom) {
dom.focus()
}
}, 150)
})
}
})
if (isKanbanStack.value) {
onClickOutside(optionsWrapperDomRef, (e) => {
if (!kanbanStackOption.value || (e.target as HTMLElement)?.closest(`.nc-select-option-color-picker`)) return
const option = (column.value?.colOptions as SelectOptionsType)?.options?.find(
(o) => o?.id && o.id === kanbanStackOption.value?.id,
)
if (option?.title !== kanbanStackOption.value?.title || option?.color !== kanbanStackOption.value?.color) {
syncOptions(true, true, kanbanStackOption.value)
} else {
emit('saveChanges', true, false)
}
})
}
</script> </script>
<template> <template>
<div class="w-full"> <div class="w-full">
<div <div
ref="optionsWrapperDomRef" ref="optionsWrapperDomRef"
class="nc-col-option-select-option overflow-x-auto scrollbar-thin-dull rounded-lg" class="nc-col-option-select-option"
:class="{ :class="{
'border-1 border-gray-200': renderedOptions.length, 'overflow-x-auto scrollbar-thin-dull rounded-lg': !isKanbanStack,
'border-1 border-gray-200': renderedOptions.length && !isKanbanStack,
}" }"
:style="{ :style="{
maxHeight: props.fromTableExplorer ? 'calc(100vh - (var(--topbar-height) * 3.6) - 320px)' : 'calc(min(30vh, 250px))', maxHeight: props.fromTableExplorer ? 'calc(100vh - (var(--topbar-height) * 3.6) - 320px)' : 'calc(min(30vh, 250px))',
}" }"
> >
<template v-if="isKanbanStack">
<div v-if="kanbanStackOption" class="flex items-center nc-select-option">
<div class="flex items-center w-full">
<NcDropdown
v-model:visible="colorMenus[kanbanStackOption.index!]"
:auto-close="false"
overlay-class-name="nc-select-option-color-picker"
>
<div class="flex-none h-6 w-6 flex cursor-pointer mx-1">
<div
class="h-6 w-6 rounded flex items-center"
:style="{
backgroundColor: kanbanStackOption.color,
color: tinycolor.isReadable(kanbanStackOption.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(kanbanStackOption.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
}"
>
<GeneralIcon icon="arrowDown" class="flex-none h-4 w-4 m-auto !text-current" />
</div>
</div>
<template #overlay>
<div>
<LazyGeneralAdvanceColorPicker
v-model="kanbanStackOption.color"
:is-open="colorMenus[kanbanStackOption.index!]"
@input="(el:string) => {
kanbanStackOption!.color = el
optionChanged(kanbanStackOption!)
}"
></LazyGeneralAdvanceColorPicker>
</div>
</template>
</NcDropdown>
<a-input
v-model:value="kanbanStackOption.title"
placeholder="Enter option name..."
class="caption !rounded-lg nc-select-col-option-select-option nc-kanban-stack-input !bg-transparent"
data-testid="nc-kanban-stack-title-input"
@keydown.enter.prevent.stop="syncOptions(true, true, kanbanStackOption!)"
@change="() => {
kanbanStackOption!.status = undefined
optionChanged(kanbanStackOption!)
}"
/>
</div>
<div
v-if="isNewStack"
class="ml-1 hover:!text-black-500 text-gray-500 cursor-pointer hover:bg-gray-200 py-1 px-1.5 rounded-md h-7 flex items-center"
@click="emit('saveChanges', true, false)"
>
<component :is="iconMap.close" class="-mt-0.25 w-4 h-4" />
</div>
</div>
</template>
<template v-else>
<InfiniteLoading v-if="isReverseLazyLoad" v-bind="$attrs" @infinite="loadListDataReverse"> <InfiniteLoading v-if="isReverseLazyLoad" v-bind="$attrs" @infinite="loadListDataReverse">
<template #spinner> <template #spinner>
<div class="flex flex-row w-full justify-center mt-2"> <div class="flex flex-row w-full justify-center mt-2">
@ -386,8 +504,16 @@ const loadListData = async ($state: any) => {
<NcDropdown v-model:visible="colorMenus[index]" :auto-close="false"> <NcDropdown v-model:visible="colorMenus[index]" :auto-close="false">
<div class="flex-none h-6 w-6 flex cursor-pointer mx-1"> <div class="flex-none h-6 w-6 flex cursor-pointer mx-1">
<div class="h-6 w-6 rounded flex items-center" :style="{ backgroundColor: element.color }"> <div
<GeneralIcon icon="arrowDown" class="flex-none h-4 w-4 m-auto !text-gray-600" /> class="h-6 w-6 rounded flex items-center"
:style="{
backgroundColor: element.color,
color: tinycolor.isReadable(element.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(element.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
}"
>
<GeneralIcon icon="arrowDown" class="flex-none h-4 w-4 m-auto !text-current" />
</div> </div>
</div> </div>
@ -444,12 +570,20 @@ const loadListData = async ($state: any) => {
<span></span> <span></span>
</template> </template>
</InfiniteLoading> </InfiniteLoading>
</template>
</div> </div>
<div v-if="validateInfos?.colOptions?.help?.[0]?.[0]" class="text-error text-[10px] mb-1 mt-2"> <div
v-if="validateInfos?.colOptions?.help?.[0]?.[0]"
class="text-error text-[10px] mb-1 mt-2"
:class="{
'pl-1': isKanbanStack,
}"
>
{{ validateInfos.colOptions.help[0][0] }} {{ validateInfos.colOptions.help[0][0] }}
</div> </div>
<NcButton <NcButton
v-if="!isKanbanStack"
type="secondary" type="secondary"
class="w-full caption" class="w-full caption"
:class="{ :class="{
@ -488,11 +622,11 @@ const loadListData = async ($state: any) => {
:deep(.nc-select-col-option-select-option) { :deep(.nc-select-col-option-select-option) {
@apply !truncate; @apply !truncate;
&:not(:focus):hover { &:not(.nc-kanban-stack-input):not(:focus):hover {
@apply !border-transparent; @apply !border-transparent;
} }
&:not(:focus) { &:not(.nc-kanban-stack-input):not(:focus) {
@apply !border-transparent; @apply !border-transparent;
} }

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

@ -499,6 +499,9 @@ const onIsExpandedUpdate = (v: boolean) => {
if (changedColumns.value.size === 0 && !isUnsavedFormExist.value) { if (changedColumns.value.size === 0 && !isUnsavedFormExist.value) {
isExpanded.value = v isExpanded.value = v
if (isKanban.value) {
emits('cancel')
}
} else if (!v && isUIAllowed('dataEdit')) { } else if (!v && isUIAllowed('dataEdit')) {
preventModalStatus.value = true preventModalStatus.value = true
} else { } else {

3
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -30,8 +30,6 @@ const isExpandedBulkUpdateForm = inject(IsExpandedBulkUpdateFormOpenInj, ref(fal
const isDropDownOpen = ref(false) const isDropDownOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const column = toRef(props, 'column') const column = toRef(props, 'column')
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
@ -100,7 +98,6 @@ const onClick = (e: Event) => {
class="flex items-center w-full text-xs text-gray-500 font-weight-medium group" class="flex items-center w-full text-xs text-gray-500 font-weight-medium group"
:class="{ :class="{
'h-full': column, 'h-full': column,
'!text-gray-400': isKanban,
'flex-col !items-start justify-center pt-0.5': isExpandedForm && !isMobileMode && !isExpandedBulkUpdateForm, 'flex-col !items-start justify-center pt-0.5': isExpandedForm && !isMobileMode && !isExpandedBulkUpdateForm,
'nc-cell-expanded-form-header cursor-pointer hover:bg-gray-100': 'nc-cell-expanded-form-header cursor-pointer hover:bg-gray-100':
isExpandedForm && !isMobileMode && isUIAllowed('fieldEdit') && !isExpandedBulkUpdateForm, isExpandedForm && !isMobileMode && isUIAllowed('fieldEdit') && !isExpandedBulkUpdateForm,

59
packages/nc-gui/components/smartsheet/kanban/EditOrAddStack.vue

@ -0,0 +1,59 @@
<script lang="ts" setup>
import type { ColumnType } from 'nocodb-sdk'
const props = defineProps<{
column: ColumnType
optionId?: string
isNewStack?: boolean
}>()
const emit = defineEmits(['submit', 'cancel'])
const { column, optionId, isNewStack } = toRefs(props)
const meta = inject(MetaInj, ref())
const { formState, addOrUpdate } = useProvideColumnCreateStore(meta, column, undefined, undefined, undefined, ref(true))
const { getMeta } = useMetas()
const reloadMetaAndData = async () => {
await getMeta(meta.value?.id as string, true)
}
async function onSubmit(
submit: boolean = false,
saveChanges: boolean = true,
payload: Partial<{ color: string; title: string; [key: string]: any }>,
) {
if (!saveChanges && submit) {
emit('submit')
return
}
const saved = await addOrUpdate(reloadMetaAndData)
if (submit && saved) {
emit('submit', true, payload)
}
}
</script>
<template>
<a-form
v-model="formState"
no-style
name="column-create-or-edit"
layout="vertical"
data-testid="add-or-edit-column"
class="w-full flex"
>
<SmartsheetColumnSelectOptions
v-model:value="formState"
is-kanban-stack
:option-id="optionId"
:is-new-stack="isNewStack"
@save-changes="onSubmit"
/>
</a-form>
</template>

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

@ -417,12 +417,12 @@ useMenuCloseOnEsc(open)
> >
<div <div
v-if="!isPublic && (activeView?.type === ViewTypes.GALLERY || activeView?.type === ViewTypes.KANBAN)" v-if="!isPublic && (activeView?.type === ViewTypes.GALLERY || activeView?.type === ViewTypes.KANBAN)"
class="flex flex-col gap-y-2 px-2 mb-3" class="flex items-center gap-2 px-2 mb-4"
> >
<div class="flex text-sm select-none text-gray-600">{{ $t('labels.coverImageField') }}</div> <div class="pl-2 flex text-sm select-none text-gray-600">{{ $t('labels.coverImageField') }}</div>
<div <div
class="nc-dropdown-cover-image-wrapper flex items-stretch border-1 border-gray-200 rounded-lg transition-all duration-0.3s" class="flex-1 nc-dropdown-cover-image-wrapper flex items-stretch border-1 border-gray-200 rounded-lg transition-all duration-0.3s"
> >
<a-select <a-select
v-model:value="coverImageColumnId" v-model:value="coverImageColumnId"
@ -607,7 +607,7 @@ useMenuCloseOnEsc(open)
</div> </div>
<div v-if="!filterQuery" class="flex px-2 gap-2 py-2"> <div v-if="!filterQuery" class="flex px-2 gap-2 py-2">
<NcButton class="nc-fields-show-all-fields" size="small" type="ghost" @click="showAllColumns = !showAllColumns"> <NcButton class="nc-fields-show-all-fields" size="small" type="ghost" @click="showAllColumns = !showAllColumns">
{{ showAllColumns ? $t('general.hideAll') : $t('general.showAll') }} {{ $t('objects.fields') }} {{ showAllColumns ? $t('general.hideAll') : $t('general.showAll') }} {{ $t('objects.fields').toLowerCase() }}
</NcButton> </NcButton>
<NcButton <NcButton
v-if="!isPublic" v-if="!isPublic"

135
packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { KanbanType } from 'nocodb-sdk' import type { ColumnType, KanbanType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes, isVirtualCol } from 'nocodb-sdk'
import type { SelectProps } from 'ant-design-vue' import type { SelectProps } from 'ant-design-vue'
provide(IsKanbanInj, ref(true)) provide(IsKanbanInj, ref(true))
@ -15,8 +15,7 @@ const isLocked = inject(IsLockedInj, ref(false))
const { fields, loadViewColumns, metaColumnById } = useViewColumnsOrThrow(activeView, meta) const { fields, loadViewColumns, metaColumnById } = useViewColumnsOrThrow(activeView, meta)
const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField, groupingFieldColumn } = const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField } = useKanbanViewStoreOrThrow()
useKanbanViewStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo() const { addUndo, defineViewScope } = useUndoRedo()
@ -64,6 +63,45 @@ const groupingFieldColumnId = computed({
}, },
}) })
const updateHideEmptyStack = async (v: boolean) => {
const payload = {
...parseProp(kanbanMetaData.value?.meta),
hide_empty_stack: v,
}
await updateKanbanMeta({
meta: payload,
})
await loadKanbanMeta()
;(activeView.value?.view as KanbanType).meta = payload
}
const isLoading = ref<'hideEmptyStack' | null>(null)
const hideEmptyStack = computed({
get: () => {
return parseProp(kanbanMetaData.value?.meta).hide_empty_stack || false
},
set: async (val: boolean) => {
isLoading.value = 'hideEmptyStack'
addUndo({
undo: {
fn: updateHideEmptyStack,
args: [hideEmptyStack.value],
},
redo: {
fn: updateHideEmptyStack,
args: [val],
},
scope: defineViewScope({ view: activeView.value }),
})
await updateHideEmptyStack(val)
isLoading.value = null
},
})
const singleSelectFieldOptions = computed<SelectProps['options']>(() => { const singleSelectFieldOptions = computed<SelectProps['options']>(() => {
return fields.value return fields.value
?.filter((el) => el.fk_column_id && metaColumnById.value[el.fk_column_id].uidt === UITypes.SingleSelect) ?.filter((el) => el.fk_column_id && metaColumnById.value[el.fk_column_id].uidt === UITypes.SingleSelect)
@ -75,20 +113,18 @@ const singleSelectFieldOptions = computed<SelectProps['options']>(() => {
}) })
}) })
const onSubmit = async () => {
open.value = false
await loadKanbanMeta()
await loadKanbanData()
}
const handleChange = () => { const handleChange = () => {
open.value = false open.value = false
} }
const getIcon = (c: ColumnType) =>
h(isVirtualCol(c) ? resolveComponent('SmartsheetHeaderVirtualCellIcon') : resolveComponent('SmartsheetHeaderCellIcon'), {
columnMeta: c,
})
</script> </script>
<template> <template>
<a-dropdown <NcDropdown
v-if="!IsPublic" v-if="!IsPublic"
v-model:visible="open" v-model:visible="open"
:trigger="['click']" :trigger="['click']"
@ -98,54 +134,75 @@ const handleChange = () => {
<div class="nc-kanban-btn"> <div class="nc-kanban-btn">
<NcButton <NcButton
v-e="['c:kanban:change-grouping-field']" v-e="['c:kanban:change-grouping-field']"
class="nc-kanban-stacked-by-menu-btn nc-toolbar-btn !border-0 !h-7" class="nc-kanban-stacked-by-menu-btn nc-toolbar-btn !border-0 !h-7 group"
size="small" size="small"
type="secondary" type="secondary"
:disabled="isLocked" :disabled="isLocked"
> >
<div class="flex items-center gap-1"> <div class="flex items-center gap-2">
<GeneralIcon icon="layers" class="mr-0.5" /> <GeneralIcon icon="settings" class="h-4 w-4" />
<span class="text-capitalize !text-sm"> <div class="flex items-center gap-0.5">
<span class="text-capitalize !text-sm flex items-center gap-1 text-gray-700">
{{ $t('activity.kanban.stackedBy') }} {{ $t('activity.kanban.stackedBy') }}
<span class="font-bold ml-0.25">{{ groupingField }}</span>
</span> </span>
<div
class="flex items-center rounded-md transition-colors duration-0.3s bg-gray-100 group-hover:bg-gray-200 px-1 min-h-5 text-gray-600"
>
<span class="font-weight-500 text-sm">{{ groupingField }}</span>
</div>
</div>
</div> </div>
</NcButton> </NcButton>
</div> </div>
<template #overlay> <template #overlay>
<div v-if="open" class="p-6 w-90 bg-white shadow-lg nc-table-toolbar-menu !border-1 border-gray-50 rounded-2xl" @click.stop> <div v-if="open" class="p-4 w-90 bg-white nc-table-toolbar-menu rounded-lg flex flex-col gap-5" @click.stop>
<div>Select a field to stack records by</div> <div class="flex flex-col gap-2">
<div class="nc-fields-list py-2"> <div>
{{ $t('general.groupingField').toLowerCase().replace(/^./, $t('general.groupingField').charAt(0).toUpperCase()) }}
</div>
<div class="nc-fields-list">
<div class="grouping-field"> <div class="grouping-field">
<a-select <a-select
v-model:value="groupingFieldColumnId" v-model:value="groupingFieldColumnId"
class="w-full nc-kanban-grouping-field-select" class="nc-select-shadow w-full nc-kanban-grouping-field-select !rounded-lg"
:options="singleSelectFieldOptions" dropdown-class-name="!rounded-lg"
placeholder="Select a Grouping Field" placeholder="Select a Grouping Field"
@change="handleChange" @change="handleChange"
@click.stop @click.stop
> >
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template <template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template>
<a-select-option v-for="option of singleSelectFieldOptions" :key="option.value" :value="option.value">
<div class="w-full flex gap-2 items-center justify-between" :title="option.label">
<div class="flex items-center gap-1">
<component
:is="getIcon(metaColumnById[option.value])"
v-if="option.value"
class="!w-3.5 !h-3.5 !text-gray-700 !ml-0"
/>
<span> {{ option.label }} </span>
</div>
<GeneralIcon
v-if="groupingFieldColumnId === option.value"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</div> </a-select-option
></a-select> ></a-select>
</div> </div>
</div> </div>
<div class="mt-4 border-1 px-4 pt-4 pb-3 border-gray-50 rounded-2xl"> </div>
<div class="text-base font-medium mb-2">Options</div> <div class="flex items-center gap-1">
<LazySmartsheetColumnEditOrAddProvider <NcSwitch v-model:checked="hideEmptyStack" size="small" class="nc-switch" :loading="isLoading === 'hideEmptyStack'">
v-if="open" <div class="text-sm text-gray-800">
:column="groupingFieldColumn" {{ $t('general.hide') }}
embed-mode {{ $t('general.empty').toLowerCase() }}
:column-label="$t('general.changes')" {{ $t('general.stack').toLowerCase() }}
hide-title </div>
hide-type </NcSwitch>
hide-additional-options
@cancel="open = false"
@submit="onSubmit"
@click.stop
@keydown.stop
/>
</div> </div>
</div> </div>
</template> </template>
</a-dropdown> </NcDropdown>
</template> </template>

6
packages/nc-gui/composables/useAttachment.ts

@ -5,9 +5,9 @@ const useAttachment = () => {
const res: string[] = [] const res: string[] = []
if (item?.data) res.push(item.data) if (item?.data) res.push(item.data)
if (item?.file) res.push(window.URL.createObjectURL(item.file)) if (item?.file) res.push(window.URL.createObjectURL(item.file))
if (item?.signedPath) res.push(`${appInfo.value.ncSiteUrl}/${item.signedPath}`) if (item?.signedPath) res.push(encodeURI(`${appInfo.value.ncSiteUrl}/${item.signedPath}`))
if (item?.signedUrl) res.push(item.signedUrl) if (item?.signedUrl) res.push(encodeURI(item.signedUrl))
if (item?.path) res.push(`${appInfo.value.ncSiteUrl}/${item.path}`) if (item?.path) res.push(encodeURI(`${appInfo.value.ncSiteUrl}/${item.path}`))
if (item?.url) res.push(item.url) if (item?.url) res.push(item.url)
return res return res
} }

9
packages/nc-gui/composables/useColumnCreateStore.ts

@ -22,6 +22,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
tableExplorerColumns?: Ref<ColumnType[] | undefined>, tableExplorerColumns?: Ref<ColumnType[] | undefined>,
fromTableExplorer?: Ref<boolean | undefined>, fromTableExplorer?: Ref<boolean | undefined>,
isColumnValid?: Ref<((value: Partial<ColumnType>) => boolean) | undefined>, isColumnValid?: Ref<((value: Partial<ColumnType>) => boolean) | undefined>,
fromKanbanStack?: Ref<boolean | undefined>,
) => { ) => {
const baseStore = useBase() const baseStore = useBase()
@ -260,13 +261,17 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
?.map((e: any) => e.errors?.join(', ')) ?.map((e: any) => e.errors?.join(', '))
.filter(Boolean) .filter(Boolean)
.join(', ') .join(', ')
if (errorMsgs) { if (errorMsgs) {
message.error(errorMsgs) message.error(errorMsgs)
} else { return
message.error(t('msg.error.formValidationFailed'))
} }
if (!fromKanbanStack?.value || (fromKanbanStack.value && !e.outOfDate)) {
message.error(t('msg.error.formValidationFailed'))
return return
} }
}
try { try {
formState.value.table_name = meta.value?.table_name formState.value.table_name = meta.value?.table_name

35
packages/nc-gui/composables/useKanbanViewStore.ts

@ -13,6 +13,8 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
throw new Error('Table meta is not available') throw new Error('Table meta is not available')
} }
const addNewStackId = 'addNewStack'
const { t } = useI18n() const { t } = useI18n()
const { api } = useApi() const { api } = useApi()
@ -141,6 +143,24 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
} }
} }
const filerDuplicateRecords = (existingRecords: Row[], newRecords: Row[]) => {
const existingRecordsMap = (existingRecords || []).reduce((acc, curr) => {
const primaryKey = extractPkFromRow(curr.row, meta!.value!.columns as ColumnType[])
if (primaryKey) {
acc[primaryKey] = curr
}
return acc
}, {} as Record<string, Row>)
return (newRecords || []).filter(({ row }) => {
const primaryKey = extractPkFromRow(row, meta!.value!.columns as ColumnType[])
if (primaryKey && existingRecordsMap[primaryKey]) {
return false
}
return true
})
}
async function loadMoreKanbanData(stackTitle: string, params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) { async function loadMoreKanbanData(stackTitle: string, params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) {
if ((!base?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic.value) return if ((!base?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic.value) return
let where = `(${groupingField.value},eq,${stackTitle})` let where = `(${groupingField.value},eq,${stackTitle})`
@ -167,7 +187,10 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
where, where,
}) })
formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, ...formatData(response!.list!)]) formattedData.value.set(stackTitle, [
...formattedData.value.get(stackTitle)!,
...filerDuplicateRecords(formattedData.value.get(stackTitle)!, formatData(response!.list!)),
])
} }
async function loadKanbanMeta() { async function loadKanbanMeta() {
@ -250,7 +273,8 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
isChanged = true isChanged = true
} }
} }
groupingFieldColOptions.value = stackMetaObj.value[fk_grp_col_id]
groupingFieldColOptions.value = [...stackMetaObj.value[fk_grp_col_id]]
if (isChanged) { if (isChanged) {
await updateKanbanStackMeta() await updateKanbanStackMeta()
@ -260,10 +284,11 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
} }
} else { } else {
// build stack meta // build stack meta
groupingFieldColOptions.value = [ groupingFieldColOptions.value = [
...((groupingFieldColumn.value?.colOptions as SelectOptionsType & { collapsed: boolean })?.options ?? []), ...((groupingFieldColumn.value?.colOptions as SelectOptionsType & { collapsed: boolean })?.options ?? []),
// enrich uncategorized stack // enrich uncategorized stack
{ id: 'uncategorized', title: null, order: 0, color: enumColor.light[2] } as any, { id: 'uncategorized', title: null, order: 0, color: themeV3Colors.gray[500] } as any,
] ]
// sort by initial order // sort by initial order
.sort((a, b) => a.order! - b.order!) .sort((a, b) => a.order! - b.order!)
@ -485,11 +510,14 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
(o) => o.title !== stackTitle, (o) => o.title !== stackTitle,
) )
;(groupingFieldColumn.value.colOptions as SelectOptionsType).options = newOptions ;(groupingFieldColumn.value.colOptions as SelectOptionsType).options = newOptions
const cdf = groupingFieldColumn.value.cdf ? groupingFieldColumn.value.cdf.replace(/^'/, '').replace(/'$/, '') : null
await api.dbTableColumn.update(groupingFieldColumn.value.id!, { await api.dbTableColumn.update(groupingFieldColumn.value.id!, {
...groupingFieldColumn.value, ...groupingFieldColumn.value,
colOptions: { colOptions: {
options: newOptions, options: newOptions,
}, },
cdf: cdf === stackTitle ? null : cdf,
} as any) } as any)
// update kanban stack meta // update kanban stack meta
@ -682,6 +710,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
shouldScrollToRight, shouldScrollToRight,
deleteRow, deleteRow,
moveHistory, moveHistory,
addNewStackId,
} }
}, },
'kanban-view-store', 'kanban-view-store',

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

@ -210,7 +210,8 @@
"set": "Set", "set": "Set",
"format": "Format", "format": "Format",
"colour": "Colour", "colour": "Colour",
"use": "Use" "use": "Use",
"stack": "Stack"
}, },
"objects": { "objects": {
"owner": "Owner", "owner": "Owner",
@ -451,7 +452,8 @@
"selectFieldsFromRightPannelToAddHere": "Select fields from right panel to add here", "selectFieldsFromRightPannelToAddHere": "Select fields from right panel to add here",
"noOptionsFound": "No options found", "noOptionsFound": "No options found",
"surveyFormSubmitConfirmMsg": "Are you sure you want to submit this form?", "surveyFormSubmitConfirmMsg": "Are you sure you want to submit this form?",
"noResultsMatchedYourSearch": "Your search did not yield any matching results" "noResultsMatchedYourSearch": "Your search did not yield any matching results",
"looksLikeThisStackIsEmpty": "Looks like this stack does not have any records"
}, },
"labels": { "labels": {
"selectView": "Select a View", "selectView": "Select a View",
@ -778,7 +780,7 @@
"relationType": "Relation type", "relationType": "Relation type",
"showThousandsSeparator": "Show thousands separator", "showThousandsSeparator": "Show thousands separator",
"signUpForFree": "Sign up for free", "signUpForFree": "Sign up for free",
"coverImageField": "Cover image field", "coverImageField": "Cover image",
"fitImage": "Fit image", "fitImage": "Fit image",
"coverImageArea": "Cover image" "coverImageArea": "Cover image"
}, },
@ -978,7 +980,7 @@
"clearCell": "Clear cell", "clearCell": "Clear cell",
"addFilterGroup": "Add filter group", "addFilterGroup": "Add filter group",
"linkRecord": "Link record", "linkRecord": "Link record",
"addNewRecord": "Add new record", "addNewRecord": "Add record",
"newRecord": "New record", "newRecord": "New record",
"tableNameCreateNewRecord": "{tableName}: Create new record", "tableNameCreateNewRecord": "{tableName}: Create new record",
"gotSavedLinkedSuccessfully": "{tableName} '{recordTitle}' got saved & linked successfully", "gotSavedLinkedSuccessfully": "{tableName} '{recordTitle}' got saved & linked successfully",
@ -1005,9 +1007,12 @@
"showJunctionTableNames": "Show Junction Table Names" "showJunctionTableNames": "Show Junction Table Names"
}, },
"kanban": { "kanban": {
"collapseStack": "Collapse Stack", "collapseStack": "Collapse stack",
"deleteStack": "Delete Stack", "collapseAll": "Collapse all",
"stackedBy": "Stacked By", "expandAll": "Expand all",
"renameStack": "Rename stack",
"deleteStack": "Delete stack",
"stackedBy": "Stacked by",
"chooseGroupingField": "Choose a Grouping Field", "chooseGroupingField": "Choose a Grouping Field",
"addOrEditStack": "Add / Edit Stack" "addOrEditStack": "Add / Edit Stack"
}, },

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

@ -194,6 +194,12 @@ import NcAudit from '~icons/nc-icons/audit'
import NcMessageCircle from '~icons/nc-icons/message-circle' import NcMessageCircle from '~icons/nc-icons/message-circle'
import NcKey from '~icons/nc-icons/key' import NcKey from '~icons/nc-icons/key'
import NcMinimize from '~icons/nc-icons/minimize'
import NcMinimizeAll from '~icons/nc-icons/minimize-all'
import NcMaximize from '~icons/nc-icons/maximize'
import NcMaximizeAll from '~icons/nc-icons/maximize-all'
import NcDrag from '~icons/nc-icons/drag'
// keep it for reference // keep it for reference
// todo: remove it after all icons are migrated // todo: remove it after all icons are migrated
/* export const iconMapOld = { /* export const iconMapOld = {
@ -617,6 +623,11 @@ export const iconMap = {
audit: NcAudit, audit: NcAudit,
messageCircle: NcMessageCircle, messageCircle: NcMessageCircle,
ncKey: NcKey, ncKey: NcKey,
minimize: NcMinimize,
minimizeAll: NcMinimizeAll,
maximize: NcMaximize,
maximizeAll: NcMaximizeAll,
ncDrag: NcDrag,
} }
export const getMdiIcon = (type: string): any => { export const getMdiIcon = (type: string): any => {

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

@ -7,10 +7,10 @@ export function parseProp(v: any): any {
} }
} }
export function stringifyProp(v: any): string | undefined { export function stringifyProp(v: any): string {
if (!v) return undefined if (!v) return '{}'
try { try {
return typeof v === 'string' ? v : JSON.stringify(v) return typeof v === 'string' ? v : JSON.stringify(v) ?? '{}'
} catch { } catch {
return '{}' return '{}'
} }

9
packages/nocodb/src/helpers/getAst.ts

@ -19,6 +19,7 @@ import {
GalleryView, GalleryView,
GridViewColumn, GridViewColumn,
KanbanView, KanbanView,
KanbanViewColumn,
View, View,
} from '~/models'; } from '~/models';
@ -57,12 +58,14 @@ const getAst = async (
let coverImageId; let coverImageId;
let dependencyFieldsForCalenderView; let dependencyFieldsForCalenderView;
let kanbanGroupColumnId;
if (view && view.type === ViewTypes.GALLERY) { if (view && view.type === ViewTypes.GALLERY) {
const gallery = await GalleryView.get(context, view.id); const gallery = await GalleryView.get(context, view.id);
coverImageId = gallery.fk_cover_image_col_id; coverImageId = gallery.fk_cover_image_col_id;
} else if (view && view.type === ViewTypes.KANBAN) { } else if (view && view.type === ViewTypes.KANBAN) {
const kanban = await KanbanView.get(context, view.id); const kanban = await KanbanView.get(context, view.id);
coverImageId = kanban.fk_cover_image_col_id; coverImageId = kanban.fk_cover_image_col_id;
kanbanGroupColumnId = kanban.fk_grp_col_id;
} else if (view && view.type === ViewTypes.CALENDAR) { } else if (view && view.type === ViewTypes.CALENDAR) {
// const calendar = await CalendarView.get(view.id); // const calendar = await CalendarView.get(view.id);
// coverImageId = calendar.fk_cover_image_col_id; // coverImageId = calendar.fk_cover_image_col_id;
@ -140,7 +143,11 @@ const getAst = async (
allowedCols = (await View.getColumns(context, view.id)).reduce( allowedCols = (await View.getColumns(context, view.id)).reduce(
(o, c) => ({ (o, c) => ({
...o, ...o,
[c.fk_column_id]: c.show || (c instanceof GridViewColumn && c.group_by), [c.fk_column_id]:
c.show ||
(c instanceof GridViewColumn && c.group_by) ||
(c instanceof KanbanViewColumn &&
c.fk_column_id === kanbanGroupColumnId),
}), }),
{}, {},
); );

2
packages/nocodb/src/models/View.ts

@ -1852,7 +1852,7 @@ export default class View implements ViewType {
} }
} else if (view.type === ViewTypes.KANBAN && !copyFromView) { } else if (view.type === ViewTypes.KANBAN && !copyFromView) {
const kanbanView = await KanbanView.get(context, view.id, ncMeta); const kanbanView = await KanbanView.get(context, view.id, ncMeta);
if (column.id === kanbanView?.fk_grp_col_id) { if (column.id === kanbanView?.fk_grp_col_id && column.pv) {
// include grouping field if it exists // include grouping field if it exists
show = true; show = true;
} else if ( } else if (

86
tests/playwright/pages/Dashboard/Kanban/index.ts

@ -52,8 +52,8 @@ export class KanbanPage extends BasePage {
async dragDropStack(param: { from: number; to: number }) { async dragDropStack(param: { from: number; to: number }) {
const { from, to } = param; const { from, to } = param;
const [fromStack, toStack] = await Promise.all([ const [fromStack, toStack] = await Promise.all([
this.rootPage.locator(`.nc-kanban-stack-head`).nth(from), this.rootPage.locator(`.nc-kanban-stack-head >> .nc-kanban-stack-drag-handler`).nth(from),
this.rootPage.locator(`.nc-kanban-stack-head`).nth(to), this.rootPage.locator(`.nc-kanban-stack-head >> .nc-kanban-stack-drag-handler`).nth(to),
]); ]);
await fromStack.dragTo(toStack); await fromStack.dragTo(toStack);
} }
@ -71,7 +71,8 @@ export class KanbanPage extends BasePage {
const stack = this.get().locator(`.nc-kanban-stack`).nth(i); const stack = this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded(); await stack.scrollIntoViewIfNeeded();
// Since otherwise stack title will be repeated as title is in two divs, with one having hidden class // Since otherwise stack title will be repeated as title is in two divs, with one having hidden class
const stackTitle = stack.locator(`.nc-kanban-stack-head >> [data-testid="truncate-label"]`); const stackTitle = stack.locator(`.nc-kanban-stack-head >> [data-testid="nc-kanban-stack-title"]`);
await stackTitle.waitFor({ state: 'visible' });
await expect(stackTitle).toHaveText(order[i], { ignoreCase: true }); await expect(stackTitle).toHaveText(order[i], { ignoreCase: true });
} }
} }
@ -119,35 +120,90 @@ export class KanbanPage extends BasePage {
} }
async addNewStack(param: { title: string }) { async addNewStack(param: { title: string }) {
await this.toolbar.clickAddEditStack(); const addNewStack = this.get().locator('.nc-kanban-add-new-stack');
await this.toolbar.addEditStack.addOption({ title: param.title }); await addNewStack.scrollIntoViewIfNeeded();
const addNewStackBtn = addNewStack.locator('.add-new-stack-btn');
await addNewStackBtn.waitFor({ state: 'visible' });
await addNewStackBtn.click();
const stackTitleInput = addNewStack.getByTestId('nc-kanban-stack-title-input');
await stackTitleInput.waitFor({ state: 'visible' });
await stackTitleInput.fill(param.title);
await this.rootPage.keyboard.press('Enter');
await this.get().getByTestId(`nc-kanban-stack-${param.title}`).waitFor({ state: 'visible' });
} }
async collapseStack(param: { index: number }) { async renameStack(param: { index: number; title: string }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.index).click(); const stackHead = this.get().locator(`.nc-kanban-stack-head`).nth(param.index);
const modal = this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`); await stackHead.scrollIntoViewIfNeeded();
await modal.locator('.ant-dropdown-menu-item:has-text("Collapse Stack")').click();
await this.stackContextMenu({ index: param.index, operation: 'rename-stack' });
const stackTitleInput = stackHead.getByTestId('nc-kanban-stack-title-input');
await stackTitleInput.waitFor({ state: 'visible' });
await stackTitleInput.fill(param.title);
await this.rootPage.keyboard.press('Enter');
await stackTitleInput.waitFor({ state: 'hidden' });
await stackHead.locator('.stack-rename-loader').waitFor({ state: 'hidden' });
await this.get().getByTestId(`nc-kanban-stack-${param.title}`).waitFor({ state: 'visible' });
}
async stackContextMenu({
index,
operation,
}: {
index: number;
operation:
| 'add-new-record'
| 'rename-stack'
| 'collapse-stack'
| 'collapse-all-stack'
| 'expand-all-stack'
| 'delete-stack';
}) {
await this.get().locator(`.nc-kanban-stack-head`).nth(index).getByTestId('nc-kanban-stack-context-menu').click();
const contextMenu = this.rootPage.locator('.nc-dropdown-kanban-stack-context-menu');
await contextMenu.waitFor({ state: 'visible' });
await contextMenu.getByTestId(`nc-kanban-context-menu-${operation}`).click();
await contextMenu.waitFor({ state: 'hidden' });
}
async collapseStack({ index }: { index: number }) {
await this.stackContextMenu({ index, operation: 'collapse-stack' });
}
async collapseAllStack({ index }: { index: number }) {
await this.stackContextMenu({ index, operation: 'collapse-all-stack' });
} }
async expandStack(param: { index: number }) { async expandStack(param: { index: number }) {
await this.rootPage.locator(`.nc-kanban-collapsed-stack`).nth(param.index).click(); await this.rootPage.locator(`.nc-kanban-collapsed-stack`).nth(param.index).click();
} }
async expandAllStack({ index }: { index: number }) {
await this.stackContextMenu({ index, operation: 'expand-all-stack' });
}
async verifyCollapseStackCount(param: { count: number }) { async verifyCollapseStackCount(param: { count: number }) {
await expect(this.rootPage.locator('.nc-kanban-collapsed-stack')).toHaveCount(param.count); await expect(this.rootPage.locator('.nc-kanban-collapsed-stack')).toHaveCount(param.count);
} }
async addCard(param: { stackIndex: number }) { async addCard(param: { stackIndex: number }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.stackIndex).click(); await this.stackContextMenu({ index: param.stackIndex, operation: 'add-new-record' });
const modal = this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Add new record")').click();
} }
async deleteStack(param: { index: number }) { async deleteStack(param: { index: number }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.index).click(); await this.stackContextMenu({ index: param.index, operation: 'delete-stack' });
const modal = this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Delete Stack")').click();
const confirmationModal = this.rootPage.locator(`div.ant-modal-content`); const confirmationModal = this.rootPage.locator(`div.ant-modal-content`);
await confirmationModal.locator(`button:has-text("Delete Stack")`).click(); await confirmationModal.locator(`button:has-text("Delete Stack")`).click();
await confirmationModal.waitFor({ state: 'hidden' });
} }
} }

3
tests/playwright/tests/db/views/viewKanban.spec.ts

@ -103,11 +103,12 @@ test.describe('View', () => {
['AIRPORT POLLOCK', 'ALONE TRIP', 'AMELIE HELLFIGHTERS'], ['AIRPORT POLLOCK', 'ALONE TRIP', 'AMELIE HELLFIGHTERS'],
['ADAPTATION HOLES', 'ALADDIN CALENDAR', 'ALICE FANTASIA'], ['ADAPTATION HOLES', 'ALADDIN CALENDAR', 'ALICE FANTASIA'],
]; ];
for (let i = 1; i <= order.length; i++) for (let i = 1; i <= order.length; i++) {
await kanban.verifyCardOrder({ await kanban.verifyCardOrder({
stackIndex: i, stackIndex: i,
order: order[i - 1], order: order[i - 1],
}); });
}
// // verify inter stack drag-drop // // verify inter stack drag-drop
// await kanban.dragDropCard({ // await kanban.dragDropCard({

Loading…
Cancel
Save