Browse Source

Merge pull request #7558 from nocodb/nc-feat/copy-paste-links-field-cell

Nc copy and and paste a linked record and date/dateTime field value from one cell to another
pull/7652/head
Raju Udava 9 months ago committed by GitHub
parent
commit
a5f2607a9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      packages/nc-gui/components/smartsheet/Row.vue
  2. 60
      packages/nc-gui/components/smartsheet/grid/Table.vue
  3. 2
      packages/nc-gui/composables/useData.ts
  4. 18
      packages/nc-gui/composables/useLTARStore.ts
  5. 58
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  6. 259
      packages/nc-gui/composables/useMultiSelect/index.ts
  7. 38
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  8. 2
      packages/nc-gui/composables/useViewSorts.ts
  9. 5
      packages/nc-gui/lang/en.json
  10. 2
      packages/nc-gui/utils/dataUtils.ts
  11. 26
      packages/nocodb/src/controllers/data-table.controller.ts
  12. 8
      packages/nocodb/src/db/BaseModelSqlv2.ts
  13. 123
      packages/nocodb/src/schema/swagger.json
  14. 216
      packages/nocodb/src/services/data-table.service.ts
  15. 2
      packages/nocodb/src/utils/acl.ts

6
packages/nc-gui/components/smartsheet/Row.vue

@ -21,7 +21,10 @@ const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow()
const { isNew, state, syncLTARRefs, clearLTARCell, addLTARRef } = useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
const { isNew, state, syncLTARRefs, clearLTARCell, addLTARRef, cleaMMCell } = useProvideSmartsheetRowStore(
meta as Ref<TableType>,
currentRow,
)
const reloadViewDataTrigger = inject(ReloadViewDataHookInj)!
@ -41,6 +44,7 @@ defineExpose({
syncLTARRefs,
clearLTARCell,
addLTARRef,
cleaMMCell,
})
</script>

