mirror of https://github.com/nocodb/nocodb
աɨռɢӄաօռɢ
2 years ago
committed by
GitHub
93 changed files with 4762 additions and 701 deletions
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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 |
||||
} |
@ -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> |
@ -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; |
@ -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/>. |
||||
* |
||||
*/ |
@ -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, |
||||
} |
||||
); |
||||
} |
||||
} |
||||
|
@ -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)); |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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/>.
|
||||
* |
||||
*/ |
Loading…
Reference in new issue