Browse Source

Merge pull request #3563 from nocodb/feat/kanban-view

feat: kanban view reimplementation
pull/3975/head
աɨռɢӄաօռɢ 2 years ago committed by GitHub
parent
commit
1f00d1fe20
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      README.md
  2. 7
      packages/nc-gui/assets/css/global.css
  3. 6
      packages/nc-gui/assets/style.scss
  4. 2
      packages/nc-gui/components.d.ts
  5. 4
      packages/nc-gui/components/cell/Checkbox.vue
  6. 2
      packages/nc-gui/components/cell/Currency.vue
  7. 2
      packages/nc-gui/components/cell/DatePicker.vue
  8. 2
      packages/nc-gui/components/cell/DateTimePicker.vue
  9. 5
      packages/nc-gui/components/cell/Decimal.vue
  10. 10
      packages/nc-gui/components/cell/Duration.vue
  11. 2
      packages/nc-gui/components/cell/Email.vue
  12. 5
      packages/nc-gui/components/cell/Float.vue
  13. 5
      packages/nc-gui/components/cell/Integer.vue
  14. 10
      packages/nc-gui/components/cell/MultiSelect.vue
  15. 9
      packages/nc-gui/components/cell/Percent.vue
  16. 8
      packages/nc-gui/components/cell/Rating.vue
  17. 9
      packages/nc-gui/components/cell/SingleSelect.vue
  18. 1
      packages/nc-gui/components/cell/Text.vue
  19. 3
      packages/nc-gui/components/cell/TextArea.vue
  20. 2
      packages/nc-gui/components/cell/TimePicker.vue
  21. 8
      packages/nc-gui/components/cell/Url.vue
  22. 2
      packages/nc-gui/components/cell/YearPicker.vue
  23. 5
      packages/nc-gui/components/cell/attachment/index.vue
  24. 59
      packages/nc-gui/components/dlg/ViewCreate.vue
  25. 7
      packages/nc-gui/components/general/TruncateText.vue
  26. 42
      packages/nc-gui/components/shared-view/Kanban.vue
  27. 43
      packages/nc-gui/components/smartsheet/Gallery.vue
  28. 44
      packages/nc-gui/components/smartsheet/Grid.vue
  29. 588
      packages/nc-gui/components/smartsheet/Kanban.vue
  30. 19
      packages/nc-gui/components/smartsheet/Toolbar.vue
  31. 17
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  32. 12
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  33. 17
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  34. 12
      packages/nc-gui/components/smartsheet/header/Cell.vue
  35. 2
      packages/nc-gui/components/smartsheet/header/CellIcon.vue
  36. 2
      packages/nc-gui/components/smartsheet/header/VirtualCellIcon.vue
  37. 22
      packages/nc-gui/components/smartsheet/sidebar/MenuBottom.vue
  38. 2
      packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue
  39. 13
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  40. 17
      packages/nc-gui/components/smartsheet/sidebar/index.vue
  41. 2
      packages/nc-gui/components/smartsheet/toolbar/Export.vue
  42. 2
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  43. 54
      packages/nc-gui/components/smartsheet/toolbar/KanbanStackEditOrAdd.vue
  44. 2
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  45. 123
      packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue
  46. 9
      packages/nc-gui/components/tabs/Smartsheet.vue
  47. 2
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  48. 4
      packages/nc-gui/components/virtual-cell/HasMany.vue
  49. 2
      packages/nc-gui/components/virtual-cell/Lookup.vue
  50. 8
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  51. 15
      packages/nc-gui/composables/useExpandedFormStore.ts
  52. 555
      packages/nc-gui/composables/useKanbanViewStore.ts
  53. 40
      packages/nc-gui/composables/useSharedView.ts
  54. 16
      packages/nc-gui/composables/useSmartsheetStore.ts
  55. 2
      packages/nc-gui/composables/useViewData.ts
  56. 2
      packages/nc-gui/context/index.ts
  57. 16
      packages/nc-gui/lang/en.json
  58. 35
      packages/nc-gui/pages/[projectType]/kanban/[viewId]/index.vue
  59. 1
      packages/nc-gui/utils/viewUtils.ts
  60. 10
      packages/noco-docs/content/en/FAQs.md
  61. 3
      packages/noco-docs/content/en/developer-resources/rest-apis.md
  62. 8
      packages/noco-docs/content/en/setup-and-usages/views.md
  63. 2
      packages/nocodb-sdk/package-lock.json
  64. 144
      packages/nocodb-sdk/src/lib/Api.ts
  65. 100
      packages/nocodb/package-lock.json
  66. 2
      packages/nocodb/package.json
  67. 4
      packages/nocodb/src/lib/db/sql-data-mapper/lib/BaseModel.ts
  68. 259
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  69. 25
      packages/nocodb/src/lib/meta/api/columnApis.ts
  70. 76
      packages/nocodb/src/lib/meta/api/dataApis/dataAliasApis.ts
  71. 2
      packages/nocodb/src/lib/meta/api/index.ts
  72. 46
      packages/nocodb/src/lib/meta/api/kanbanViewApis.ts
  73. 91
      packages/nocodb/src/lib/meta/api/publicApis/publicDataApis.ts
  74. 4
      packages/nocodb/src/lib/meta/api/publicApis/publicDataExportApis.ts
  75. 17
      packages/nocodb/src/lib/meta/api/utilApis.ts
  76. 4
      packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts
  77. 40
      packages/nocodb/src/lib/migrations/v2/nc_020_add_kanban_meta_col.ts
  78. 112
      packages/nocodb/src/lib/models/KanbanView.ts
  79. 108
      packages/nocodb/src/lib/models/KanbanViewColumn.ts
  80. 5
      packages/nocodb/src/lib/models/Model.ts
  81. 133
      packages/nocodb/src/lib/models/View.ts
  82. 8
      packages/nocodb/src/lib/utils/projectAcl.ts
  83. 12
      packages/nocodb/tests/unit/TestDbMngr.ts
  84. 21
      packages/nocodb/tests/unit/rest/tests/table.test.ts
  85. 668
      packages/nocodb/tests/unit/rest/tests/tableRow.test.ts
  86. 581
      packages/nocodb/tests/unit/rest/tests/viewRow.test.ts
  87. 564
      scripts/cypress/integration/common/4h_kanban.js
  88. 2
      scripts/cypress/integration/test/pg-restViews.js
  89. 2
      scripts/cypress/integration/test/restViews.js
  90. 2
      scripts/cypress/integration/test/xcdb-restViews.js
  91. 80
      scripts/cypress/support/commands.js
  92. 47
      scripts/cypress/support/page_objects/mainPage.js
  93. 362
      scripts/sdk/swagger.json

2
README.md

