Browse Source

Merge pull request #3025 from nocodb/feat/expanded-form

pull/3076/head
Braks 2 years ago committed by GitHub
parent
commit
479cc2bbd6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      packages/nc-gui-v2/components.d.ts
  2. 4
      packages/nc-gui-v2/components/smartsheet-column/SelectOptions.vue
  3. 5
      packages/nc-gui-v2/components/smartsheet-header/Cell.vue
  4. 1
      packages/nc-gui-v2/components/smartsheet-header/Menu.vue
  5. 10
      packages/nc-gui-v2/components/smartsheet-header/VirtualCell.vue
  6. 32
      packages/nc-gui-v2/components/smartsheet/Cell.vue
  7. 56
      packages/nc-gui-v2/components/smartsheet/Grid.vue
  8. 28
      packages/nc-gui-v2/components/smartsheet/Row.vue
  9. 93
      packages/nc-gui-v2/components/smartsheet/expanded-form/Comments.vue
  10. 60
      packages/nc-gui-v2/components/smartsheet/expanded-form/Header.vue
  11. 139
      packages/nc-gui-v2/components/smartsheet/expanded-form/index.vue
  12. 28
      packages/nc-gui-v2/components/virtual-cell/BelongsTo.vue
  13. 49
      packages/nc-gui-v2/components/virtual-cell/HasMany.vue
  14. 45
      packages/nc-gui-v2/components/virtual-cell/ManyToMany.vue
  15. 33
      packages/nc-gui-v2/components/virtual-cell/components/ItemChip.vue
  16. 77
      packages/nc-gui-v2/components/virtual-cell/components/ListChildItems.vue
  17. 80
      packages/nc-gui-v2/components/virtual-cell/components/ListItems.vue
  18. 1
      packages/nc-gui-v2/composables/index.ts
  19. 209
      packages/nc-gui-v2/composables/useExpandedFormStore.ts
  20. 28
      packages/nc-gui-v2/composables/useLTARStore.ts
  21. 105
      packages/nc-gui-v2/composables/useSmartsheetRowStore.ts
  22. 44
      packages/nc-gui-v2/composables/useViewData.ts
  23. 30
      packages/nc-gui-v2/package-lock.json
  24. 1
      packages/nc-gui-v2/package.json
  25. 6
      packages/nc-gui-v2/plugins/domPurify.ts
  26. 1
      packages/nc-gui-v2/tsconfig.json
  27. 11
      packages/nc-gui-v2/utils/dataUtils.ts
  28. 1
      packages/nc-gui-v2/utils/index.ts
  29. 1
      packages/nc-gui/components/project/spreadsheet/components/ExpandedForm.vue
  30. 2
      packages/nocodb-sdk/src/lib/Api.ts
  31. 2
      scripts/sdk/swagger.json

5
packages/nc-gui-v2/components.d.ts vendored