60
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import axios from 'axios'
import { nextTick } from '@vue/runtime-core'
import { type ColumnReqType, type ColumnType, type PaginatedType, type TableType, type ViewType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import {
UITypes,
ViewTypes,
@ -29,15 +29,18 @@ import {
getEnumColorByIndex,
iconMap,
inject,
isBt,
isColumnRequiredAndNull,
isDrawerOrModalExist,
isEeUI,
isMac,
isMm,
message,
onClickOutside,
onMounted,
provide,
ref,
useApi,
useEventListener,
useI18n,
useMultiSelect,
@ -98,6 +101,8 @@ const dataRef = toRef(props, 'data')
const paginationStyleRef = toRef(props, 'pagination')
const { api } = useApi()
const {
loadData,
changePage,
@ -263,17 +268,19 @@ provide(JsonExpandInj, isJsonExpand)
async function clearCell(ctx: { row: number; col: number } | null, skipUpdate = false) {
if (!ctx || !hasEditPermission.value || (!isLinksOrLTAR(fields.value[ctx.col]) && isVirtualCol(fields.value[ctx.col]))) return
if (fields.value[ctx.col]?.uidt === UITypes.Links) {
return message.info(t('msg.linkColumnClearNotSupportedYet'))
}
const rowObj = dataRef.value[ctx.row]
const columnObj = fields.value[ctx.col]
if (isVirtualCol(columnObj)) {
let mmClearResult
if (isMm(columnObj) && rowRefs.value) {
mmClearResult = await rowRefs.value[ctx.row]!.cleaMMCell(columnObj)
}
addUndo({
undo: {
fn: async (ctx: { row: number; col: number }, col: ColumnType, row: Row, pg: PaginatedType) => {
fn: async (ctx: { row: number; col: number }, col: ColumnType, row: Row, pg: PaginatedType, mmClearResult: any[]) => {
if (paginationDataRef.value?.pageSize === pg.pageSize) {
if (paginationDataRef.value?.page !== pg.page) {
await changePage?.(pg.page!)
@ -286,11 +293,21 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
columnObj.id === col.id
) {
rowObj.row[columnObj.title] = row.row[columnObj.title]
if (rowRefs.value) {
await rowRefs.value[ctx.row]!.addLTARRef(rowObj.row[columnObj.title], columnObj)
await rowRefs.value[ctx.row]!.syncLTARRefs(rowObj.row)
if (isBt(columnObj)) {
rowObj.row[columnObj.title] = row.row[columnObj.title]
await rowRefs.value[ctx.row]!.addLTARRef(rowObj.row[columnObj.title], columnObj)
await rowRefs.value[ctx.row]!.syncLTARRefs(rowObj.row)
} else if (isMm(columnObj)) {
await api.dbDataTableRow.nestedLink(
meta.value?.id as string,
columnObj.id as string,
encodeURIComponent(rowId as string),
mmClearResult,
)
rowObj.row[columnObj.title] = mmClearResult?.length ? mmClearResult?.length : null
}
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
@ -305,7 +322,7 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
throw new Error(t('msg.pageSizeChanged'))
}
},
args: [clone(ctx), clone(columnObj), clone(rowObj), clone(paginationDataRef.value)],
args: [clone(ctx), clone(columnObj), clone(rowObj), clone(paginationDataRef.value), mmClearResult],
},
redo: {
fn: async (ctx: { row: number; col: number }, col: ColumnType, row: Row, pg: PaginatedType) => {
@ -318,7 +335,11 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
const columnObj = fields.value[ctx.col]
if (rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && columnObj.id === col.id) {
if (rowRefs.value) {
await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
if (isBt(columnObj)) {
await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
} else if (isMm(columnObj)) {
await rowRefs.value[ctx.row]!.cleaMMCell(columnObj)
}
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
activeCell.col = ctx.col
@ -336,7 +357,8 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
},
scope: defineViewScope({ view: view.value }),
})
if (rowRefs.value) await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
if (isBt(columnObj) && rowRefs.value) await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
return
}
@ -733,6 +755,9 @@ const {
},
bulkUpdateRows,
fillHandle,
view,
paginationDataRef,
changePage,
)
function scrollToRow(row?: number) {
@ -895,13 +920,20 @@ async function clearSelectedRangeOfCells() {
const cols = fields.value.slice(startCol, endCol + 1)
const rows = dataRef.value.slice(startRow, endRow + 1)
const props = []
let isInfoShown = false
for (const row of rows) {
for (const col of cols) {
if (!row || !col || !col.title) continue
// TODO handle LinkToAnotherRecord
if (isVirtualCol(col)) continue
if (isVirtualCol(col)) {
if ((isBt(col) || isMm(col)) && !isInfoShown) {
message.info(t('msg.info.groupClearIsNotSupportedOnLinksColumn'))
isInfoShown = true
}
continue
}
row.row[col.title] = null
props.push(col.title)

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

@ -480,7 +480,7 @@ export function useData(args: {
base.value.title as string,
metaValue?.title as string,
encodeURIComponent(rowId),
type as 'mm' | 'hm',
type as RelationTypes,
column.title as string,
encodeURIComponent(relatedRowId),
)

18
packages/nc-gui/composables/useLTARStore.ts

@ -1,13 +1,11 @@
import {
import { UITypes, dateFormats, parseStringDateTime, timeFormats } from 'nocodb-sdk'
import type {
type ColumnType,
type LinkToAnotherRecordType,
type PaginatedType,
RelationTypes,
type RequestParams,
type TableType,
UITypes,
dateFormats,
parseStringDateTime,
timeFormats,
} from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import {
@ -230,7 +228,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
baseId,
meta.value.id,
encodeURIComponent(rowId.value),
colOptions.value.type as 'mm' | 'hm',
colOptions.value.type as RelationTypes,
column?.value?.id,
{
limit: String(childrenExcludedListPagination.size),
@ -284,7 +282,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
childrenList.value = await $api.public.dataNestedList(
sharedView.value?.uuid as string,
encodeURIComponent(rowId.value),
colOptions.value.type as 'mm' | 'hm',
colOptions.value.type as RelationTypes,
column.value.id,
{
limit: String(childrenListPagination.size),
@ -304,7 +302,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
(base?.value?.id || (sharedView.value?.view as any)?.base_id) as string,
meta.value.id,
encodeURIComponent(rowId.value),
colOptions.value.type as 'mm' | 'hm',
colOptions.value.type as RelationTypes,
column?.value?.id,
{
limit: String(childrenListPagination.size),
@ -395,7 +393,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
base.value.id as string,
metaValue.id!,
encodeURIComponent(rowId.value),
colOptions.value.type as 'mm' | 'hm',
colOptions.value.type as RelationTypes,
column?.value?.id,
encodeURIComponent(getRelatedTableRowId(row) as string),
)
@ -461,7 +459,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
base.value.id as string,
metaValue.id as string,
encodeURIComponent(rowId.value),
colOptions.value.type as 'mm' | 'hm',
colOptions.value.type as RelationTypes,
column?.value?.id,
encodeURIComponent(getRelatedTableRowId(row) as string) as string,
)

58
packages/nc-gui/composables/useMultiSelect/convertCellData.ts

@ -1,8 +1,8 @@
import dayjs from 'dayjs'
import type { ColumnType, SelectOptionsType } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, SelectOptionsType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { AppInfo } from '~/composables/useGlobal'
import { parseProp } from '#imports'
import { isBt, isMm, parseProp } from '#imports'
export default function convertCellData(
args: { to: UITypes; value: string; column: ColumnType; appInfo: AppInfo; files?: FileList | File[]; oldValue?: unknown },
@ -56,7 +56,7 @@ export default function convertCellData(
}
return null
case UITypes.Date: {
const parsedDate = dayjs(value, column?.meta?.date_format ?? 'YYYY-MM-DD')
const parsedDate = dayjs(value, parseProp(column?.meta)?.date_format ?? 'YYYY-MM-DD')
if (!parsedDate.isValid()) {
if (isMultiple) {
return null
@ -67,7 +67,10 @@ export default function convertCellData(
return parsedDate.format('YYYY-MM-DD')
}
case UITypes.DateTime: {
const parsedDateTime = dayjs(value)
const parsedDateTime = dayjs(
value,
`${parseProp(column?.meta)?.date_format ?? 'YYYY-MM-DD'} ${parseProp(column?.meta)?.time_format ?? 'HH:mm'}`,
)
if (!parsedDateTime.isValid()) {
if (isMultiple) {
return null
@ -246,7 +249,52 @@ export default function convertCellData(
return parsedVal || value
}
case UITypes.LinkToAnotherRecord:
case UITypes.LinkToAnotherRecord: {
if (isMultiple) {
return undefined
}
if (isBt(column)) {
const parsedVal = typeof value === 'string' ? JSON.parse(value) : value
if (
!(parsedVal && typeof parsedVal === 'object' && !Array.isArray(parsedVal) && Object.keys(parsedVal)) ||
parsedVal?.fk_related_model_id !== (column.colOptions as LinkToAnotherRecordType)?.fk_related_model_id
) {
throw new Error(`Unsupported conversion for ${to}`)
}
return parsedVal
} else {
throw new Error(`Unsupported conversion for ${to}`)
}
}
case UITypes.Links: {
if (isMultiple) {
return undefined
}
if (isMm(column)) {
const parsedVal = typeof value === 'string' ? JSON.parse(value) : value
if (
!(
parsedVal &&
typeof parsedVal === 'object' &&
!Array.isArray(parsedVal) &&
// eslint-disable-next-line no-prototype-builtins
['rowId', 'columnId', 'fk_related_model_id', 'value'].every((key) => (parsedVal as Object).hasOwnProperty(key))
) ||
parsedVal?.fk_related_model_id !== (column.colOptions as LinkToAnotherRecordType).fk_related_model_id
) {
throw new Error(`Unsupported conversion for ${to}`)
}
return parsedVal
} else {
throw new Error(`Unsupported conversion for ${to}`)
}
}
case UITypes.Lookup:
case UITypes.Rollup:
case UITypes.Formula:

259
packages/nc-gui/composables/useMultiSelect/index.ts

@ -2,8 +2,8 @@ import type { Ref } from 'vue'
import { computed } from 'vue'
import dayjs from 'dayjs'
import type { MaybeRef } from '@vueuse/core'
import type { ColumnType, LinkToAnotherRecordType, TableType, UserFieldRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, dateFormats, isDateMonthFormat, isSystemColumn, isVirtualCol, timeFormats } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, PaginatedType, TableType, UserFieldRecordType, ViewType } from 'nocodb-sdk'
import { UITypes, dateFormats, isDateMonthFormat, isSystemColumn, isVirtualCol, timeFormats } from 'nocodb-sdk'
import { parse } from 'papaparse'
import type { Cell } from './cellRange'
import { CellRange } from './cellRange'
@ -12,11 +12,14 @@ import type { Nullable, Row } from '#imports'
import {
extractPkFromRow,
extractSdkResponseErrorMsg,
isBt,
isDrawerOrModalExist,
isExpandedCellInputExist,
isMac,
isMm,
isTypableInputColumn,
message,
parseProp,
reactive,
ref,
unref,
@ -27,6 +30,7 @@ import {
useGlobal,
useI18n,
useMetas,
useUndoRedo,
} from '#imports'
const MAIN_MOUSE_PRESSED = 0
@ -49,6 +53,9 @@ export function useMultiSelect(
syncCellData?: Function,
bulkUpdateRows?: Function,
fillHandle?: MaybeRef<HTMLElement | undefined>,
view?: MaybeRef<ViewType | undefined>,
paginationData?: MaybeRef<PaginatedType | undefined>,
changePage?: (page: number) => void,
) {
const meta = ref(_meta)
@ -66,12 +73,18 @@ export function useMultiSelect(
const { api } = useApi()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const editEnabled = ref(_editEnabled)
const isMouseDown = ref(false)
const isFillMode = ref(false)
const activeView = ref(view)
const paginationDataRef = ref(paginationData)
const selectedRange = reactive(new CellRange())
const fillRange = reactive(new CellRange())
@ -129,6 +142,23 @@ export function useMultiSelect(
}
}
if (isBt(columnObj)) {
// fk_related_model_id is used to prevent paste operation in different fk_related_model_id cell
textToCopy = {
fk_related_model_id: (columnObj.colOptions as LinkToAnotherRecordType).fk_related_model_id,
value: textToCopy || null,
}
}
if (isMm(columnObj)) {
textToCopy = {
rowId: extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]),
columnId: columnObj.id,
fk_related_model_id: (columnObj.colOptions as LinkToAnotherRecordType).fk_related_model_id,
value: !isNaN(+textToCopy) ? +textToCopy : 0,
}
}
if (typeof textToCopy === 'object') {
textToCopy = JSON.stringify(textToCopy)
} else {
@ -143,7 +173,7 @@ export function useMultiSelect(
})
}
if ([UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(columnObj.uidt)) {
if ([UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(columnObj.uidt as UITypes)) {
// remove `"`
// e.g. "2023-05-12T08:03:53.000Z" -> 2023-05-12T08:03:53.000Z
textToCopy = textToCopy.replace(/["']/g, '')
@ -164,14 +194,15 @@ export function useMultiSelect(
// therefore, here we reformat to the correct datetime format based on the meta
textToCopy = d.format(constructDateTimeFormat(columnObj))
if (!dayjs(textToCopy).isValid()) {
if (!d.isValid()) {
// return empty string for invalid datetime
return ''
}
}
if (columnObj.uidt === UITypes.Date) {
const dateFormat = columnObj.meta?.date_format
const dateFormat = parseProp(columnObj.meta)?.date_format
if (dateFormat && isDateMonthFormat(dateFormat)) {
// any date month format (e.g. YYYY-MM) couldn't be stored in database
// with date type since it is not a valid date
@ -798,6 +829,7 @@ export function useMultiSelect(
const propsToPaste: string[] = []
let pastedRows = 0
let isInfoShown = false
for (let i = 0; i < pasteMatrixRows; i++) {
const pasteRow = rowsToPaste[i]
@ -811,6 +843,10 @@ export function useMultiSelect(
const pasteCol = colsToPaste[j]
if (!isPasteable(pasteRow, pasteCol)) {
if ((isBt(pasteCol) || isMm(pasteCol)) && !isInfoShown) {
message.info(t('msg.info.groupPasteIsNotSupportedOnLinksColumn'))
isInfoShown = true
}
continue
}
@ -847,15 +883,10 @@ export function useMultiSelect(
const columnObj = unref(fields)[activeCell.col]
// handle belongs to column
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
(columnObj.colOptions as LinkToAnotherRecordType)?.type === RelationTypes.BELONGS_TO
) {
const clipboardContext = JSON.parse(clipboardData!)
rowObj.row[columnObj.title!] = convertCellData(
if (isBt(columnObj)) {
const pasteVal = convertCellData(
{
value: clipboardContext,
value: clipboardData,
to: columnObj.uidt as UITypes,
column: columnObj,
appInfo: unref(appInfo),
@ -863,19 +894,210 @@ export function useMultiSelect(
isMysql(meta.value?.source_id),
)
if (pasteVal === undefined) return
const foreignKeyColumn = meta.value?.columns?.find(
(column: ColumnType) => column.id === (columnObj.colOptions as LinkToAnotherRecordType)?.fk_child_column_id,
)
if (!foreignKeyColumn) return
const relatedTableMeta = await getMeta((columnObj.colOptions as LinkToAnotherRecordType).fk_related_model_id!)
if (!foreignKeyColumn) return
// update old row to allow undo redo as bt column update only through foreignKeyColumn title
rowObj.oldRow[columnObj.title!] = rowObj.row[columnObj.title!]
rowObj.oldRow[foreignKeyColumn.title!] = rowObj.row[columnObj.title!]
? extractPkFromRow(rowObj.row[columnObj.title!], (relatedTableMeta as any)!.columns!)
: null
rowObj.row[foreignKeyColumn.title!] = extractPkFromRow(clipboardContext, (relatedTableMeta as any)!.columns!)
rowObj.row[columnObj.title!] = pasteVal?.value
rowObj.row[foreignKeyColumn.title!] = pasteVal?.value
? extractPkFromRow(pasteVal.value, (relatedTableMeta as any)!.columns!)
: null
return await syncCellData?.({ ...activeCell, updatedColumnTitle: foreignKeyColumn.title })
}
if (isMm(columnObj)) {
const pasteVal = convertCellData(
{
value: clipboardData,
to: columnObj.uidt as UITypes,
column: columnObj,
appInfo: unref(appInfo),
},
isMysql(meta.value?.source_id),
)
if (pasteVal === undefined) return
const pasteRowPk = extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[])
if (!pasteRowPk) return
const oldCellValue = rowObj.row[columnObj.title!]
rowObj.row[columnObj.title!] = pasteVal.value
let result
try {
result = await api.dbDataTableRow.nestedListCopyPasteOrDeleteAll(
meta.value?.id as string,
columnObj.id as string,
[
{
operation: 'copy',
rowId: pasteVal.rowId,
columnId: pasteVal.columnId,
fk_related_model_id: pasteVal.fk_related_model_id,
},
{
operation: 'paste',
rowId: pasteRowPk,
columnId: columnObj.id as string,
fk_related_model_id:
(columnObj.colOptions as LinkToAnotherRecordType).fk_related_model_id || pasteVal.fk_related_model_id,
},
],
{ viewId: activeView?.value?.id },
)
} catch {
rowObj.row[columnObj.title!] = oldCellValue
return
}
if (result && result?.link && result?.unlink && Array.isArray(result.link) && Array.isArray(result.unlink)) {
if (!result.link.length && !result.unlink.length) {
rowObj.row[columnObj.title!] = oldCellValue
return
}
addUndo({
redo: {
fn: async (
activeCell: Cell,
col: ColumnType,
row: Row,
pg: PaginatedType,
value: number,
result: { link: any[]; unlink: any[] },
) => {
if (paginationDataRef.value?.pageSize === pg?.pageSize) {
if (paginationDataRef.value?.page !== pg?.page) {
await changePage?.(pg?.page!)
}
const pasteRowPk = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = unref(data)[activeCell.row]
const columnObj = unref(fields)[activeCell.col]
if (
pasteRowPk === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
columnObj.id === col.id
) {
await Promise.all([
result.link.length &&
api.dbDataTableRow.nestedLink(
meta.value?.id as string,
columnObj.id as string,
encodeURIComponent(pasteRowPk),
result.link,
{
viewId: activeView?.value?.id,
},
),
result.unlink.length &&
api.dbDataTableRow.nestedUnlink(
meta.value?.id as string,
columnObj.id as string,
encodeURIComponent(pasteRowPk),
result.unlink,
{ viewId: activeView?.value?.id },
),
])
rowObj.row[columnObj.title!] = value
await syncCellData?.(activeCell)
} else {
throw new Error(t('msg.recordCouldNotBeFound'))
}
} else {
throw new Error(t('msg.pageSizeChanged'))
}
},
args: [
clone(activeCell),
clone(columnObj),
clone(rowObj),
clone(paginationDataRef.value),
clone(pasteVal.value),
result,
],
},
undo: {
fn: async (
activeCell: Cell,
col: ColumnType,
row: Row,
pg: PaginatedType,
value: number,
result: { link: any[]; unlink: any[] },
) => {
if (paginationDataRef.value?.pageSize === pg.pageSize) {
if (paginationDataRef.value?.page !== pg.page) {
await changePage?.(pg.page!)
}
const pasteRowPk = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = unref(data)[activeCell.row]
const columnObj = unref(fields)[activeCell.col]
if (
pasteRowPk === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
columnObj.id === col.id
) {
await Promise.all([
result.unlink.length &&
api.dbDataTableRow.nestedLink(
meta.value?.id as string,
columnObj.id as string,
encodeURIComponent(pasteRowPk),
result.unlink,
),
result.link.length &&
api.dbDataTableRow.nestedUnlink(
meta.value?.id as string,
columnObj.id as string,
encodeURIComponent(pasteRowPk),
result.link,
),
])
rowObj.row[columnObj.title!] = value
await syncCellData?.(activeCell)
} else {
throw new Error(t('msg.recordCouldNotBeFound'))
}
} else {
throw new Error(t('msg.pageSizeChanged'))
}
},
args: [
clone(activeCell),
clone(columnObj),
clone(rowObj),
clone(paginationDataRef.value),
clone(oldCellValue),
result,
],
},
scope: defineViewScope({ view: activeView?.value }),
})
}
return await syncCellData?.(activeCell)
}
if (!isPasteable(rowObj, columnObj, true)) {
return
}
@ -918,13 +1140,20 @@ export function useMultiSelect(
const props = []
let pasteValue
let isInfoShown = false
const files = e.clipboardData?.files
for (const row of rows) {
// TODO handle insert new row
if (!row || row.rowMeta.new) continue
for (const col of cols) {
if (!col.title || !isPasteable(row, col)) {
if ((isBt(col) || isMm(col)) && !isInfoShown) {
message.info(t('msg.info.groupPasteIsNotSupportedOnLinksColumn'))
isInfoShown = true
}
continue
}

38
packages/nc-gui/composables/useSmartsheetRowStore.ts

@ -83,7 +83,7 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
base.value.id as string,
metaValue?.id as string,
encodeURIComponent(rowId),
type as 'mm' | 'hm',
type,
column.id as string,
encodeURIComponent(relatedRowId),
)
@ -185,6 +185,41 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
})
}
// clear MM cell
const cleaMMCell = async (column: ColumnType) => {
try {
if (!column || !isLinksOrLTAR(column)) return
if (isNew.value) {
state.value[column.title!] = null
} else if (currentRow.value) {
if ((<LinkToAnotherRecordType>column.colOptions)?.type === RelationTypes.MANY_TO_MANY) {
if (!currentRow.value.row[column.title!]) return
console.log('currentRow.value.row, meta.value?.columns', currentRow.value.row, meta.value?.columns)
const result = await $api.dbDataTableRow.nestedListCopyPasteOrDeleteAll(
meta.value?.id as string,
column.id as string,
[
{
operation: 'deleteAll',
rowId: extractPkFromRow(currentRow.value.row, meta.value?.columns as ColumnType[]) as string,
columnId: column.id as string,
fk_related_model_id: (column.colOptions as LinkToAnotherRecordType)?.fk_related_model_id as string,
},
],
)
currentRow.value.row[column.title!] = null
return Array.isArray(result.unlink) ? result.unlink : []
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
return {
row,
state,
@ -196,6 +231,7 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
loadRow,
currentRow,
clearLTARCell,
cleaMMCell,
}
},
'smartsheet-row-store',

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

@ -1,7 +1,7 @@
import type { ColumnType, SortType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { UndoRedoAction } from 'lib'
import type { EventHook } from '@vueuse/core'
import type { UndoRedoAction } from '~/lib'
import {
IsPublicInj,
ReloadViewDataHookInj,

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

@ -1255,7 +1255,10 @@
"thankYou": "Thank you!",
"submittedFormData": "You have successfully submitted the form data.",
"editingSystemKeyNotSupported": "Editing system key not supported",
"notAvailableAtTheMoment": "Not available at the moment"
"notAvailableAtTheMoment": "Not available at the moment",
"groupPasteIsNotSupportedOnLinksColumn": "Group paste operation is not supported on Links/LinkToAnotherRecord column",
"groupClearIsNotSupportedOnLinksColumn": "Group clear operation is not supported on Links/LinkToAnotherRecord column"
},
"error": {
"scopesRequired": "Scopes required",

2
packages/nc-gui/utils/dataUtils.ts

@ -1,7 +1,7 @@
import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import type { Row } from 'lib'
import { isColumnRequiredAndNull } from './columnUtils'
import type { Row } from '~/lib'
export const extractPkFromRow = (row: Record<string, any>, columns: ColumnType[]) => {
if (!row || !columns) return null

26
packages/nocodb/src/controllers/data-table.controller.ts

@ -193,4 +193,30 @@ export class DataTableController {
cookie: req,
});
}
// todo: naming
@Post(['/api/v2/tables/:modelId/links/:columnId/records'])
@Acl('nestedDataListCopyPasteOrDeleteAll')
async nestedListCopyPasteOrDeleteAll(
@Req() req: Request,
@Param('modelId') modelId: string,
@Query('viewId') viewId: string,
@Param('columnId') columnId: string,
@Body()
data: {
operation: 'copy' | 'paste';
rowId: string;
columnId: string;
fk_related_model_id: string;
}[],
) {
return await this.dataTableService.nestedListCopyPasteOrDeleteAll({
modelId,
query: req.query,
viewId,
columnId,
data,
cookie: req,
});
}
}

8
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -1330,6 +1330,7 @@ class BaseModelSqlv2 {
public async mmList(
{ colId, parentId },
args: { limit?; offset?; fieldsSet?: Set<string> } = {},
selectAllRecords = false,
) {
const { where, sort, ...rest } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
@ -1375,8 +1376,10 @@ class BaseModelSqlv2 {
await this.applySortAndFilter({ table: childTable, where, qb, sort });
// todo: sanitize
qb.limit(+rest?.limit || 25);
qb.offset(+rest?.offset || 0);
if (!selectAllRecords) {
qb.limit(+rest?.limit || 25);
}
qb.offset(selectAllRecords ? 0 : +rest?.offset || 0);
const children = await this.execAndParse(qb, await childTable.getColumns());
const proto = await (
@ -5165,6 +5168,7 @@ class BaseModelSqlv2 {
]),
);
});
if (parentTable.primaryKeys.length > 1) {
childRowsQb.where((qb) => {
for (const childId of childIds) {

123
packages/nocodb/src/schema/swagger.json

@ -16330,6 +16330,105 @@
]
}
},
"/api/v2/tables/{tableId}/links/{columnId}/records": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true,
"description": "Table ID"
},
{
"schema": {
"type": "string"
},
"name": "viewId",
"in": "query",
"description": "View ID"
}
],
"post": {
"summary": "Copy paste or deleteAll nested link",
"operationId": "db-data-table-row-nested-list-copy-paste-or-deleteAll",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
},
"examples": {
"Example 1" : {
"value": {
"link": [
{"Id": 1},
{"Id": 2},
{"Id": 3},
{"Id": 5},
{"Id": 6}
],
"unlink": [
{"Id": 4}
]
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": ["DB Data Table Row"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/nestedListCopyPasteOrDeleteAllReq"
},
"examples": {
"Example 1": {
"value": [
{
"operation": "copy",
"rowId": "1",
"columnId": "ca2ppiy8raidc09",
"fk_related_model_id": "m797s6vbrqvo2pv"
},
{
"operation": "paste",
"rowId": "2",
"columnId": "ca2ppiy8raidc09",
"fk_related_model_id": "m797s6vbrqvo2pv"
}
]
},
"Example 2": {
"value": [
{
"operation": "deleteAll",
"rowId": "1",
"columnId": "ca2ppiy8raidc09",
"fk_related_model_id": "m797s6vbrqvo2pv"
}
]
}
}
}
}
},
"description": "Copy links from the one cell and paste them into another cell or delete all records from cell",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
}
},
"/api/v1/command_palette": {
"parameters": [],
"post": {
@ -23945,6 +24044,30 @@
"required": false
}
}
},
"nestedListCopyPasteOrDeleteAllReq": {
"type": "array",
"minItems": 1,
"maxItems": 2,
"items": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["copy", "paste", "deleteAll"]
},
"rowId": {
"type": "string"
},
"columnId":{
"type": "string"
},
"fk_related_model_id":{
"type": "string"
}
},
"required": ["operation", "rowId", "columnId", "fk_related_model_id"]
}
}
},
"responses": {

216
packages/nocodb/src/services/data-table.service.ts

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { isLinksOrLTAR, RelationTypes } from 'nocodb-sdk';
import { nocoExecute } from 'nc-help';
import { validatePayload } from 'src/helpers';
import type { LinkToAnotherRecordColumn } from '~/models';
import { DatasService } from '~/services/datas.service';
import { NcError } from '~/helpers/catchError';
@ -443,6 +444,196 @@ export class DataTableService {
return true;
}
// todo: naming & optimizing
async nestedListCopyPasteOrDeleteAll(param: {
cookie: any;
viewId: string;
modelId: string;
columnId: string;
query: any;
data: {
operation: 'copy' | 'paste' | 'deleteAll';
rowId: string;
columnId: string;
fk_related_model_id: string;
}[];
}) {
validatePayload(
'swagger.json#/components/schemas/nestedListCopyPasteOrDeleteAllReq',
param.data,
);
const operationMap = param.data.reduce(
(map, p) => {
map[p.operation] = p;
return map;
},
{} as Record<
'copy' | 'paste' | 'deleteAll',
{
operation: 'copy' | 'paste' | 'deleteAll';
rowId: string;
columnId: string;
fk_related_model_id: string;
}
>,
);
if (
!operationMap.deleteAll &&
operationMap.copy.fk_related_model_id !==
operationMap.paste.fk_related_model_id
) {
throw new Error(
'The operation is not supported on different fk_related_model_id',
);
}
const { model, view } = await this.getModelAndView(param);
const source = await Source.get(model.source_id);
const baseModel = await Model.getBaseModelSQL({
id: model.id,
viewId: view?.id,
dbDriver: await NcConnectionMgrv2.get(source),
});
if (
operationMap.deleteAll &&
!(await baseModel.exist(operationMap.deleteAll.rowId))
) {
NcError.notFound(
`Record with id '${operationMap.deleteAll.rowId}' not found`,
);
} else if (operationMap.copy && operationMap.paste) {
const [existsCopyRow, existsPasteRow] = await Promise.all([
baseModel.exist(operationMap.copy.rowId),
baseModel.exist(operationMap.paste.rowId),
]);
if (!existsCopyRow && !existsPasteRow) {
NcError.notFound(
`Record with id '${operationMap.copy.rowId}' and '${operationMap.paste.rowId}' not found`,
);
} else if (!existsCopyRow) {
NcError.notFound(
`Record with id '${operationMap.copy.rowId}' not found`,
);
} else if (!existsPasteRow) {
NcError.notFound(
`Record with id '${operationMap.paste.rowId}' not found`,
);
}
}
const column = await this.getColumn(param);
const colOptions = await column.getColOptions<LinkToAnotherRecordColumn>();
const relatedModel = await colOptions.getRelatedTable();
await relatedModel.getColumns();
if (colOptions.type !== RelationTypes.MANY_TO_MANY) return;
const { dependencyFields } = await getAst({
model: relatedModel,
query: param.query,
extractOnlyPrimaries: !(param.query?.f || param.query?.fields),
});
const listArgs: any = dependencyFields;
try {
listArgs.filterArr = JSON.parse(listArgs.filterArrJson);
} catch (e) {}
try {
listArgs.sortArr = JSON.parse(listArgs.sortArrJson);
} catch (e) {}
if (operationMap.deleteAll) {
let deleteCellNestedList = await baseModel.mmList(
{
colId: column.id,
parentId: operationMap.deleteAll.rowId,
},
listArgs as any,
true,
);
if (deleteCellNestedList && Array.isArray(deleteCellNestedList)) {
await baseModel.removeLinks({
colId: column.id,
childIds: deleteCellNestedList,
rowId: operationMap.deleteAll.rowId,
cookie: param.cookie,
});
// extract only pk row data
deleteCellNestedList = deleteCellNestedList.map((nestedList) => {
return relatedModel.primaryKeys.reduce((acc, col) => {
acc[col.title || col.column_name] =
nestedList[col.title || col.column_name];
return acc;
}, {});
});
} else {
deleteCellNestedList = [];
}
return { link: [], unlink: deleteCellNestedList };
} else if (operationMap.copy && operationMap.paste) {
const [copiedCellNestedList, pasteCellNestedList] = await Promise.all([
baseModel.mmList(
{
colId: operationMap.copy.columnId,
parentId: operationMap.copy.rowId,
},
listArgs as any,
true,
),
baseModel.mmList(
{
colId: column.id,
parentId: operationMap.paste.rowId,
},
listArgs as any,
true,
),
]);
const filteredRowsToLink = this.filterAndMapRows(
copiedCellNestedList,
pasteCellNestedList,
relatedModel.primaryKeys,
);
const filteredRowsToUnlink = this.filterAndMapRows(
pasteCellNestedList,
copiedCellNestedList,
relatedModel.primaryKeys,
);
await Promise.all([
filteredRowsToLink.length &&
baseModel.addLinks({
colId: column.id,
childIds: filteredRowsToLink,
rowId: operationMap.paste.rowId,
cookie: param.cookie,
}),
filteredRowsToUnlink.length &&
baseModel.removeLinks({
colId: column.id,
childIds: filteredRowsToUnlink,
rowId: operationMap.paste.rowId,
cookie: param.cookie,
}),
]);
return { link: filteredRowsToLink, unlink: filteredRowsToUnlink };
}
}
private validateIds(rowIds: any[] | any) {
if (Array.isArray(rowIds)) {
const map = new Map<string, boolean>();
@ -465,4 +656,29 @@ export class DataTableService {
NcError.unprocessableEntity('Invalid row id ' + rowIds);
}
}
private filterAndMapRows(
sourceList: Record<string, any>[],
targetList: Record<string, any>[],
primaryKeys: Column<any>[],
): Record<string, any>[] {
return sourceList
.filter(
(sourceRow: Record<string, any>) =>
!targetList.some((targetRow: Record<string, any>) =>
primaryKeys.every(
(key) =>
sourceRow[key.title || key.column_name] ===
targetRow[key.title || key.column_name],
),
),
)
.map((item: Record<string, any>) =>
primaryKeys.reduce((acc, key) => {
acc[key.title || key.column_name] =
item[key.title || key.column_name];
return acc;
}, {} as Record<string, any>),
);
}
}

2
packages/nocodb/src/utils/acl.ts

@ -120,6 +120,7 @@ const permissionScopes = {
'nestedDataList',
'nestedDataLink',
'nestedDataUnlink',
'nestedListCopyPasteOrDeleteAll',
'baseUserList',
// Base API Tokens
@ -227,6 +228,7 @@ const rolePermissions:
nestedDataLink: true,
nestedDataUnlink: true,
nestedListCopyPasteOrDeleteAll: true,
// TODO add ACL with base scope
// upload: true,
// uploadViaURL: true,

Loading…
Cancel
Save