@ -221,7 +221,7 @@ Access Dashboard using : [http://localhost:8080/dashboard](http://localhost:8080
![6](https://user-images.githubusercontent.com/5435402/133759242-2311a127-17c8-406c-b865-1a2e9c8ee398.png)
<br>
![5](https://user-images.githubusercontent.com/35857179/151526876-f6a0e472-9bbc-45ba-a771-9118e03bc748.png)
![5](https://user-images.githubusercontent.com/35857179/192694648-736866af-c1cb-4158-9f3b-6cc089f00283.png)
<br>
![6](https://user-images.githubusercontent.com/35857179/151526883-4c670f8b-7c5c-421f-9e95-54d3a84a72ba.png)

7
packages/nc-gui/assets/css/global.css

@ -22,4 +22,11 @@ Apply Vazirmatn for rtl
.rtl .v-application .ml-n1 {
margin-left: 0px !important;
}
/*
For Drag and Drop
*/
.grabbing * {
cursor: grabbing;
}

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

@ -91,6 +91,12 @@ a {
@apply bg-primary bg-opacity-20 hover:(bg-primary bg-opacity-20);
}
// for highlighing toolbar kanban menu item
.nc-kanban-btn > .ant-btn {
@apply bg-green-400 bg-opacity-20 hover:(bg-primary bg-opacity-20);
}
.nc-locked-overlay {
@apply absolute h-full w-full z-2 top-0 left-0;
}

2
packages/nc-gui/components.d.ts vendored

@ -117,7 +117,9 @@ declare module '@vue/runtime-core' {
MdiAlpha: typeof import('~icons/mdi/alpha')['default']
MdiAlphaA: typeof import('~icons/mdi/alpha-a')['default']
MdiApi: typeof import('~icons/mdi/api')['default']
MdiArrowCollapse: typeof import('~icons/mdi/arrow-collapse')['default']
MdiArrowDownDropCircle: typeof import('~icons/mdi/arrow-down-drop-circle')['default']
MdiArrowDownDropCircleOutline: typeof import('~icons/mdi/arrow-down-drop-circle-outline')['default']
MdiArrowExpand: typeof import('~icons/mdi/arrow-expand')['default']
MdiArrowLeftBold: typeof import('~icons/mdi/arrow-left-bold')['default']
MdiAt: typeof import('~icons/mdi/at')['default']

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

@ -48,14 +48,14 @@ function onClick() {
<div
class="flex"
:class="{
'justify-center': !isForm,
'justify-center': !isForm && !readOnly,
'w-full': isForm,
'nc-cell-hover-show': !vModel && !readOnly,
'opacity-0': readOnly && !vModel,
}"
@click="onClick"
>
<div class="px-1 pt-1 rounded-full items-center" :class="{ 'bg-gray-100': !vModel }">
<div class="px-1 pt-1 rounded-full items-center" :class="{ 'bg-gray-100': !vModel, '!ml-[-8px]': readOnly }">
<component
:is="getMdiIcon(vModel ? checkboxMeta.icon.checked : checkboxMeta.icon.unchecked)"
:style="{

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

@ -45,7 +45,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="w-full h-full border-none outline-none"
class="w-full h-full border-none outline-none px-2"
@blur="editEnabled = false"
/>

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

@ -62,7 +62,7 @@ const placeholder = computed(() => (isDateInvalid ? 'Invalid date' : ''))
<a-date-picker
v-model:value="localState"
:bordered="false"
class="!w-full px-1"
class="!w-full !px-0 !border-none"
:format="dateFormat"
:placeholder="placeholder"
:allow-clear="!readOnly"

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

@ -62,7 +62,7 @@ watch(
v-model:value="localState"
:show-time="true"
:bordered="false"
class="!w-full px-1"
class="!w-full !px-0 !border-none"
format="YYYY-MM-DD HH:mm"
:placeholder="isDateInvalid ? 'Invalid date' : ''"
:allow-clear="!readOnly"

5
packages/nc-gui/components/cell/Decimal.vue

@ -26,13 +26,12 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none p-0 border-none w-full h-full prose-sm"
class="outline-none px-2 border-none w-full h-full text-sm"
type="number"
step="0.1"
@blur="editEnabled = false"
/>
<span v-else class="prose-sm">{{ vModel }}</span>
<span v-else class="text-sm">{{ vModel }}</span>
</template>
<style scoped lang="scss">

10
packages/nc-gui/components/cell/Duration.vue

@ -75,6 +75,8 @@ const submitDuration = () => {
v-if="editEnabled"
ref="durationInput"
v-model="localState"
class="w-full !border-none p-0"
:class="{ '!p-2': editEnabled }"
:placeholder="durationPlaceholder"
@blur="submitDuration"
@keypress="checkDurationFormat($event)"
@ -91,14 +93,8 @@ const submitDuration = () => {
</template>
<style scoped>
.duration-cell-wrapper {
padding: 10px;
}
.duration-warning {
text-align: left;
margin-top: 10px;
color: #e65100;
@apply text-left mt-[10px] text-[#e65100];
}
</style>

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

@ -24,7 +24,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</script>
<template>
<input v-if="editEnabled" :ref="focus" v-model="vModel" class="outline-none text-sm" @blur="editEnabled = false" />
<input v-if="editEnabled" :ref="focus" v-model="vModel" class="outline-none text-sm px-2" @blur="editEnabled = false" />
<a v-else-if="validEmail" class="text-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank">
{{ vModel }}

5
packages/nc-gui/components/cell/Float.vue

@ -26,13 +26,12 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none p-0 border-none w-full h-full prose-sm"
class="outline-none p-0 border-none w-full h-full text-sm"
type="number"
step="0.1"
@blur="editEnabled = false"
/>
<span v-else class="prose-sm">{{ vModel }}</span>
<span v-else class="text-sm">{{ vModel }}</span>
</template>
<style scoped lang="scss">

5
packages/nc-gui/components/cell/Integer.vue

@ -30,13 +30,12 @@ function onKeyDown(evt: KeyboardEvent) {
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none p-0 border-none w-full h-full prose-sm"
class="outline-none p-0 border-none w-full h-full text-sm"
type="number"
@blur="editEnabled = false"
@keydown="onKeyDown"
/>
<span v-else class="prose-sm">{{ vModel }}</span>
<span v-else class="text-sm">{{ vModel }}</span>
</template>
<style scoped lang="scss">

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

@ -4,6 +4,7 @@ import type { SelectOptionsType } from 'nocodb-sdk'
import {
ActiveCellInj,
ColumnInj,
IsKanbanInj,
ReadonlyInj,
computed,
h,
@ -38,6 +39,8 @@ const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const options = computed<SelectOptionsType[]>(() => {
if (column?.value.colOptions) {
const opts = column.value.colOptions
@ -134,13 +137,14 @@ watch(isOpen, (n, _o) => {
:show-search="false"
:open="isOpen"
:disabled="readOnly"
:class="{ '!ml-[-8px]': readOnly }"
dropdown-class-name="nc-dropdown-multi-select-cell"
@keydown="handleKeys"
@click="isOpen = !isOpen"
>
<a-select-option v-for="op of options" :key="op.id" :value="op.title" @click.stop>
<a-tag class="rounded-tag" :color="op.color">
<span class="text-slate-500">{{ op.title }}</span>
<span class="text-slate-500" :class="{ 'text-sm': isKanban }">{{ op.title }}</span>
</a-tag>
</a-select-option>
@ -154,7 +158,7 @@ watch(isOpen, (n, _o) => {
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@close="onClose"
>
<span class="w-full text-slate-500">{{ val }}</span>
<span class="w-full text-slate-500" :class="{ 'text-sm': isKanban }">{{ val }}</span>
</a-tag>
</template>
</a-select>
@ -188,7 +192,7 @@ watch(isOpen, (n, _o) => {
border-radius: 12px;
}
:deep(.ant-tag) {
@apply "rounded-tag";
@apply "rounded-tag" my-[2px];
}
:deep(.ant-tag-close-icon) {
@apply "text-slate-500";

9
packages/nc-gui/components/cell/Percent.vue

@ -15,7 +15,12 @@ const vModel = useVModel(props, 'modelValue', emits)
</script>
<template>
<input v-if="editEnabled" v-model="vModel" type="number" />
<input
v-if="editEnabled"
v-model="vModel"
class="w-full !border-none text-base"
:class="{ '!p-2': editEnabled }"
type="number"
/>
<span v-else>{{ vModel }}</span>
</template>

8
packages/nc-gui/components/cell/Rating.vue

@ -32,7 +32,13 @@ const vModel = computed({
</script>
<template>
<a-rate v-model:value="vModel" :count="ratingMeta.max" :style="`color: ${ratingMeta.color}`" :disabled="!editEnabled">
<a-rate
v-model:value="vModel"
:count="ratingMeta.max"
:style="`color: ${ratingMeta.color}; padding: 0px 5px`"
:class="{ '!ml-[-8px]': !editEnabled }"
:disabled="!editEnabled"
>
<template #character>
<MdiStar v-if="ratingMeta.icon.full === 'mdi-star'" class="text-sm" />
<MdiHeart v-if="ratingMeta.icon.full === 'mdi-heart'" class="text-sm" />

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

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionsType } from 'nocodb-sdk'
import { ActiveCellInj, ColumnInj, ReadonlyInj, computed, inject, ref, useEventListener, watch } from '#imports'
import { ActiveCellInj, ColumnInj, IsKanbanInj, ReadonlyInj, computed, inject, ref, useEventListener, watch } from '#imports'
interface Props {
modelValue?: string | undefined
@ -21,6 +21,8 @@ const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const vModel = computed({
get: () => modelValue,
set: (val) => emit('update:modelValue', val || null),
@ -80,7 +82,7 @@ watch(isOpen, (n, _o) => {
>
<a-select-option v-for="op of options" :key="op.title" :value="op.title" @click.stop>
<a-tag class="rounded-tag" :color="op.color">
<span class="text-slate-500">{{ op.title }}</span>
<span class="text-slate-500" :class="{ 'text-sm': isKanban }">{{ op.title }}</span>
</a-tag>
</a-select-option>
</a-select>
@ -88,8 +90,7 @@ watch(isOpen, (n, _o) => {
<style scoped lang="scss">
.rounded-tag {
padding: 0px 12px;
border-radius: 12px;
@apply py-0 px-[12px] rounded-[12px];
}
:deep(.ant-tag) {
@apply "rounded-tag";

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

@ -23,6 +23,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
:ref="focus"
v-model="vModel"
class="h-full w-full outline-none bg-transparent"
:class="{ '!p-2': editEnabled }"
@blur="editEnabled = false"
/>

3
packages/nc-gui/components/cell/TextArea.vue

@ -21,7 +21,8 @@ const focus: VNodeRef = (el) => (el as HTMLTextAreaElement)?.focus()
:ref="focus"
v-model="vModel"
rows="4"
class="h-full w-full min-h-[60px] outline-none"
class="h-full w-full min-h-[60px] outline-none border-none"
:class="{ 'p-2': editEnabled }"
@blur="editEnabled = false"
@keydown.alt.enter.stop
@keydown.shift.enter.stop

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

@ -74,7 +74,7 @@ watch(
:bordered="false"
use12-hours
format="HH:mm"
class="!w-full px-1"
class="!w-full !px-0 !border-none"
:placeholder="isTimeInvalid ? 'Invalid time' : ''"
:allow-clear="!readOnly"
:input-read-only="true"

8
packages/nc-gui/components/cell/Url.vue

@ -73,7 +73,13 @@ watch(
<template>
<div class="flex flex-row items-center justify-between w-full h-full">
<input v-if="editEnabled" :ref="focus" v-model="vModel" class="outline-none text-sm w-full" @blur="editEnabled = false" />
<input
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none text-sm w-full px-2"
@blur="editEnabled = false"
/>
<nuxt-link
v-else-if="isValid && !cellUrlOptions?.overlay"

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

@ -61,7 +61,7 @@ const placeholder = computed(() => (isYearInvalid ? 'Invalid year' : ''))
v-model:value="localState"
picker="year"
:bordered="false"
class="!w-full px-1"
class="!w-full !px-0 !border-none"
:placeholder="placeholder"
:allow-clear="!readOnly"
:input-read-only="true"

5
packages/nc-gui/components/cell/attachment/index.vue

@ -5,6 +5,7 @@ import { useSortable } from './sort'
import {
DropZoneRef,
IsGalleryInj,
IsKanbanInj,
inject,
isImage,
nextTick,
@ -31,6 +32,8 @@ const emits = defineEmits<Emits>()
const isGallery = inject(IsGalleryInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const dropZoneInjection = inject(DropZoneRef, ref())
const attachmentCellRef = ref<HTMLDivElement>()
@ -62,7 +65,7 @@ watch(
() => {
if (dropZoneInjection?.value) return
if (!rowIndex && (isForm.value || isGallery.value)) {
if (!rowIndex && (isForm.value || isGallery.value || isKanban.value)) {
currentCellRef.value = attachmentCellRef.value
} else {
nextTick(() => {

59
packages/nc-gui/components/dlg/ViewCreate.vue

@ -1,9 +1,9 @@
<script setup lang="ts">
import type { ComponentPublicInstance } from '@vue/runtime-core'
import type { Form as AntForm } from 'ant-design-vue'
import type { Form as AntForm, SelectProps } from 'ant-design-vue'
import { capitalize } from '@vue/runtime-core'
import type { FormType, GalleryType, GridType, KanbanType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
import { UITypes, ViewTypes } from 'nocodb-sdk'
import {
MetaInj,
ViewListInj,
@ -25,6 +25,7 @@ interface Props {
type: ViewTypes
title?: string
selectedViewId?: string
groupingFieldColumnId?: string
}
interface Emits {
@ -36,6 +37,8 @@ interface Form {
title: string
type: ViewTypes
copy_from_id: string | null
// for kanban view only
grp_column_id: string | null
}
const props = defineProps<Props>()
@ -60,9 +63,12 @@ const form = reactive<Form>({
title: props.title || '',
type: props.type,
copy_from_id: null,
grp_column_id: null,
})
const formRules = [
const singleSelectFieldOptions = ref<SelectProps['options']>([])
const viewNameRules = [
// name is required
{ required: true, message: `${t('labels.viewName')} ${t('general.required')}` },
// name is unique
@ -77,6 +83,11 @@ const formRules = [
},
]
const groupingFieldColumnRules = [
// name is required
{ required: true, message: `${t('general.groupingField')} ${t('general.required')}` },
]
const typeAlias = computed(
() =>
({
@ -91,7 +102,9 @@ watch(vModel, (value) => value && init())
watch(
() => props.type,
(newType) => (form.type = newType),
(newType) => {
form.type = newType
},
)
function init() {
@ -101,6 +114,25 @@ function init() {
form.copy_from_id = props.selectedViewId
}
// preset the grouping field column
if (props.type === ViewTypes.KANBAN) {
singleSelectFieldOptions.value = meta
.value!.columns!.filter((el) => el.uidt === UITypes.SingleSelect)
.map((field) => {
return {
value: field.id,
label: field.title,
}
})
if (props.groupingFieldColumnId) {
// take from the one from copy view
form.grp_column_id = props.groupingFieldColumnId
} else {
// take the first option
form.grp_column_id = singleSelectFieldOptions.value?.[0]?.value as string
}
}
nextTick(() => {
const el = inputEl?.$el as HTMLInputElement
@ -132,6 +164,8 @@ async function onSubmit() {
case ViewTypes.FORM:
data = await api.dbView.formCreate(_meta.id, form)
break
case ViewTypes.KANBAN:
data = await api.dbView.kanbanCreate(_meta.id, form)
}
if (data) {
@ -156,9 +190,24 @@ async function onSubmit() {
</template>
<a-form ref="formValidator" layout="vertical" :model="form">
<a-form-item :label="$t('labels.viewName')" name="title" :rules="formRules">
<a-form-item :label="$t('labels.viewName')" name="title" :rules="viewNameRules">
<a-input ref="inputEl" v-model:value="form.title" autofocus @keydown.enter="onSubmit" />
</a-form-item>
<a-form-item
v-if="form.type === ViewTypes.KANBAN"
:label="$t('general.groupingField')"
name="grp_column_id"
:rules="groupingFieldColumnRules"
>
<a-select
v-model:value="form.grp_column_id"
class="w-full nc-kanban-grouping-field-select"
:options="singleSelectFieldOptions"
:disabled="props.groupingFieldColumnId"
placeholder="Select a Grouping Field"
not-found-content="No Single Select Field can be found. Please create one first."
/>
</a-form-item>
</a-form>
<template #footer>

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

@ -36,14 +36,11 @@ const shortName = computed(() =>
<template #title>
<slot />
</template>
<div>{{ shortName }}</div>
<div class="w-full">{{ shortName }}</div>
</a-tooltip>
<div v-else>
<div v-else class="w-full">
<slot />
</div>
<div ref="text" class="hidden">
<slot />
</div>

42
packages/nc-gui/components/shared-view/Kanban.vue

@ -0,0 +1,42 @@
<script setup lang="ts">
import {
ActiveViewInj,
FieldsInj,
IsPublicInj,
MetaInj,
ReadonlyInj,
ReloadViewDataHookInj,
useProvideKanbanViewStore,
} from '#imports'
const { sharedView, meta, sorts, nestedFilters } = useSharedView()
const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, true)
provide(MetaInj, meta)
provide(ActiveViewInj, sharedView)
provide(FieldsInj, ref(meta.value?.columns || []))
provide(IsPublicInj, ref(true))
useProvideSmartsheetStore(sharedView, meta, true, sorts, nestedFilters)
useProvideKanbanViewStore(meta, sharedView, true)
</script>
<template>
<div class="nc-container h-full mt-1.5 px-12">
<div class="flex flex-col h-full flex-1 min-w-0">
<LazySmartsheetToolbar />
<div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50">
<LazySmartsheetKanban />
</div>
</div>
</div>
</template>

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

@ -12,6 +12,7 @@ import {
PaginationDataInj,
ReadonlyInj,
ReloadRowDataHookInj,
ReloadViewDataHookInj,
ReloadViewMetaHookInj,
computed,
createEventHook,
@ -91,8 +92,6 @@ const attachments = (record: any): Attachment[] => {
}
const expandForm = (row: RowType, state?: Record<string, any>) => {
if (!isUIAllowed('xcDatatableEditable')) return
const rowId = extractPkFromRow(row.row, meta.value!.columns!)
if (rowId) {
@ -252,24 +251,28 @@ watch(view, async (nextView) => {
<LazySmartsheetPagination />
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:row="expandedFormRow"
:state="expandedFormRowState"
:meta="meta"
:view="view"
/>
<LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
:key="route.query.rowId"
v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta"
:row-id="route.query.rowId"
:view="view"
/>
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:row="expandedFormRow"
:state="expandedFormRowState"
:meta="meta"
:view="view"
/>
</Suspense>
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
:key="route.query.rowId"
v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta"
:row-id="route.query.rowId"
:view="view"
/>
</Suspense>
</div>
</template>

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

@ -139,7 +139,7 @@ reloadViewDataHook?.on(async (shouldShowLoading) => {
const skipRowRemovalOnCancel = ref(false)
const expandForm = (row: Row, state?: Record<string, any>, fromToolbar = false) => {
const rowId = extractPkFromRow(row.row, meta.value!.columns!)
const rowId = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
if (rowId) {
router.push({
@ -572,25 +572,29 @@ watch([() => selected.row, () => selected.col], ([row, col]) => {
<LazySmartsheetPagination />
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:row="expandedFormRow"
:state="expandedFormRowState"
:meta="meta"
:view="view"
@update:model-value="!skipRowRemovalOnCancel && removeRowIfNew(expandedFormRow)"
/>
<LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
:key="route.query.rowId"
v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta"
:row-id="route.query.rowId"
:view="view"
/>
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:row="expandedFormRow"
:state="expandedFormRowState"
:meta="meta"
:view="view"
@update:model-value="!skipRowRemovalOnCancel && removeRowIfNew(expandedFormRow)"
/>
</Suspense>
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
:key="route.query.rowId"
v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta"
:row-id="route.query.rowId"
:view="view"
/>
</Suspense>
</div>
</template>

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

@ -0,0 +1,588 @@
<script lang="ts" setup>
import Draggable from 'vuedraggable'
import { UITypes, ViewTypes, isVirtualCol } from 'nocodb-sdk'
import {
ActiveViewInj,
FieldsInj,
IsFormInj,
IsGalleryInj,
IsGridInj,
IsKanbanInj,
IsLockedInj,
IsPublicInj,
MetaInj,
OpenNewRecordFormHookInj,
ReadonlyInj,
inject,
onBeforeMount,
onBeforeUnmount,
provide,
useKanbanViewStoreOrThrow,
} from '#imports'
import type { Row as RowType } from '~/lib'
const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref())
const reloadViewDataHook = inject(ReloadViewDataHookInj)
const reloadViewMetaHook = inject(ReloadViewMetaHookInj)
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj, createEventHook())
const isLocked = inject(IsLockedInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const expandedFormDlg = ref(false)
const expandedFormRow = ref<RowType>()
const expandedFormRowState = ref<Record<string, any>>()
const deleteStackVModel = ref(false)
const stackToBeDeleted = ref('')
const stackIdxToBeDeleted = ref(0)
const route = useRoute()
const router = useRouter()
const {
loadKanbanData,
loadMoreKanbanData,
loadKanbanMeta,
kanbanMetaData,
formattedData,
updateOrSaveRow,
updateKanbanMeta,
addEmptyRow,
groupingFieldColOptions,
updateKanbanStackMeta,
groupingField,
countByStack,
deleteStack,
shouldScrollToRight,
deleteRow,
} = useKanbanViewStoreOrThrow()
const { isUIAllowed } = useUIPermission()
const { appInfo } = $(useGlobal())
provide(IsFormInj, ref(false))
provide(IsGalleryInj, ref(false))
provide(IsGridInj, ref(false))
provide(IsKanbanInj, ref(true))
provide(ReadonlyInj, !isUIAllowed('xcDatatableEditable'))
const hasEditPermission = $computed(() => isUIAllowed('xcDatatableEditable'))
const fields = inject(FieldsInj, ref([]))
const kanbanContainerRef = ref()
const selectedStackTitle = ref('')
const isRowEmpty = (record: any, col: any) => {
const val = record.row[col.title]
if (!val) return true
return Array.isArray(val) && val.length === 0
}
reloadViewDataHook?.on(async () => {
await loadKanbanMeta()
await loadKanbanData()
})
reloadViewMetaHook?.on(async () => {
await loadKanbanMeta()
})
const expandForm = (row: RowType, state?: Record<string, any>) => {
const rowId = extractPkFromRow(row.row, meta.value!.columns!)
if (rowId) {
router.push({
query: {
...route.query,
rowId,
},
})
} else {
expandedFormRow.value = row
expandedFormRowState.value = state
expandedFormDlg.value = true
}
}
const _contextMenu = ref(false)
const contextMenu = computed({
get: () => _contextMenu.value,
set: (val) => {
if (hasEditPermission) {
_contextMenu.value = val
}
},
})
const contextMenuTarget = ref<RowType | null>(null)
const showContextMenu = (e: MouseEvent, target?: RowType) => {
e.preventDefault()
if (target) {
contextMenuTarget.value = target
}
}
const expandedFormOnRowIdDlg = computed({
get() {
return !!route.query.rowId
},
set(val) {
if (!val)
router.push({
query: {
...route.query,
rowId: undefined,
},
})
},
})
const expandFormClick = async (e: MouseEvent, row: RowType) => {
if (e.target as HTMLElement) {
expandForm(row)
}
}
/** Block dragging the stack to first index (reserved for uncategorized) **/
function onMoveCallback(event: { draggedContext: { futureIndex: number } }) {
if (event.draggedContext.futureIndex === 0) {
return false
}
}
async function onMoveStack(event: any) {
if (event.moved) {
const { oldIndex, newIndex } = event.moved
const { grp_column_id, meta: stack_meta } = kanbanMetaData.value
groupingFieldColOptions.value[oldIndex].order = newIndex
groupingFieldColOptions.value[newIndex].order = oldIndex
const stackMetaObj = JSON.parse(stack_meta as string) || {}
stackMetaObj[grp_column_id as string] = groupingFieldColOptions.value
await updateKanbanMeta({
meta: stackMetaObj,
})
}
}
async function onMove(event: any, stackKey: string) {
if (event.added) {
const ele = event.added.element
ele.row[groupingField.value] = stackKey
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! + 1)
await updateOrSaveRow(ele)
} else if (event.removed) {
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! - 1)
}
}
const kanbanListScrollHandler = async (e: any) => {
if (e.target.scrollTop + e.target.clientHeight >= e.target.scrollHeight) {
const stackTitle = e.target.getAttribute('data-stack-title')
const pageSize = appInfo.defaultLimit || 25
const page = Math.ceil(formattedData.value.get(stackTitle)!.length / pageSize)
await loadMoreKanbanData(stackTitle, { offset: page * pageSize })
}
}
const kanbanListRef = (kanbanListElement: HTMLElement) => {
if (kanbanListElement) {
kanbanListElement.removeEventListener('scroll', kanbanListScrollHandler)
kanbanListElement.addEventListener('scroll', kanbanListScrollHandler)
}
}
const handleDeleteStackClick = (stackTitle: string, stackIdx: number) => {
deleteStackVModel.value = true
stackToBeDeleted.value = stackTitle
stackIdxToBeDeleted.value = stackIdx
}
const handleDeleteStackConfirmClick = async () => {
await deleteStack(stackToBeDeleted.value, stackIdxToBeDeleted.value)
deleteStackVModel.value = false
}
const handleCollapseStack = async (stackIdx: number) => {
groupingFieldColOptions.value[stackIdx].collapsed = !groupingFieldColOptions.value[stackIdx].collapsed
if (!isPublic.value) {
await updateKanbanStackMeta()
}
}
const openNewRecordFormHookHandler = async () => {
const newRow = await addEmptyRow()
// preset the grouping field value
newRow.row = {
[groupingField.value]: selectedStackTitle.value,
}
// increase total count by 1
countByStack.value.set(null, countByStack.value.get(null)! + 1)
// open the expanded form
expandForm(newRow)
}
openNewRecordFormHook?.on(openNewRecordFormHookHandler)
onBeforeMount(async () => {
await loadKanbanMeta()
await loadKanbanData()
})
// remove openNewRecordFormHookHandler before unmounting
// so that it won't be triggered multiple times
onBeforeUnmount(() => openNewRecordFormHook.off(openNewRecordFormHookHandler))
// reset context menu target on hide
watch(contextMenu, () => {
if (!contextMenu.value) {
contextMenuTarget.value = null
}
})
watch(view, async (nextView) => {
if (nextView?.type === ViewTypes.KANBAN) {
// load kanban meta
await loadKanbanMeta()
// load kanban data
await loadKanbanData()
// horizontally scroll to the end of the kanban container
// when a new option is added within kanban view
if (shouldScrollToRight.value) {
kanbanContainerRef.value.scrollTo({
left: kanbanContainerRef.value.scrollWidth,
behavior: 'smooth',
})
// reset shouldScrollToRight
shouldScrollToRight.value = false
}
}
})
</script>
<template>
<div class="flex h-full bg-white px-2">
<div ref="kanbanContainerRef" class="nc-kanban-container flex my-4 px-3 overflow-x-scroll overflow-y-hidden">
<a-dropdown v-model:visible="contextMenu" :trigger="['contextmenu']" overlay-class-name="nc-dropdown-kanban-context-menu">
<!-- Draggable Stack -->
<Draggable
v-model="groupingFieldColOptions"
class="flex gap-4"
item-key="id"
group="kanban-stack"
draggable=".nc-kanban-stack"
filter=".not-draggable"
:move="onMoveCallback"
@start="(e) => e.target.classList.add('grabbing')"
@end="(e) => e.target.classList.remove('grabbing')"
@change="onMoveStack($event)"
>
<template #item="{ element: stack, index: stackIdx }">
<div class="nc-kanban-stack" :class="{ 'w-[50px]': stack.collapsed }">
<!-- Non Collapsed Stacks -->
<a-card
v-if="!stack.collapsed"
:key="stack.id"
class="mx-4 !bg-[#f0f2f5] flex flex-col w-[280px] h-full rounded-[12px]"
:class="{
'not-draggable': stack.title === null || isLocked || isPublic || !hasEditPermission,
'!cursor-default': isLocked || !hasEditPermission,
}"
:head-style="{ paddingBottom: '0px' }"
:body-style="{ padding: '0px', height: '100%' }"
>
<!-- Header Color Bar -->
<div :style="`background-color: ${stack.color}`" class="nc-kanban-stack-head-color h-[10px]"></div>
<!-- Skeleton -->
<a-skeleton v-if="!formattedData.get(stack.title) || !countByStack" class="p-4" />
<!-- Stack -->
<a-layout v-else class="!bg-[#f0f2f5]">
<a-layout-header>
<div class="nc-kanban-stack-head font-bold flex items-center px-[15px]">
<a-dropdown :trigger="['click']" overlay-class-name="nc-dropdown-kanban-stack-context-menu">
<div
class="flex items-center w-full"
:class="{ 'capitalize': stack.title === null, 'cursor-pointer': !isLocked }"
>
<LazyGeneralTruncateText>{{ stack.title ?? 'uncategorized' }}</LazyGeneralTruncateText>
<span v-if="!isLocked" class="w-full flex w-[15px]">
<mdi-menu-down class="text-grey text-lg ml-auto" />
</span>
</div>
<template v-if="!isLocked" #overlay>
<a-menu class="ml-6 !text-sm !px-0 !py-2 !rounded">
<a-menu-item
v-if="hasEditPermission && !isPublic && !isLocked"
v-e="['c:kanban:add-new-record']"
@click="
() => {
selectedStackTitle = stack.title
openNewRecordFormHook.trigger(stack.title)
}
"
>
<div class="py-2 flex gap-2 items-center">
<mdi-plus class="text-gray-500" />
{{ $t('activity.addNewRecord') }}
</div>
</a-menu-item>
<a-menu-item v-e="['c:kanban:collapse-stack']" @click="handleCollapseStack(stackIdx)">
<div class="py-2 flex gap-2 items-center">
<mdi-arrow-collapse class="text-gray-500" />
{{ $t('activity.kanban.collapseStack') }}
</div>
</a-menu-item>
<a-menu-item
v-if="stack.title !== null && !isPublic && hasEditPermission"
v-e="['c:kanban:delete-stack']"
@click="handleDeleteStackClick(stack.title, stackIdx)"
>
<div class="py-2 flex gap-2 items-center">
<mdi-delete class="text-gray-500" />
{{ $t('activity.kanban.deleteStack') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
<a-layout-content class="overflow-y-hidden">
<div :ref="kanbanListRef" class="nc-kanban-list h-full overflow-y-auto" :data-stack-title="stack.title">
<!-- Draggable Record Card -->
<Draggable
:list="formattedData.get(stack.title)"
item-key="row.Id"
draggable=".nc-kanban-item"
group="kanban-card"
class="h-full"
filter=".not-draggable"
@start="(e) => e.target.classList.add('grabbing')"
@end="(e) => e.target.classList.remove('grabbing')"
@change="onMove($event, stack.title)"
>
<template #item="{ element: record }">
<div class="nc-kanban-item py-2 px-[15px]">
<LazySmartsheetRow :row="record">
<a-card
hoverable
:data-stack="stack.title"
class="!rounded-lg h-full overflow-hidden break-all max-w-[450px] shadow-lg"
:class="{
'not-draggable': isLocked || !hasEditPermission || isPublic,
'!cursor-default': isLocked || !hasEditPermission || isPublic,
}"
:body-style="{ padding: '10px' }"
@click="expandFormClick($event, record)"
@contextmenu="showContextMenu($event, record)"
>
<div
v-for="col in fields"
:key="`record-${record.row.id}-${col.id}`"
class="flex flex-col rounded-lg w-full"
>
<!-- Smartsheet Header (Virtual) Cell -->
<div v-if="!isRowEmpty(record, col)" class="flex flex-row w-full justify-start pt-2">
<div class="w-full text-gray-400">
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" :hide-menu="true" />
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="true" />
</div>
</div>
<!-- Smartsheet (Virtual) Cell -->
<div
v-if="!isRowEmpty(record, col)"
class="flex flex-row w-full items-center justify-start pl-[6px]"
:class="{ '!ml-[-12px]': col.uidt === UITypes.SingleSelect }"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(col)"
v-model="record.row[col.title]"
class="text-sm pt-1"
:column="col"
:row="record"
/>
<LazySmartsheetCell
v-else
v-model="record.row[col.title]"
class="text-sm pt-1"
:column="col"
:edit-enabled="false"
:read-only="true"
/>
</div>
</div>
</a-card>
</LazySmartsheetRow>
</div>
</template>
</Draggable>
</div>
</a-layout-content>
<a-layout-footer>
<div v-if="formattedData.get(stack.title) && countByStack.get(stack.title) >= 0" class="mt-5 text-center">
<!-- Stack Title -->
<mdi-plus
v-if="!isPublic && !isLocked"
class="text-pint-500 text-lg text-primary cursor-pointer"
@click="
() => {
selectedStackTitle = stack.title
openNewRecordFormHook.trigger(stack.title)
}
"
/>
<!-- Record Count -->
<div class="nc-kanban-data-count">
{{ formattedData.get(stack.title).length }} / {{ countByStack.get(stack.title) }}
{{ countByStack.get(stack.title) !== 1 ? $t('objects.records') : $t('objects.record') }}
</div>
</div>
</a-layout-footer>
</a-layout>
</a-card>
<!-- Collapsed Stacks -->
<a-card
v-else
:key="`${stack.id}-collapsed`"
:style="`background-color: ${stack.color} !important`"
class="nc-kanban-collapsed-stack mx-4 flex items-center w-[300px] h-[50px] rounded-[12px] cursor-pointer h-full !pr-[10px]"
:class="{
'not-draggable': stack.title === null || isLocked || isPublic || !hasEditPermission,
}"
:body-style="{ padding: '0px', height: '100%', width: '100%', background: '#f0f2f5 !important' }"
>
<div class="items-center justify-between" @click="handleCollapseStack(stackIdx)">
<!-- Skeleton -->
<a-skeleton
v-if="!formattedData.get(stack.title) || !countByStack"
class="!w-[150px] pl-5"
:paragraph="false"
/>
<div v-else class="nc-kanban-data-count mt-[12px] mx-[10px]">
<!-- Stack title -->
<div class="float-right flex gap-2 items-center cursor-pointer font-bold">
<LazyGeneralTruncateText>{{ stack.title }}</LazyGeneralTruncateText>
<mdi-menu-down class="text-grey text-lg" />
</div>
<!-- Record Count -->
{{ formattedData.get(stack.title).length }} / {{ countByStack.get(stack.title) }}
{{ countByStack.get(stack.title) !== 1 ? $t('objects.records') : $t('objects.record') }}
</div>
</div>
</a-card>
</div>
</template>
</Draggable>
<!-- Drop down Menu -->
<template v-if="!isLocked && !isPublic && hasEditPermission" #overlay>
<a-menu class="shadow !rounded !py-0" @click="contextMenu = false">
<a-menu-item v-if="contextMenuTarget" @click="expandForm(contextMenuTarget)">
<div v-e="['a:kanban:expand-record']" class="nc-project-menu-item nc-kanban-context-menu-item">
<MdiArrowExpand class="flex" />
<!-- Expand Record -->
{{ $t('activity.expandRecord') }}
</div>
</a-menu-item>
<a-divider class="!m-0 !p-0" />
<a-menu-item v-if="contextMenuTarget" @click="deleteRow(contextMenuTarget)">
<div v-e="['a:kanban:delete-record']" class="nc-project-menu-item nc-kanban-context-menu-item">
<MdiDeleteOutline class="flex" />
<!-- Delete Record -->
{{ $t('activity.deleteRecord') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<div class="flex-1" />
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:row="expandedFormRow"
:state="expandedFormRowState"
:meta="meta"
:view="view"
/>
</Suspense>
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
:key="route.query.rowId"
v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta"
:row-id="route.query.rowId"
:view="view"
/>
</Suspense>
<a-modal v-model:visible="deleteStackVModel" class="!top-[35%]" wrap-class-name="nc-modal-kanban-delete-stack">
<template #title>
{{ $t('activity.deleteKanbanStack') }}
</template>
<div>
{{ $t('msg.info.deleteKanbanStackConfirmation', { stackToBeDeleted, groupingField }) }}
</div>
<template #footer>
<a-button key="back" v-e="['c:kanban:cancel-delete-stack']" @click="deleteStackVModel = false">
{{ $t('general.cancel') }}
</a-button>
<a-button key="submit" v-e="['c:kanban:confirm-delete-stack']" type="primary" @click="handleDeleteStackConfirmClick">
{{ $t('general.delete') }}
</a-button>
</template>
</a-modal>
</template>
<style lang="scss" scoped>
// override ant design style
.a-layout,
.ant-layout-header,
.ant-layout-content {
@apply !bg-[#f0f2f5];
}
.ant-layout-header {
@apply !h-[30px] !leading-[30px] !px-[5px] !my-[10px];
}
.nc-kanban-collapsed-stack {
transform: rotate(-90deg) translateX(-100%);
transform-origin: left top 0px;
transition: left 0.2s ease-in-out 0s;
}
</style>

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

@ -1,7 +1,7 @@
<script setup lang="ts">
import { IsPublicInj, inject, ref, useSharedView, useSidebar, useSmartsheetStoreOrThrow, useUIPermission } from '#imports'
const { isGrid, isForm, isGallery, isSqlView } = useSmartsheetStoreOrThrow()
const { isGrid, isForm, isGallery, isKanban, isSqlView } = useSmartsheetStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
@ -18,30 +18,33 @@ const { allowCSVDownload } = useSharedView()
style="z-index: 7"
>
<LazySmartsheetToolbarViewActions
v-if="(isGrid || isGallery) && !isPublic && isUIAllowed('dataInsert')"
v-if="(isGrid || isGallery || isKanban) && !isPublic && isUIAllowed('dataInsert')"
:show-system-fields="false"
class="ml-1"
/>
<LazySmartsheetToolbarViewInfo v-if="!isUIAllowed('dataInsert') && !isPublic" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery" :show-system-fields="false" />
<LazySmartsheetToolbarStackedBy v-if="isKanban" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery" />
<LazySmartsheetToolbarKanbanStackEditOrAdd v-if="isKanban" />
<LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban" :show-system-fields="false" />
<LazySmartsheetToolbarShareView v-if="(isForm || isGrid) && !isPublic" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarExport v-if="(!isPublic && !isUIAllowed('dataInsert')) || (isPublic && allowCSVDownload)" />
<LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarShareView v-if="(isForm || isGrid || isKanban) && !isPublic" />
<LazySmartsheetToolbarExport v-if="(!isPublic && !isUIAllowed('dataInsert')) || (isPublic && allowCSVDownload)" />
<div class="flex-1" />
<LazySmartsheetToolbarReload v-if="!isPublic && !isForm" />
<LazySmartsheetToolbarAddRow v-if="isUIAllowed('dataInsert') && !isPublic && !isForm && !isSqlView" />
<LazySmartsheetToolbarSearchData v-if="(isGrid || isGallery) && !isPublic" class="shrink mx-2" />
<LazySmartsheetToolbarSearchData v-if="(isGrid || isGallery || isKanban) && !isPublic" class="shrink mx-2" />
<template v-if="!isOpen && !isPublic">
<div class="border-l-1 pl-3">

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

@ -2,6 +2,7 @@
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import {
IsFormInj,
IsKanbanInj,
MetaInj,
ReloadViewDataHookInj,
computed,
@ -35,6 +36,8 @@ const meta = inject(MetaInj, ref())
const isForm = inject(IsFormInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const reloadDataTrigger = inject(ReloadViewDataHookInj)
const advancedOptions = ref(false)
@ -60,7 +63,10 @@ const uiTypesOptions = computed<typeof uiTypes>(() => {
const reloadMetaAndData = async () => {
await getMeta(meta.value?.id as string, true)
reloadDataTrigger?.trigger()
if (!isKanban.value) {
reloadDataTrigger?.trigger()
}
}
async function onSubmit() {
@ -118,7 +124,13 @@ onMounted(() => {
<a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical">
<div class="flex flex-col gap-2">
<a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.title">
<a-input ref="antInput" v-model:value="formState.title" class="nc-column-name-input" @input="onAlter(8)" />
<a-input
ref="antInput"
v-model:value="formState.title"
class="nc-column-name-input"
:disabled="isKanban"
@input="onAlter(8)"
/>
</a-form-item>
<a-form-item
@ -129,6 +141,7 @@ onMounted(() => {
v-model:value="formState.uidt"
show-search
class="nc-column-type-input"
:disabled="isKanban"
dropdown-class-name="nc-dropdown-column-type"
@change="onUidtOrIdTypeChange"
>

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

@ -1,7 +1,7 @@
<script setup lang="ts">
import Draggable from 'vuedraggable'
import { UITypes } from 'nocodb-sdk'
import { enumColor, onMounted, useColumnCreateStoreOrThrow, useVModel, watch } from '#imports'
import { IsKanbanInj, enumColor, onMounted, useColumnCreateStoreOrThrow, useVModel, watch } from '#imports'
const props = defineProps<{
value: any
@ -16,11 +16,16 @@ const { isPg, isMysql } = useProject()
const { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow()
let options = $ref<any[]>([])
const colorMenus = $ref<any>({})
const colors = $ref(enumColor.light)
const inputs = ref()
const defaultOption = ref()
const isKanban = inject(IsKanbanInj, ref(false))
const validators = {
'colOptions.options': [
{
@ -126,9 +131,8 @@ watch(inputs, () => {
<div class="w-full">
<Draggable :list="options" item-key="id" handle=".nc-child-draggable-icon">
<template #item="{ element, index }">
<div class="flex py-1 items-center">
<MdiDragVertical small class="nc-child-draggable-icon handle" />
<div class="flex py-1 items-center nc-select-option">
<MdiDragVertical v-if="!isKanban" small class="nc-child-draggable-icon handle" />
<a-dropdown
v-model:visible="colorMenus[index]"
:trigger="['click']"

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

@ -5,6 +5,7 @@ import type { Ref } from 'vue'
import {
FieldsInj,
IsFormInj,
IsKanbanInj,
MetaInj,
ReloadRowDataHookInj,
computedInject,
@ -50,6 +51,8 @@ const fields = computedInject(FieldsInj, (_fields) => {
return _fields?.value ?? []
})
const isKanban = inject(IsKanbanInj, ref(false))
provide(MetaInj, meta)
const { commentsDrawer, changedColumns, state: rowState, isNew, loadRow } = useProvideExpandedFormStore(meta, row)
@ -61,7 +64,7 @@ if (props.loadRow) {
if (props.rowId) {
try {
await loadRow(props.rowId)
} catch (e) {
} catch (e: any) {
if (e.response?.status === 404) {
// todo: i18n
message.error('Record not found')
@ -107,6 +110,15 @@ reloadHook.on(() => {
})
provide(ReloadRowDataHookInj, reloadHook)
if (isKanban.value) {
// adding column titles to changedColumns if they are preset
for (const [k, v] of Object.entries(row.value.row)) {
if (v) {
changedColumns.value.add(k)
}
}
}
</script>
<script lang="ts">
@ -124,7 +136,7 @@ export default {
:closable="false"
class="nc-drawer-expanded-form"
>
<SmartsheetExpandedFormHeader :view="view" @cancel="onClose" />
<SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" />
<div class="!bg-gray-100 rounded flex-1">
<div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]">
@ -149,6 +161,7 @@ export default {
v-model="row.row[col.title]"
:column="col"
:edit-enabled="true"
:active="true"
@update:model-value="changedColumns.add(col.title)"
/>
</div>

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { ColumnInj, IsFormInj, inject, provide, ref, toRef, useUIPermission } from '#imports'
import { ColumnInj, IsFormInj, IsKanbanInj, inject, provide, ref, toRef, useUIPermission } from '#imports'
const props = defineProps<{ column: ColumnType & { meta: any }; required?: boolean | number; hideMenu?: boolean }>()
@ -8,6 +8,8 @@ const hideMenu = toRef(props, 'hideMenu')
const isForm = inject(IsFormInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const column = toRef(props, 'column')
const { isUIAllowed } = useUIPermission()
@ -18,9 +20,11 @@ const editColumnDropdown = ref(false)
</script>
<template>
<div class="flex items-center w-full text-xs text-normal text-gray-500 font-weight-medium" :class="{ 'h-full': column }">
<LazySmartsheetHeaderCellIcon v-if="column" />
<div
class="flex items-center w-full text-xs text-gray-500 font-weight-medium"
:class="{ 'h-full': column, '!text-gray-400': isKanban }"
>
<SmartsheetHeaderCellIcon v-if="column" />
<span v-if="column" class="name" style="white-space: nowrap" :title="column.title">{{ column.title }}</span>
<span v-if="(column.rqd && !column.cdf) || required" class="text-red-500">&nbsp;*</span>

2
packages/nc-gui/components/smartsheet/header/CellIcon.vue

@ -85,5 +85,5 @@ const icon = computed(() => {
</script>
<template>
<component :is="icon" class="text-grey mx-1 !text-sm" />
<component :is="icon" class="text-grey mx-1 !text-xs" />
</template>

2
packages/nc-gui/components/smartsheet/header/VirtualCellIcon.vue

@ -77,5 +77,5 @@ const icon = computed(() => {
</script>
<template>
<component :is="icon.icon" class="mx-1 !text-sm" :class="icon.color" />
<component :is="icon.icon" class="mx-1 !text-xs" :class="icon.color" />
</template>

22
packages/nc-gui/components/smartsheet/sidebar/MenuBottom.vue

@ -94,6 +94,28 @@ function onOpenModal(type: ViewTypes, title = '') {
</a-tooltip>
</a-menu-item>
<a-menu-item
key="kanban"
class="group !flex !items-center !my-0 nc-create-kanban-view"
@click="onOpenModal(ViewTypes.KANBAN)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.kanban') }}
</template>
<div class="nc-project-menu-item text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.KANBAN].icon" :style="{ color: viewIcons[ViewTypes.KANBAN].color }" />
<div>{{ $t('objects.viewType.kanban') }}</div>
<div class="flex-1" />
<mdi-plus class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<div class="w-full h-4" />
</div>
</a-menu>

2
packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue

@ -27,7 +27,7 @@ const emits = defineEmits<Emits>()
const { t } = useI18n()
interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string }): void
(event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string; groupingFieldColumnId?: string }): void
(event: 'deleted'): void
}

13
packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { ViewType, ViewTypes } from 'nocodb-sdk'
import type { KanbanType, ViewType, ViewTypes } from 'nocodb-sdk'
import type { WritableComputedRef } from '@vue/reactivity'
import {
IsLockedInj,
@ -23,7 +23,7 @@ interface Emits {
(event: 'changeView', view: Record<string, any>): void
(event: 'rename', view: ViewType): void
(event: 'delete', view: ViewType): void
(event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string }): void
(event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string; groupingFieldColumnId?: string }): void
}
const props = defineProps<Props>()
@ -101,7 +101,12 @@ function focusInput(el: HTMLInputElement) {
/** Duplicate a view */
// todo: This is not really a duplication, maybe we need to implement a true duplication?
function onDuplicate() {
emits('openModal', { type: vModel.value.type!, title: vModel.value.title, copyViewId: vModel.value.id })
emits('openModal', {
type: vModel.value.type!,
title: vModel.value.title,
copyViewId: vModel.value.id,
groupingFieldColumnId: (vModel.value.view as KanbanType).grp_column_id!,
})
$e('c:view:copy', { view: vModel.value.type })
}
@ -189,7 +194,7 @@ function onStopEdit() {
{{ $t('activity.copyView') }}
</template>
<MdiContentCopy class="hidden group-hover:block text-gray-500" @click.stop="onDuplicate" />
<MdiContentCopy class="hidden group-hover:block text-gray-500 nc-view-copy-icon" @click.stop="onDuplicate" />
</a-tooltip>
<template v-if="!vModel.is_default">

17
packages/nc-gui/components/smartsheet/sidebar/index.vue

@ -50,6 +50,9 @@ let viewCreateTitle = $ref('')
/** selected view id for copying view meta */
let selectedViewId = $ref('')
/** Kanban Grouping Column Id for copying view meta */
let kanbanGrpColumnId = $ref('')
/** is view creation modal open */
let modalOpen = $ref(false)
@ -86,11 +89,22 @@ watch(
)
/** Open view creation modal */
function openModal({ type, title = '', copyViewId }: { type: ViewTypes; title: string; copyViewId: string }) {
function openModal({
type,
title = '',
copyViewId,
groupingFieldColumnId,
}: {
type: ViewTypes
title: string
copyViewId: string
groupingFieldColumnId: string
}) {
modalOpen = true
viewCreateType = type
viewCreateTitle = title
selectedViewId = copyViewId
kanbanGrpColumnId = groupingFieldColumnId
}
/** Handle view creation */
@ -131,6 +145,7 @@ async function onCreate(view: ViewType) {
:title="viewCreateTitle"
:type="viewCreateType"
:selected-view-id="selectedViewId"
:grouping-field-column-id="kanbanGrpColumnId"
@created="onCreate"
/>
</a-layout-sider>

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

@ -3,7 +3,7 @@
<a-button v-e="['c:actions']" class="nc-actions-menu-btn nc-toolbar-btn">
<div class="flex gap-2 items-center">
<MdiDownload class="group-hover:text-accent text-gray-500" />
<span class="text-capitalize !text-sm font-weight-normal">Download</span>
<span class="text-capitalize !text-sm font-weight-normal">{{ $t('general.download') }}</span>
<MdiMenuDown class="text-grey" />
</div>
</a-button>

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

@ -179,7 +179,7 @@ const getIcon = (c: ColumnType) =>
<a-divider class="!my-2" />
<div v-if="!isPublic" class="p-2 py-1 flex" @click.stop>
<div v-if="!isPublic" class="p-2 py-1 flex nc-fields-show-system-fields" @click.stop>
<a-checkbox v-model:checked="showSystemFields" class="!items-center">
<span class="text-xs"> {{ $t('activity.showSystemFields') }}</span>
</a-checkbox>

54
packages/nc-gui/components/smartsheet/toolbar/KanbanStackEditOrAdd.vue

@ -0,0 +1,54 @@
<script setup lang="ts">
import { IsLockedInj, IsPublicInj, useKanbanViewStoreOrThrow } from '#imports'
const { isUIAllowed } = useUIPermission()
const { groupingFieldColumn } = useKanbanViewStoreOrThrow()
const isLocked = inject(IsLockedInj, ref(false))
const addOrEditStackDropdown = ref(false)
const IsPublic = inject(IsPublicInj, ref(false))
const handleSubmit = async () => {
addOrEditStackDropdown.value = false
}
provide(IsKanbanInj, ref(true))
</script>
<template>
<a-dropdown
v-if="!IsPublic && isUIAllowed('edit-column')"
v-model:visible="addOrEditStackDropdown"
:trigger="['click']"
overlay-class-name="nc-dropdown-kanban-add-edit-stack-menu"
>
<div class="nc-kanban-btn">
<a-button
v-e="['c:kanban:edit-or-add-stack']"
class="nc-kanban-add-edit-stack-menu-btn nc-toolbar-btn"
:disabled="isLocked"
>
<div class="flex items-center gap-1">
<MdiPlusCircleOutline />
<span class="text-capitalize !text-sm font-weight-normal">
{{ $t('activity.kanban.addOrEditStack') }}
</span>
<MdiMenuDown class="text-grey" />
</div>
</a-button>
</div>
<template #overlay>
<LazySmartsheetColumnEditOrAddProvider
v-if="addOrEditStackDropdown"
:column="groupingFieldColumn"
@submit="handleSubmit"
@cancel="addOrEditStackDropdown = false"
@click.stop
@keydown.stop
/>
</template>
</a-dropdown>
</template>

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

@ -271,7 +271,7 @@ watch(passwordProtected, (value) => {
<div>
<!-- Allow Download -->
<a-checkbox
v-if="shared && shared.type === ViewTypes.GRID"
v-if="shared && (shared.type === ViewTypes.GRID || shared.type === ViewTypes.KANBAN)"
v-model:checked="allowCSVDownload"
data-cy="nc-modal-share-view__with-csv-download"
class="!text-xs"

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

@ -0,0 +1,123 @@
<script setup lang="ts">
import { UITypes } from 'nocodb-sdk'
import type { KanbanType } from 'nocodb-sdk'
import type { SelectProps } from 'ant-design-vue'
import {
ActiveViewInj,
IsLockedInj,
IsPublicInj,
MetaInj,
ReloadViewDataHookInj,
computed,
inject,
ref,
useKanbanViewStoreOrThrow,
useViewColumns,
watch,
} from '#imports'
const meta = inject(MetaInj, ref())
const activeView = inject(ActiveViewInj, ref())
const IsPublic = inject(IsPublicInj, ref(false))
const reloadDataHook = inject(ReloadViewDataHookInj)!
const isLocked = inject(IsLockedInj, ref(false))
const { fields, loadViewColumns, metaColumnById } = useViewColumns(activeView, meta, () => reloadDataHook.trigger())
const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField } = useKanbanViewStoreOrThrow()
const stackedByDropdown = ref(false)
watch(
() => activeView.value?.id,
async (newVal, oldVal) => {
if (newVal !== oldVal && meta.value) {
await loadViewColumns()
}
},
{ immediate: true },
)
const groupingFieldColumnId = computed({
get: () => kanbanMetaData.value.grp_column_id,
set: async (val) => {
if (val) {
await updateKanbanMeta({
grp_column_id: val,
})
await loadKanbanMeta()
await loadKanbanData()
;(activeView.value?.view as KanbanType).grp_column_id = val
}
},
})
const singleSelectFieldOptions = computed<SelectProps['options']>(() => {
return fields.value
?.filter((el) => el.fk_column_id && metaColumnById.value[el.fk_column_id].uidt === UITypes.SingleSelect)
.map((field) => {
return {
value: field.fk_column_id,
label: field.title,
}
})
})
const handleChange = () => {
stackedByDropdown.value = false
}
</script>
<template>
<a-dropdown
v-if="!IsPublic"
v-model:visible="stackedByDropdown"
:trigger="['click']"
overlay-class-name="nc-dropdown-kanban-stacked-by-menu"
>
<div class="nc-kanban-btn">
<a-button
v-e="['c:kanban:change-grouping-field']"
class="nc-kanban-stacked-by-menu-btn nc-toolbar-btn"
:disabled="isLocked"
>
<div class="flex items-center gap-1">
<mdi-arrow-down-drop-circle-outline />
<span class="text-capitalize !text-sm font-weight-normal">
{{ $t('activity.kanban.stackedBy') }}
<span class="font-bold">{{ groupingField }}</span>
</span>
<MdiMenuDown class="text-grey" />
</div>
</a-button>
</div>
<template #overlay>
<div
v-if="stackedByDropdown"
class="p-3 min-w-[280px] bg-gray-50 shadow-lg nc-table-toolbar-menu max-h-[max(80vh,500px)] overflow-auto !border"
@click.stop
>
<div>
<span class="font-bold"> {{ $t('activity.kanban.chooseGroupingField') }}</span>
<a-divider class="!my-2" />
</div>
<div class="nc-fields-list py-1">
<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
/>
</div>
</div>
</div>
</template>
</a-dropdown>
</template>

9
packages/nc-gui/components/tabs/Smartsheet.vue

@ -17,6 +17,7 @@ import {
ref,
toRef,
useMetas,
useProvideKanbanViewStore,
useProvideSmartsheetStore,
watch,
} from '#imports'
@ -38,11 +39,13 @@ const meta = computed<TableType | undefined>(() => activeTab.value && metas.valu
const reloadEventHook = createEventHook()
const reloadViewMetaEventHook = createEventHook()
const { isGallery, isGrid, isForm, isKanban, isLocked } = useProvideSmartsheetStore(activeView, meta)
const openNewRecordFormHook = createEventHook()
const { isGallery, isGrid, isForm, isLocked } = useProvideSmartsheetStore(activeView, meta)
const reloadViewMetaEventHook = createEventHook()
useProvideKanbanViewStore(meta, activeView)
// todo: move to store
provide(MetaInj, meta)
@ -74,6 +77,8 @@ watch(isLocked, (nextValue) => (treeViewIsLockedInj.value = nextValue), { immedi
<LazySmartsheetGallery v-else-if="isGallery" />
<LazySmartsheetForm v-else-if="isForm && !$route.query.reload" />
<LazySmartsheetKanban v-else-if="isKanban" />
</div>
</div>
</template>

2
packages/nc-gui/components/virtual-cell/BelongsTo.vue

@ -87,7 +87,7 @@ const unlinkRef = async (rec: Record<string, any>) => {
<component
:is="addIcon"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 select-none group-hover:(text-gray-500) nc-plus"
@click="listItemsDlg = true"
@click.stop="listItemsDlg = true"
/>
</div>

4
packages/nc-gui/components/virtual-cell/HasMany.vue

@ -105,13 +105,13 @@ const onAttachRecord = () => {
<div v-if="!isLocked" class="flex justify-end gap-1 min-h-[30px] items-center">
<MdiArrowExpand
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
@click="childListDlg = true"
@click.stop="childListDlg = true"
/>
<MdiPlus
v-if="!readOnly && isUIAllowed('xcDatatableEditable')"
class="select-none text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus"
@click="listItemsDlg = true"
@click.stop="listItemsDlg = true"
/>
</div>
</template>

2
packages/nc-gui/components/virtual-cell/Lookup.vue

@ -49,7 +49,7 @@ const lookupColumnMetaProps = useColumn(lookupColumn)
</script>
<template>
<div class="h-full flex gap-1">
<div class="h-full flex gap-1 overflow-x-auto p-1">
<template v-if="lookupColumn">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn)">

8
packages/nc-gui/components/virtual-cell/ManyToMany.vue

@ -97,20 +97,22 @@ const onAttachRecord = () => {
@unlink="unlinkRef(cell.item)"
/>
<span v-if="cells?.length === 10" class="caption pointer ml-1 grey--text" @click="childListDlg = true">more... </span>
<span v-if="cells?.length === 10" class="caption pointer ml-1 grey--text" @click.stop="childListDlg = true">
more...
</span>
</template>
</div>
<div v-if="!isLocked" class="flex justify-end gap-1 min-h-[30px] items-center">
<MdiArrowExpand
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
@click="childListDlg = true"
@click.stop="childListDlg = true"
/>
<MdiPlus
v-if="!readOnly && isUIAllowed('xcDatatableEditable')"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus"
@click="listItemsDlg = true"
@click.stop="listItemsDlg = true"
/>
</div>
</template>

15
packages/nc-gui/composables/useExpandedFormStore.ts

@ -1,4 +1,4 @@
import { UITypes } from 'nocodb-sdk'
import { UITypes, ViewTypes } from 'nocodb-sdk'
import type { ColumnType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import dayjs from 'dayjs'
@ -13,6 +13,7 @@ import {
useApi,
useI18n,
useInjectionState,
useKanbanViewStoreOrThrow,
useNuxtApp,
useProject,
useProvideSmartsheetRowStore,
@ -41,6 +42,10 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const rowStore = useProvideSmartsheetRowStore(meta, row)
const activeView = inject(ActiveViewInj, ref())
const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
const { sharedView } = useSharedView()
// getters
@ -135,7 +140,9 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
return obj
}, {} as Record<string, any>)
if (row.value.rowMeta?.new) {
const isNewRow = row.value.rowMeta?.new ?? false
if (isNewRow) {
data = await $api.dbTableRow.create('noco', project.value.title as string, meta.value.title, updateOrInsertObj)
Object.assign(row.value, {
@ -174,6 +181,10 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
return message.info(t('msg.info.noColumnsToUpdate'))
}
if (activeView.value?.type === ViewTypes.KANBAN) {
addOrEditStackRow(row.value, isNewRow)
}
message.success(`${primaryValue.value || 'Row'} updated successfully.`)
changedColumns.value = new Set()

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

@ -0,0 +1,555 @@
import type { ComputedRef, Ref } from 'vue'
import type { Api, ColumnType, KanbanType, SelectOptionType, SelectOptionsType, TableType, ViewType } from 'nocodb-sdk'
import { useI18n } from 'vue-i18n'
import { message } from 'ant-design-vue'
import type { Row } from '~/lib'
import { SharedViewPasswordInj, deepCompare, enumColor, extractPkFromRow, useInjectionState, useNuxtApp } from '#imports'
type GroupingFieldColOptionsType = SelectOptionType & { collapsed: boolean }
const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
(
meta: Ref<TableType | KanbanType | undefined>,
viewMeta: Ref<ViewType | KanbanType | undefined> | ComputedRef<(ViewType & { id: string }) | undefined>,
shared = false,
) => {
if (!meta) {
throw new Error('Table meta is not available')
}
const { t } = useI18n()
const { api } = useApi()
const { project } = useProject()
const { $e, $api } = useNuxtApp()
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow()
const { sharedView, fetchSharedViewData, fetchSharedViewGroupedData } = useSharedView()
const { isUIAllowed } = useUIPermission()
const isPublic = ref(shared) || inject(IsPublicInj, ref(false))
const password = ref<string | null>(null)
provide(SharedViewPasswordInj, password)
// kanban view meta data
const kanbanMetaData = ref<KanbanType>({})
// grouping field column options - e.g. title, fk_column_id, color etc
const groupingFieldColOptions = ref<GroupingFieldColOptionsType[]>([])
// formattedData structure
// {
// [val1] : [
// {row: {...}, oldRow: {...}, rowMeta: {...}},
// {row: {...}, oldRow: {...}, rowMeta: {...}},
// ...
// ],
// [val2] : [
// {row: {...}, oldRow: {...}, rowMeta: {...}},
// {row: {...}, oldRow: {...}, rowMeta: {...}},
// ...
// ],
// }
const formattedData = ref<Map<string | null, Row[]>>(new Map<string | null, Row[]>())
// countByStack structure
// {
// "uncategorized": 0,
// [val1]: 10,
// [val2]: 20
// }
const countByStack = ref<Map<string | null, number>>(new Map<string | null, number>())
// grouping field title
const groupingField = ref<string>('')
// grouping field column
const groupingFieldColumn = ref<ColumnType | undefined>()
// stack meta in object format
const stackMetaObj = ref<Record<string, GroupingFieldColOptionsType[]>>({})
const shouldScrollToRight = ref(false)
const formatData = (list: Record<string, any>[]) =>
list.map((row) => ({
row: { ...row },
oldRow: { ...row },
rowMeta: {},
}))
async function loadKanbanData() {
if ((!project?.value?.id || !meta.value?.id || !viewMeta?.value?.id) && !isPublic.value) return
// reset formattedData & countByStack to avoid storing previous data after changing grouping field
formattedData.value = new Map<string | null, Row[]>()
countByStack.value = new Map<string | null, number>()
let res
if (isPublic.value) {
res = await fetchSharedViewGroupedData(groupingFieldColumn!.value!.id!)
} else {
res = await api.dbViewRow.groupedDataList(
'noco',
project.value.id!,
meta.value!.id!,
viewMeta.value!.id!,
groupingFieldColumn!.value!.id!,
{},
{},
)
}
for (const data of res) {
const key = data.key
formattedData.value.set(key, formatData(data.value.list))
countByStack.value.set(key, data.value.pageInfo.totalRows || 0)
}
}
async function loadMoreKanbanData(stackTitle: string, params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) {
if ((!project?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic.value) return
let where = `(${groupingField.value},eq,${stackTitle})`
if (stackTitle === null) {
where = `(${groupingField.value},is,null)`
}
const response = !isPublic.value
? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, {
...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where,
})
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value })
formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, ...formatData(response.list)])
}
async function loadKanbanMeta() {
if (!viewMeta?.value?.id || !meta?.value?.columns) return
kanbanMetaData.value = isPublic.value
? (sharedView.value?.view as KanbanType)
: await $api.dbView.kanbanRead(viewMeta.value.id)
// set groupingField
groupingFieldColumn.value =
(meta.value.columns as ColumnType[]).filter((f) => f.id === kanbanMetaData.value.grp_column_id)[0] || {}
groupingField.value = groupingFieldColumn.value.title!
const { grp_column_id, meta: stack_meta } = kanbanMetaData.value
stackMetaObj.value = stack_meta ? JSON.parse(stack_meta as string) : {}
if (stackMetaObj.value && grp_column_id && stackMetaObj.value[grp_column_id]) {
// keep the existing order (index of the array) but update the values done outside kanban
let isChanged = false
let hasNewOptionsAdded = false
for (const option of (groupingFieldColumn.value.colOptions as SelectOptionsType)?.options ?? []) {
const idx = stackMetaObj.value[grp_column_id].findIndex((ele) => ele.id === option.id)
if (idx !== -1) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { collapsed, ...rest } = stackMetaObj.value[grp_column_id][idx]
if (!deepCompare(rest, option)) {
// update the option in stackMetaObj
stackMetaObj.value[grp_column_id][idx] = {
...stackMetaObj.value[grp_column_id][idx],
...option,
}
// rename the key in formattedData & countByStack
if (option.title !== rest.title) {
// option.title is new key
// rest.title is old key
formattedData.value.set(option.title!, formattedData.value.get(rest.title!)!)
countByStack.value.set(option.title!, countByStack.value.get(rest.title!)!)
// update grouping field value under the edited stack
await bulkUpdateGroupingFieldValue(option.title!)
}
isChanged = true
}
} else {
// new option found - add to stackMetaObj
stackMetaObj.value[grp_column_id].push({
...option,
collapsed: false,
})
formattedData.value.set(option.title!, [])
countByStack.value.set(option.title!, 0)
isChanged = true
hasNewOptionsAdded = true
}
}
// handle deleted options
const columnOptionIds = (groupingFieldColumn.value?.colOptions as SelectOptionsType)?.options.map(({ id }) => id)
const cols = stackMetaObj.value[grp_column_id].filter(({ id }) => id !== 'uncategorized' && !columnOptionIds.includes(id))
for (const col of cols) {
const idx = stackMetaObj.value[grp_column_id].map((ele: Record<string, any>) => ele.id).indexOf(col.id)
if (idx !== -1) {
stackMetaObj.value[grp_column_id].splice(idx, 1)
// there are two cases
// 1. delete option from Add / Edit Stack in kanban view
// 2. delete option from grid view, then switch to kanban view
// for the second case, formattedData.value and countByStack.value would be empty at this moment
// however, the data will be correct after rendering
if (formattedData.value.size && countByStack.value.size && formattedData.value.has(col.title!)) {
// for the first case, no reload is executed.
// hence, we set groupingField to null for all records under the target stack
await bulkUpdateGroupingFieldValue(col.title!, true)
// merge the to-be-deleted stack to uncategorized stack
formattedData.value.set(null, [...(formattedData.value.get(null) || []), ...formattedData.value.get(col.title!)!])
// update the record count
countByStack.value.set(null, (countByStack.value.get(null) || 0) + (countByStack.value.get(col.title!) || 0))
}
isChanged = true
}
}
groupingFieldColOptions.value = stackMetaObj.value[grp_column_id]
if (isChanged) {
await updateKanbanStackMeta()
if (hasNewOptionsAdded) {
shouldScrollToRight.value = true
}
}
} 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] },
]
// sort by initial order
.sort((a, b) => a.order! - b.order!)
// enrich `collapsed`
.map((ele) => ({
...ele,
collapsed: false,
}))
await updateKanbanStackMeta()
}
}
async function updateKanbanStackMeta() {
const { grp_column_id } = kanbanMetaData.value
if (grp_column_id) {
stackMetaObj.value[grp_column_id] = groupingFieldColOptions.value
await updateKanbanMeta({
meta: stackMetaObj.value,
})
}
}
async function updateKanbanMeta(updateObj: Partial<KanbanType>) {
if (!viewMeta?.value?.id || !isUIAllowed('xcDatatableEditable')) return
await $api.dbView.kanbanUpdate(viewMeta.value.id, {
...kanbanMetaData.value,
...updateObj,
})
}
async function insertRow(row: Record<string, any>, rowIndex = formattedData.value.get(null)!.length) {
try {
const insertObj = (meta?.value?.columns as ColumnType[]).reduce((o: Record<string, any>, col) => {
if (!col.ai && row?.[col.title as string] !== null) {
o[col.title!] = row?.[col.title as string]
}
return o
}, {})
const insertedData = await $api.dbViewRow.create(
NOCO,
project?.value.id as string,
meta.value?.id as string,
viewMeta?.value?.id as string,
insertObj,
)
formattedData.value.get(null)?.splice(rowIndex ?? 0, 1, {
row: insertedData,
rowMeta: {},
oldRow: { ...insertedData },
})
return insertedData
} catch (error: any) {
message.error(await extractSdkResponseErrorMsg(error))
}
}
async function updateRowProperty(toUpdate: Row, property: string) {
try {
const id = extractPkFromRow(toUpdate.row, meta?.value?.columns as ColumnType[])
const updatedRowData = await $api.dbViewRow.update(
NOCO,
project?.value.id as string,
meta.value?.id as string,
viewMeta?.value?.id as string,
id,
{
[property]: toUpdate.row[property],
},
// todo:
// {
// query: { ignoreWebhook: !saved }
// }
)
// audit
$api.utils
.auditRowUpdate(id, {
fk_model_id: meta.value?.id as string,
column_name: property,
row_id: id,
value: getHTMLEncodedText(toUpdate.row[property]),
prev_value: getHTMLEncodedText(toUpdate.oldRow[property]),
})
.then(() => {})
/** update row data(to sync formula and other related columns) */
Object.assign(toUpdate.row, updatedRowData)
Object.assign(toUpdate.oldRow, updatedRowData)
} catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`)
}
}
async function updateOrSaveRow(row: Row) {
if (row.rowMeta.new) {
await insertRow(row.row, formattedData.value.get(row.row.title!)!.indexOf(row))
} else {
await updateRowProperty(row, groupingField.value)
}
}
async function bulkUpdateGroupingFieldValue(stackTitle: string, moveToUncategorizedStack = false) {
try {
// set groupingField to target value for all records under the target stack
// if isTargetValueNull is true, then it means the cards under stackTitle will move to Uncategorized stack
const groupingFieldVal = moveToUncategorizedStack ? null : stackTitle
await api.dbTableRow.bulkUpdateAll(
'noco',
project.value.id!,
meta.value?.id as string,
{
[groupingField.value]: groupingFieldVal,
},
{
where: `(${groupingField.value},eq,${stackTitle})`,
},
)
if (formattedData.value.has(stackTitle)) {
// update to groupingField value to target value
formattedData.value.set(
stackTitle,
formattedData.value.get(stackTitle)!.map((o) => ({
...o,
row: {
...o.row,
[groupingField.value]: groupingFieldVal,
},
oldRow: {
...o.oldRow,
[groupingField.value]: o.row[groupingField.value],
},
})),
)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
async function deleteStack(stackTitle: string, stackIdx: number) {
if (!viewMeta?.value?.id || !groupingFieldColumn.value) return
try {
// set groupingField to null for all records under the target stack
await bulkUpdateGroupingFieldValue(stackTitle, true)
// merge the to-be-deleted stack to uncategorized stack
formattedData.value.set(null, [...formattedData.value.get(null)!, ...formattedData.value.get(stackTitle)!])
countByStack.value.set(null, (countByStack.value.get(null) || 0) + (countByStack.value.get(stackTitle) || 0))
// clear state for the to-be-deleted stack
formattedData.value.delete(stackTitle)
countByStack.value.delete(stackTitle)
// delete the stack, i.e. grouping field value
const newOptions = (groupingFieldColumn.value.colOptions as SelectOptionsType).options.filter(
(o) => o.title !== stackTitle,
)
;(groupingFieldColumn.value.colOptions as SelectOptionsType).options = newOptions
await api.dbTableColumn.update(groupingFieldColumn.value.id!, {
...groupingFieldColumn.value,
colOptions: {
options: newOptions,
},
} as any)
// update kanban stack meta
groupingFieldColOptions.value.splice(stackIdx, 1)
stackMetaObj.value[kanbanMetaData.value.grp_column_id!] = groupingFieldColOptions.value
await updateKanbanStackMeta()
$e('a:kanban:delete-stack')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
function addEmptyRow(addAfter = formattedData.value.get(null)!.length) {
formattedData.value.get(null)!.splice(addAfter, 0, {
row: {},
oldRow: {},
rowMeta: { new: true },
})
return formattedData.value.get(null)![addAfter]
}
function addOrEditStackRow(row: Row, isNewRow: boolean) {
const stackTitle = row.row[groupingField.value]
const oldStackTitle = row.oldRow[groupingField.value]
if (isNewRow) {
// add a new record
if (stackTitle) {
// push the row to target stack
formattedData.value.get(stackTitle)!.push(row)
// increase the current count in the target stack by 1
countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! + 1)
// clear the one under uncategorized since we don't reload the view
removeRowFromUncategorizedStack()
} else {
// data will be still in Uncategorized stack
// no action is required
}
} else {
// update existing record
const targetPrimaryKey = extractPkFromRow(row.row, meta!.value!.columns as ColumnType[])
const idxToUpdateOrDelete = formattedData.value
.get(oldStackTitle)!
.findIndex((ele) => extractPkFromRow(ele.row, meta!.value!.columns as ColumnType[]) === targetPrimaryKey)
if (idxToUpdateOrDelete !== -1) {
if (stackTitle !== oldStackTitle) {
// remove old row from countByStack & formattedData
countByStack.value.set(oldStackTitle, countByStack.value.get(oldStackTitle)! - 1)
const updatedRow = formattedData.value.get(oldStackTitle)!
updatedRow.splice(idxToUpdateOrDelete, 1)
formattedData.value.set(oldStackTitle, updatedRow)
// add new row to countByStack & formattedData
countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! + 1)
formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, row])
} else {
// update the row in formattedData
const updatedRow = formattedData.value.get(stackTitle)!
updatedRow[idxToUpdateOrDelete] = row
formattedData.value.set(oldStackTitle, updatedRow)
}
}
}
}
function removeRowFromTargetStack(row: Row) {
// primary key of Row to be deleted
const targetPrimaryKey = extractPkFromRow(row.row, meta!.value!.columns as ColumnType[])
// stack title of Row to be deleted
const stackTitle = row.row[groupingField.value]
// remove target row from formattedData
formattedData.value.set(
stackTitle,
formattedData.value
.get(stackTitle)!
.filter((ele) => extractPkFromRow(ele.row, meta!.value!.columns as ColumnType[]) !== targetPrimaryKey),
)
// decrease countByStack of target stack by 1
countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! - 1)
}
function removeRowFromUncategorizedStack() {
// remove the last record
formattedData.value.get(null)!.pop()
// decrease total count by 1
countByStack.value.set(null, countByStack.value.get(null)! - 1)
}
async function deleteRow(row: Row) {
try {
if (!row.rowMeta.new) {
const id = (meta?.value?.columns as ColumnType[])
?.filter((c) => c.pk)
.map((c) => row.row[c.title!])
.join('___')
const deleted = await deleteRowById(id as string)
if (!deleted) {
return
}
}
// remove deleted row from state
removeRowFromTargetStack(row)
} catch (e: any) {
message.error(`${t('msg.error.deleteRowFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
}
}
async function deleteRowById(id: string) {
if (!id) {
throw new Error("Delete not allowed for table which doesn't have primary Key")
}
const res: any = await $api.dbViewRow.delete(
'noco',
project.value.id as string,
meta.value?.id as string,
viewMeta.value?.id as string,
id,
)
if (res.message) {
message.info(
`Row delete failed: ${`Unable to delete row with ID ${id} because of the following:
\n${res.message.join('\n')}.\n
Clear the data first & try again`})}`,
)
return false
}
return true
}
return {
loadKanbanData,
loadMoreKanbanData,
loadKanbanMeta,
updateKanbanMeta,
kanbanMetaData,
formattedData,
countByStack,
groupingField,
groupingFieldColOptions,
groupingFieldColumn,
updateOrSaveRow,
addEmptyRow,
addOrEditStackRow,
deleteStack,
updateKanbanStackMeta,
removeRowFromUncategorizedStack,
shouldScrollToRight,
deleteRow,
}
},
'kanban-view-store',
)
export { useProvideKanbanViewStore }
export function useKanbanViewStoreOrThrow() {
const kanbanViewStore = useKanbanViewStore()
if (kanbanViewStore == null) throw new Error('Please call `useProvideKanbanViewStore` on the appropriate parent component')
return kanbanViewStore
}

40
packages/nc-gui/composables/useSharedView.ts

@ -1,4 +1,14 @@
import type { ExportTypes, FilterType, PaginatedType, RequestParams, SortType, TableType, ViewType } from 'nocodb-sdk'
import type {
Api,
ExportTypes,
FilterType,
KanbanType,
PaginatedType,
RequestParams,
SortType,
TableType,
ViewType,
} from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import { computed, useGlobal, useMetas, useNuxtApp, useState } from '#imports'
@ -19,11 +29,11 @@ export function useSharedView() {
const allowCSVDownload = useState<boolean>('allowCSVDownload', () => false)
const meta = useState<TableType | undefined>('meta', () => undefined)
const meta = useState<TableType | KanbanType | undefined>('meta', () => undefined)
const formColumns = computed(
() =>
meta.value?.columns
(meta.value as TableType)?.columns
?.filter(
(f: Record<string, any>) =>
f.show && f.uidt !== UITypes.Rollup && f.uidt !== UITypes.Lookup && f.uidt !== UITypes.Formula,
@ -80,7 +90,30 @@ export function useSharedView() {
},
},
)
return data
}
const fetchSharedViewGroupedData = async (columnId: string, params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) => {
if (!sharedView.value) return
const page = paginationData.value.page || 1
const pageSize = paginationData.value.pageSize || appInfoDefaultLimit
const data = await $api.public.groupedDataList(
sharedView.value.uuid!,
columnId,
{
offset: (page - 1) * pageSize,
filterArrJson: JSON.stringify(nestedFilters.value),
sortArrJson: JSON.stringify(sorts.value),
...params,
} as any,
{
headers: {
'xc-password': password.value,
},
},
)
return data
}
@ -111,6 +144,7 @@ export function useSharedView() {
meta,
nestedFilters,
fetchSharedViewData,
fetchSharedViewGroupedData,
paginationData,
sorts,
exportFile,

16
packages/nc-gui/composables/useSmartsheetStore.ts

@ -1,12 +1,12 @@
import { ViewTypes } from 'nocodb-sdk'
import type { FilterType, SortType, TableType, ViewType } from 'nocodb-sdk'
import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { computed, reactive, ref, unref, useInjectionState, useNuxtApp, useProject, useTemplateRefsList } from '#imports'
const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
(
view: Ref<ViewType | undefined>,
meta: Ref<TableType | undefined>,
meta: Ref<TableType | KanbanType | undefined>,
shared = false,
initalSorts?: Ref<SortType[]>,
initialFilters?: Ref<FilterType[]>,
@ -26,14 +26,17 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
// getters
const isLocked = computed(() => view.value?.lock_type === 'locked')
const isPkAvail = computed(() => meta.value?.columns?.some((c) => c.pk))
const isPkAvail = computed(() => (meta.value as TableType)?.columns?.some((c) => c.pk))
const isGrid = computed(() => view.value?.type === ViewTypes.GRID)
const isForm = computed(() => view.value?.type === ViewTypes.FORM)
const isSharedForm = computed(() => isForm.value && shared)
const isGallery = computed(() => view.value?.type === ViewTypes.GALLERY)
const isKanban = computed(() => view.value?.type === ViewTypes.KANBAN)
const isSharedForm = computed(() => isForm.value && shared)
const xWhere = computed(() => {
let where
const col = meta.value?.columns?.find(({ id }) => id === search.field) || meta.value?.columns?.find((v) => v.pv)
const col =
(meta.value as TableType)?.columns?.find(({ id }) => id === search.field) ||
(meta.value as TableType)?.columns?.find((v) => v.pv)
if (!col) return
if (!search.query.trim()) return
@ -45,7 +48,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
return where
})
const isSqlView = computed(() => meta.value?.type === 'view')
const isSqlView = computed(() => (meta.value as TableType)?.type === 'view')
const sorts = ref<SortType[]>(unref(initalSorts) ?? [])
const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? [])
@ -61,6 +64,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
isForm,
isGrid,
isGallery,
isKanban,
cellRefs,
isSharedForm,
sorts,

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

@ -21,7 +21,7 @@ import {
} from '#imports'
import type { Row } from '~/lib'
const formatData = (list: Record<string, any>[]) =>
const formatData = (list: Row[]) =>
list.map((row) => ({
row: { ...row },
oldRow: { ...row },

2
packages/nc-gui/context/index.ts

@ -16,11 +16,11 @@ export const ChangePageInj: InjectionKey<ReturnType<typeof useViewData>['changeP
export const IsFormInj: InjectionKey<Ref<boolean>> = Symbol('is-form-injection')
export const IsGridInj: InjectionKey<Ref<boolean>> = Symbol('is-grid-injection')
export const IsGalleryInj: InjectionKey<Ref<boolean>> = Symbol('is-gallery-injection')
export const IsKanbanInj: InjectionKey<Ref<boolean>> = Symbol('is-kanban-injection')
export const IsLockedInj: InjectionKey<Ref<boolean>> = Symbol('is-locked-injection')
export const CellValueInj: InjectionKey<Ref<any>> = Symbol('cell-value-injection')
export const ActiveViewInj: InjectionKey<Ref<ViewType>> = Symbol('active-view-injection')
export const ReadonlyInj: InjectionKey<boolean> = Symbol('readonly-injection')
/** when bool is passed, it indicates if a loading spinner should be visible while reloading */
export const ReloadViewDataHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-data-injection')
export const ReloadViewMetaHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-meta-injection')

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

@ -67,7 +67,8 @@
"reachOut": "Reach out here",
"betaNote": "This feature is currently in beta.",
"moreInfo": "More information can be found here",
"logs": "Logs"
"logs": "Logs",
"groupingField": "Grouping Field"
},
"objects": {
"project": "Project",
@ -307,6 +308,7 @@
"deleteProject": "Delete Project",
"refreshProject": "Refresh projects",
"saveProject": "Save Project",
"deleteKanbanStack": "Delete stack?",
"createProjectExtended": {
"extDB": "Create By Connecting <br>To An External Database",
"excel": "Create Project from excel",
@ -410,12 +412,21 @@
"addNewRecord": "Add new record",
"useConnectionUrl": "Use Connection URL",
"toggleCommentsDraw": "Toggle comments draw",
"expandRecord": "Expand Record",
"deleteRecord": "Delete Record",
"erd": {
"showColumns": "Show Columns",
"showPkAndFk": "Show Primary and Foreign Keys",
"showSqlViews": "Show SQL Views",
"showMMTables": "Show Many to Many tables",
"showJunctionTableNames": "Show Junction Table Names"
},
"kanban": {
"collapseStack": "Collapse Stack",
"deleteStack": "Delete Stack",
"stackedBy": "Stacked By",
"chooseGroupingField": "Choose a Grouping Field",
"addOrEditStack": "Add / Edit Stack"
}
},
"tooltip": {
@ -580,7 +591,8 @@
"generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base",
"deleteViewConfirmation": "Are you sure you want to delete this view?",
"deleteTableConfirmation": "Do you want to delete the table",
"showM2mTables": "Show M2M Tables"
"showM2mTables": "Show M2M Tables",
"deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack."
},
"error": {
"searchProject": "Your search for {search} found no results",

35
packages/nc-gui/pages/[projectType]/kanban/[viewId]/index.vue

@ -0,0 +1,35 @@
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { definePageMeta } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
layout: 'shared-view',
})
const route = useRoute()
const { loadSharedView } = useSharedView()
const showPassword = ref(false)
try {
await loadSharedView(route.params.viewId as string)
} catch (e: any) {
if (e?.response?.status === 403) {
showPassword.value = true
} else {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<NuxtLayout id="content" class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>
<LazySharedViewKanban v-else />
</NuxtLayout>
</template>

1
packages/nc-gui/utils/viewUtils.ts

@ -21,4 +21,5 @@ export const viewTypeAlias = {
[ViewTypes.GRID]: 'grid',
[ViewTypes.FORM]: 'form',
[ViewTypes.GALLERY]: 'gallery',
[ViewTypes.KANBAN]: 'kanban',
}

10
packages/noco-docs/content/en/FAQs.md

@ -50,12 +50,12 @@ PackageVersion: **0.97.0**
- In it you will notice advanced features are all available for free.
- ACL
- Collaboration
- Advanced views : Form View, Gallery view, Kanban (coming soon)
- Share view,
- Embed view
- Password protected view,
- Advanced Views : Form View, Gallery View & Kanban View
- Share View
- Embed View
- Password protected View
- Automations
- API token support.
- API Token Support
- And we would never move these features from free to an enterprise version of NocoDB.
- There is no limitations to number of projects, rows or columns either.

3
packages/noco-docs/content/en/developer-resources/rest-apis.md

@ -43,6 +43,7 @@ Currently, the default value for {orgs} is <b>noco</b>. Users will be able to ch
| Public | Get | public | csvExport | /api/v1/db/public/shared-view/{sharedViewUuid}/rows/export/{type} |
| Public | Get | public | dataRelationList | /api/v1/db/public/shared-view/{sharedViewUuid}/nested/{columnName} |
| Public | Get | public | sharedViewMetaGet | /api/v1/db/public/shared-view/{sharedViewUuid}/meta |
| Public | Get | public | groupedDataList | /api/v1/db/public/shared-view/{sharedViewUuid}/group/{columnId} |
### Data APIs
@ -62,6 +63,7 @@ Currently, the default value for {orgs} is <b>noco</b>. Users will be able to ch
| Data | Patch | dbTableRow | update | /api/v1/db/data/{orgs}/{projectName}/{tableName}/{rowId} |
| Data | Delete| dbTableRow | delete | /api/v1/db/data/{orgs}/{projectName}/{tableName}/{rowId} |
| Data | Get | dbTableRow | count | /api/v1/db/data/{orgs}/{projectName}/{tableName}/count |
| Data | Get | dbTableRow | groupedDataList | /api/v1/db/data/{orgs}/{projectName}/{tableName}/group/{columnId} |
| Data | Get | dbViewRow | list | /api/v1/db/data/{orgs}/{projectName}/{tableName}/views/{viewName} |
| Data | Get | dbViewRow | findOne | /api/v1/db/data/{orgs}/{projectName}/{tableName}/views/{viewName}/find-one |
| Data | Get | dbViewRow | groupBy | /api/v1/db/data/{orgs}/{projectName}/{tableName}/views/{viewName}/groupby |
@ -71,6 +73,7 @@ Currently, the default value for {orgs} is <b>noco</b>. Users will be able to ch
| Data | Patch | dbViewRow | update | /api/v1/db/data/{orgs}/{projectName}/{tableName}/views/{viewName}/{rowId} |
| Data | Delete| dbViewRow | delete | /api/v1/db/data/{orgs}/{projectName}/{tableName}/views/{viewName}/{rowId} |
| Data | Get | dbViewRow | count | /api/v1/db/data/{orgs}/{projectName}/{tableName}/views/{viewName}/count |
| Data | Get | dbViewRow | groupedDataList | /api/v1/db/data/{orgs}/{projectName}/{tableName}/views/{viewName}/group/{columnId} |
### Meta APIs

8
packages/noco-docs/content/en/setup-and-usages/views.md

@ -35,18 +35,22 @@ Grid View, as a default type of view, allows you to display your data in a sprea
Form View allows you to arrange fields in a form to input data.
https://user-images.githubusercontent.com/35857179/188585121-94d0260d-6dbd-4e34-9758-a1a3709fc416.png
![image](https://user-images.githubusercontent.com/35857179/188585121-94d0260d-6dbd-4e34-9758-a1a3709fc416.png)
<!-- ![image](https://user-images.githubusercontent.com/35857179/163355269-73d2a9d4-bafb-47c0-8c0d-d0e66503b47a.png) -->
You can drag-drop columns from the form to form-field-menu-bar as requried.
### Gallery View
Gallery View allows you to display images as thumbnails with other fields just like a gallery.
![image](https://user-images.githubusercontent.com/86527202/189322216-f8df0b69-5177-4ebc-be28-c11e3efb41a4.png)
### Kanban View
Kanban View allows you to visualise your data using cards at various stacks.
![image](https://user-images.githubusercontent.com/35857179/192695066-2927ac83-ea08-43af-9178-776df018f465.png)
## View Permission Types

2
packages/nocodb-sdk/package-lock.json generated

@ -15806,4 +15806,4 @@
"dev": true
}
}
}
}

144
packages/nocodb-sdk/src/lib/Api.ts

@ -136,7 +136,7 @@ export interface ViewType {
show_system_fields?: boolean;
lock_type?: 'collaborative' | 'locked' | 'personal';
type?: number;
view?: FormType | GridType | GalleryType;
view?: FormType | GridType | GalleryType | KanbanType;
}
export interface TableInfoType {
@ -312,7 +312,7 @@ export interface FormulaType {
}
export interface SelectOptionsType {
options: SelectOptionType;
options: SelectOptionType[];
}
export interface SelectOptionType {
@ -380,10 +380,10 @@ export interface KanbanType {
id?: string;
title?: string;
alias?: string;
public?: boolean;
password?: string;
columns?: KanbanColumnType[];
fk_model_id?: string;
grp_column_id?: string | null;
meta?: string | object;
}
export interface FormType {
@ -1898,6 +1898,65 @@ export class Api<
format: 'json',
...params,
}),
/**
* No description
*
* @tags DB view
* @name KanbanCreate
* @request POST:/api/v1/db/meta/tables/{tableId}/kanbans
* @response `200` `object` OK
*/
kanbanCreate: (
tableId: string,
data: KanbanType,
params: RequestParams = {}
) =>
this.request<object, any>({
path: `/api/v1/db/meta/tables/${tableId}/kanbans`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* No description
*
* @tags DB view
* @name KanbanUpdate
* @request PATCH:/api/v1/db/meta/kanbans/{kanbanId}
* @response `200` `void` OK
*/
kanbanUpdate: (
kanbanId: string,
data: KanbanType,
params: RequestParams = {}
) =>
this.request<void, any>({
path: `/api/v1/db/meta/kanbans/${kanbanId}`,
method: 'PATCH',
body: data,
type: ContentType.Json,
...params,
}),
/**
* No description
*
* @tags DB view
* @name KanbanRead
* @request GET:/api/v1/db/meta/kanbans/{kanbanId}
* @response `200` `KanbanType` OK
*/
kanbanRead: (kanbanId: string, params: RequestParams = {}) =>
this.request<KanbanType, any>({
path: `/api/v1/db/meta/kanbans/${kanbanId}`,
method: 'GET',
format: 'json',
...params,
}),
};
dbViewShare = {
/**
@ -2348,6 +2407,31 @@ export class Api<
...params,
}),
/**
* No description
*
* @tags DB table row
* @name GroupedDataList
* @summary Table Group by Column
* @request GET:/api/v1/db/data/{orgs}/{projectName}/{tableName}/group/{columnId}
* @response `200` `any` OK
*/
groupedDataList: (
orgs: string,
projectName: string,
tableName: string,
columnId: string,
query?: { fields?: any[]; sort?: any[]; where?: string; nested?: any },
params: RequestParams = {}
) =>
this.request<any, any>({
path: `/api/v1/db/data/${orgs}/${projectName}/${tableName}/group/${columnId}`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* No description
*
@ -2532,11 +2616,13 @@ export class Api<
projectName: string,
tableName: string,
data: any,
query?: { where?: string },
params: RequestParams = {}
) =>
this.request<any, any>({
path: `/api/v1/db/data/bulk/${orgs}/${projectName}/${tableName}/all`,
method: 'PATCH',
query: query,
body: data,
type: ContentType.Json,
format: 'json',
@ -2557,11 +2643,13 @@ export class Api<
projectName: string,
tableName: string,
data: any,
query?: { where?: string },
params: RequestParams = {}
) =>
this.request<any, any>({
path: `/api/v1/db/data/bulk/${orgs}/${projectName}/${tableName}/all`,
method: 'DELETE',
query: query,
body: data,
type: ContentType.Json,
format: 'json',
@ -2708,6 +2796,32 @@ export class Api<
}),
};
dbViewRow = {
/**
* No description
*
* @tags DB view row
* @name GroupedDataList
* @summary Table Group by Column
* @request GET:/api/v1/db/data/{orgs}/{projectName}/{tableName}/views/{viewName}/group/{columnId}
* @response `200` `any` OK
*/
groupedDataList: (
orgs: string,
projectName: string,
tableName: string,
viewName: string,
columnId: string,
query?: { fields?: any[]; sort?: any[]; where?: string; nested?: any },
params: RequestParams = {}
) =>
this.request<any, any>({
path: `/api/v1/db/data/${orgs}/${projectName}/${tableName}/views/${viewName}/group/${columnId}`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* No description
*
@ -2963,6 +3077,28 @@ export class Api<
}),
};
public = {
/**
* No description
*
* @tags Public
* @name GroupedDataList
* @request GET:/api/v1/db/public/shared-view/{sharedViewUuid}/group/{columnId}
* @response `200` `any` OK
*/
groupedDataList: (
sharedViewUuid: string,
columnId: string,
query?: { limit?: string; offset?: string },
params: RequestParams = {}
) =>
this.request<any, any>({
path: `/api/v1/db/public/shared-view/${sharedViewUuid}/group/${columnId}`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* No description
*

100
packages/nocodb/package-lock.json generated

@ -70,7 +70,7 @@
"mysql2": "^2.2.5",
"nanoid": "^3.1.20",
"nc-common": "0.0.6",
"nc-help": "0.2.68",
"nc-help": "0.2.76",
"nc-lib-gui": "0.97.0",
"nc-plugin": "0.1.2",
"ncp": "^2.0.0",
@ -785,9 +785,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.3.tgz",
"integrity": "sha512-38Y8f7YUhce/K7RMwTp7m0uCumpv9hZkitCbBClqQIow1qSbCvGkcegKOXpEWCQLfWmevgRiWokZ1GkpfhbZug==",
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
"integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
"dependencies": {
"regenerator-runtime": "^0.13.4"
},
@ -1468,9 +1468,9 @@
}
},
"node_modules/@rudderstack/rudder-sdk-node": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rudderstack/rudder-sdk-node/-/rudder-sdk-node-1.1.3.tgz",
"integrity": "sha512-zpuwF68zpRvt10LBmtU5sfaSOO4bbNtIIJAx7nne1hHoJnbeAwOUzDnp+UYKidjils3hBO3d38lPjlU/czBRTQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rudderstack/rudder-sdk-node/-/rudder-sdk-node-1.1.4.tgz",
"integrity": "sha512-z4hK6ZTPJXbpFTFoeh1MZOR+6kjEY6HZ1txxFH156RYC0PWerSO9IPLxLywh6nkOxzVS+iveiLaOD2iTWZWU6g==",
"dependencies": {
"@segment/loosely-validate-event": "^2.0.0",
"axios": "0.26.0",
@ -3503,9 +3503,9 @@
}
},
"node_modules/axios-retry": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.2.5.tgz",
"integrity": "sha512-a8umkKbfIkTiYJQLx3v3TzKM85TGKB8ZQYz4zwykt2fpO64TsRlUhjaPaAb3fqMWCXFm2YhWcd8V5FHDKO9bSA==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.3.1.tgz",
"integrity": "sha512-RohAUQTDxBSWLFEnoIG/6bvmy8l3TfpkclgStjl5MDCMBDgapAWCmr1r/9harQfWC8bzLC8job6UcL1A1Yc+/Q==",
"dependencies": {
"@babel/runtime": "^7.15.4",
"is-retry-allowed": "^2.2.0"
@ -4036,9 +4036,9 @@
"dev": true
},
"node_modules/bull": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.8.4.tgz",
"integrity": "sha512-vDNhM/pvfFY3+msulMbqPBdBO7ntKxRZRtMfi3EguVW/Ozo4uez+B81I8ZoDxYCLgSOBfwRuPnFtcv7QNzm4Ew==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.9.0.tgz",
"integrity": "sha512-yiaSb41dywjIhJ3i1mczjQGDmM6pLIoM1Ea0Gcf5HKDxOoEzL5i9XEEKW7fbsj7u083UEOnQ4gSWfbWIUDO6JQ==",
"dependencies": {
"cron-parser": "^4.2.1",
"debuglog": "^1.0.0",
@ -13910,9 +13910,9 @@
}
},
"node_modules/logform": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.4.1.tgz",
"integrity": "sha512-7XB/tqc3VRbri9pRjU6E97mQ8vC27ivJ3lct4jhyT+n0JNDd4YKldFl0D75NqDp46hk8RC7Ma1Vjv/UPf67S+A==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.4.2.tgz",
"integrity": "sha512-W4c9himeAwXEdZ05dQNerhFz2XG80P9Oj0loPUMV23VC2it0orMHQhJm4hdnnor3rd1HsGf6a2lPwBM1zeXHGw==",
"dependencies": {
"@colors/colors": "1.5.0",
"fecha": "^4.2.0",
@ -15254,9 +15254,9 @@
}
},
"node_modules/nc-help": {
"version": "0.2.68",
"resolved": "https://registry.npmjs.org/nc-help/-/nc-help-0.2.68.tgz",
"integrity": "sha512-KaG+cykMPU165RDwcJbpJvXhhg631/pD+ubsF7L/w7NneOaYqE22zNb+jAAzF0M2jHPk65yXDp3tyXOILpz2Ig==",
"version": "0.2.76",
"resolved": "https://registry.npmjs.org/nc-help/-/nc-help-0.2.76.tgz",
"integrity": "sha512-vSVQBDfxeGNdztnuKqj4Xd+dIaCYPuulIqH94lIHqUPq12OzrLEbBqVhXTgZ6GhKYWUeiVJBClky0qpWQPQJeg==",
"dependencies": {
"@rudderstack/rudder-sdk-node": "^1.1.3",
"axios": "^0.21.1",
@ -19216,9 +19216,9 @@
}
},
"node_modules/safe-stable-stringify": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz",
"integrity": "sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.0.tgz",
"integrity": "sha512-eehKHKpab6E741ud7ZIMcXhKcP6TSIezPkNZhy5U8xC6+VvrRdUA2tMgxGxaGl4cz7c2Ew5+mg5+wNB16KQqrA==",
"engines": {
"node": ">=10"
}
@ -24474,10 +24474,11 @@
}
},
"node_modules/winston": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.8.0.tgz",
"integrity": "sha512-Iix1w8rIq2kBDkGvclO0db2CVOHYVamCIkVWcUbs567G9i2pdB+gvqLgDgxx4B4HXHYD6U4Zybh6ojepUOqcFQ==",
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.8.2.tgz",
"integrity": "sha512-MsE1gRx1m5jdTTO9Ld/vND4krP2To+lgDoMEHGGa4HIlAUyXJtfc7CxQcGXVyz2IBpw5hbFkj2b/AtUdQwyRew==",
"dependencies": {
"@colors/colors": "1.5.0",
"@dabh/diagnostics": "^2.0.2",
"async": "^3.2.3",
"is-stream": "^2.0.0",
@ -25314,9 +25315,9 @@
}
},
"@babel/runtime": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.3.tgz",
"integrity": "sha512-38Y8f7YUhce/K7RMwTp7m0uCumpv9hZkitCbBClqQIow1qSbCvGkcegKOXpEWCQLfWmevgRiWokZ1GkpfhbZug==",
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
"integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
@ -25824,9 +25825,9 @@
}
},
"@rudderstack/rudder-sdk-node": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rudderstack/rudder-sdk-node/-/rudder-sdk-node-1.1.3.tgz",
"integrity": "sha512-zpuwF68zpRvt10LBmtU5sfaSOO4bbNtIIJAx7nne1hHoJnbeAwOUzDnp+UYKidjils3hBO3d38lPjlU/czBRTQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rudderstack/rudder-sdk-node/-/rudder-sdk-node-1.1.4.tgz",
"integrity": "sha512-z4hK6ZTPJXbpFTFoeh1MZOR+6kjEY6HZ1txxFH156RYC0PWerSO9IPLxLywh6nkOxzVS+iveiLaOD2iTWZWU6g==",
"requires": {
"@segment/loosely-validate-event": "^2.0.0",
"axios": "0.26.0",
@ -27549,9 +27550,9 @@
}
},
"axios-retry": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.2.5.tgz",
"integrity": "sha512-a8umkKbfIkTiYJQLx3v3TzKM85TGKB8ZQYz4zwykt2fpO64TsRlUhjaPaAb3fqMWCXFm2YhWcd8V5FHDKO9bSA==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.3.1.tgz",
"integrity": "sha512-RohAUQTDxBSWLFEnoIG/6bvmy8l3TfpkclgStjl5MDCMBDgapAWCmr1r/9harQfWC8bzLC8job6UcL1A1Yc+/Q==",
"requires": {
"@babel/runtime": "^7.15.4",
"is-retry-allowed": "^2.2.0"
@ -27976,9 +27977,9 @@
"dev": true
},
"bull": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.8.4.tgz",
"integrity": "sha512-vDNhM/pvfFY3+msulMbqPBdBO7ntKxRZRtMfi3EguVW/Ozo4uez+B81I8ZoDxYCLgSOBfwRuPnFtcv7QNzm4Ew==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.9.0.tgz",
"integrity": "sha512-yiaSb41dywjIhJ3i1mczjQGDmM6pLIoM1Ea0Gcf5HKDxOoEzL5i9XEEKW7fbsj7u083UEOnQ4gSWfbWIUDO6JQ==",
"requires": {
"cron-parser": "^4.2.1",
"debuglog": "^1.0.0",
@ -35640,9 +35641,9 @@
}
},
"logform": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.4.1.tgz",
"integrity": "sha512-7XB/tqc3VRbri9pRjU6E97mQ8vC27ivJ3lct4jhyT+n0JNDd4YKldFl0D75NqDp46hk8RC7Ma1Vjv/UPf67S+A==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.4.2.tgz",
"integrity": "sha512-W4c9himeAwXEdZ05dQNerhFz2XG80P9Oj0loPUMV23VC2it0orMHQhJm4hdnnor3rd1HsGf6a2lPwBM1zeXHGw==",
"requires": {
"@colors/colors": "1.5.0",
"fecha": "^4.2.0",
@ -36722,9 +36723,9 @@
"integrity": "sha512-3AryS9uwa5NfISLxMciUonrH7YfXp+nlahB9T7girXIsLQrmwX4MdnuKs32akduCOGpKmjTJSWmATULbuMkbfw=="
},
"nc-help": {
"version": "0.2.68",
"resolved": "https://registry.npmjs.org/nc-help/-/nc-help-0.2.68.tgz",
"integrity": "sha512-KaG+cykMPU165RDwcJbpJvXhhg631/pD+ubsF7L/w7NneOaYqE22zNb+jAAzF0M2jHPk65yXDp3tyXOILpz2Ig==",
"version": "0.2.76",
"resolved": "https://registry.npmjs.org/nc-help/-/nc-help-0.2.76.tgz",
"integrity": "sha512-vSVQBDfxeGNdztnuKqj4Xd+dIaCYPuulIqH94lIHqUPq12OzrLEbBqVhXTgZ6GhKYWUeiVJBClky0qpWQPQJeg==",
"requires": {
"@rudderstack/rudder-sdk-node": "^1.1.3",
"axios": "^0.21.1",
@ -39827,9 +39828,9 @@
}
},
"safe-stable-stringify": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz",
"integrity": "sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg=="
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.0.tgz",
"integrity": "sha512-eehKHKpab6E741ud7ZIMcXhKcP6TSIezPkNZhy5U8xC6+VvrRdUA2tMgxGxaGl4cz7c2Ew5+mg5+wNB16KQqrA=="
},
"safer-buffer": {
"version": "2.1.2",
@ -44004,10 +44005,11 @@
}
},
"winston": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.8.0.tgz",
"integrity": "sha512-Iix1w8rIq2kBDkGvclO0db2CVOHYVamCIkVWcUbs567G9i2pdB+gvqLgDgxx4B4HXHYD6U4Zybh6ojepUOqcFQ==",
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.8.2.tgz",
"integrity": "sha512-MsE1gRx1m5jdTTO9Ld/vND4krP2To+lgDoMEHGGa4HIlAUyXJtfc7CxQcGXVyz2IBpw5hbFkj2b/AtUdQwyRew==",
"requires": {
"@colors/colors": "1.5.0",
"@dabh/diagnostics": "^2.0.2",
"async": "^3.2.3",
"is-stream": "^2.0.0",

2
packages/nocodb/package.json

@ -155,7 +155,7 @@
"mysql2": "^2.2.5",
"nanoid": "^3.1.20",
"nc-common": "0.0.6",
"nc-help": "0.2.68",
"nc-help": "0.2.76",
"nc-lib-gui": "0.97.0",
"nc-plugin": "0.1.2",
"ncp": "^2.0.0",

4
packages/nocodb/src/lib/db/sql-data-mapper/lib/BaseModel.ts

@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/ban-types,prefer-const */
import Knex from 'knex';
import Filter from '../../../models/Filter'
import Sort from '../../../models/Sort'
const autoBind = require('auto-bind');
const _ = require('lodash');
@ -1515,6 +1517,8 @@ export interface XcFilter {
offset?: string | number;
sort?: string;
fields?: string;
filterArr?: Filter[]
sortArr?: Sort[]
}
export interface XcFilterWithAlias extends XcFilter {

259
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts

@ -2,6 +2,7 @@ import autoBind from 'auto-bind';
import _ from 'lodash';
import Model from '../../../../models/Model';
import SelectOption from '../../../../models/SelectOption';
import { XKnex } from '../../index';
import LinkToAnotherRecordColumn from '../../../../models/LinkToAnotherRecordColumn';
import RollupColumn from '../../../../models/RollupColumn';
@ -21,6 +22,7 @@ import View from '../../../../models/View';
import {
AuditOperationSubTypes,
AuditOperationTypes,
isVirtualCol,
RelationTypes,
SortType,
UITypes,
@ -407,7 +409,7 @@ class BaseModelSqlv2 {
.where(_wherePk(parentTable.primaryKeys, p))
);
// todo: sanitize
query.limit(+rest?.limit || 20);
query.limit(+rest?.limit || 25);
query.offset(+rest?.offset || 0);
return this.isSqlite ? this.dbDriver.select().from(query) : query;
@ -512,7 +514,7 @@ class BaseModelSqlv2 {
.where(_wherePk(parentTable.primaryKeys, id))
);
// todo: sanitize
qb.limit(+rest?.limit || 20);
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
await childModel.selectObject({ qb });
@ -615,7 +617,7 @@ class BaseModelSqlv2 {
.select(this.dbDriver.raw('? as ??', [id, GROUP_COL]));
// todo: sanitize
query.limit(+rest?.limit || 20);
query.limit(+rest?.limit || 25);
query.offset(+rest?.offset || 0);
return this.isSqlite ? this.dbDriver.select().from(query) : query;
@ -680,7 +682,7 @@ class BaseModelSqlv2 {
await childModel.selectObject({ qb });
// todo: sanitize
qb.limit(+rest?.limit || 20);
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
const children = await this.extractRawQueryAndExec(qb);
@ -1316,9 +1318,15 @@ class BaseModelSqlv2 {
}
}
public async selectObject({ qb }: { qb: QueryBuilder }): Promise<void> {
public async selectObject({
qb,
columns: _columns,
}: {
qb: QueryBuilder;
columns?: Column[];
}): Promise<void> {
const res = {};
const columns = await this.model.getColumns();
const columns = _columns ?? (await this.model.getColumns());
for (const column of columns) {
switch (column.uidt) {
case 'LinkToAnotherRecord':
@ -2348,6 +2356,243 @@ class BaseModelSqlv2 {
});
}
public async groupedList(
args: {
groupColumnId: string;
ignoreFilterSort?: boolean;
options?: (string | number | null | boolean)[];
} & Partial<XcFilter>
): Promise<
{
key: string;
value: Record<string, unknown>[];
}[]
> {
try {
const { where, ...rest } = this._getListArgs(args as any);
const column = await this.model
.getColumns()
.then((cols) => cols?.find((col) => col.id === args.groupColumnId));
if (!column) NcError.notFound('Column not found');
if (isVirtualCol(column))
NcError.notImplemented('Grouping for virtual columns not implemented');
// extract distinct group column values
let groupingValues: Set<any>;
if (args.options?.length) {
groupingValues = new Set(args.options);
} else if (column.uidt === UITypes.SingleSelect) {
const colOptions = await column.getColOptions<{
options: SelectOption[];
}>();
groupingValues = new Set(
(colOptions?.options ?? []).map((opt) => opt.title)
);
groupingValues.add(null);
} else {
groupingValues = new Set(
(
await this.dbDriver(this.model.table_name)
.select(column.column_name)
.distinct()
).map((row) => row[column.column_name])
);
groupingValues.add(null);
}
const qb = this.dbDriver(this.model.table_name);
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
await this.selectObject({ qb });
// todo: refactor and move to a method (applyFilterAndSort)
const aliasColObjMap = await this.model.getAliasColObjMap();
let sorts = extractSortsObject(args?.sort, aliasColObjMap);
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
// todo: replace with view id
if (!args.ignoreFilterSort && this.viewId) {
await conditionV2(
[
new Filter({
children:
(await Filter.rootFilterList({ viewId: this.viewId })) || [],
is_group: true,
}),
new Filter({
children: args.filterArr || [],
is_group: true,
logical_op: 'and',
}),
new Filter({
children: filterObj,
is_group: true,
logical_op: 'and',
}),
],
qb,
this.dbDriver
);
if (!sorts)
sorts = args.sortArr?.length
? args.sortArr
: await Sort.list({ viewId: this.viewId });
if (sorts?.['length']) await sortV2(sorts, qb, this.dbDriver);
} else {
await conditionV2(
[
new Filter({
children: args.filterArr || [],
is_group: true,
logical_op: 'and',
}),
new Filter({
children: filterObj,
is_group: true,
logical_op: 'and',
}),
],
qb,
this.dbDriver
);
if (!sorts) sorts = args.sortArr;
if (sorts?.['length']) await sortV2(sorts, qb, this.dbDriver);
}
// sort by primary key if not autogenerated string
// if autogenerated string sort by created_at column if present
if (this.model.primaryKey && this.model.primaryKey.ai) {
qb.orderBy(this.model.primaryKey.column_name);
} else if (
this.model.columns.find((c) => c.column_name === 'created_at')
) {
qb.orderBy('created_at');
}
const groupedQb = this.dbDriver.from(
this.dbDriver
.unionAll(
[...groupingValues].map((r) => {
const query = qb.clone();
if (r === null) {
query.whereNull(column.column_name);
} else {
query.where(column.column_name, r);
}
return this.isSqlite ? this.dbDriver.select().from(query) : query;
}),
!this.isSqlite
)
.as('__nc_grouped_list')
);
const proto = await this.getProto();
const result = (await groupedQb)?.map((d) => {
d.__proto__ = proto;
return d;
});
const groupedResult = result.reduce<Map<string | number | null, any[]>>(
(aggObj, row) => {
if (!aggObj.has(row[column.title])) {
aggObj.set(row[column.title], []);
}
aggObj.get(row[column.title]).push(row);
return aggObj;
},
new Map()
);
const r = [...groupingValues].map((key) => ({
key,
value: groupedResult.get(key) ?? [],
}));
return r;
} catch (e) {
console.log(e);
throw e;
}
}
public async groupedListCount(
args: { groupColumnId: string; ignoreFilterSort?: boolean } & XcFilter
) {
const column = await this.model
.getColumns()
.then((cols) => cols?.find((col) => col.id === args.groupColumnId));
if (!column) NcError.notFound('Column not found');
if (isVirtualCol(column))
NcError.notImplemented('Grouping for virtual columns not implemented');
const qb = this.dbDriver(this.model.table_name)
.count('*', { as: 'count' })
.groupBy(column.column_name);
// todo: refactor and move to a common method (applyFilterAndSort)
const aliasColObjMap = await this.model.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(args.where, aliasColObjMap);
// todo: replace with view id
if (!args.ignoreFilterSort && this.viewId) {
await conditionV2(
[
new Filter({
children:
(await Filter.rootFilterList({ viewId: this.viewId })) || [],
is_group: true,
}),
new Filter({
children: args.filterArr || [],
is_group: true,
logical_op: 'and',
}),
new Filter({
children: filterObj,
is_group: true,
logical_op: 'and',
}),
],
qb,
this.dbDriver
);
} else {
await conditionV2(
[
new Filter({
children: args.filterArr || [],
is_group: true,
logical_op: 'and',
}),
new Filter({
children: filterObj,
is_group: true,
logical_op: 'and',
}),
],
qb,
this.dbDriver
);
}
await this.selectObject({
qb,
columns: [new Column({ ...column, title: 'key' })],
});
return await qb;
}
private async extractRawQueryAndExec(qb: QueryBuilder) {
let query = qb.toQuery();
if (!this.isPg && !this.isMssql) {
@ -2472,7 +2717,7 @@ function extractCondition(nestedArrayConditions, aliasColObjMap) {
function applyPaginate(
query,
{
limit = 20,
limit = 25,
offset = 0,
ignoreLimit = false,
}: XcFilter & { ignoreLimit?: boolean }

25
packages/nocodb/src/lib/meta/api/columnApis.ts

@ -34,6 +34,7 @@ import mapDefaultPrimaryValue from '../helpers/mapDefaultPrimaryValue';
import NcConnectionMgrv2 from '../../utils/common/NcConnectionMgrv2';
import { metaApiMetrics } from '../helpers/apiMetrics';
import FormulaColumn from '../../models/FormulaColumn';
import KanbanView from '../../models/KanbanView';
import { MetaTable } from '../../utils/globals';
const randomID = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 10);
@ -541,7 +542,7 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
// handle single quote for default value
if (driverType === 'mysql' || driverType === 'mysql2') {
colBody.cdf = colBody.cdf.replace(/'/g, "\'");
colBody.cdf = colBody.cdf.replace(/'/g, "'");
} else {
colBody.cdf = colBody.cdf.replace(/'/g, "''");
}
@ -837,7 +838,7 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
// handle single quote for default value
if (driverType === 'mysql' || driverType === 'mysql2') {
colBody.cdf = colBody.cdf.replace(/'/g, "\'");
colBody.cdf = colBody.cdf.replace(/'/g, "'");
} else {
colBody.cdf = colBody.cdf.replace(/'/g, "''");
}
@ -927,7 +928,7 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
]);
} else {
await baseModel.bulkUpdateAll(
{ where: `(${column.column_name},eq,${option.title})` },
{ where: `(${column.title},eq,${option.title})` },
{ [column.column_name]: null },
{ cookie: req }
);
@ -1093,7 +1094,7 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
]);
} else {
await baseModel.bulkUpdateAll(
{ where: `(${column.column_name},eq,${option.title})` },
{ where: `(${column.title},eq,${option.title})` },
{ [column.column_name]: newOp.title },
{ cookie: req }
);
@ -1166,7 +1167,7 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
]);
} else {
await baseModel.bulkUpdateAll(
{ where: `(${column.column_name},eq,${ch.temp_title})` },
{ where: `(${column.title},eq,${ch.temp_title})` },
{ [column.column_name]: newOp.title },
{ cookie: req }
);
@ -1509,9 +1510,21 @@ export async function columnDelete(req: Request, res: Response<TableType>) {
}
Tele.emit('evt', { evt_type: 'raltion:deleted' });
break;
case UITypes.ForeignKey:
case UITypes.ForeignKey: {
NcError.notImplemented();
break;
}
// @ts-ignore
case UITypes.SingleSelect: {
if (column.uidt === UITypes.SingleSelect) {
if (await KanbanView.IsColumnBeingUsedAsGroupingField(column.id)) {
NcError.badRequest(
`The column '${column.column_name}' is being used in Kanban View. Please delete Kanban View first.`
);
}
}
/* falls through to default */
}
default: {
const tableUpdateBody = {
...table,

76
packages/nocodb/src/lib/meta/api/dataApis/dataAliasApis.ts

@ -222,6 +222,70 @@ async function dataExist(req: Request, res: Response) {
res.json(await baseModel.exist(req.params.rowId));
}
// todo: Handle the error case where view doesnt belong to model
async function groupedDataList(req: Request, res: Response) {
const { model, view } = await getViewAndModelFromRequestByAliasOrId(req);
res.json(await getGroupedDataList(model, view, req));
}
async function getGroupedDataList(model, view: View, req) {
const base = await Base.get(model.base_id);
const baseModel = await Model.getBaseModelSQL({
id: model.id,
viewId: view?.id,
dbDriver: NcConnectionMgrv2.get(base),
});
const requestObj = await getAst({ model, query: req.query, view });
const listArgs: any = { ...req.query };
try {
listArgs.filterArr = JSON.parse(listArgs.filterArrJson);
} catch (e) {}
try {
listArgs.sortArr = JSON.parse(listArgs.sortArrJson);
} catch (e) {}
try {
listArgs.options = JSON.parse(listArgs.optionsArrJson);
} catch (e) {}
let data = [];
// let count = 0
try {
const groupedData = await baseModel.groupedList({
...listArgs,
groupColumnId: req.params.columnId,
});
data = await nocoExecute(
{ key: 1, value: requestObj },
groupedData,
{},
listArgs
);
const countArr = await baseModel.groupedListCount({
...listArgs,
groupColumnId: req.params.columnId,
});
data = data.map((item) => {
// todo: use map to avoid loop
const count =
countArr.find((countItem) => countItem.key === item.key)?.count ?? 0;
item.value = new PagedResponseImpl(item.value, {
...req.query,
count: count,
});
return item;
});
} catch (e) {
console.log(e);
// show empty result instead of throwing error here
// e.g. search some text in a numeric field
}
return data;
}
const router = Router({ mergeParams: true });
// table data crud apis
@ -243,6 +307,12 @@ router.get(
ncMetaAclMw(dataGroupBy, 'dataGroupBy')
);
router.get(
'/api/v1/db/data/:orgs/:projectName/:tableName/group/:columnId',
apiMetrics,
ncMetaAclMw(groupedDataList, 'groupedDataList')
);
router.get(
'/api/v1/db/data/:orgs/:projectName/:tableName/:rowId/exist',
apiMetrics,
@ -304,6 +374,12 @@ router.get(
ncMetaAclMw(dataGroupBy, 'dataGroupBy')
);
router.get(
'/api/v1/db/data/:orgs/:projectName/:tableName/views/:viewName/group/:columnId',
apiMetrics,
ncMetaAclMw(groupedDataList, 'groupedDataList')
);
router.get(
'/api/v1/db/data/:orgs/:projectName/:tableName/views/:viewName/:rowId/exist',
apiMetrics,

2
packages/nocodb/src/lib/meta/api/index.ts

@ -16,6 +16,7 @@ import auditApis from './auditApis';
import hookApis from './hookApis';
import pluginApis from './pluginApis';
import gridViewColumnApis from './gridViewColumnApis';
import kanbanViewApis from './kanbanViewApis';
import { userApis } from './userApi';
// import extractProjectIdAndAuthenticate from './helpers/extractProjectIdAndAuthenticate';
import utilApis from './utilApis';
@ -90,6 +91,7 @@ export default function (router: Router, server) {
router.use(hookFilterApis);
router.use(swaggerApis);
router.use(syncSourceApis);
router.use(kanbanViewApis);
userApis(router);

46
packages/nocodb/src/lib/meta/api/kanbanViewApis.ts

@ -0,0 +1,46 @@
import { Request, Response, Router } from 'express';
import { KanbanType, ViewTypes } from 'nocodb-sdk';
import View from '../../models/View';
import KanbanView from '../../models/KanbanView';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
export async function kanbanViewGet(req: Request, res: Response<KanbanType>) {
res.json(await KanbanView.get(req.params.kanbanViewId));
}
export async function kanbanViewCreate(req: Request<any, any>, res) {
Tele.emit('evt', { evt_type: 'vtable:created', show_as: 'kanban' });
const view = await View.insert({
...req.body,
// todo: sanitize
fk_model_id: req.params.tableId,
type: ViewTypes.KANBAN,
});
res.json(view);
}
export async function kanbanViewUpdate(req, res) {
Tele.emit('evt', { evt_type: 'view:updated', type: 'kanban' });
res.json(await KanbanView.update(req.params.kanbanViewId, req.body));
}
const router = Router({ mergeParams: true });
router.post(
'/api/v1/db/meta/tables/:tableId/kanbans',
metaApiMetrics,
ncMetaAclMw(kanbanViewCreate, 'kanbanViewCreate')
);
router.patch(
'/api/v1/db/meta/kanbans/:kanbanViewId',
metaApiMetrics,
ncMetaAclMw(kanbanViewUpdate, 'kanbanViewUpdate')
);
router.get(
'/api/v1/db/meta/kanbans/:kanbanViewId',
metaApiMetrics,
ncMetaAclMw(kanbanViewGet, 'kanbanViewGet')
);
export default router;

91
packages/nocodb/src/lib/meta/api/publicApis/publicDataApis.ts

@ -24,7 +24,9 @@ export async function dataList(req: Request, res: Response) {
const view = await View.getByUUID(req.params.sharedViewUuid);
if (!view) NcError.notFound('Not found');
if (view.type !== ViewTypes.GRID) NcError.notFound('Not found');
if (view.type !== ViewTypes.GRID && view.type !== ViewTypes.KANBAN) {
NcError.notFound('Not found');
}
if (view.password && view.password !== req.headers?.['xc-password']) {
return NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD);
@ -79,6 +81,89 @@ export async function dataList(req: Request, res: Response) {
}
}
// todo: Handle the error case where view doesnt belong to model
async function groupedDataList(req: Request, res: Response) {
try {
const view = await View.getByUUID(req.params.sharedViewUuid);
if (!view) NcError.notFound('Not found');
if (view.type !== ViewTypes.GRID && view.type !== ViewTypes.KANBAN) {
NcError.notFound('Not found');
}
if (view.password && view.password !== req.headers?.['xc-password']) {
return NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD);
}
const model = await Model.getByIdOrName({
id: view?.fk_model_id,
});
res.json(await getGroupedDataList(model, view, req));
} catch (e) {
console.log(e);
res.status(500).json({ msg: e.message });
}
}
async function getGroupedDataList(model, view: View, req) {
const base = await Base.get(model.base_id);
const baseModel = await Model.getBaseModelSQL({
id: model.id,
viewId: view?.id,
dbDriver: NcConnectionMgrv2.get(base),
});
const requestObj = await getAst({ model, query: req.query, view });
const listArgs: any = { ...req.query };
try {
listArgs.filterArr = JSON.parse(listArgs.filterArrJson);
} catch (e) {}
try {
listArgs.sortArr = JSON.parse(listArgs.sortArrJson);
} catch (e) {}
try {
listArgs.options = JSON.parse(listArgs.optionsArrJson);
} catch (e) {}
let data = [];
try {
const groupedData = await baseModel.groupedList({
...listArgs,
groupColumnId: req.params.columnId,
});
data = await nocoExecute(
{ key: 1, value: requestObj },
groupedData,
{},
listArgs
);
const countArr = await baseModel.groupedListCount({
...listArgs,
groupColumnId: req.params.columnId,
});
data = data.map((item) => {
// todo: use map to avoid loop
const count =
countArr.find((countItem) => countItem.key === item.key)?.count ?? 0;
item.value = new PagedResponseImpl(item.value, {
...req.query,
count: count,
});
return item;
});
} catch (e) {
// show empty result instead of throwing error here
// e.g. search some text in a numeric field
}
return data;
}
async function dataInsert(
req: Request & { files: any[] },
res: Response,
@ -345,6 +430,10 @@ router.get(
'/api/v1/db/public/shared-view/:sharedViewUuid/rows',
catchError(dataList)
);
router.get(
'/api/v1/db/public/shared-view/:sharedViewUuid/group/:columnId',
catchError(groupedDataList)
);
router.get(
'/api/v1/db/public/shared-view/:sharedViewUuid/nested/:columnId',
catchError(relDataList)

4
packages/nocodb/src/lib/meta/api/publicApis/publicDataExportApis.ts

@ -16,7 +16,7 @@ import getAst from '../../../db/sql-data-mapper/lib/sql/helpers/getAst';
async function exportExcel(req: Request, res: Response) {
const view = await View.getByUUID(req.params.publicDataUuid);
if (!view) NcError.notFound('Not found');
if (view.type !== ViewTypes.GRID) NcError.notFound('Not found');
if (view.type !== ViewTypes.GRID && view.type !== ViewTypes.KANBAN) NcError.notFound('Not found');
if (view.password && view.password !== req.headers?.['xc-password']) {
NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD);
@ -47,7 +47,7 @@ async function exportCsv(req: Request, res: Response) {
const fields = req.query.fields;
if (!view) NcError.notFound('Not found');
if (view.type !== ViewTypes.GRID) NcError.notFound('Not found');
if (view.type !== ViewTypes.GRID && view.type !== ViewTypes.KANBAN) NcError.notFound('Not found');
if (view.password && view.password !== req.headers?.['xc-password']) {
NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD);

17
packages/nocodb/src/lib/meta/api/utilApis.ts

@ -10,6 +10,7 @@ import NcConfigFactory, {
import User from '../../models/User';
import catchError from '../helpers/catchError';
import axios from 'axios';
import { feedbackForm } from 'nc-help';
const versionCache = {
releaseVersion: null,
@ -19,6 +20,7 @@ const versionCache = {
export async function testConnection(req: Request, res: Response) {
res.json(await SqlMgrv2.testConnection(req.body));
}
export async function appInfo(req: Request, res: Response) {
const projectHasAdmin = !(await User.isFirst());
const result = {
@ -80,17 +82,10 @@ export async function versionInfo(_req: Request, res: Response) {
res.json(response);
}
export async function feedbackFormGet(_req: Request, res: Response) {
axios
.get('https://nocodb.com/api/v1/feedback_form', {
timeout: 5000,
})
.then((response) => {
res.json(response.data);
})
.catch((e) => {
res.json({ error: e.message });
});
export function feedbackFormGet(_req: Request, res: Response) {
feedbackForm()
.then((form) => res.json(form))
.catch((e) => res.json({ error: e.message }));
}
export async function appHealth(_: Request, res: Response) {

4
packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts

@ -7,6 +7,7 @@ import * as nc_016_alter_hooklog_payload_types from './v2/nc_016_alter_hooklog_p
import * as nc_017_add_user_token_version_column from './v2/nc_017_add_user_token_version_column';
import * as nc_018_add_meta_in_view from './v2/nc_018_add_meta_in_view';
import * as nc_019_add_meta_in_meta_tables from './v2/nc_019_add_meta_in_meta_tables';
import * as nc_020_add_kanban_meta_col from './v2/nc_020_add_kanban_meta_col';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -25,6 +26,7 @@ export default class XcMigrationSourcev2 {
'nc_017_add_user_token_version_column',
'nc_018_add_meta_in_view',
'nc_019_add_meta_in_meta_tables',
'nc_020_add_kanban_meta_col',
]);
}
@ -52,6 +54,8 @@ export default class XcMigrationSourcev2 {
return nc_018_add_meta_in_view;
case 'nc_019_add_meta_in_meta_tables':
return nc_019_add_meta_in_meta_tables;
case 'nc_020_add_kanban_meta_col':
return nc_020_add_kanban_meta_col;
}
}
}

40
packages/nocodb/src/lib/migrations/v2/nc_020_add_kanban_meta_col.ts

@ -0,0 +1,40 @@
import Knex from 'knex';
import { MetaTable } from '../../utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.KANBAN_VIEW, (table) => {
table.string('grp_column_id');
table.text('meta');
});
};
const down = async (knex) => {
await knex.schema.alterTable(MetaTable.KANBAN_VIEW, (table) => {
table.dropColumns('grp_column_id');
table.dropColumns('meta');
});
};
export { up, down };
/**
* @copyright Copyright (c) 2021, Xgene Cloud Ltd
*
* @author Wing-Kam Wong <wingkwong.code@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

112
packages/nocodb/src/lib/models/KanbanView.ts

@ -1,23 +1,117 @@
import Noco from '../Noco';
import { MetaTable } from '../utils/globals';
import { KanbanType } from 'nocodb-sdk';
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import View from './View';
import NocoCache from '../cache/NocoCache';
export default class KanbanView {
export default class KanbanView implements KanbanType {
fk_view_id: string;
title: string;
show: boolean;
is_default: boolean;
order: number;
project_id?: string;
base_id?: string;
grp_column_id?: string;
meta?: string | object;
fk_view_id: string;
// below fields are not in use at this moment
// keep them for time being
show?: boolean;
order?: number;
uuid?: string;
public?: boolean;
password?: string;
show_all_fields?: boolean;
constructor(data: KanbanView) {
Object.assign(this, data);
}
public static async get(viewId: string, ncMeta = Noco.ncMeta) {
const view = await ncMeta.metaGet2(null, null, MetaTable.KANBAN_VIEW, {
fk_view_id: viewId,
});
let view =
viewId &&
(await NocoCache.get(
`${CacheScope.KANBAN_VIEW}:${viewId}`,
CacheGetType.TYPE_OBJECT
));
if (!view) {
view = await ncMeta.metaGet2(null, null, MetaTable.KANBAN_VIEW, {
fk_view_id: viewId,
});
await NocoCache.set(`${CacheScope.KANBAN_VIEW}:${viewId}`, view);
}
return view && new KanbanView(view);
}
public static async IsColumnBeingUsedAsGroupingField(
columnId: string,
ncMeta = Noco.ncMeta
) {
return (
(
await ncMeta.metaList2(null, null, MetaTable.KANBAN_VIEW, {
condition: {
grp_column_id: columnId,
},
})
).length > 0
);
}
static async insert(view: Partial<KanbanView>, ncMeta = Noco.ncMeta) {
const insertObj = {
project_id: view.project_id,
base_id: view.base_id,
fk_view_id: view.fk_view_id,
grp_column_id: view.grp_column_id,
meta: view.meta,
};
if (!(view.project_id && view.base_id)) {
const viewRef = await View.get(view.fk_view_id);
insertObj.project_id = viewRef.project_id;
insertObj.base_id = viewRef.base_id;
}
await ncMeta.metaInsert2(
null,
null,
MetaTable.KANBAN_VIEW,
insertObj,
true
);
return this.get(view.fk_view_id, ncMeta);
}
static async update(
kanbanId: string,
body: Partial<KanbanView>,
ncMeta = Noco.ncMeta
) {
// get existing cache
const key = `${CacheScope.KANBAN_VIEW}:${kanbanId}`;
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
const updateObj = {
...body,
meta:
typeof body.meta === 'string'
? body.meta
: JSON.stringify(body.meta ?? {}),
};
if (o) {
o = { ...o, ...updateObj };
// set cache
await NocoCache.set(key, o);
}
// update meta
return await ncMeta.metaUpdate(
null,
null,
MetaTable.KANBAN_VIEW,
updateObj,
{
fk_view_id: kanbanId,
}
);
}
}

108
packages/nocodb/src/lib/models/KanbanViewColumn.ts

@ -0,0 +1,108 @@
import Noco from '../Noco';
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import View from './View';
import NocoCache from '../cache/NocoCache';
export default class KanbanViewColumn {
id: string;
title?: string;
show?: boolean;
order?: number;
fk_view_id: string;
fk_column_id: string;
project_id?: string;
base_id?: string;
constructor(data: KanbanViewColumn) {
Object.assign(this, data);
}
public static async get(kanbanViewColumnId: string, ncMeta = Noco.ncMeta) {
let view =
kanbanViewColumnId &&
(await NocoCache.get(
`${CacheScope.KANBAN_VIEW_COLUMN}:${kanbanViewColumnId}`,
CacheGetType.TYPE_OBJECT
));
if (!view) {
view = await ncMeta.metaGet2(
null,
null,
MetaTable.KANBAN_VIEW_COLUMNS,
kanbanViewColumnId
);
await NocoCache.set(
`${CacheScope.KANBAN_VIEW_COLUMN}:${kanbanViewColumnId}`,
view
);
}
return view && new KanbanViewColumn(view);
}
static async insert(column: Partial<KanbanViewColumn>, ncMeta = Noco.ncMeta) {
const insertObj = {
fk_view_id: column.fk_view_id,
fk_column_id: column.fk_column_id,
order: await ncMeta.metaGetNextOrder(MetaTable.KANBAN_VIEW_COLUMNS, {
fk_view_id: column.fk_view_id,
}),
show: column.show,
project_id: column.project_id,
base_id: column.base_id,
};
if (!(column.project_id && column.base_id)) {
const viewRef = await View.get(column.fk_view_id, ncMeta);
insertObj.project_id = viewRef.project_id;
insertObj.base_id = viewRef.base_id;
}
const { id, fk_column_id } = await ncMeta.metaInsert2(
null,
null,
MetaTable.KANBAN_VIEW_COLUMNS,
insertObj
);
await NocoCache.set(`${CacheScope.KANBAN_VIEW_COLUMN}:${fk_column_id}`, id);
await NocoCache.appendToList(
CacheScope.KANBAN_VIEW_COLUMN,
[column.fk_view_id],
`${CacheScope.KANBAN_VIEW_COLUMN}:${id}`
);
return this.get(id, ncMeta);
}
public static async list(
viewId: string,
ncMeta = Noco.ncMeta
): Promise<KanbanViewColumn[]> {
let views = await NocoCache.getList(CacheScope.KANBAN_VIEW_COLUMN, [
viewId,
]);
if (!views.length) {
views = await ncMeta.metaList2(
null,
null,
MetaTable.KANBAN_VIEW_COLUMNS,
{
condition: {
fk_view_id: viewId,
},
orderBy: {
order: 'asc',
},
}
);
await NocoCache.setList(CacheScope.KANBAN_VIEW_COLUMN, [viewId], views);
}
views.sort(
(a, b) =>
(a.order != null ? a.order : Infinity) -
(b.order != null ? b.order : Infinity)
);
return views?.map((v) => new KanbanViewColumn(v));
}
}

5
packages/nocodb/src/lib/models/Model.ts

@ -404,7 +404,10 @@ export default class Model implements TableType {
const insertObj = {};
for (const col of await this.getColumns()) {
if (isVirtualCol(col)) continue;
const val = data?.[col.column_name] ?? data?.[col.title];
const val =
data?.[col.column_name] !== undefined
? data?.[col.column_name]
: data?.[col.title];
if (val !== undefined) {
insertObj[sanitize(col.column_name)] = val;
}

133
packages/nocodb/src/lib/models/View.ts

@ -13,9 +13,10 @@ import GalleryView from './GalleryView';
import GridViewColumn from './GridViewColumn';
import Sort from './Sort';
import Filter from './Filter';
import { isSystemColumn, ViewType, ViewTypes } from 'nocodb-sdk';
import { isSystemColumn, UITypes, ViewType, ViewTypes } from 'nocodb-sdk';
import GalleryViewColumn from './GalleryViewColumn';
import FormViewColumn from './FormViewColumn';
import KanbanViewColumn from './KanbanViewColumn';
import Column from './Column';
import NocoCache from '../cache/NocoCache';
import { extractProps } from '../meta/helpers/extractProps';
@ -35,7 +36,9 @@ export default class View implements ViewType {
fk_model_id: string;
model?: Model;
view?: FormView | GridView | KanbanView | GalleryView;
columns?: Array<FormViewColumn | GridViewColumn | GalleryViewColumn>;
columns?: Array<
FormViewColumn | GridViewColumn | GalleryViewColumn | KanbanViewColumn
>;
sorts: Sort[];
filter: Filter;
@ -222,6 +225,7 @@ export default class View implements ViewType {
view: Partial<View> &
Partial<FormView | GridView | GalleryView | KanbanView> & {
copy_from_id?: string;
grp_column_id?: string;
created_at?;
updated_at?;
},
@ -270,6 +274,10 @@ export default class View implements ViewType {
`${CacheScope.VIEW}:${view_id}`
);
let columns: any[] = await (
await Model.getByIdOrName({ id: view.fk_model_id }, ncMeta)
).getColumns(ncMeta);
// insert view metadata based on view type
switch (view.type) {
case ViewTypes.GRID:
@ -302,11 +310,20 @@ export default class View implements ViewType {
ncMeta
);
break;
}
case ViewTypes.KANBAN:
// set grouping field
(view as KanbanView).grp_column_id = view.grp_column_id;
let columns: any[] = await (
await Model.getByIdOrName({ id: view.fk_model_id }, ncMeta)
).getColumns(ncMeta);
await KanbanView.insert(
{
...(copyFromView?.view || {}),
...view,
fk_view_id: view_id,
},
ncMeta
);
break;
}
if (copyFromView) {
const sorts = await copyFromView.getSorts(ncMeta);
@ -338,17 +355,72 @@ export default class View implements ViewType {
{
let order = 1;
let galleryShowLimit = 0;
let kanbanShowCount = 0;
let kanbanAttachmentCount = 0;
if (view.type === ViewTypes.KANBAN && !copyFromView) {
// sort by primary value & attachment first, then by singleLineText & Number
// so that later we can handle control `show` easily
columns.sort((a, b) => {
const primaryValueOrder = b.pv - a.pv;
const attachmentOrder =
+(b.uidt === UITypes.Attachment) - +(a.uidt === UITypes.Attachment);
const singleLineTextOrder =
+(b.uidt === UITypes.SingleLineText) -
+(a.uidt === UITypes.SingleLineText);
const numberOrder =
+(b.uidt === UITypes.Number) - +(a.uidt === UITypes.Number);
const defaultOrder = b.order - a.order;
return (
primaryValueOrder ||
attachmentOrder ||
singleLineTextOrder ||
numberOrder ||
defaultOrder
);
});
}
for (const vCol of columns) {
let show = 'show' in vCol ? vCol.show : true;
if (view.type === ViewTypes.GALLERY) {
const galleryView = await GalleryView.get(view_id, ncMeta);
if (vCol.id === galleryView.fk_cover_image_col_id || vCol.pv || galleryShowLimit < 3) {
if (
vCol.id === galleryView.fk_cover_image_col_id ||
vCol.pv ||
galleryShowLimit < 3
) {
show = true;
galleryShowLimit++;
} else {
show = false;
}
} else if (view.type === ViewTypes.KANBAN && !copyFromView) {
const kanbanView = await KanbanView.get(view_id, ncMeta);
if (vCol.id === kanbanView?.grp_column_id) {
// include grouping field if it exists
show = true;
} else if (vCol.pv) {
// Show primary key
show = true;
kanbanShowCount++;
} else if (
vCol.uidt === UITypes.Attachment &&
kanbanAttachmentCount < 1
) {
// Show at most 1 attachment
show = true;
kanbanAttachmentCount++;
kanbanShowCount++;
} else if (kanbanShowCount < 3 && !isSystemColumn(vCol)) {
// show at most 3 non-system columns
show = true;
kanbanShowCount++;
} else {
// other columns will be hidden
show = false;
}
}
// if columns is list of virtual columns then get the parent column
@ -412,6 +484,15 @@ export default class View implements ViewType {
ncMeta
);
break;
case ViewTypes.KANBAN:
await KanbanViewColumn.insert(
{
...insertObj,
fk_view_id: view.id,
},
ncMeta
);
break;
}
}
}
@ -463,6 +544,17 @@ export default class View implements ViewType {
);
}
break;
case ViewTypes.KANBAN:
{
col = await KanbanViewColumn.insert(
{
...param,
fk_view_id: view.id,
},
ncMeta
);
}
break;
}
return col;
@ -479,7 +571,11 @@ export default class View implements ViewType {
static async getColumns(
viewId: string,
ncMeta = Noco.ncMeta
): Promise<Array<GridViewColumn | FormViewColumn | GalleryViewColumn>> {
): Promise<
Array<
GridViewColumn | FormViewColumn | GalleryViewColumn | KanbanViewColumn
>
> {
let columns: Array<GridViewColumn | any> = [];
const view = await this.get(viewId, ncMeta);
@ -488,13 +584,15 @@ export default class View implements ViewType {
case ViewTypes.GRID:
columns = await GridViewColumn.list(viewId, ncMeta);
break;
case ViewTypes.GALLERY:
columns = await GalleryViewColumn.list(viewId, ncMeta);
break;
case ViewTypes.FORM:
columns = await FormViewColumn.list(viewId, ncMeta);
break;
case ViewTypes.KANBAN:
columns = await KanbanViewColumn.list(viewId, ncMeta);
break;
}
return columns;
@ -559,7 +657,9 @@ export default class View implements ViewType {
show: boolean;
},
ncMeta = Noco.ncMeta
): Promise<GridViewColumn | FormViewColumn | GalleryViewColumn | any> {
): Promise<
GridViewColumn | FormViewColumn | GalleryViewColumn | KanbanViewColumn | any
> {
const view = await this.get(viewId);
const table = this.extractViewColumnsTableName(view);
console.log(table);
@ -598,13 +698,12 @@ export default class View implements ViewType {
show: colData.show,
});
case ViewTypes.KANBAN:
// TODO: Use the following when KanbanViewColumn is ready to avoid cache issue
// return await KanbanViewColumn.insert({
// fk_view_id: viewId,
// fk_column_id: fkColId,
// order: colData.order,
// show: colData.show,
// });
return await KanbanViewColumn.insert({
fk_view_id: viewId,
fk_column_id: fkColId,
order: colData.order,
show: colData.show,
});
break;
case ViewTypes.FORM:
return await FormViewColumn.insert({

8
packages/nocodb/src/lib/utils/projectAcl.ts

@ -74,6 +74,8 @@ export default {
projectInfoGet: true,
gridColumnUpdate: true,
galleryViewGet: true,
kanbanViewGet: true,
groupedDataList: true,
// old
xcTableAndViewList: true,
@ -185,7 +187,9 @@ export default {
dataGroupBy: true,
commentsCount: true,
galleryViewGet: true,
alleryViewGet: true,
kanbanViewGet: true,
groupedDataList: true,
xcTableAndViewList: true,
xcVirtualTableList: true,
@ -236,6 +240,8 @@ export default {
projectInfoGet: true,
galleryViewGet: true,
kanbanViewGet: true,
groupedDataList: true,
mmList: true,
hmList: true,

12
packages/nocodb/tests/unit/TestDbMngr.ts

@ -110,7 +110,7 @@ export default class TestDbMngr {
await TestDbMngr.resetMetaSqlite();
TestDbMngr.metaKnex = knex(TestDbMngr.getMetaDbConfig());
return
}
}
TestDbMngr.metaKnex = knex(TestDbMngr.getDbConfigWithNoDb());
await TestDbMngr.resetDatabase(TestDbMngr.metaKnex, TestDbMngr.dbName);
@ -129,12 +129,12 @@ export default class TestDbMngr {
await TestDbMngr.seedSakila();
TestDbMngr.sakilaKnex = knex(TestDbMngr.getSakilaDbConfig());
return
}
}
TestDbMngr.sakilaKnex = knex(TestDbMngr.getDbConfigWithNoDb());
await TestDbMngr.resetDatabase(TestDbMngr.sakilaKnex, TestDbMngr.sakilaDbName);
await TestDbMngr.sakilaKnex.destroy();
TestDbMngr.sakilaKnex = knex(TestDbMngr.getSakilaDbConfig());
await TestDbMngr.useDatabase(TestDbMngr.sakilaKnex, TestDbMngr.sakilaDbName);
}
@ -217,7 +217,7 @@ export default class TestDbMngr {
return sakilaDbConfig;
}
static async seedSakila() {
static async seedSakila() {
const testsDir = __dirname.replace('tests/unit', 'tests');
if(TestDbMngr.isSqlite()){
@ -263,4 +263,4 @@ export default class TestDbMngr {
);
}
}
}
}

21
packages/nocodb/tests/unit/rest/tests/table.test.ts

@ -45,7 +45,7 @@ function tableTest() {
return new Error('Tables is not be created');
}
if (response.body.columns.length !== (defaultColumns(context))) {
if (response.body.columns.length !== defaultColumns(context)) {
return new Error('Columns not saved properly');
}
@ -121,15 +121,15 @@ function tableTest() {
})
.expect(400);
if (!response.text.includes('Duplicate table alias')) {
console.error(response.text);
return new Error('Wrong api response');
}
if (!response.text.includes('Duplicate table alias')) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
});
it('Create table with title length more than the limit', async function () {
@ -152,7 +152,6 @@ function tableTest() {
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
});
it('Create table with title having leading white space', async function () {
@ -221,7 +220,7 @@ function tableTest() {
.send({})
.expect(200);
if (response.body.id !== table.id) new Error('Wrong table');
if (response.body.id !== table.id) new Error('Wrong table');
});
// todo: flaky test, order condition is sometimes not met

668
packages/nocodb/tests/unit/rest/tests/tableRow.test.ts

File diff suppressed because it is too large Load Diff

581
packages/nocodb/tests/unit/rest/tests/viewRow.test.ts

File diff suppressed because it is too large Load Diff

564
scripts/cypress/integration/common/4h_kanban.js

@ -0,0 +1,564 @@
import { mainPage } from "../../support/page_objects/mainPage";
import {
isTestSuiteActive,
isXcdb,
} from "../../support/page_objects/projectConstants";
import { loginPage } from "../../support/page_objects/navigation";
// kanban grouping field configuration
//
function configureGroupingField(field, closeMenu = true) {
cy.get(".nc-kanban-stacked-by-menu-btn").click();
cy.getActiveMenu(".nc-dropdown-kanban-stacked-by-menu")
.should("exist")
.find(".nc-kanban-grouping-field-select")
.click();
cy.get(".ant-select-dropdown:visible")
.should("exist")
.find(`.ant-select-item`)
.contains(new RegExp("^" + field + "$", "g"))
.should("exist")
.click();
if (closeMenu) {
cy.get(".nc-kanban-stacked-by-menu-btn").click();
}
cy.get(".nc-kanban-stacked-by-menu-btn")
.contains(`Stacked By ${field}`)
.should("exist");
}
// number of kanban stacks altogether
//
function verifyKanbanStackCount(count) {
cy.get(".nc-kanban-stack").should("have.length", count);
}
// order of kanban stacks
//
function verifyKanbanStackOrder(order) {
cy.get(".nc-kanban-stack").each(($el, index) => {
cy.wrap($el).should("contain", order[index]);
});
}
// kanban stack footer numbers
//
function verifyKanbanStackFooterCount(count) {
cy.get(".nc-kanban-stack").each(($el, index) => {
cy.wrap($el)
.scrollIntoView()
.find(".nc-kanban-data-count")
.should(
"contain",
`${count[index]} record${count[index] !== 1 ? "s" : ""}`
);
});
}
// kanban card count in a stack
//
function verifyKanbanStackCardCount(count) {
cy.get(".nc-kanban-stack").each(($el, index) => {
if (count[index] > 0) {
cy.wrap($el)
.find(".nc-kanban-item")
.should("exist")
.should("have.length", count[index]);
}
});
}
// order of cards within a stack
//
function verifyKanbanStackCardOrder(order, stackIndex, cardIndex) {
cy.get(".nc-kanban-stack")
.eq(stackIndex)
.find(".nc-kanban-item")
.eq(cardIndex)
.should("contain", order);
}
// drag drop kanban card
//
function dragAndDropKanbanCard(srcCard, dstCard) {
cy.get(`.nc-kanban-item .ant-card :visible:contains("${srcCard}")`).drag(
`.nc-kanban-item :visible:contains("${dstCard}")`
);
}
// drag drop kanban stack
//
function dragAndDropKanbanStack(srcStack, dstStack) {
cy.get(`.nc-kanban-stack-head :contains("${srcStack}")`).drag(
`.nc-kanban-stack-head :contains("${dstStack}")`
);
}
let localDebug = false;
function addOption(index, value) {
cy.getActiveMenu(".nc-dropdown-edit-column")
.find(".ant-btn-dashed")
.should("exist")
.click();
cy.get(".nc-dropdown-edit-column .nc-select-option").should(
"have.length",
index
);
cy.get(".nc-dropdown-edit-column .nc-select-option")
.last()
.find("input")
.click()
.type(value);
}
function editColumn() {
cy.get(`[data-title="Rating"]`).first().scrollIntoView();
cy.get(`th:contains("Rating") .nc-icon.ant-dropdown-trigger`)
.trigger("mouseover", { force: true })
.click({ force: true });
cy.getActiveMenu(".nc-dropdown-column-operations")
.find(".nc-column-edit")
.click();
cy.inputHighlightRenderWait();
// change column type and verify
cy.getActiveMenu(".nc-dropdown-edit-column")
.find(".nc-column-type-input")
.last()
.click()
.type("SingleSelect");
cy.getActiveSelection(".nc-dropdown-column-type")
.find(".ant-select-item-option")
.contains("SingleSelect")
.click();
cy.inputHighlightRenderWait();
addOption(1, "G");
addOption(2, "PG");
addOption(3, "PG-13");
addOption(4, "R");
addOption(5, "NC-17");
cy.getActiveMenu(".nc-dropdown-edit-column")
.find(".ant-btn-primary:visible")
.contains("Save")
.click();
cy.toastWait("Column updated");
}
// test suite
//
export const genTest = (apiType, dbType) => {
if (!isTestSuiteActive(apiType, dbType)) return;
let clear;
describe(`${apiType.toUpperCase()} api - Kanban`, () => {
before(() => {
cy.restoreLocalStorage();
if (dbType === "postgres" || dbType === "xcdb") {
cy.openTableTab("Film", 25);
if (dbType === "postgres") {
// delete SQL views
cy.deleteTable("NicerButSlowerFilmList");
cy.deleteTable("FilmList");
}
// edit `rating` column: from custom DB type to single select
editColumn();
cy.closeTableTab("Film");
}
clear = Cypress.LocalStorage.clear;
Cypress.LocalStorage.clear = () => {};
});
// beforeEach(() => {
// cy.restoreLocalStorage();
// });
//
// afterEach(() => {
// cy.saveLocalStorage();
// });
after(() => {
Cypress.LocalStorage.clear = clear;
cy.saveLocalStorage();
});
/**
class name specific to kanban view
.nc-kanban-stacked-by-menu-btn
.nc-dropdown-kanban-stacked-by-menu
.nc-kanban-add-edit-stack-menu-btn
.nc-dropdown-kanban-add-edit-stack-menu
.nc-kanban-grouping-field-select
.nc-dropdown-kanban-stack-context-menu
**/
it("Create Kanban view", () => {
if (localDebug === false) {
cy.openTableTab("Film", 25);
cy.viewCreate("kanban");
}
});
it("Rename Kanban view", () => {
cy.viewRename("kanban", 0, "Film Kanban");
});
it("Configure grouping field", () => {
configureGroupingField("Rating", true);
});
it("Verify kanban stacks", () => {
verifyKanbanStackCount(6);
verifyKanbanStackOrder([
"uncategorized",
"G",
"PG",
"PG-13",
"R",
"NC-17",
]);
verifyKanbanStackFooterCount([0, 178, 194, 223, 195, 210]);
verifyKanbanStackCardCount([0, 25, 25, 25, 25, 25]);
});
it("Hide fields", () => {
mainPage.hideAllColumns();
mainPage.unhideField("Title", "kanban");
verifyKanbanStackCardCount([0, 25, 25, 25, 25, 25]);
});
it("Verify card order", () => {
// verify 3 cards from each stack
verifyKanbanStackCardOrder("ACE GOLDFINGER", 1, 0);
verifyKanbanStackCardOrder("AFFAIR PREJUDICE", 1, 1);
verifyKanbanStackCardOrder("AFRICAN EGG", 1, 2);
verifyKanbanStackCardOrder("ACADEMY DINOSAUR", 2, 0);
verifyKanbanStackCardOrder("AGENT TRUMAN", 2, 1);
verifyKanbanStackCardOrder("ALASKA PHANTOM", 2, 2);
verifyKanbanStackCardOrder("AIRPLANE SIERRA", 3, 0);
verifyKanbanStackCardOrder("ALABAMA DEVIL", 3, 1);
verifyKanbanStackCardOrder("ALTER VICTORY", 3, 2);
verifyKanbanStackCardOrder("AIRPORT POLLOCK", 4, 0);
verifyKanbanStackCardOrder("ALONE TRIP", 4, 1);
verifyKanbanStackCardOrder("AMELIE HELLFIGHTERS", 4, 2);
verifyKanbanStackCardOrder("ADAPTATION HOLES", 5, 0);
verifyKanbanStackCardOrder("ALADDIN CALENDAR", 5, 1);
verifyKanbanStackCardOrder("ALICE FANTASIA", 5, 2);
});
it.skip("Verify inter-stack drag and drop", () => {
dragAndDropKanbanCard("ACE GOLDFINGER", "ACADEMY DINOSAUR");
verifyKanbanStackCardOrder("AFFAIR PREJUDICE", 1, 0);
verifyKanbanStackCardOrder("ACE GOLDFINGER", 2, 0);
verifyKanbanStackCardOrder("ACADEMY DINOSAUR", 2, 1);
dragAndDropKanbanCard("ACE GOLDFINGER", "AFFAIR PREJUDICE");
verifyKanbanStackCardOrder("ACE GOLDFINGER", 1, 0);
verifyKanbanStackCardOrder("AFFAIR PREJUDICE", 1, 1);
verifyKanbanStackCardOrder("ACADEMY DINOSAUR", 2, 0);
});
it.skip("Verify intra-stack drag and drop", () => {
dragAndDropKanbanCard("ACE GOLDFINGER", "AFFAIR PREJUDICE");
verifyKanbanStackCardOrder("AFFAIR PREJUDICE", 1, 0);
verifyKanbanStackCardOrder("ACE GOLDFINGER", 1, 1);
dragAndDropKanbanCard("ACE GOLDFINGER", "AFFAIR PREJUDICE");
verifyKanbanStackCardOrder("ACE GOLDFINGER", 1, 0);
verifyKanbanStackCardOrder("AFFAIR PREJUDICE", 1, 1);
});
it("Verify stack drag drop", () => {
verifyKanbanStackOrder([
"uncategorized",
"G",
"PG",
"PG-13",
"R",
"NC-17",
]);
dragAndDropKanbanStack("PG-13", "R");
verifyKanbanStackOrder([
"uncategorized",
"G",
"PG",
"R",
"PG-13",
"NC-17",
]);
dragAndDropKanbanStack("PG-13", "R");
verifyKanbanStackOrder([
"uncategorized",
"G",
"PG",
"PG-13",
"R",
"NC-17",
]);
});
it("Verify Sort", () => {
mainPage.sortField("Title", "Z → A");
verifyKanbanStackCardOrder("YOUNG LANGUAGE", 1, 0);
verifyKanbanStackCardOrder("WEST LION", 1, 1);
verifyKanbanStackCardOrder("WORST BANGER", 2, 0);
verifyKanbanStackCardOrder("WORDS HUNTER", 2, 1);
mainPage.clearSort();
verifyKanbanStackCardOrder("ACE GOLDFINGER", 1, 0);
verifyKanbanStackCardOrder("AFFAIR PREJUDICE", 1, 1);
verifyKanbanStackCardOrder("ACADEMY DINOSAUR", 2, 0);
verifyKanbanStackCardOrder("AGENT TRUMAN", 2, 1);
});
it("Verify Filter", () => {
mainPage.filterField("Title", "is like", "BA");
verifyKanbanStackCardOrder("BAKED CLEOPATRA", 1, 0);
verifyKanbanStackCardOrder("BALLROOM MOCKINGBIRD", 1, 1);
verifyKanbanStackCardOrder("ARIZONA BANG", 2, 0);
verifyKanbanStackCardOrder("EGYPT TENENBAUMS", 2, 1);
mainPage.filterReset();
verifyKanbanStackCardOrder("ACE GOLDFINGER", 1, 0);
verifyKanbanStackCardOrder("AFFAIR PREJUDICE", 1, 1);
verifyKanbanStackCardOrder("ACADEMY DINOSAUR", 2, 0);
verifyKanbanStackCardOrder("AGENT TRUMAN", 2, 1);
});
// it("Stack context menu- rename stack", () => {
// verifyKanbanStackCount(6);
// cy.get('.nc-kanban-stack-head').eq(1).find('.ant-dropdown-trigger').click();
// cy.getActiveMenu('.nc-dropdown-kanban-stack-context-menu').should('be.visible');
// cy.getActiveMenu('.nc-dropdown-kanban-stack-context-menu')
// .find('.ant-dropdown-menu-item')
// .contains('Rename Stack')
// .click();
// })
it("Stack context menu- delete stack", () => {});
it("Stack context menu- collapse stack", () => {});
it("Copy view", () => {
mainPage.sortField("Title", "Z → A");
mainPage.filterField("Title", "is like", "BA");
cy.viewCopy(1);
// verify copied view
cy.get(".nc-kanban-stacked-by-menu-btn")
.contains(`Stacked By Rating`)
.should("exist");
verifyKanbanStackCount(6);
verifyKanbanStackOrder([
"uncategorized",
"G",
"PG",
"PG-13",
"R",
"NC-17",
]);
verifyKanbanStackFooterCount([0, 4, 5, 8, 6, 6]);
verifyKanbanStackCardOrder("BAREFOOT MANCHURIAN", 1, 0);
verifyKanbanStackCardOrder("WORST BANGER", 2, 0);
cy.viewDelete(1);
});
it("Add stack", () => {
cy.viewOpen("kanban", 0);
cy.get(".nc-kanban-add-edit-stack-menu-btn").should("exist").click();
cy.getActiveMenu(".nc-dropdown-kanban-add-edit-stack-menu").should(
"be.visible"
);
cy.getActiveMenu(".nc-dropdown-kanban-add-edit-stack-menu")
.find(".ant-btn-dashed")
.click();
cy.getActiveMenu(".nc-dropdown-kanban-add-edit-stack-menu")
.find(".nc-select-option")
.last()
.click()
.type("Test{enter}");
verifyKanbanStackCount(7);
verifyKanbanStackOrder([
"uncategorized",
"G",
"PG",
"PG-13",
"R",
"NC-17",
"Test",
]);
});
it("Collapse stack", () => {
cy.get(".nc-kanban-stack-head").last().scrollIntoView();
cy.get(".nc-kanban-stack-head").last().click();
cy.getActiveMenu(".nc-dropdown-kanban-stack-context-menu").should(
"be.visible"
);
// collapse stack
cy.getActiveMenu(".nc-dropdown-kanban-stack-context-menu")
.find(".ant-dropdown-menu-item")
.contains("Collapse Stack")
.click();
cy.get(".nc-kanban-collapsed-stack")
.should("exist")
.should("have.length", 1);
// expand back
cy.get(".nc-kanban-collapsed-stack").click();
cy.get(".nc-kanban-collapsed-stack").should("not.exist");
});
it("Add record to stack", () => {
mainPage.hideAllColumns();
mainPage.toggleShowSystemFields();
mainPage.unhideField("LanguageId", "kanban");
mainPage.unhideField("Title", "kanban");
mainPage.filterReset();
mainPage.clearSort();
// skip for xcdb: many mandatory fields
if (!isXcdb()) {
cy.get(".nc-kanban-stack-head").last().scrollIntoView();
cy.get(".nc-kanban-stack-head").last().click();
cy.getActiveMenu(".nc-dropdown-kanban-stack-context-menu").should(
"be.visible"
);
// add record
cy.getActiveMenu(".nc-dropdown-kanban-stack-context-menu")
.find(".ant-dropdown-menu-item")
.contains("Add new record")
.click();
cy.getActiveDrawer(".nc-drawer-expanded-form").should("be.visible");
cy.get(".nc-expand-col-Title")
.find(".nc-cell > input")
.should("exist")
.first()
.clear()
.type("New record");
cy.get(".nc-expand-col-LanguageId")
.find(".nc-cell > input")
.should("exist")
.first()
.clear()
.type("1");
cy.getActiveDrawer(".nc-drawer-expanded-form")
.find("button")
.contains("Save row")
.click();
cy.toastWait("updated successfully");
cy.get("body").type("{esc}");
// verify if the new record is in the stack
verifyKanbanStackCount(7);
verifyKanbanStackOrder([
"uncategorized",
"G",
"PG",
"PG-13",
"R",
"NC-17",
"Test",
]);
verifyKanbanStackCardCount([0, 25, 25, 25, 25, 25, 1]);
}
mainPage.toggleShowSystemFields();
});
it("Expand record", () => {
// mainPage.toggleShowSystemFields();
// mainPage.showAllColumns();
cy.get(".nc-kanban-stack").eq(1).find(".nc-kanban-item").eq(0).click();
cy.get(".nc-expand-col-Title")
.find(".nc-cell > input")
.then(($el) => {
expect($el[0].value).to.have.string("ACE GOLDFINGER");
});
cy.get("body").type("{esc}");
});
it("Stack context menu- delete stack", () => {
if (!isXcdb()) {
cy.get(".nc-kanban-stack-head").last().scrollIntoView();
cy.get(".nc-kanban-stack-head").last().click();
cy.getActiveMenu(".nc-dropdown-kanban-stack-context-menu").should(
"be.visible"
);
cy.getActiveMenu(".nc-dropdown-kanban-stack-context-menu")
.find(".ant-dropdown-menu-item")
.contains("Delete Stack")
.click();
cy.getActiveModal(".nc-modal-kanban-delete-stack").should("be.visible");
cy.getActiveModal(".nc-modal-kanban-delete-stack")
.find(".ant-btn-primary")
.click();
verifyKanbanStackCount(6);
verifyKanbanStackOrder([
"uncategorized",
"G",
"PG",
"PG-13",
"R",
"NC-17",
]);
verifyKanbanStackCardCount([1, 25, 25, 25, 25, 25]);
}
});
it("Delete Kanban view", () => {
cy.viewDelete(0);
cy.closeTableTab("Film");
});
});
};
/**
* @copyright Copyright (c) 2021, Xgene Cloud Ltd
*
* @author Pranav C Balan <pranavxc@gmail.com>
* @author Raju Udava <sivadstala@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

2
scripts/cypress/integration/test/pg-restViews.js

@ -7,6 +7,7 @@ let t4d = require("../common/4d_table_view_grid_locked");
let t4e = require("../common/4e_form_view_share");
let t4f = require("../common/4f_pg_grid_view_share");
let t4g = require("../common/4g_table_view_expanded_form");
let t4h = require("../common/4h_kanban");
const {
setCurrentMode,
} = require("../../support/page_objects/projectConstants");
@ -23,6 +24,7 @@ const nocoTestSuite = (apiType, dbType) => {
t4e.genTest(apiType, dbType);
t4f.genTest(apiType, dbType);
t4g.genTest(apiType, dbType);
t4h.genTest(apiType, dbType);
};
nocoTestSuite("rest", "postgres");

2
scripts/cypress/integration/test/restViews.js

@ -7,6 +7,7 @@ let t4d = require("../common/4d_table_view_grid_locked");
let t4e = require("../common/4e_form_view_share");
let t4f = require("../common/4f_grid_view_share");
let t4g = require("../common/4g_table_view_expanded_form");
let t4h = require("../common/4h_kanban");
const {
setCurrentMode,
} = require("../../support/page_objects/projectConstants");
@ -23,6 +24,7 @@ const nocoTestSuite = (apiType, dbType) => {
t4e.genTest(apiType, dbType);
t4f.genTest(apiType, dbType);
t4g.genTest(apiType, dbType);
t4h.genTest(apiType, dbType);
};
nocoTestSuite("rest", "mysql");

2
scripts/cypress/integration/test/xcdb-restViews.js

@ -6,6 +6,7 @@ let t4c = require("../common/4c_form_view_detailed");
let t4d = require("../common/4d_table_view_grid_locked");
let t4e = require("../common/4e_form_view_share");
let t4f = require("../common/4f_grid_view_share");
let t4h = require("../common/4h_kanban");
const {
setCurrentMode,
} = require("../../support/page_objects/projectConstants");
@ -21,6 +22,7 @@ const nocoTestSuite = (apiType, dbType) => {
t4d.genTest(apiType, dbType);
// to be fixed t4e.genTest(apiType, dbType);
t4f.genTest(apiType, dbType);
t4h.genTest(apiType, dbType);
};
nocoTestSuite("rest", "xcdb");

80
scripts/cypress/support/commands.js

@ -526,6 +526,86 @@ Cypress.Commands.add("gotoProjectsPage", () => {
cy.get(`.nc-project-page-title:contains("My Projects")`).should("exist");
});
// View basic routines
//
function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
// viewCreate
// : viewType: grid, gallery, kanban, form
// : creates view with default name
//
Cypress.Commands.add("viewCreate", (viewType) => {
// click on 'Grid/Gallery/Form/Kanban' button on Views bar
cy.get(`.nc-create-${viewType}-view`).click();
// Pop up window, click Submit (accepting default name for view)
cy.getActiveModal(".nc-modal-view-create").find(".ant-btn-primary").click();
cy.toastWait("View created successfully");
// validate if view was created && contains default name 'Country1'
cy.get(`.nc-${viewType}-view-item`)
.contains(`${capitalizeFirstLetter(viewType)}-1`)
.should("exist");
});
// viewDelete
// : delete view by index (0-based, exclude default view)
//
Cypress.Commands.add("viewDelete", (viewIndex) => {
// click on delete icon (becomes visible on hovering mouse)
cy.get(".nc-view-delete-icon").eq(viewIndex).click({ force: true });
cy.wait(300);
// click on 'Delete' button on confirmation modal
cy.getActiveModal(".nc-modal-view-delete").find(".ant-btn-dangerous").click();
cy.toastWait("View deleted successfully");
});
// viewDuplicate
// : duplicate view by index (0-based, *include* default view)
//
Cypress.Commands.add("viewCopy", (viewIndex) => {
// click on delete icon (becomes visible on hovering mouse)
cy.get(".nc-view-copy-icon").eq(viewIndex).click({ force: true });
cy.wait(300);
// click on 'Delete' button on confirmation modal
cy.getActiveModal(".nc-modal-view-create").find(".ant-btn-primary").click();
cy.toastWait("View created successfully");
});
// viewRename
// : rename view by index (0-based, exclude default view)
//
Cypress.Commands.add("viewRename", (viewType, viewIndex, newName) => {
// click on edit-icon (becomes visible on hovering mouse)
cy.get(`.nc-${viewType}-view-item`).eq(viewIndex).dblclick();
// feed new name
cy.get(`.nc-${viewType}-view-item input`).clear().type(`${newName}{enter}`);
cy.toastWait("View renamed successfully");
// validate
cy.get(`.nc-${viewType}-view-item`).contains(`${newName}`).should("exist");
});
// viewOpen
// : open view by index (0-based, exclude default view)
//
Cypress.Commands.add("viewOpen", (viewType, viewIndex) => {
// click on view
cy.get(`.nc-${viewType}-view-item`).eq(viewIndex).click();
});
// openTableView
// : open view by type & name
//
Cypress.Commands.add("openTableView", (viewType, viewName) => {
cy.get(`.nc-${viewType}-view-item`).contains(`${viewName}`).click();
});
// Drag n Drop
// refer: https://stackoverflow.com/a/55409853
/*

47
scripts/cypress/support/page_objects/mainPage.js

@ -389,24 +389,63 @@ export class _mainPage {
.contains("Webhooks");
};
hideAllColumns = () => {
cy.get(".nc-fields-menu-btn").should("exist").click();
cy.getActiveMenu(".nc-dropdown-fields-menu")
.find(".ant-btn")
.contains("Hide all")
.click();
cy.get(".nc-fields-menu-btn").should("exist").click();
};
showAllColumns = () => {
cy.get(".nc-fields-menu-btn").should("exist").click();
cy.getActiveMenu(".nc-dropdown-fields-menu")
.find(".ant-btn")
.contains("Show all")
.click();
cy.get(".nc-fields-menu-btn").should("exist").click();
};
toggleShowSystemFields = () => {
cy.get(".nc-fields-menu-btn").should("exist").click();
cy.getActiveMenu(".nc-dropdown-fields-menu")
.find(".nc-fields-show-system-fields")
.click();
cy.get(".nc-fields-menu-btn").should("exist").click();
};
hideField = (field) => {
cy.get(`th[data-title="${field}"]`).should("be.visible");
cy.get(".nc-fields-menu-btn").click();
// cy.getActiveMenu(".nc-dropdown-fields-menu")
// .find(`.nc-fields-list label:contains(${field}):visible`)
// .click();
cy.getActiveMenu(".nc-dropdown-fields-menu")
.find(`.nc-fields-list label:contains(${field}):visible`)
.find(`.nc-fields-list label:visible`)
.contains(new RegExp("^" + field + "$", "g"))
.click();
cy.get(".nc-fields-menu-btn").click();
cy.get(`th[data-title="${field}"]`).should("not.exist");
};
unhideField = (field) => {
unhideField = (field, viewType = "grid") => {
if (viewType === "grid") {
cy.get(`th[data-title="${field}"]`).should("not.exist");
}
cy.get(`th[data-title="${field}"]`).should("not.exist");
cy.get(".nc-fields-menu-btn").click();
// cy.getActiveMenu(".nc-dropdown-fields-menu")
// .find(`.nc-fields-list label:contains(${field}):visible`)
// .click();
cy.getActiveMenu(".nc-dropdown-fields-menu")
.find(`.nc-fields-list label:contains(${field}):visible`)
.find(`.nc-fields-list label:visible`)
.contains(new RegExp("^" + field + "$", "g"))
.click();
cy.get(".nc-fields-menu-btn").click();
cy.get(`th[data-title="${field}"]`).should("be.visible");
if (viewType === "grid") {
cy.get(`th[data-title="${field}"]`).should("be.visible");
}
};
sortField = (field, criteria) => {

362
scripts/sdk/swagger.json

@ -2506,6 +2506,99 @@
]
}
},
"/api/v1/db/meta/tables/{tableId}/kanbans": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true
}
],
"post": {
"summary": "",
"operationId": "db-view-kanban-create",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {}
}
}
}
}
},
"tags": [
"DB view"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Kanban"
}
}
}
}
}
},
"/api/v1/db/meta/kanbans/{kanbanId}": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "kanbanId",
"in": "path",
"required": true
}
],
"patch": {
"summary": "",
"operationId": "db-view-kanban-update",
"responses": {
"200": {
"description": "OK"
}
},
"tags": [
"DB view"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Kanban"
}
}
}
}
},
"get": {
"summary": "",
"operationId": "db-view-kanban-read",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Kanban"
}
}
}
}
},
"tags": [
"DB view"
]
}
},
"/api/v1/db/meta/projects/{projectId}/meta-diff": {
"parameters": [
{
@ -2813,6 +2906,180 @@
}
}
},
"/api/v1/db/data/{orgs}/{projectName}/{tableName}/views/{viewName}/group/{columnId}": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "orgs",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"name": "projectName",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"name": "tableName",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"name": "viewName",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"name": "columnId",
"in": "path",
"required": true
}
],
"get": {
"summary": "Table Group by Column",
"operationId": "db-view-row-grouped-data-list",
"description": "",
"tags": [
"DB view row"
],
"parameters": [
{
"schema": {
"type": "array"
},
"in": "query",
"name": "fields"
},
{
"schema": {
"type": "array"
},
"in": "query",
"name": "sort"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where"
},
{
"schema": {},
"in": "query",
"name": "nested",
"description": "Query params for nested data"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/api/v1/db/data/{orgs}/{projectName}/{tableName}/group/{columnId}": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "orgs",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"name": "projectName",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"name": "tableName",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"name": "columnId",
"in": "path",
"required": true
}
],
"get": {
"summary": "Table Group by Column",
"operationId": "db-table-row-grouped-data-list",
"description": "",
"tags": [
"DB table row"
],
"parameters": [
{
"schema": {
"type": "array"
},
"in": "query",
"name": "fields"
},
{
"schema": {
"type": "array"
},
"in": "query",
"name": "sort"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where"
},
{
"schema": {},
"in": "query",
"name": "nested",
"description": "Query params for nested data"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/api/v1/db/data/{orgs}/{projectName}/{tableName}/views/{viewName}": {
"parameters": [
{
@ -3600,6 +3867,13 @@
"name": "tableName",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where"
}
],
"patch": {
@ -4112,6 +4386,67 @@
]
}
},
"/api/v1/db/public/shared-view/{sharedViewUuid}/group/{columnId}": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "sharedViewUuid",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"name": "columnId",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"in": "header",
"name": "xc-password",
"description": "Shared view password"
}
],
"get": {
"summary": "",
"operationId": "public-grouped-data-list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
}
},
"tags": [
"Public"
],
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "limit"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "offset"
}
]
}
},
"/api/v1/db/public/shared-view/{sharedViewUuid}/rows": {
"parameters": [
{
@ -6211,6 +6546,9 @@
},
{
"$ref": "#/components/schemas/Gallery"
},
{
"$ref": "#/components/schemas/Kanban"
}
]
}
@ -7005,7 +7343,9 @@
"properties": {
"options": {
"type": "array",
"$ref": "#/components/schemas/SelectOption"
"items": {
"$ref": "#/components/schemas/SelectOption"
}
}
},
"required": [
@ -7171,7 +7511,7 @@
}
},
"GridColumn": {
"title": "GalleryColumn",
"title": "GridColumn",
"type": "object",
"description": "",
"properties": {
@ -7231,12 +7571,6 @@
"alias": {
"type": "string"
},
"public": {
"type": "boolean"
},
"password": {
"type": "string"
},
"columns": {
"type": "array",
"items": {
@ -7245,6 +7579,18 @@
},
"fk_model_id": {
"type": "string"
},
"grp_column_id": {
"type": [
"string",
"null"
]
},
"meta": {
"type": [
"string",
"object"
]
}
}
},

Loading…
Cancel
Save