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. 14
      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. 998
      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. 396
      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. 155
      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

14
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">
<path d="M6 14H2V10" stroke="#374151" 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="M10 2H14V6" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.9999 2L9.33325 6.66667" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</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="currentColor" 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"
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>

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;
}
}
.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
.ant-select-dropdown {
@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 isKanban = inject(IsKanbanInj, ref(false))
const readOnly = inject(ReadonlyInj)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
@ -110,8 +112,8 @@ useSelectedCellKeyupListener(active, (e) => {
<div
class="flex items-center"
:class="{
'w-full justify-start': isEditColumnMenu || isGallery || isForm,
'justify-center': !isEditColumnMenu && !isGallery && !isForm,
'w-full justify-start': isEditColumnMenu || isGallery || isKanban || isForm,
'justify-center': !isEditColumnMenu && !isGallery && !isKanban && !isForm,
'py-2': isEditColumnMenu,
}"
@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]`"
>
<div
v-bind="$attrs"
:title="localState?.format(dateFormat)"
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>
<template>
<div class="nc-cell-field relative">
<div v-bind="$attrs" class="nc-cell-field relative">
<NcDropdown
:visible="isOpen"
: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>
<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>
</template>

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

@ -1,6 +1,8 @@
<script setup lang="ts">
import type { UserFieldRecordType } from 'nocodb-sdk'
interface Props {
modelValue?: string | null
modelValue?: UserFieldRecordType[] | UserFieldRecordType | string | null
}
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]`"
>
<div
v-bind="$attrs"
:title="localState?.format('HH:mm')"
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 }]"
: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
ref="datePickerRef"
type="text"

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