@ -76,9 +76,11 @@ declare module '@vue/runtime-core' {
MaterialSymbolsFileCopyOutline: typeof import('~icons/material-symbols/file-copy-outline')['default']
MaterialSymbolsMenu: typeof import('~icons/material-symbols/menu')['default']
MaterialSymbolsTranslate: typeof import('~icons/material-symbols/translate')['default']
MdiAccountCircle: typeof import('~icons/mdi/account-circle')['default']
MdiAccountGroup: typeof import('~icons/mdi/account-group')['default']
MdiApi: typeof import('~icons/mdi/api')['default']
MdiArrowExpand: typeof import('~icons/mdi/arrow-expand')['default']
MdiArrowExpandIcon: typeof import('~icons/mdi/arrow-expand-icon')['default']
MdiArrowLeftBold: typeof import('~icons/mdi/arrow-left-bold')['default']
MdiAt: typeof import('~icons/mdi/at')['default']
MdiCalculator: typeof import('~icons/mdi/calculator')['default']
@ -88,6 +90,7 @@ declare module '@vue/runtime-core' {
MdiCheck: typeof import('~icons/mdi/check')['default']
MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
MdiCloseThick: typeof import('~icons/mdi/close-thick')['default']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
MdiContentSave: typeof import('~icons/mdi/content-save')['default']
MdiDatabase: typeof import('~icons/mdi/database')['default']
@ -106,6 +109,7 @@ declare module '@vue/runtime-core' {
MdiHeart: typeof import('~icons/mdi/heart')['default']
MdiHook: typeof import('~icons/mdi/hook')['default']
MdiInformation: typeof import('~icons/mdi/information')['default']
MdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
MdiLink: typeof import('~icons/mdi/link')['default']
MdiLinkVariantRemove: typeof import('~icons/mdi/link-variant-remove')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default']
@ -124,6 +128,7 @@ declare module '@vue/runtime-core' {
MdiSlack: typeof import('~icons/mdi/slack')['default']
MdiStar: typeof import('~icons/mdi/star')['default']
MdiStore: typeof import('~icons/mdi/store')['default']
MdiTableArrowRight: typeof import('~icons/mdi/table-arrow-right')['default']
MdiTableBorder: typeof import('~icons/mdi/table-border')['default']
MdiThumbUp: typeof import('~icons/mdi/thumb-up')['default']
MdiTrashCan: typeof import('~icons/mdi/trash-can')['default']

4
packages/nc-gui-v2/components/smartsheet-column/SelectOptions.vue

@ -86,7 +86,7 @@ watch(inputs, () => {
<template>
<div class="w-full">
<draggable :list="options" item-key="id" handle=".nc-child-draggable-icon">
<Draggable :list="options" item-key="id" handle=".nc-child-draggable-icon">
<template #item="{ element, index }">
<div class="flex py-1 align-center">
<MdiDragIcon small class="nc-child-draggable-icon handle" />
@ -105,7 +105,7 @@ watch(inputs, () => {
<div class="flex align-center"><MdiPlusIcon /><span class="flex-auto">Add option</span></div>
</a-button>
</template>
</draggable>
</Draggable>
</div>
</template>

5
packages/nc-gui-v2/components/smartsheet-header/Cell.vue

@ -2,7 +2,7 @@
import type { ColumnType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { inject, toRef } from 'vue'
import { ColumnInj, MetaInj } from '~/context'
import { ColumnInj, IsFormInj, MetaInj } from '~/context'
import { useProvideColumnCreateStore } from '#imports'
const props = defineProps<{ column: ColumnType & { meta: any }; hideMenu?: boolean }>()
@ -11,6 +11,7 @@ const hideMenu = toRef(props, 'hideMenu')
provide(ColumnInj, column)
const meta = inject(MetaInj)
const isForm = inject(IsFormInj, false)
// instantiate column update store
useProvideColumnCreateStore(meta as Ref<TableType>, column)
@ -23,7 +24,7 @@ useProvideColumnCreateStore(meta as Ref<TableType>, column)
<template v-if="!hideMenu">
<div class="flex-1" />
<SmartsheetHeaderMenu />
<SmartsheetHeaderMenu v-if="!isForm" />
</template>
</div>
</template>

1
packages/nc-gui-v2/components/smartsheet-header/Menu.vue

@ -1,5 +1,4 @@
<script lang="ts" setup>
import { onClickOutside } from '@vueuse/core'
import { Modal } from 'ant-design-vue'
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'

10
packages/nc-gui-v2/components/smartsheet-header/VirtualCell.vue

@ -1,12 +1,9 @@
<script setup lang="ts">
import { substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
import type { ColumnType, FormulaType, LinkToAnotherRecordType, LookupType, RollupType, TableType } from 'nocodb-sdk'
import { toRef } from 'vue'
import { $computed } from 'vue/macros'
import type { Ref } from 'vue'
import { useMetas } from '~/composables'
import { ColumnInj, MetaInj } from '~/context'
import { provide, useProvideColumnCreateStore } from '#imports'
import { ColumnInj, IsFormInj, MetaInj } from '~/context'
import { provide, toRef, useMetas, useProvideColumnCreateStore } from '#imports'
const props = defineProps<{ column: ColumnType & { meta: any }; hideMenu?: boolean }>()
const column = toRef(props, 'column')
@ -15,6 +12,7 @@ const hideMenu = toRef(props, 'hideMenu')
provide(ColumnInj, column)
const { metas } = useMetas()
const meta = inject(MetaInj)
const isForm = inject(IsFormInj, false)
const { isLookup, isBt, isRollup, isMm, isHm, isFormula } = useVirtualCell(column)
@ -100,7 +98,7 @@ useProvideColumnCreateStore(meta as Ref<TableType>, column)
<template v-if="!hideMenu">
<v-spacer />
<SmartsheetHeaderMenu :virtual="true" />
<SmartsheetHeaderMenu v-if="!isForm" :virtual="true" />
</template>
</div>
</template>

32
packages/nc-gui-v2/components/smartsheet/Cell.vue

@ -141,35 +141,3 @@ const syncAndNavigate = (dir: NavigateDir) => {
<CellText v-else v-model="vModel" />
</div>
</template>
<style scoped>
textarea {
outline: none;
}
div {
width: 100%;
height: 100%;
color: var(--v-textColor-base);
}
.nc-hint {
font-size: 0.61rem;
color: grey;
}
.nc-cell {
@apply relative h-full;
width: inherit;
display: inherit;
}
.nc-locked-overlay {
position: absolute;
z-index: 2;
height: 100%;
width: 100%;
top: 0;
left: 0;
}
</style>

56
packages/nc-gui-v2/components/smartsheet/Grid.vue

@ -29,6 +29,7 @@ import {
ReloadViewDataHookInj,
} from '~/context'
import { NavigateDir } from '~/lib'
import { enumColor } from '~/utils'
const meta = inject(MetaInj)
@ -58,6 +59,9 @@ const addColumnDropdown = ref(false)
const contextMenu = ref(false)
const contextMenuTarget = ref(false)
const expandedFormDlg = ref(false)
const expandedFormRow = ref<Row>()
const expandedFormRowState = ref<Record<string, any>>()
const visibleColLength = $computed(() => fields.value?.length)
@ -71,6 +75,7 @@ const {
deleteRow,
deleteSelectedRows,
selectedAllRecords,
loadAggCommentsCount,
} = useViewData(meta, view as any, xWhere)
const { loadGridViewColumns, updateWidth, resizingColWidth, resizingCol } = useGridViewColumnWidth(view as any)
@ -82,8 +87,9 @@ provide(IsGridInj, true)
provide(PaginationDataInj, paginationData)
provide(ChangePageInj, changePage)
reloadViewDataHook?.on(() => {
loadData()
reloadViewDataHook?.on(async () => {
await loadData()
loadAggCommentsCount()
})
const selectCell = (row: number, col: number) => {
@ -271,6 +277,12 @@ const onNavigate = (dir: NavigateDir) => {
break
}
}
const expandForm = (row: Row, state: Record<string, any>) => {
expandedFormRow.value = row
expandedFormRowState.value = state
expandedFormDlg.value = true
}
</script>
<template>
@ -281,7 +293,7 @@ const onNavigate = (dir: NavigateDir) => {
<thead>
<tr>
<th>
<div class="flex align-center w-[80px]">
<div class="flex align-center w-[80px] px-1">
<div class="group-hover:hidden" :class="{ hidden: selectedAllRecords }">#</div>
<div
:class="{ hidden: !selectedAllRecords, flex: selectedAllRecords }"
@ -322,9 +334,11 @@ const onNavigate = (dir: NavigateDir) => {
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) of data" :key="rowIndex" class="nc-grid-row">
<td key="row-index" class="caption nc-grid-cell group">
<div class="flex items-center w-[80px]">
<SmartsheetRow v-for="(row, rowIndex) of data" :key="rowIndex" :row="row">
<template #default="{ state }">
<tr class="nc-grid-row group">
<td key="row-index" class="caption nc-grid-cell">
<div class="align-center flex w-[80px]">
<div class="group-hover:hidden" :class="{ hidden: row.rowMeta.selected }">{{ rowIndex + 1 }}</div>
<div
:class="{ hidden: !row.rowMeta.selected, flex: row.rowMeta.selected }"
@ -332,8 +346,18 @@ const onNavigate = (dir: NavigateDir) => {
>
<a-checkbox v-model:checked="row.rowMeta.selected" />
<span class="flex-1" />
<span
v-if="row.rowMeta?.commentCount"
class="py-1 px-3 rounded-full text-xs"
:style="{ backgroundColor: enumColor.light[row.rowMeta.commentCount % enumColor.light.length] }"
@click="expandForm(row, state)"
>{{ row.rowMeta.commentCount }}</span
>
<div class="cursor-pointer flex items-center border-1 active:ring rounded p-1 hover:bg-primary/10">
<MdiArrowExpand class="select-none transform hover:(text-pink-500 scale-120)" />
<MdiArrowExpand
class="select-none transform hover:(text-pink-500 scale-120)"
@click="expandForm(row, state)"
/>
</div>
</div>
</div>
@ -378,6 +402,8 @@ const onNavigate = (dir: NavigateDir) => {
</div>
</td>
</tr>
</template>
</SmartsheetRow>
<tr v-if="!isLocked">
<td
@ -415,6 +441,14 @@ const onNavigate = (dir: NavigateDir) => {
</div>
<SmartsheetPagination />
<SmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:row="expandedFormRow"
:state="expandedFormRowState"
:meta="meta"
/>
</div>
</template>
@ -430,13 +464,11 @@ const onNavigate = (dir: NavigateDir) => {
min-height: 41px !important;
height: 41px !important;
position: relative;
//padding: 0 5px;
}
& > div {
td > div {
overflow: hidden;
@apply flex align-center h-auto;
padding: 0 5px;
}
@apply flex align-center h-auto px-1;
}
table,

28
packages/nc-gui-v2/components/smartsheet/Row.vue

@ -0,0 +1,28 @@
<script lang="ts" setup>
import type { Row } from '~/composables'
import { useProvideSmartsheetRowStore, useSmartsheetStoreOrThrow } from '#imports'
interface Props {
row: Row
}
const props = defineProps<Props>()
const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow()
const { isNew, state, syncLTARRefs } = useProvideSmartsheetRowStore(meta, currentRow)
// on changing isNew(new record insert) status sync LTAR cell values
watch(isNew, async (nextVal, prevVal) => {
if (prevVal && !nextVal) {
await syncLTARRefs(currentRow.value.row)
// update row values without invoking api
currentRow.value.row = { ...currentRow.value.row, ...state.value }
currentRow.value.oldRow = { ...currentRow.value.row, ...state.value }
}
})
</script>
<template>
<slot :state="state" />
</template>

93
packages/nc-gui-v2/components/smartsheet/expanded-form/Comments.vue

@ -0,0 +1,93 @@
<script setup lang="ts">
import { nextTick, useExpandedFormStoreOrThrow } from '#imports'
import { enumColor, timeAgo } from '~/utils'
import MdiAccountIcon from '~icons/mdi/account-circle'
const { loadCommentsAndLogs, commentsAndLogs, isCommentsLoading, commentsOnly, saveComment, isYou, comment } =
useExpandedFormStoreOrThrow()
const commentsWrapperEl = ref<HTMLDivElement>()
await loadCommentsAndLogs()
watch(
commentsAndLogs,
() => {
nextTick(() => {
if (commentsWrapperEl.value) commentsWrapperEl.value.scrollTop = commentsWrapperEl.value?.scrollHeight
})
},
{ immediate: true },
)
</script>
<template>
<div class="h-full d-flex flex-column w-full">
<div ref="commentsWrapperEl" class="flex-grow-1 min-h-[100px] overflow-y-auto scrollbar-thin-primary p-2">
<v-skeleton-loader v-if="isCommentsLoading && !commentsAndLogs" type="list-item-avatar-two-line@8" />
<template v-else>
<div v-for="log of commentsAndLogs" :key="log.id" class="flex gap-1 text-xs">
<MdiAccountIcon class="row-span-2" :class="isYou(log.user) ? 'text-pink-300' : 'text-blue-300 '" />
<div class="flex-grow">
<p class="mb-1 caption edited-text text-[10px] text-gray">
{{ isYou(log.user) ? 'You' : log.user == null ? 'Shared base' : log.user }}
{{ log.op_type === 'COMMENT' ? 'commented' : log.op_sub_type === 'INSERT' ? 'created' : 'edited' }}
</p>
<p
v-if="log.op_type === 'COMMENT'"
class="caption mb-0 nc-chip w-full min-h-20px"
:style="{ backgroundColor: enumColor.light[2] }"
>
{{ log.description }}
</p>
<p v-else v-dompurify-html="log.details" class="caption mb-0" style="word-break: break-all" />
<p class="time text-right text-[10px] mb-0">
{{ timeAgo(log.created_at) }}
</p>
</div>
</div>
</template>
</div>
<div class="border-1 my-2 w-full ml-6" />
<div class="p-0">
<div class="flex justify-center">
<a-checkbox v-model:checked="commentsOnly" @change="loadCommentsAndLogs"
><span class="text-[11px] text-gray-500">Comments only</span>
</a-checkbox>
</div>
<div class="flex-shrink-1 mt-2 d-flex pl-4">
<a-input
v-model:value="comment"
class="!text-xs"
ghost
:class="{ focus: showborder }"
@focusin="showborder = true"
@focusout="showborder = false"
@keyup.enter.prevent="saveComment"
>
<template #addonBefore>
<div class="flex align-center">
<mdi-account-circle class="text-lg text-pink-300" small @click="saveComment" />
</div>
</template>
<template #suffix>
<mdi-keyboard-return v-if="comment" class="text-sm" small @click="saveComment" />
</template>
</a-input>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.red.lighten-4) {
@apply bg-red-100;
}
:deep(.green.lighten-4) {
@apply bg-green-100;
}
</style>

60
packages/nc-gui-v2/components/smartsheet/expanded-form/Header.vue

@ -0,0 +1,60 @@
<script lang="ts" setup>
import {
computed,
useExpandedFormStoreOrThrow,
useSmartsheetRowStoreOrThrow,
useSmartsheetStoreOrThrow,
useUIPermission,
} from '#imports'
import MdiDoorOpen from '~icons/mdi/door-open'
import MdiDoorClosed from '~icons/mdi/door-closed'
const emit = defineEmits(['cancel'])
const { meta } = useSmartsheetStoreOrThrow()
const { commentsDrawer, primaryValue, save: _save } = useExpandedFormStoreOrThrow()
const { isNew, syncLTARRefs } = useSmartsheetRowStoreOrThrow()
const { isUIAllowed } = useUIPermission()
const save = async () => {
if (isNew.value) {
const data = await _save()
await syncLTARRefs(data)
} else {
await _save()
}
}
const drawerToggleIcon = computed(() => (commentsDrawer.value ? MdiDoorOpen : MdiDoorClosed))
// todo: accept as a prop / inject
const iconColor = '#1890ff'
</script>
<template>
<div class="flex p-2 align-center gap-2">
<h5 class="text-lg font-weight-medium flex align-center gap-1 mb-0">
<mdi-table-arrow-right :style="{ color: iconColor }" />
<template v-if="meta">
{{ meta.title }}
</template>
<template v-else>
{{ table }}
</template>
<template v-if="primaryValue">: {{ primaryValue }}</template>
</h5>
<div class="flex-grow" />
<mdi-reload class="cursor-pointer select-none" />
<component :is="drawerToggleIcon" class="cursor-pointer select-none" @click="commentsDrawer = !commentsDrawer" />
<a-button size="small" class="!text" @click="emit('cancel')">
<!-- Cancel -->
{{ $t('general.cancel') }}
</a-button>
<a-button size="small" :disabled="!isUIAllowed('tableRowUpdate')" type="primary" @click="save">
<!-- Save Row -->
{{ $t('activity.saveRow') }}
</a-button>
</div>
</template>
<style scoped></style>

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

@ -0,0 +1,139 @@
<script setup lang="ts">
import type { ColumnType, TableType } from 'nocodb-sdk'
import { isVirtualCol } from 'nocodb-sdk'
import Comments from './Comments.vue'
import Header from './Header.vue'
import {
computedInject,
provide,
toRef,
useNuxtApp,
useProvideExpandedFormStore,
useProvideSmartsheetStore,
useVModel,
watch,
} from '#imports'
import { NOCO } from '~/lib'
import { extractPkFromRow } from '~/utils'
import type { Row } from '~/composables'
import { FieldsInj, IsFormInj, MetaInj } from '~/context'
interface Props {
modelValue: string | null
row: Row
state?: Record<string, any> | null
meta: TableType
loadRow?: boolean
useMetaFields?: boolean
}
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue'])
const row = toRef(props, 'row')
const state = toRef(props, 'state')
const meta = toRef(props, 'meta')
const _fields = computedInject(FieldsInj, (_fields) => {
if (props.useMetaFields) {
return meta.value.columns ?? []
}
return _fields?.value ?? []
})
provide(MetaInj, meta)
const { commentsDrawer, changedColumns, state: rowState } = useProvideExpandedFormStore(meta, row)
const { $api } = useNuxtApp()
if (props.loadRow) {
const { project } = useProject()
row.value.row = await $api.dbTableRow.read(
NOCO,
project.value.id as string,
meta.value.title,
extractPkFromRow(row.value.row, meta.value.columns as ColumnType[]),
)
row.value.oldRow = { ...row.value.row }
row.value.rowMeta = {}
}
useProvideSmartsheetStore(ref({}) as any, meta)
provide(IsFormInj, true)
// accept as a prop
// const row: Row = { row: {}, rowMeta: {}, oldRow: {} }
watch(
state,
() => {
if (state.value) {
rowState.value = state.value
} else {
rowState.value = {}
}
},
{ immediate: true },
)
const isExpanded = useVModel(props, 'modelValue', emits)
</script>
<template>
<a-modal v-model:visible="isExpanded" :footer="null" width="min(90vw,1000px)" :body-style="{ padding: 0 }" :closable="false">
<Header @cancel="isExpanded = false" />
<a-card class="!bg-gray-100">
<div class="flex h-full nc-form-wrapper items-stretch">
<div class="flex-grow overflow-auto scrollbar-thin-primary">
<div class="w-[500px] mx-auto">
<div v-for="col in fields" :key="col.title" class="mt-2">
<SmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" />
<SmartsheetHeaderCell v-else :column="col" />
<div class="!bg-white rounded px-1 min-h-[35px] flex align-center">
<SmartsheetVirtualCell v-if="isVirtualCol(col)" v-model="row.row[col.title]" :row="row" :column="col" />
<SmartsheetCell
v-else
v-model="row.row[col.title]"
:column="col"
:edit-enabled="true"
@update:model-value="changedColumns.add(col.title)"
/>
</div>
</div>
</div>
</div>
<div class="nc-comments-drawer min-w-0 min-h-full max-h-full" :class="{ active: commentsDrawer }">
<div class="h-full">
<Comments v-if="commentsDrawer" />
</div>
</div>
</div>
</a-card>
</a-modal>
</template>
<style scoped lang="scss">
:deep(input, select, textarea) {
@apply !bg-white;
}
:deep(.ant-modal-body) {
@apply !bg-gray-100;
}
.nc-comments-drawer {
@apply w-0 transition-width ease-in-out duration-200;
overflow: hidden;
&.active {
@apply w-[250px] border-left-1;
}
}
.nc-form-wrapper {
max-height: max(calc(90vh - 100px), 600px);
height: max-content !important;
}
</style>

28
packages/nc-gui-v2/components/virtual-cell/BelongsTo.vue

@ -3,7 +3,7 @@ import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import ItemChip from './components/ItemChip.vue'
import ListItems from './components/ListItems.vue'
import { inject, ref, useProvideLTARStore } from '#imports'
import { inject, ref, useProvideLTARStore, useSmartsheetRowStoreOrThrow } from '#imports'
import { CellValueInj, ColumnInj, ReloadViewDataHookInj, RowInj } from '~/context'
const column = inject(ColumnInj)
@ -18,20 +18,39 @@ const active = false
const listItemsDlg = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { loadRelatedTableMeta, relatedTablePrimaryValueProp, unlink } = useProvideLTARStore(
column as Ref<Required<ColumnType>>,
row,
isNew,
reloadTrigger.trigger,
)
await loadRelatedTableMeta()
const value = computed(() => {
if (cellValue?.value) {
return cellValue?.value
} else if (isNew.value) {
return state?.value?.[column?.value.title as string]
}
return null
})
const unlinkRef = async (rec: Record<string, any>) => {
if (isNew.value) {
removeLTARRef(rec, column?.value as ColumnType)
} else {
await unlink(rec)
}
}
</script>
<template>
<div class="flex w-full chips-wrapper align-center" :class="{ active }">
<div class="chips d-flex align-center flex-grow">
<template v-if="cellValue">
<ItemChip :item="cellValue" :value="cellValue[relatedTablePrimaryValueProp]" @unlink="unlink(cellValue)" />
<template v-if="value">
<ItemChip :item="value" :value="value[relatedTablePrimaryValueProp]" @unlink="unlinkRef(value)" />
</template>
</div>
<div class="flex-1 flex justify-end gap-1 min-h-[30px] align-center">
@ -40,8 +59,7 @@ await loadRelatedTableMeta()
@click="listItemsDlg = true"
/>
</div>
<ListItems v-model="listItemsDlg" />
<ListItems v-model="listItemsDlg" @attach-record="listItemsDlg = true" />
</div>
</template>

49
packages/nc-gui-v2/components/virtual-cell/HasMany.vue

@ -4,8 +4,8 @@ import type { Ref } from 'vue'
import ItemChip from './components/ItemChip.vue'
import ListChildItems from './components/ListChildItems.vue'
import ListItems from './components/ListItems.vue'
import { computed, inject, ref, useProvideLTARStore } from '#imports'
import { CellValueInj, ColumnInj, ReloadViewDataHookInj, RowInj } from '~/context'
import { computed, inject, ref, useProvideLTARStore, useSmartsheetRowStoreOrThrow } from '#imports'
import { CellValueInj, ColumnInj, IsFormInj, ReloadViewDataHookInj, RowInj } from '~/context'
const column = inject(ColumnInj)!
@ -15,20 +15,32 @@ const row = inject(RowInj)!
const reloadTrigger = inject(ReloadViewDataHookInj)!
const isForm = inject(IsFormInj)
const listItemsDlg = ref(false)
const childListDlg = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { loadRelatedTableMeta, relatedTablePrimaryValueProp, unlink } = useProvideLTARStore(
column as Ref<Required<ColumnType>>,
row,
isNew,
reloadTrigger.trigger,
)
await loadRelatedTableMeta()
const localCellValue = computed(() => {
if (cellValue?.value) {
return cellValue?.value
} else if (isNew.value) {
return state?.value?.[column?.value.title as string]
}
return []
})
const cells = computed(() =>
cellValue.value.reduce((acc: any[], curr: any) => {
localCellValue.value.reduce((acc: any[], curr: any) => {
if (!relatedTablePrimaryValueProp.value) return acc
const value = curr[relatedTablePrimaryValueProp.value]
@ -38,15 +50,25 @@ const cells = computed(() =>
return [...acc, { value, item: curr }]
}, [] as any[]),
)
const unlinkRef = async (rec: Record<string, any>) => {
if (isNew.value) {
removeLTARRef(rec, column?.value as ColumnType)
} else {
await unlink(rec)
}
}
</script>
<template>
<div class="flex align-center items-center gap-1 w-full chips-wrapper">
<template v-if="!isForm">
<div class="chips flex align-center img-container flex-grow hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cellValue">
<ItemChip v-for="(cell, i) of cells" :key="i" :value="cell.value" @unlink="unlink(cell.item)" />
<span v-if="cellValue?.length === 10" class="caption pointer ml-1 grey--text" @click="childListDlg = true">more... </span>
<template v-if="cells">
<ItemChip v-for="(cell, i) of cells" :key="i" :item="ch" :value="cell.value" @unlink="unlinkRef(cell.item)" />
<span v-if="cellValue?.length === 10" class="caption pointer ml-1 grey--text" @click="childListDlg = true"
>more...
</span>
</template>
</div>
<div class="flex-grow flex justify-end gap-1 min-h-[30px] align-center">
@ -56,8 +78,17 @@ const cells = computed(() =>
/>
<MdiPlus class="select-none text-sm nc-action-icon text-gray-500/50 hover:text-gray-500" @click="listItemsDlg = true" />
</div>
</template>
<ListItems v-model="listItemsDlg" />
<ListChildItems v-model="childListDlg" @attach-record=";(childListDlg = false), (listItemsDlg = true)" />
<ListChildItems
v-model="childListDlg"
@attach-record="
() => {
childListDlg = false
listItemsDlg = true
}
"
/>
</div>
</template>

45
packages/nc-gui-v2/components/virtual-cell/ManyToMany.vue

@ -4,8 +4,8 @@ import type { Ref } from 'vue'
import ItemChip from './components/ItemChip.vue'
import ListChildItems from './components/ListChildItems.vue'
import ListItems from './components/ListItems.vue'
import { computed, inject, ref, useProvideLTARStore } from '#imports'
import { CellValueInj, ColumnInj, ReloadViewDataHookInj, RowInj } from '~/context'
import { computed, inject, ref, useProvideLTARStore, useSmartsheetRowStoreOrThrow } from '#imports'
import { CellValueInj, ColumnInj, IsFormInj, ReloadViewDataHookInj, RowInj } from '~/context'
const column = inject(ColumnInj)!
@ -15,20 +15,33 @@ const cellValue = inject(CellValueInj)!
const reloadTrigger = inject(ReloadViewDataHookInj)!
const isForm = inject(IsFormInj)
const listItemsDlg = ref(false)
const childListDlg = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { loadRelatedTableMeta, relatedTablePrimaryValueProp, unlink } = useProvideLTARStore(
column as Ref<Required<ColumnType>>,
row,
isNew,
reloadTrigger.trigger,
)
await loadRelatedTableMeta()
const localCellValue = computed(() => {
if (cellValue?.value) {
return cellValue?.value
} else if (isNew.value) {
return state?.value?.[column?.value.title as string]
}
return []
})
const cells = computed(() =>
cellValue.value.reduce((acc: any[], curr: any) => {
localCellValue.value.reduce((acc: any[], curr: any) => {
if (!relatedTablePrimaryValueProp.value) return acc
const value = curr[relatedTablePrimaryValueProp.value]
@ -38,15 +51,24 @@ const cells = computed(() =>
return [...acc, { value, item: curr }]
}, [] as any[]),
)
const unlinkRef = async (rec: Record<string, any>) => {
if (isNew.value) {
removeLTARRef(rec, column?.value as ColumnType)
} else {
await unlink(rec)
}
}
</script>
<template>
<div class="flex align-center gap-1 w-full h-full chips-wrapper">
<template v-if="!isForm">
<div class="chips flex align-center img-container flex-grow hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cellValue">
<ItemChip v-for="(cell, i) of cells" :key="i" :value="cell.value" @unlink="unlink(cell.item)" />
<template v-if="cells">
<ItemChip v-for="(cell, i) of cells" :key="i" :item="ch" :value="cell.value" @unlink="unlinkRef(cell.item)" />
<span v-if="cellValue?.length === 10" class="caption pointer ml-1 grey--text" @click="childListDlg = true">more... </span>
<span v-if="value?.length === 10" class="caption pointer ml-1 grey--text" @click="childListDlg = true">more... </span>
</template>
</div>
@ -55,10 +77,19 @@ const cells = computed(() =>
<MdiPlus class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500" @click="listItemsDlg = true" />
</div>
</template>
<ListItems v-model="listItemsDlg" />
<ListChildItems v-model="childListDlg" @attach-record=";(childListDlg = false), (listItemsDlg = true)" />
<ListChildItems
v-model="childListDlg"
@attach-record="
() => {
childListDlg = false
listItemsDlg = true
}
"
/>
</div>
</template>

33
packages/nc-gui-v2/components/virtual-cell/components/ItemChip.vue

@ -1,23 +1,44 @@
<script setup lang="ts">
import { ActiveCellInj, ReadonlyInj } from '~/context'
import MdiCloseThickIcon from '~icons/mdi/close-thick'
import { useLTARStoreOrThrow } from '#imports'
import { ActiveCellInj, IsFormInj, ReadonlyInj } from '~/context'
interface Props {
value?: string | number | boolean
item?: any
}
const { value } = defineProps<Props>()
const { value, item } = defineProps<Props>()
const emit = defineEmits(['unlink'])
const { relatedTableMeta } = useLTARStoreOrThrow()
const readonly = inject(ReadonlyInj, false)
const active = inject(ActiveCellInj, ref(false))
const isForm = inject(IsFormInj)
const expandedFormDlg = ref(false)
</script>
<template>
<div class="group py-1 px-2 flex align-center gap-1 bg-gray-200/50 hover:bg-gray-200 rounded-[20px]" :class="{ active }">
<div
class="group py-1 px-2 flex align-center gap-1 bg-gray-200/50 hover:bg-gray-200 rounded-[20px]"
:class="{ active }"
@click="expandedFormDlg = true"
>
<span class="name">{{ value }}</span>
<div v-show="active" v-if="!readonly" class="flex align-center">
<MdiCloseThickIcon class="unlink-icon text-xs text-gray-500/50 group-hover:text-gray-500" @click="emit('unlink')" />
<div v-show="active || isForm" v-if="!readonly" class="flex align-center">
<MdiCloseThick class="unlink-icon text-xs text-gray-500/50 group-hover:text-gray-500" @click.stop="emit('unlink')" />
</div>
<SmartsheetExpandedForm
v-if="expandedFormDlg"
v-model="expandedFormDlg"
:row="{ row: item }"
:meta="relatedTableMeta"
load-row
use-meta-fields
/>
</div>
</template>

77
packages/nc-gui-v2/components/virtual-cell/components/ListChildItems.vue

@ -1,11 +1,15 @@
<script lang="ts" setup>
import { useLTARStoreOrThrow, useVModel, watch } from '#imports'
import { Modal } from 'ant-design-vue'
import type { ColumnType } from 'nocodb-sdk'
import { useLTARStoreOrThrow, useSmartsheetRowStoreOrThrow, useVModel, watch } from '#imports'
import { ColumnInj, IsFormInj } from '~/context'
const props = defineProps<{ modelValue?: boolean }>()
const emit = defineEmits(['update:modelValue', 'attachRecord'])
const vModel = useVModel(props, 'modelValue', emit)
const isForm = inject(IsFormInj, false)
const column = inject(ColumnInj)
const {
childrenList,
@ -16,40 +20,66 @@ const {
relatedTablePrimaryValueProp,
unlink,
getRelatedTableRowId,
relatedTableMeta,
} = useLTARStoreOrThrow()
watch(vModel, (nextVal) => {
if (nextVal) {
const { isNew, state, removeLTARRef } = useSmartsheetRowStoreOrThrow()
watch([vModel, isForm], (nextVal) => {
if (nextVal[0] || nextVal[1]) {
loadChildrenList()
}
})
const unlinkRow = async (row: Record<string, any>) => {
if (isNew.value) {
removeLTARRef(row, column?.value as ColumnType)
} else {
await unlink(row)
await loadChildrenList()
}
}
const container = computed(() =>
isForm
? h('div', {
class: 'w-full p-2',
})
: Modal,
)
const expandedFormDlg = ref(false)
const expandedFormRow = ref()
</script>
<template>
<a-modal v-model:visible="vModel" :footer="null" title="Child list">
<component :is="container" v-model:visible="vModel" :footer="null" title="Child list">
<div class="max-h-[max(calc(100vh_-_300px)_,500px)] flex flex-col">
<div class="flex mb-4 align-center gap-2">
<div class="flex-1" />
<MdiReload class="cursor-pointer text-gray-500" @click="loadChildrenList" />
<MdiReload v-if="!isForm" class="cursor-pointer text-gray-500" @click="loadChildrenList" />
<a-button type="primary" size="small" @click="emit('attachRecord')">
<a-button type="primary" class="!text-xs" size="small" @click="emit('attachRecord')">
<div class="flex align-center gap-1">
<!-- todo: row is not defined? @click="unlinkRow(row)" -->
<MdiLinkVariantRemove class="text-xs text-white" />
<MdiLinkVariantRemove class="text-xs text-white" @click="unlinkRow(row)" />
Link to '{{ meta.title }}'
</div>
</a-button>
</div>
<template v-if="childrenList?.pageInfo?.totalRows">
<template v-if="(isNew && state?.[column?.title]?.length) || childrenList?.pageInfo?.totalRows">
<div class="flex-1 overflow-auto min-h-0">
<a-card v-for="(row, i) of childrenList?.list ?? []" :key="i" class="ma-2 hover:(!bg-gray-200/50 shadow-md)">
<a-card
v-for="(row, i) of childrenList?.list ?? state?.[column?.title] ?? []"
:key="i"
class="ma-2 hover:(!bg-gray-200/50 shadow-md)"
@click="
() => {
expandedFormRow = row
expandedFormDlg = true
}
"
>
<div class="flex align-center">
<div class="flex-grow overflow-hidden min-w-0">
{{ row[relatedTablePrimaryValueProp]
@ -57,14 +87,20 @@ const unlinkRow = async (row: Record<string, any>) => {
</div>
<div class="flex-1"></div>
<div class="flex gap-2">
<MdiLinkVariantRemove class="text-xs text-grey hover:(!text-red-500) cursor-pointer" @click="unlinkRow(row)" />
<MdiDeleteOutline class="text-xs text-grey hover:(!text-red-500) cursor-pointer" @click="deleteRelatedRow(row)" />
<MdiLinkVariantRemove
class="text-xs text-grey hover:(!text-red-500) cursor-pointer"
@click.stop="unlinkRow(row)"
/>
<MdiDeleteOutline
class="text-xs text-grey hover:(!text-red-500) cursor-pointer"
@click.stop="deleteRelatedRow(row)"
/>
</div>
</div>
</a-card>
</div>
<a-pagination
v-if="childrenList?.pageInfo"
v-if="!isNew && childrenList?.pageInfo"
v-model:current="childrenListPagination.page"
v-model:page-size="childrenListPagination.size"
class="mt-2 mx-auto"
@ -75,7 +111,16 @@ const unlinkRow = async (row: Record<string, any>) => {
</template>
<a-empty v-else class="my-10" />
</div>
</a-modal>
<SmartsheetExpandedForm
v-if="expandedFormDlg && expandedFormRow"
v-model="expandedFormDlg"
:row="{ row: expandedFormRow }"
:meta="relatedTableMeta"
load-row
use-meta-fields
/>
</component>
</template>
<style scoped lang="scss">

80
packages/nc-gui-v2/components/virtual-cell/components/ListItems.vue

@ -1,5 +1,8 @@
<script lang="ts" setup>
import { useLTARStoreOrThrow, useVModel, watch } from '#imports'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { computed, useLTARStoreOrThrow, useSmartsheetRowStoreOrThrow, useVModel } from '#imports'
import { ColumnInj } from '~/context'
const props = defineProps<{ modelValue: boolean }>()
@ -7,6 +10,8 @@ const emit = defineEmits(['update:modelValue', 'addNewRecord'])
const vModel = useVModel(props, 'modelValue', emit)
const column = inject(ColumnInj)
const {
childrenExcludedList,
loadChildrenExcludedList,
@ -14,18 +19,62 @@ const {
relatedTablePrimaryValueProp,
link,
getRelatedTableRowId,
relatedTableMeta,
meta,
row,
} = useLTARStoreOrThrow()
watch(vModel, (nextVal) => {
if (nextVal) {
loadChildrenExcludedList()
}
})
const { addLTARRef, isNew } = useSmartsheetRowStoreOrThrow()
const linkRow = async (row: Record<string, any>) => {
if (isNew.value) {
addLTARRef(row, column?.value as ColumnType)
} else {
await link(row)
}
vModel.value = false
}
watch(vModel, () => {
if (vModel.value) {
loadChildrenExcludedList()
}
})
const expandedFormDlg = ref(false)
/** populate initial state for a new row which is parent/child of current record */
const newRowState = computed(() => {
const colOpt = (column?.value as ColumnType)?.colOptions as LinkToAnotherRecordType
const colInRelatedTable: ColumnType | undefined = relatedTableMeta?.value?.columns?.find((col) => {
if (col.uidt !== UITypes.LinkToAnotherRecord) return false
const colOpt1 = col?.colOptions as LinkToAnotherRecordType
if (colOpt1?.fk_related_model_id !== meta.value.id) return false
if (colOpt.type === RelationTypes.MANY_TO_MANY && colOpt1?.type === RelationTypes.MANY_TO_MANY) {
return (
colOpt.fk_parent_column_id === colOpt1.fk_child_column_id && colOpt.fk_child_column_id === colOpt1.fk_parent_column_id
)
} else {
return (
colOpt.fk_parent_column_id === colOpt1.fk_parent_column_id && colOpt.fk_child_column_id === colOpt1.fk_child_column_id
)
}
})
if (!colInRelatedTable) return {}
const relatedTableColOpt = colInRelatedTable?.colOptions as LinkToAnotherRecordType
if (!relatedTableColOpt) return {}
if (relatedTableColOpt.type === RelationTypes.BELONGS_TO) {
return {
[colInRelatedTable.title as string]: row?.value?.row,
}
} else {
return {
[colInRelatedTable.title as string]: row?.value && [row.value.row],
}
}
})
</script>
<template>
@ -40,19 +89,19 @@ const linkRow = async (row: Record<string, any>) => {
></a-input>
<div class="flex-1" />
<MdiReload class="cursor-pointer text-gray-500" @click="loadChildrenExcludedList" />
<a-button type="primary" size="small" @click="emit('addNewRecord')">Add new record</a-button>
<a-button type="primary" size="small" @click="expandedFormDlg = true">Add new record</a-button>
</div>
<template v-if="childrenExcludedList?.pageInfo?.totalRows">
<div class="flex-1 overflow-auto min-h-0">
<a-card
v-for="(row, i) in childrenExcludedList?.list ?? []"
v-for="(refRow, i) in childrenExcludedList?.list ?? []"
:key="i"
class="ma-2 cursor-pointer hover:(!bg-gray-200/50 shadow-md) group"
@click="linkRow(row)"
@click="linkRow(refRow)"
>
{{ row[relatedTablePrimaryValueProp]
{{ refRow[relatedTablePrimaryValueProp]
}}<span class="hidden group-hover:(inline) text-gray-400 text-[11px] ml-1"
>(Primary key : {{ getRelatedTableRowId(row) }})</span
>(Primary key : {{ getRelatedTableRowId(refRow) }})</span
>
</a-card>
</div>
@ -67,6 +116,15 @@ const linkRow = async (row: Record<string, any>) => {
/>
</template>
<a-empty v-else class="my-10" />
<SmartsheetExpandedForm
v-if="expandedFormDlg"
v-model="expandedFormDlg"
:meta="relatedTableMeta"
:row="{ row: {}, oldRow: {}, rowMeta: { new: true } }"
:state="newRowState"
use-meta-fields
/>
</div>
</a-modal>
</template>

1
packages/nc-gui-v2/composables/index.ts

@ -20,3 +20,4 @@ export * from './useVirtualCell'
export * from './useColumnCreateStore'
export * from './useSmartsheetStore'
export * from './useLTARStore'
export * from './useExpandedFormStore'

209
packages/nc-gui-v2/composables/useExpandedFormStore.ts

@ -0,0 +1,209 @@
import { UITypes } from 'nocodb-sdk'
import type { ColumnType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { message, notification } from 'ant-design-vue'
import dayjs from 'dayjs'
import { useApi, useInjectionState, useProject, useProvideSmartsheetRowStore } from '#imports'
import { NOCO } from '~/lib'
import { useNuxtApp } from '#app'
import type { Row } from '~/composables/useViewData'
import { extractPkFromRow, extractSdkResponseErrorMsg } from '~/utils'
const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((meta: Ref<TableType>, row: Ref<Row>) => {
const { $e, $state, $api } = useNuxtApp()
const { api, isLoading: isCommentsLoading, error: commentsError } = useApi()
// { useGlobalInstance: true },
// state
const commentsOnly = ref(false)
const commentsAndLogs = ref([])
const comment = ref('')
const commentsDrawer = ref(false)
const changedColumns = ref(new Set<string>())
const { project } = useProject()
const rowStore = useProvideSmartsheetRowStore(meta, row)
// todo
// const activeView = inject(ActiveViewInj)
// const { updateOrSaveRow, insertRow } = useViewData(meta, activeView as any)
// getters
const primaryValue = computed(() => {
if (row?.value?.row) {
const col = meta?.value?.columns?.find((c) => c.pv)
if (!col) {
return
}
const value = row.value.row?.[col.title as string]
const uidt = col.uidt
if (uidt === UITypes.Date) {
return (/^\d+$/.test(value) ? dayjs(+value) : dayjs(value)).format('YYYY-MM-DD')
} else if (uidt === UITypes.DateTime) {
return (/^\d+$/.test(value) ? dayjs(+value) : dayjs(value)).format('YYYY-MM-DD HH:mm')
} else if (uidt === UITypes.Time) {
let dateTime = dayjs(value)
if (!dateTime.isValid()) {
dateTime = dayjs(value, 'HH:mm:ss')
}
if (!dateTime.isValid()) {
dateTime = dayjs(`1999-01-01 ${value}`)
}
if (!dateTime.isValid()) {
return value
}
return dateTime.format('HH:mm:ss')
}
return value
}
})
// actions
const loadCommentsAndLogs = async () => {
if (!row.value) return
const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
if (!rowId) return
commentsAndLogs.value =
(
await api.utils.commentList({
row_id: rowId,
fk_model_id: meta.value.id as string,
comments_only: commentsOnly.value,
})
)?.reverse?.() || []
}
const isYou = (email: string) => {
return $state.user?.value?.email === email
}
const saveComment = async () => {
try {
if (!row.value || !comment.value) return
const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
if (!rowId) return
await api.utils.commentRow({
fk_model_id: meta.value?.id as string,
row_id: rowId,
// todo: swagger type correction
description: comment.value,
} as any)
comment.value = ''
message.success('Comment added successfully')
await loadCommentsAndLogs()
} catch (e: any) {
message.error(e.message)
}
$e('a:row-expand:comment')
}
const save = async () => {
let data
try {
// todo:
// if (this.presetValues) {
// // cater presetValues
// for (const k in this.presetValues) {
// this.$set(this.changedColumns, k, true);
// }
// }
const updateOrInsertObj = [...changedColumns.value].reduce((obj, col) => {
obj[col] = row.value.row[col]
return obj
}, {} as Record<string, any>)
if (row.value.rowMeta.new) {
data = await $api.dbTableRow.create('noco', project.value.title as string, meta.value.title, updateOrInsertObj)
/* todo:
// save hasmany and manytomany relations from local state
if (this.$refs.virtual && Array.isArray(this.$refs.virtual)) {
for (const vcell of this.$refs.virtual) {
if (vcell.save) {
await vcell.save(this.localState);
}
}
} */
row.value = {
row: data,
rowMeta: {},
oldRow: { ...data },
}
/// todo:
// await this.reload();
} else if (Object.keys(updateOrInsertObj).length) {
const id = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
if (!id) {
return message.info("Update not allowed for table which doesn't have primary Key")
}
await $api.dbTableRow.update(NOCO, project.value.title as string, meta.value.title, id, updateOrInsertObj)
for (const key of Object.keys(updateOrInsertObj)) {
// audit
$api.utils
.auditRowUpdate(id, {
fk_model_id: meta.value.id,
column_name: key,
row_id: id,
value: getPlainText(updateOrInsertObj[key]),
prev_value: getPlainText(row.value.oldRow[key]),
})
.then(() => {})
}
} else {
return message.info('No columns to update')
}
// this.$emit('update:oldRow', { ...this.localState });
// this.changedColumns = {};
// this.$emit('input', this.localState);
// this.$emit('update:isNew', false);
notification.success({
message: `${primaryValue.value || 'Row'} updated successfully.`,
// position: 'bottom-right',
})
changedColumns.value = new Set()
} catch (e: any) {
notification.error({ message: `Failed to update row`, description: await extractSdkResponseErrorMsg(e) })
}
$e('a:row-expand:add')
return data
}
return {
...rowStore,
commentsOnly,
loadCommentsAndLogs,
commentsAndLogs,
isCommentsLoading,
commentsError,
saveComment,
comment,
isYou,
commentsDrawer,
row,
primaryValue,
save,
changedColumns,
}
}, 'expanded-form-store')
export { useProvideExpandedFormStore }
export function useExpandedFormStoreOrThrow() {
const expandedFormStore = useExpandedFormStore()
if (expandedFormStore == null) throw new Error('Please call `useExpandedFormStore` on the appropriate parent component')
return expandedFormStore
}
// todo: move to utils
function getPlainText(htmlString: string) {
const div = document.createElement('div')
div.textContent = htmlString || ''
return div.innerHTML
}

28
packages/nc-gui-v2/composables/useLTARStore.ts

@ -1,5 +1,5 @@
import type { ColumnType, LinkToAnotherRecordType, PaginatedType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import { Modal, notification } from 'ant-design-vue'
import { useInjectionState, useMetas, useProject } from '#imports'
import { NOCO } from '~/lib'
@ -13,7 +13,7 @@ interface DataApiResponse {
/** Store for managing Link to another cells */
const [useProvideLTARStore, useLTARStore] = useInjectionState(
(column: Ref<Required<ColumnType>>, row?: Ref<Row>, reloadData = () => {}) => {
(column: Ref<Required<ColumnType>>, row?: Ref<Row>, isNewRow: ComputedRef<boolean> | Ref<boolean>, reloadData = () => {}) => {
// state
const { metas, getMeta } = useMetas()
const { project } = useProject()
@ -61,12 +61,32 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const relatedTablePrimaryValueProp = computed(() => {
return (relatedTableMeta?.value?.columns?.find((c) => c.pv) || relatedTableMeta?.value?.columns?.[0])?.title
})
const relatedTablePrimaryKeyProps = computed(() => {
return relatedTableMeta?.value?.columns?.filter((c) => c.pk)?.map((c) => c.title) ?? []
})
const primaryValueProp = computed(() => {
return (meta?.value?.columns?.find((c: Required<ColumnType>) => c.pv) || relatedTableMeta?.value?.columns?.[0])?.title
})
const loadChildrenExcludedList = async () => {
try {
/** if new row load all records */
if (isNewRow?.value) {
childrenExcludedList.value = await $api.dbTableRow.list(
NOCO,
project.value.id as string,
relatedTableMeta?.value?.id as string,
{
limit: childrenExcludedListPagination.size,
offset: childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1),
where:
childrenExcludedListPagination.query &&
`(${relatedTablePrimaryValueProp.value},like,${childrenExcludedListPagination.query})`,
fields: [relatedTablePrimaryValueProp.value, ...relatedTablePrimaryKeyProps.value],
} as any,
)
} else {
childrenExcludedList.value = await $api.dbTableRow.nestedChildrenExcludedList(
NOCO,
project.value.id as string,
@ -83,6 +103,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
`(${relatedTablePrimaryValueProp.value},like,${childrenExcludedListPagination.query})`,
} as any,
)
}
} catch (e: any) {
notification.error({
message: 'Failed to load list',
@ -93,6 +114,8 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const loadChildrenList = async () => {
try {
if (colOptions.type === 'bt') return
childrenList.value = await $api.dbTableRow.nestedList(
NOCO,
project.value.id as string,
@ -198,6 +221,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
column?.value?.title,
getRelatedTableRowId(row) as string,
)
await loadChildrenList()
} catch (e: any) {
notification.error({
message: 'Linking failed',

105
packages/nc-gui-v2/composables/useSmartsheetRowStore.ts

@ -0,0 +1,105 @@
import { notification } from 'ant-design-vue'
import { UITypes } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, RelationTypes, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { useNuxtApp } from '#app'
import { useInjectionState, useMetas, useProject, useVirtualCell } from '#imports'
import type { Row } from '~/composables/useViewData'
import { NOCO } from '~/lib'
import { extractPkFromRow, extractSdkResponseErrorMsg } from '~/utils'
const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState((meta: Ref<TableType>, row: Ref<Row>) => {
const { $api } = useNuxtApp()
const { project } = useProject()
const { metas } = useMetas()
// state
const state = ref<Record<string, Record<string, any> | Record<string, any>[] | null>>({})
// getters
const isNew = computed(() => row.value?.rowMeta?.new ?? false)
// actions
const addLTARRef = async (value: Record<string, any>, column: ColumnType) => {
const { isHm, isMm, isBt } = $(useVirtualCell(ref(column)))
if (isHm || isMm) {
state.value[column.title!] = state.value[column.title!] || []
state.value[column.title!]!.push(value)
} else if (isBt) {
state.value[column.title!] = value
}
}
// actions
const removeLTARRef = async (value: Record<string, any>, column: ColumnType) => {
const { isHm, isMm, isBt } = $(useVirtualCell(ref(column)))
if (isHm || isMm) {
state.value[column.title!]?.splice(state.value[column.title!]?.indexOf(value), 1)
} else if (isBt) {
state.value[column.title!] = null
}
}
const linkRecord = async (rowId: string, relatedRowId: string, column: ColumnType, type: RelationTypes) => {
try {
await $api.dbTableRow.nestedAdd(
NOCO,
project.value.title as string,
meta.value.title as string,
rowId,
type,
column.title as string,
relatedRowId,
)
} catch (e: any) {
notification.error({
message: 'Linking failed',
description: await extractSdkResponseErrorMsg(e),
})
}
}
/** sync LTAR relations kept in local state */
const syncLTARRefs = async (row: Record<string, any>) => {
const id = extractPkFromRow(row, meta.value.columns as ColumnType[])
for (const column of meta?.value?.columns ?? []) {
if (column.uidt !== UITypes.LinkToAnotherRecord) continue
const colOptions = column?.colOptions as LinkToAnotherRecordType
const { isHm, isMm, isBt } = $(useVirtualCell(ref(column)))
const relatedTableMeta = metas.value?.[colOptions?.fk_related_model_id as string]
if (isHm || isMm) {
const relatedRows = (state.value?.[column.title!] ?? []) as Record<string, any>[]
for (const relatedRow of relatedRows) {
await linkRecord(id, extractPkFromRow(relatedRow, relatedTableMeta.columns as ColumnType[]), column, colOptions.type)
}
} else if (isBt && state?.value?.[column.title!]) {
await linkRecord(
id,
extractPkFromRow(state.value?.[column.title!] as Record<string, any>, relatedTableMeta.columns as ColumnType[]),
column,
colOptions.type,
)
}
}
}
return {
row,
state,
isNew,
// todo: use better name
addLTARRef,
removeLTARRef,
syncLTARRefs,
}
}, 'smartsheet-row-store')
export { useProvideSmartsheetRowStore }
export function useSmartsheetRowStoreOrThrow() {
const smartsheetRowStore = useSmartsheetRowStore()
if (smartsheetRowStore == null) throw new Error('Please call `useSmartsheetRowStore` on the appropriate parent component')
return smartsheetRowStore
}

44
packages/nc-gui-v2/composables/useViewData.ts

@ -1,10 +1,10 @@
import type { Api, GalleryType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import type { Api, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import { notification } from 'ant-design-vue'
import { useNuxtApp } from '#app'
import { useProject } from '#imports'
import { NOCO } from '~/lib'
import { extractSdkResponseErrorMsg } from '~/utils'
import { extractPkFromRow, extractSdkResponseErrorMsg } from '~/utils'
const formatData = (list: Record<string, any>[]) =>
list.map((row) => ({
@ -16,7 +16,10 @@ const formatData = (list: Record<string, any>[]) =>
export interface Row {
row: Record<string, any>
oldRow: Record<string, any>
rowMeta?: any
rowMeta: {
new?: boolean
commentCount?: number
}
}
export function useViewData(
@ -24,8 +27,13 @@ export function useViewData(
viewMeta: Ref<ViewType & { id: string }> | ComputedRef<ViewType & { id: string }> | undefined,
where?: ComputedRef<string | undefined>,
) {
if (!meta) {
throw new Error('Table meta is not available')
}
const formattedData = ref<Row[]>([])
const paginationData = ref<PaginatedType>({ page: 1, pageSize: 25 })
const aggCommentCount = ref<Record<string, number>>({})
const galleryData = ref<GalleryType | undefined>(undefined)
const { project } = useProject()
@ -56,6 +64,32 @@ export function useViewData(
where: where?.value ?? '',
}))
/** load row comments count */
const loadAggCommentsCount = async () => {
// todo: handle in public api
// if (this.isPublicView) {
// return;
// }
const ids = formattedData.value
?.filter(({ rowMeta: { new: isNew } }) => !isNew)
?.map(({ row }) => {
return extractPkFromRow(row, meta?.value?.columns as ColumnType[])
})
if (!ids?.length) return
aggCommentCount.value = await $api.utils.commentCount({
ids,
fk_model_id: meta.value.id as string,
})
for (const row of formattedData.value) {
const id = extractPkFromRow(row.row, meta?.value?.columns as ColumnType[])
row.rowMeta.commentCount = aggCommentCount.value?.find((c) => c.row_id === id)?.count || 0
}
}
const loadData = async (params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) => {
if (!project?.value?.id || !meta?.value?.id || !viewMeta?.value?.id) return
const response = await $api.dbViewRow.list('noco', project.value.id, meta.value.id, viewMeta.value.id, {
@ -64,6 +98,8 @@ export function useViewData(
})
formattedData.value = formatData(response.list)
paginationData.value = response.pageInfo
loadAggCommentsCount()
}
const loadGalleryData = async () => {
@ -265,5 +301,7 @@ export function useViewData(
syncCount,
galleryData,
loadGalleryData,
aggCommentCount,
loadAggCommentsCount,
}
}

30
packages/nc-gui-v2/package-lock.json generated

@ -22,6 +22,7 @@
"unique-names-generator": "^4.7.1",
"url": "^0.11.0",
"util": "^0.12.4",
"vue-dompurify-html": "^3.0.0",
"vue-i18n": "^9.1.10",
"vue-toastification": "^2.0.0-rc.5",
"vuedraggable": "^4.1.0",
@ -5389,6 +5390,11 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.10.tgz",
"integrity": "sha512-o7Fg/AgC7p/XpKjf/+RC3Ok6k4St5F7Q6q6+Nnm3p2zGWioAY6dh0CbbuwOhH2UcSzKsdniE/YnE2/92JcsA+g=="
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@ -14534,6 +14540,17 @@
"bundle-runner": "^0.0.1"
}
},
"node_modules/vue-dompurify-html": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/vue-dompurify-html/-/vue-dompurify-html-3.0.0.tgz",
"integrity": "sha512-S6PMeJU7S3w0TnxMWWd4iydc7oPdOER1GmW9rsgiRwHvcw+nUi2v6BgERcFBULlM+x6PXsfu5P/Rm4reVvWH5A==",
"dependencies": {
"dompurify": "^2.3.4"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-eslint-parser": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.0.3.tgz",
@ -19300,6 +19317,11 @@
"domelementtype": "^2.2.0"
}
},
"dompurify": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.10.tgz",
"integrity": "sha512-o7Fg/AgC7p/XpKjf/+RC3Ok6k4St5F7Q6q6+Nnm3p2zGWioAY6dh0CbbuwOhH2UcSzKsdniE/YnE2/92JcsA+g=="
},
"domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@ -26034,6 +26056,14 @@
"bundle-runner": "^0.0.1"
}
},
"vue-dompurify-html": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/vue-dompurify-html/-/vue-dompurify-html-3.0.0.tgz",
"integrity": "sha512-S6PMeJU7S3w0TnxMWWd4iydc7oPdOER1GmW9rsgiRwHvcw+nUi2v6BgERcFBULlM+x6PXsfu5P/Rm4reVvWH5A==",
"requires": {
"dompurify": "^2.3.4"
}
},
"vue-eslint-parser": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.0.3.tgz",

1
packages/nc-gui-v2/package.json

@ -26,6 +26,7 @@
"socket.io-client": "^4.5.1",
"sortablejs": "^1.15.0",
"unique-names-generator": "^4.7.1",
"vue-dompurify-html": "^3.0.0",
"url": "^0.11.0",
"util": "^0.12.4",
"vue-i18n": "^9.1.10",

6
packages/nc-gui-v2/plugins/domPurify.ts

@ -0,0 +1,6 @@
import VueDOMPurifyHTML from 'vue-dompurify-html'
import { defineNuxtPlugin } from 'nuxt/app'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(VueDOMPurifyHTML)
})

1
packages/nc-gui-v2/tsconfig.json

@ -11,7 +11,6 @@
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"types": [
"@nuxt/types",
"@intlify/vite-plugin-vue-i18n/client",
"vue-i18n",
"unplugin-icons/types/vue",

11
packages/nc-gui-v2/utils/dataUtils.ts

@ -0,0 +1,11 @@
import type { ColumnType } from 'nocodb-sdk'
export const extractPkFromRow = (row: Record<string, any>, columns: ColumnType[]) => {
return (
row &&
columns
?.filter((c) => c.pk)
.map((c) => row?.[c.title as string])
.join('___')
)
}

1
packages/nc-gui-v2/utils/index.ts

@ -15,3 +15,4 @@ export * from './columnUtils'
export * from './validation'
export * from './viewUtils'
export * from './currencyUtils'
export * from './dataUtils'

1
packages/nc-gui/components/project/spreadsheet/components/ExpandedForm.vue

@ -678,6 +678,7 @@ h5 {
padding: 8px;
border-radius: 8px;
}
</style>
<!--
/**

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

@ -3111,7 +3111,7 @@ export class Api<
* @response `200` `void` OK
*/
commentRow: (
data: { row_id: string; fk_model_id: string; comment: string },
data: { row_id: string; fk_model_id: string; description?: string },
params: RequestParams = {}
) =>
this.request<void, any>({

2
scripts/sdk/swagger.json

@ -4448,7 +4448,7 @@
"fk_model_id": {
"type": "string"
},
"comment": {
"description": {
"type": "string"
}
},

Loading…
Cancel
Save