@ -52,22 +52,22 @@ const defaultColors = computed<string[][]>(() => {
return allColors
})
const localIsDefaultColorTab = ref(true)
const localIsDefaultColorTab = ref<'true' | 'false'>('true')
const isDefaultColorTab = computed({
get: () => {
if (showActiveColorTab.value && vModel.value) {
for (const colorGrp of defaultColors.value) {
if (colorGrp.includes(vModel.value)) {
return true
return 'true'
}
}
return false
return 'false'
}
return localIsDefaultColorTab.value
},
set: (val: boolean) => {
set: (val: 'true' | 'false') => {
localIsDefaultColorTab.value = val
if (showActiveColorTab.value) {
@ -110,7 +110,7 @@ watch(
<template>
<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">
<a-tab-pane :key="true">
<a-tab-pane key="true">
<template #tab>
<div class="tab" data-testid="nc-default-colors-tab">Default colors</div>
</template>
@ -131,7 +131,7 @@ watch(
</div>
</div>
</a-tab-pane>
<a-tab-pane :key="false">
<a-tab-pane key="false">
<template #tab>
<div class="tab" data-testid="nc-custom-colors-tab">
<div>Custom colours</div>

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

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

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

@ -186,7 +186,7 @@ useEventListener(NcButton, 'mousedown', () => {
.nc-button.ant-btn[disabled],
.ant-btn-text.nc-button.ant-btn[disabled] {
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] {

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

@ -1,9 +1,16 @@
<script lang="ts" setup>
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',
placement: 'left',
loading: false,
},
)
@ -11,9 +18,13 @@ const emit = defineEmits(['change', 'update:checked'])
const checked = useVModel(props, 'checked', emit)
const { loading } = toRefs(props)
const switchSize = computed(() => (['default', 'small'].includes(props.size) ? props.size : undefined))
const onChange = (e: boolean, updateValue = false) => {
if (loading.value) return
if (updateValue) {
checked.value = e
}
@ -23,7 +34,15 @@ const onChange = (e: boolean, updateValue = false) => {
</script>
<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 />
</span>
<a-switch
@ -33,12 +52,21 @@ const onChange = (e: boolean, updateValue = false) => {
:class="{
'size-xsmall': size === 'xsmall',
}"
:loading="loading"
v-bind="$attrs"
:size="switchSize"
@change="onChange"
>
</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 />
</span>
</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
e.preventDefault()
if (target) {
@ -183,27 +183,41 @@ watch(
</script>
<template>
<a-dropdown
<NcDropdown
v-model:visible="contextMenu"
:trigger="isSqlView ? [] : ['contextmenu']"
overlay-class-name="nc-dropdown-grid-context-menu"
>
<template #overlay>
<a-menu class="shadow !rounded !py-0" @click="contextMenu = false">
<a-menu-item v-if="contextMenuTarget" @click="deleteRow(contextMenuTarget.row)">
<div v-e="['a:row:delete']" class="nc-base-menu-item">
<NcMenu @click="contextMenu = false">
<NcMenuItem v-if="contextMenuTarget" @click="expandForm(contextMenuTarget.row)">
<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 -->
{{ $t('activity.deleteRow') }}
</div>
</a-menu-item>
</NcMenuItem>
<!-- <a-menu-item v-if="contextMenuTarget" @click="openNewRecordFormHook.trigger()"> -->
<!-- <div v-e="['a:row:insert']" class="nc-base-menu-item"> -->
<!-- <NcMenuItem v-if="contextMenuTarget" @click="openNewRecordFormHook.trigger()"> -->
<!-- <div v-e="['a:row:insert']" class="flex items-center gap-2"> -->
<!-- &lt;!&ndash; Insert New Row &ndash;&gt; -->
<!-- {{ $t('activity.insertRow') }} -->
<!-- </div> -->
<!-- </a-menu-item> -->
</a-menu>
<!-- </NcMenuItem> -->
</NcMenu>
</template>
<div
@ -227,7 +241,7 @@ watch(
:body-style="{ padding: '16px !important' }"
:data-testid="`nc-gallery-card-${record.row.id}`"
@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>
<a-carousel
@ -308,7 +322,7 @@ watch(
<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-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" />
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="true" />
@ -349,7 +363,7 @@ watch(
</template>
</div>
</div>
</a-dropdown>
</NcDropdown>
<LazySmartsheetPagination
v-model:pagination-data="paginationData"
@ -454,7 +468,7 @@ watch(
.nc-card-col-header {
:deep(.nc-cell-icon),
:deep(.nc-virtual-cell-icon) {
@apply ml-0;
@apply ml-0 !w-3.5 !h-3.5;
}
}

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

File diff suppressed because it is too large Load Diff

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

@ -43,10 +43,10 @@ const isTab = computed(() => {
<LazySmartsheetToolbarCalendarRange v-if="isCalendar" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban || isMap" :show-system-fields="false" />
<LazySmartsheetToolbarStackedBy v-if="isKanban" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban || isMap" :show-system-fields="false" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap" />
<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 { UITypes, UITypesName, isLinksOrLTAR, isSelfReferencingTableColumn, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import Icon from '../header/Icon.vue'
import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
import MdiIdentifierIcon from '~icons/mdi/identifier'

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

@ -1,6 +1,7 @@
<script setup lang="ts">
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'
interface Option {
@ -9,20 +10,25 @@ interface Option {
id?: string
fk_colum_id?: string
order?: number
status?: 'remove'
status?: 'remove' | 'new'
index?: number
}
const props = defineProps<{
value: any
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 { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow()
const { isKanbanStack, optionId, isNewStack } = toRefs(props)
const { setAdditionalValidations, validateInfos, column } = useColumnCreateStoreOrThrow()
// const { base } = storeToRefs(useBase())
@ -61,7 +67,7 @@ const validators = {
validator: (_: any, _opt: any) => {
return new Promise<void>((resolve, reject) => {
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) {
return reject(new Error(t('msg.selectOption.cantBeNull')))
@ -87,43 +93,13 @@ setAdditionalValidations({
...validators,
} as any)
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
})
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
const kanbanStackOption = computed(() => {
if (isNewStack.value) {
return renderedOptions.value[renderedOptions.value.length - 1]
} else if (optionId.value) {
return renderedOptions.value.find((o) => o.id === optionId.value)
}
return null
})
const getNextColor = () => {
@ -142,15 +118,20 @@ const addNewOption = () => {
title: '',
color: getNextColor(),
index: options.value.length,
...(isKanbanStack.value ? { status: 'new' } : {}),
}
options.value.push(tempOption)
options.value.push(tempOption as Option)
isReverseLazyLoad.value = true
if (isKanbanStack.value) {
renderedOptions.value = options.value
} else {
isReverseLazyLoad.value = true
loadedOptionAnchor.value = options.value.length - OPTIONS_PAGE_COUNT
loadedOptionAnchor.value = Math.max(loadedOptionAnchor.value, 0)
loadedOptionAnchor.value = options.value.length - OPTIONS_PAGE_COUNT
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
@ -174,7 +155,7 @@ const addNewOption = () => {
// 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
vModel.value.colOptions = vModel.value.colOptions || {}
vModel.value.colOptions.options = options.value
@ -189,6 +170,10 @@ const syncOptions = () => {
const { status: _s, ...rest } = op
return rest
})
if (saveChanges) {
emit('saveChanges', submit, true, payload)
}
}
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) => {
if (o.id !== undefined && changedElement.id !== undefined) {
return o.id === changedElement.id
@ -238,7 +223,7 @@ const optionChanged = (changedElement: Option) => {
vModel.value.cdf = defaultOption.value.map((o) => o.title).join(',')
}
}
syncOptions()
syncOptions(saveChanges)
}
const undoRemoveRenderedOption = (index: number) => {
@ -344,112 +329,261 @@ const loadListData = async ($state: any) => {
$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>
<template>
<div class="w-full">
<div
ref="optionsWrapperDomRef"
class="nc-col-option-select-option overflow-x-auto scrollbar-thin-dull rounded-lg"
class="nc-col-option-select-option"
: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="{
maxHeight: props.fromTableExplorer ? 'calc(100vh - (var(--topbar-height) * 3.6) - 320px)' : 'calc(min(30vh, 250px))',
}"
>
<InfiniteLoading v-if="isReverseLazyLoad" v-bind="$attrs" @infinite="loadListDataReverse">
<template #spinner>
<div class="flex flex-row w-full justify-center mt-2">
<GeneralLoader />
</div>
</template>
<template #complete>
<span></span>
</template>
</InfiniteLoading>
<Draggable :list="renderedOptions" item-key="id" handle=".nc-child-draggable-icon" @change="syncOptions">
<template #item="{ element, index }">
<div class="flex py-1 items-center nc-select-option hover:bg-gray-100 group">
<div
class="flex items-center w-full"
:data-testid="`select-column-option-${index}`"
:class="{ removed: element.status === 'remove' }"
<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
v-if="!isKanban"
class="nc-child-draggable-icon p-2 flex cursor-pointer"
:data-testid="`select-option-column-handle-icon-${element.title}`"
>
<component :is="iconMap.dragVertical" small class="handle" />
<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>
<NcDropdown v-model:visible="colorMenus[index]" :auto-close="false">
<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 }">
<GeneralIcon icon="arrowDown" class="flex-none h-4 w-4 m-auto !text-gray-600" />
</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>
<template #overlay>
<div>
<LazyGeneralAdvanceColorPicker
v-model="element.color"
:is-open="colorMenus[index]"
@input="(el:string) => (element.color = el)"
></LazyGeneralAdvanceColorPicker>
</div>
</template>
</NcDropdown>
<a-input
v-model:value="element.title"
class="caption !rounded-lg nc-select-col-option-select-option !bg-transparent"
:data-testid="`select-column-option-input-${index}`"
:disabled="element.status === 'remove'"
@keydown.enter.prevent="element.title?.trim() && addNewOption()"
@change="optionChanged(element)"
/>
<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">
<template #spinner>
<div class="flex flex-row w-full justify-center mt-2">
<GeneralLoader />
</div>
</template>
<template #complete>
<span></span>
</template>
</InfiniteLoading>
<Draggable :list="renderedOptions" item-key="id" handle=".nc-child-draggable-icon" @change="syncOptions">
<template #item="{ element, index }">
<div class="flex py-1 items-center nc-select-option hover:bg-gray-100 group">
<div
class="flex items-center w-full"
:data-testid="`select-column-option-${index}`"
:class="{ removed: element.status === 'remove' }"
>
<div
v-if="!isKanban"
class="nc-child-draggable-icon p-2 flex cursor-pointer"
:data-testid="`select-option-column-handle-icon-${element.title}`"
>
<component :is="iconMap.dragVertical" small class="handle" />
</div>
<div
v-if="element.status !== 'remove'"
:data-testid="`select-column-option-remove-${index}`"
class="mx-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 invisible group-hover:visible"
@click="removeRenderedOption(index)"
>
<component :is="iconMap.close" class="-mt-0.25 w-4 h-4" />
</div>
<div
v-else
:data-testid="`select-column-option-remove-undo-${index}`"
class="mx-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 invisible group-hover:visible"
@click="undoRemoveRenderedOption(index)"
>
<MdiArrowULeftBottom
class="hover:!text-black-500 text-gray-500 cursor-pointer w-4 h-4"
<NcDropdown v-model:visible="colorMenus[index]" :auto-close="false">
<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,
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>
<template #overlay>
<div>
<LazyGeneralAdvanceColorPicker
v-model="element.color"
:is-open="colorMenus[index]"
@input="(el:string) => (element.color = el)"
></LazyGeneralAdvanceColorPicker>
</div>
</template>
</NcDropdown>
<a-input
v-model:value="element.title"
class="caption !rounded-lg nc-select-col-option-select-option !bg-transparent"
:data-testid="`select-column-option-input-${index}`"
:disabled="element.status === 'remove'"
@keydown.enter.prevent="element.title?.trim() && addNewOption()"
@change="optionChanged(element)"
/>
</div>
<div
v-if="element.status !== 'remove'"
:data-testid="`select-column-option-remove-${index}`"
class="mx-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 invisible group-hover:visible"
@click="removeRenderedOption(index)"
>
<component :is="iconMap.close" class="-mt-0.25 w-4 h-4" />
</div>
<div
v-else
:data-testid="`select-column-option-remove-undo-${index}`"
class="mx-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 invisible group-hover:visible"
@click="undoRemoveRenderedOption(index)"
/>
>
<MdiArrowULeftBottom
class="hover:!text-black-500 text-gray-500 cursor-pointer w-4 h-4"
@click="undoRemoveRenderedOption(index)"
/>
</div>
</div>
</div>
</template>
</Draggable>
<InfiniteLoading v-if="!isReverseLazyLoad" v-bind="$attrs" @infinite="loadListData">
<template #spinner>
<div class="flex flex-row w-full justify-center mt-2">
<GeneralLoader />
</div>
</template>
<template #complete>
<span></span>
</template>
</InfiniteLoading>
</template>
</Draggable>
<InfiniteLoading v-if="!isReverseLazyLoad" v-bind="$attrs" @infinite="loadListData">
<template #spinner>
<div class="flex flex-row w-full justify-center mt-2">
<GeneralLoader />
</div>
</template>
<template #complete>
<span></span>
</template>
</InfiniteLoading>
</template>
</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] }}
</div>
<NcButton
v-if="!isKanbanStack"
type="secondary"
class="w-full caption"
:class="{
@ -488,11 +622,11 @@ const loadListData = async ($state: any) => {
:deep(.nc-select-col-option-select-option) {
@apply !truncate;
&:not(:focus):hover {
&:not(.nc-kanban-stack-input):not(:focus):hover {
@apply !border-transparent;
}
&:not(:focus) {
&:not(.nc-kanban-stack-input):not(:focus) {
@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) {
isExpanded.value = v
if (isKanban.value) {
emits('cancel')
}
} else if (!v && isUIAllowed('dataEdit')) {
preventModalStatus.value = true
} 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 isKanban = inject(IsKanbanInj, ref(false))
const column = toRef(props, 'column')
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="{
'h-full': column,
'!text-gray-400': isKanban,
'flex-col !items-start justify-center pt-0.5': isExpandedForm && !isMobileMode && !isExpandedBulkUpdateForm,
'nc-cell-expanded-form-header cursor-pointer hover:bg-gray-100':
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
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
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
v-model:value="coverImageColumnId"
@ -607,7 +607,7 @@ useMenuCloseOnEsc(open)
</div>
<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">
{{ showAllColumns ? $t('general.hideAll') : $t('general.showAll') }} {{ $t('objects.fields') }}
{{ showAllColumns ? $t('general.hideAll') : $t('general.showAll') }} {{ $t('objects.fields').toLowerCase() }}
</NcButton>
<NcButton
v-if="!isPublic"

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { KanbanType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { ColumnType, KanbanType } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import type { SelectProps } from 'ant-design-vue'
provide(IsKanbanInj, ref(true))
@ -15,8 +15,7 @@ const isLocked = inject(IsLockedInj, ref(false))
const { fields, loadViewColumns, metaColumnById } = useViewColumnsOrThrow(activeView, meta)
const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField, groupingFieldColumn } =
useKanbanViewStoreOrThrow()
const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField } = useKanbanViewStoreOrThrow()
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']>(() => {
return fields.value
?.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 = () => {
open.value = false
}
const getIcon = (c: ColumnType) =>
h(isVirtualCol(c) ? resolveComponent('SmartsheetHeaderVirtualCellIcon') : resolveComponent('SmartsheetHeaderCellIcon'), {
columnMeta: c,
})
</script>
<template>
<a-dropdown
<NcDropdown
v-if="!IsPublic"
v-model:visible="open"
:trigger="['click']"
@ -98,54 +134,75 @@ const handleChange = () => {
<div class="nc-kanban-btn">
<NcButton
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"
type="secondary"
:disabled="isLocked"
>
<div class="flex items-center gap-1">
<GeneralIcon icon="layers" class="mr-0.5" />
<span class="text-capitalize !text-sm">
{{ $t('activity.kanban.stackedBy') }}
<span class="font-bold ml-0.25">{{ groupingField }}</span>
</span>
<div class="flex items-center gap-2">
<GeneralIcon icon="settings" class="h-4 w-4" />
<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') }}
</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>
</NcButton>
</div>
<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>Select a field to stack records by</div>
<div class="nc-fields-list py-2">
<div class="grouping-field">
<a-select
v-model:value="groupingFieldColumnId"
class="w-full nc-kanban-grouping-field-select"
:options="singleSelectFieldOptions"
placeholder="Select a Grouping Field"
@change="handleChange"
@click.stop
>
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template
></a-select>
<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 class="flex flex-col gap-2">
<div>
{{ $t('general.groupingField').toLowerCase().replace(/^./, $t('general.groupingField').charAt(0).toUpperCase()) }}
</div>
<div class="nc-fields-list">
<div class="grouping-field">
<a-select
v-model:value="groupingFieldColumnId"
class="nc-select-shadow w-full nc-kanban-grouping-field-select !rounded-lg"
dropdown-class-name="!rounded-lg"
placeholder="Select a Grouping Field"
@change="handleChange"
@click.stop
>
<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>
</div>
</div>
</div>
<div class="mt-4 border-1 px-4 pt-4 pb-3 border-gray-50 rounded-2xl">
<div class="text-base font-medium mb-2">Options</div>
<LazySmartsheetColumnEditOrAddProvider
v-if="open"
:column="groupingFieldColumn"
embed-mode
:column-label="$t('general.changes')"
hide-title
hide-type
hide-additional-options
@cancel="open = false"
@submit="onSubmit"
@click.stop
@keydown.stop
/>
<div class="flex items-center gap-1">
<NcSwitch v-model:checked="hideEmptyStack" size="small" class="nc-switch" :loading="isLoading === 'hideEmptyStack'">
<div class="text-sm text-gray-800">
{{ $t('general.hide') }}
{{ $t('general.empty').toLowerCase() }}
{{ $t('general.stack').toLowerCase() }}
</div>
</NcSwitch>
</div>
</div>
</template>
</a-dropdown>
</NcDropdown>
</template>

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

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

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

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

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

@ -13,6 +13,8 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
throw new Error('Table meta is not available')
}
const addNewStackId = 'addNewStack'
const { t } = useI18n()
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] = {}) {
if ((!base?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic.value) return
let where = `(${groupingField.value},eq,${stackTitle})`
@ -167,7 +187,10 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
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() {
@ -250,7 +273,8 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
isChanged = true
}
}
groupingFieldColOptions.value = stackMetaObj.value[fk_grp_col_id]
groupingFieldColOptions.value = [...stackMetaObj.value[fk_grp_col_id]]
if (isChanged) {
await updateKanbanStackMeta()
@ -260,10 +284,11 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
}
} else {
// build stack meta
groupingFieldColOptions.value = [
...((groupingFieldColumn.value?.colOptions as SelectOptionsType & { collapsed: boolean })?.options ?? []),
// 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((a, b) => a.order! - b.order!)
@ -485,11 +510,14 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
(o) => o.title !== stackTitle,
)
;(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!, {
...groupingFieldColumn.value,
colOptions: {
options: newOptions,
},
cdf: cdf === stackTitle ? null : cdf,
} as any)
// update kanban stack meta
@ -682,6 +710,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
shouldScrollToRight,
deleteRow,
moveHistory,
addNewStackId,
}
},
'kanban-view-store',

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

@ -210,7 +210,8 @@
"set": "Set",
"format": "Format",
"colour": "Colour",
"use": "Use"
"use": "Use",
"stack": "Stack"
},
"objects": {
"owner": "Owner",
@ -451,7 +452,8 @@
"selectFieldsFromRightPannelToAddHere": "Select fields from right panel to add here",
"noOptionsFound": "No options found",
"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": {
"selectView": "Select a View",
@ -778,7 +780,7 @@
"relationType": "Relation type",
"showThousandsSeparator": "Show thousands separator",
"signUpForFree": "Sign up for free",
"coverImageField": "Cover image field",
"coverImageField": "Cover image",
"fitImage": "Fit image",
"coverImageArea": "Cover image"
},
@ -978,7 +980,7 @@
"clearCell": "Clear cell",
"addFilterGroup": "Add filter group",
"linkRecord": "Link record",
"addNewRecord": "Add new record",
"addNewRecord": "Add record",
"newRecord": "New record",
"tableNameCreateNewRecord": "{tableName}: Create new record",
"gotSavedLinkedSuccessfully": "{tableName} '{recordTitle}' got saved & linked successfully",
@ -1005,9 +1007,12 @@
"showJunctionTableNames": "Show Junction Table Names"
},
"kanban": {
"collapseStack": "Collapse Stack",
"deleteStack": "Delete Stack",
"stackedBy": "Stacked By",
"collapseStack": "Collapse stack",
"collapseAll": "Collapse all",
"expandAll": "Expand all",
"renameStack": "Rename stack",
"deleteStack": "Delete stack",
"stackedBy": "Stacked by",
"chooseGroupingField": "Choose a Grouping Field",
"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 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
// todo: remove it after all icons are migrated
/* export const iconMapOld = {
@ -617,6 +623,11 @@ export const iconMap = {
audit: NcAudit,
messageCircle: NcMessageCircle,
ncKey: NcKey,
minimize: NcMinimize,
minimizeAll: NcMinimizeAll,
maximize: NcMaximize,
maximizeAll: NcMaximizeAll,
ncDrag: NcDrag,
}
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 {
if (!v) return undefined
export function stringifyProp(v: any): string {
if (!v) return '{}'
try {
return typeof v === 'string' ? v : JSON.stringify(v)
return typeof v === 'string' ? v : JSON.stringify(v) ?? '{}'
} catch {
return '{}'
}

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

@ -19,6 +19,7 @@ import {
GalleryView,
GridViewColumn,
KanbanView,
KanbanViewColumn,
View,
} from '~/models';
@ -57,12 +58,14 @@ const getAst = async (
let coverImageId;
let dependencyFieldsForCalenderView;
let kanbanGroupColumnId;
if (view && view.type === ViewTypes.GALLERY) {
const gallery = await GalleryView.get(context, view.id);
coverImageId = gallery.fk_cover_image_col_id;
} else if (view && view.type === ViewTypes.KANBAN) {
const kanban = await KanbanView.get(context, view.id);
coverImageId = kanban.fk_cover_image_col_id;
kanbanGroupColumnId = kanban.fk_grp_col_id;
} else if (view && view.type === ViewTypes.CALENDAR) {
// const calendar = await CalendarView.get(view.id);
// coverImageId = calendar.fk_cover_image_col_id;
@ -140,7 +143,11 @@ const getAst = async (
allowedCols = (await View.getColumns(context, view.id)).reduce(
(o, c) => ({
...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) {
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
show = true;
} 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 }) {
const { from, to } = param;
const [fromStack, toStack] = await Promise.all([
this.rootPage.locator(`.nc-kanban-stack-head`).nth(from),
this.rootPage.locator(`.nc-kanban-stack-head`).nth(to),
this.rootPage.locator(`.nc-kanban-stack-head >> .nc-kanban-stack-drag-handler`).nth(from),
this.rootPage.locator(`.nc-kanban-stack-head >> .nc-kanban-stack-drag-handler`).nth(to),
]);
await fromStack.dragTo(toStack);
}
@ -71,7 +71,8 @@ export class KanbanPage extends BasePage {
const stack = this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded();
// 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 });
}
}
@ -119,35 +120,90 @@ export class KanbanPage extends BasePage {
}
async addNewStack(param: { title: string }) {
await this.toolbar.clickAddEditStack();
await this.toolbar.addEditStack.addOption({ title: param.title });
const addNewStack = this.get().locator('.nc-kanban-add-new-stack');
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 }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.index).click();
const modal = this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Collapse Stack")').click();
async renameStack(param: { index: number; title: string }) {
const stackHead = this.get().locator(`.nc-kanban-stack-head`).nth(param.index);
await stackHead.scrollIntoViewIfNeeded();
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 }) {
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 }) {
await expect(this.rootPage.locator('.nc-kanban-collapsed-stack')).toHaveCount(param.count);
}
async addCard(param: { stackIndex: number }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.stackIndex).click();
const modal = this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Add new record")').click();
await this.stackContextMenu({ index: param.stackIndex, operation: 'add-new-record' });
}
async deleteStack(param: { index: number }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.index).click();
const modal = this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Delete Stack")').click();
await this.stackContextMenu({ index: param.index, operation: 'delete-stack' });
const confirmationModal = this.rootPage.locator(`div.ant-modal-content`);
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'],
['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({
stackIndex: i,
order: order[i - 1],
});
}
// // verify inter stack drag-drop
// await kanban.dragDropCard({

Loading…
Cancel
Save