Browse Source

Merge branch 'develop' into feat/gui-v2-form-view

pull/3030/head
Wing-Kam Wong 2 years ago
parent
commit
23ce02f927
  1. 8
      packages/nc-gui-v2/components.d.ts
  2. 230
      packages/nc-gui-v2/components/cell/MultiSelect.vue
  3. 123
      packages/nc-gui-v2/components/cell/SingleSelect.vue
  4. 25
      packages/nc-gui-v2/components/cell/attachment/index.vue
  5. 110
      packages/nc-gui-v2/components/general/ColorPicker.vue
  6. 2
      packages/nc-gui-v2/components/general/MiniSidebar.vue
  7. 4
      packages/nc-gui-v2/components/monaco/Editor.vue
  8. 29
      packages/nc-gui-v2/components/smartsheet-column/CheckboxOptions.vue
  9. 8
      packages/nc-gui-v2/components/smartsheet-column/EditOrAdd.vue
  10. 60
      packages/nc-gui-v2/components/smartsheet-column/FormulaOptions.vue
  11. 29
      packages/nc-gui-v2/components/smartsheet-column/RatingOptions.vue
  12. 112
      packages/nc-gui-v2/components/smartsheet-column/SelectOptions.vue
  13. 5
      packages/nc-gui-v2/components/smartsheet-header/Cell.vue
  14. 9
      packages/nc-gui-v2/components/smartsheet-header/Menu.vue
  15. 7
      packages/nc-gui-v2/components/smartsheet-header/VirtualCell.vue
  16. 39
      packages/nc-gui-v2/components/smartsheet/Cell.vue
  17. 182
      packages/nc-gui-v2/components/smartsheet/Grid.vue
  18. 28
      packages/nc-gui-v2/components/smartsheet/Row.vue
  19. 93
      packages/nc-gui-v2/components/smartsheet/expanded-form/Comments.vue
  20. 60
      packages/nc-gui-v2/components/smartsheet/expanded-form/Header.vue
  21. 139
      packages/nc-gui-v2/components/smartsheet/expanded-form/index.vue
  22. 3
      packages/nc-gui-v2/components/smartsheet/sidebar/MenuBottom.vue
  23. 182
      packages/nc-gui-v2/components/smartsheet/sidebar/menu/ApiSnippet.vue
  24. 28
      packages/nc-gui-v2/components/virtual-cell/BelongsTo.vue
  25. 2
      packages/nc-gui-v2/components/virtual-cell/Formula.vue
  26. 69
      packages/nc-gui-v2/components/virtual-cell/HasMany.vue
  27. 59
      packages/nc-gui-v2/components/virtual-cell/ManyToMany.vue
  28. 33
      packages/nc-gui-v2/components/virtual-cell/components/ItemChip.vue
  29. 81
      packages/nc-gui-v2/components/virtual-cell/components/ListChildItems.vue
  30. 82
      packages/nc-gui-v2/components/virtual-cell/components/ListItems.vue
  31. 1
      packages/nc-gui-v2/composables/index.ts
  32. 209
      packages/nc-gui-v2/composables/useExpandedFormStore.ts
  33. 5
      packages/nc-gui-v2/composables/useGlobal/state.ts
  34. 5
      packages/nc-gui-v2/composables/useGlobal/types.ts
  35. 60
      packages/nc-gui-v2/composables/useLTARStore.ts
  36. 105
      packages/nc-gui-v2/composables/useSmartsheetRowStore.ts
  37. 10
      packages/nc-gui-v2/composables/useSmartsheetStore.ts
  38. 53
      packages/nc-gui-v2/composables/useViewData.ts
  39. 1
      packages/nc-gui-v2/nuxt.config.ts
  40. 962
      packages/nc-gui-v2/package-lock.json
  41. 4
      packages/nc-gui-v2/package.json
  42. 6
      packages/nc-gui-v2/plugins/domPurify.ts
  43. 9
      packages/nc-gui-v2/plugins/state.ts
  44. 1
      packages/nc-gui-v2/tsconfig.json
  45. 11
      packages/nc-gui-v2/utils/dataUtils.ts
  46. 1
      packages/nc-gui-v2/utils/index.ts
  47. 1
      packages/nc-gui/components/project/spreadsheet/components/ExpandedForm.vue
  48. 10
      packages/nocodb-sdk/src/lib/Api.ts
  49. 41
      scripts/sdk/swagger.json

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

@ -9,6 +9,7 @@ declare module '@vue/runtime-core' {
export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert']
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
ABadgeRibbon: typeof import('ant-design-vue/es')['BadgeRibbon']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACarousel: typeof import('ant-design-vue/es')['Carousel']
@ -57,6 +58,7 @@ declare module '@vue/runtime-core' {
ATableColumn: typeof import('ant-design-vue/es')['TableColumn']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
@ -74,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']
@ -86,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']
@ -105,6 +110,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']
@ -114,6 +120,7 @@ declare module '@vue/runtime-core' {
MdiMoonFull: typeof import('~icons/mdi/moon-full')['default']
MdiNotebookCheckOutline: typeof import('~icons/mdi/notebook-check-outline')['default']
MdiOpenInNew: typeof import('~icons/mdi/open-in-new')['default']
MdiOperator: typeof import('~icons/mdi/operator')['default']
MdiPlus: typeof import('~icons/mdi/plus')['default']
MdiPlusOutline: typeof import('~icons/mdi/plus-outline')['default']
MdiReload: typeof import('~icons/mdi/reload')['default']
@ -122,6 +129,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']

230
packages/nc-gui-v2/components/cell/MultiSelect.vue

@ -1,76 +1,196 @@
<script lang="ts" setup>
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk'
import { computed, inject } from '#imports'
import { ColumnInj } from '~/context'
import { ActiveCellInj, ColumnInj } from '~/context'
import MdiCloseCircle from '~icons/mdi/close-circle'
interface Props {
modelValue: string | null
modelValue: string | string[] | undefined
}
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { isMysql } = useProject()
const column = inject(ColumnInj)
// const isForm = inject<boolean>('isForm', false)
// const editEnabled = inject(EditModeInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const options = computed(() => column?.value?.dtxp?.split(',').map((v) => v.replace(/\\'/g, "'").replace(/^'|'$/g, '')) || [])
const selectedIds = ref<string[]>([])
const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const localState = computed({
get() {
return modelValue?.match(/(?:[^',]|\\')+(?='?(?:,|$))/g)?.map((v: string) => v.replace(/\\'/g, "'"))
},
set(val?: string[]) {
emit('update:modelValue', val?.filter((v) => options.value.includes(v)).join(','))
const options = computed(() => {
if (column?.value.colOptions) {
const opts = column.value.colOptions
? column.value.colOptions.options.filter((el: SelectOptionType) => el.title !== '') || []
: []
for (const op of opts.filter((el: SelectOptionType) => el.order === null)) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '')
}
return opts
}
return []
})
const vModel = computed({
get: () => selectedIds.value.map((el) => options.value.find((op: SelectOptionType) => op.id === el).title),
set: (val) => emit('update:modelValue', val.length === 0 ? null : val.join(',')),
})
const selectedTitles = computed(() =>
modelValue
? typeof modelValue === 'string'
? isMysql
? modelValue.split(',').sort((a, b) => {
const opa = options.value.find((el: SelectOptionType) => el.title === a)
const opb = options.value.find((el: SelectOptionType) => el.title === b)
if (opa && opb) {
return opa.order - opb.order
}
return 0
})
: modelValue.split(',')
: modelValue
: [],
)
const handleKeys = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault()
isOpen.value = false
break
case 'Enter':
e.stopPropagation()
break
}
}
const handleClose = (e: MouseEvent) => {
if (aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
}
}
onMounted(() => {
selectedIds.value = selectedTitles.value.map((el) => {
return options.value.find((op: SelectOptionType) => op.title === el).id
})
})
useEventListener(document, 'click', handleClose)
watch(
() => modelValue,
(_n, _o) => {
selectedIds.value = selectedTitles.value.map((el) => {
return options.value.find((op: SelectOptionType) => op.title === el).id
})
},
)
watch(isOpen, (n, _o) => {
if (n === false) {
aselect.value.blur()
}
})
</script>
<template>
<!--
<v-select
v-model="localState"
:items="options"
hide-details
:clearable="!column.rqd"
variation="outlined"
multiple
/>
-->
<v-combobox
v-model="localState"
:items="options"
multiple
chips
flat
dense
solo
hide-details
deletable-chips
class="text-center mt-0"
<a-select
ref="aselect"
v-model:value="vModel"
mode="multiple"
class="w-full"
placeholder="Select an option"
:bordered="false"
show-arrow
:show-search="false"
:open="isOpen"
@keydown="handleKeys"
@click="isOpen = !isOpen"
>
<!-- <template #selection="data"> -->
<!-- <v-chip -->
<!-- :key="data.item" -->
<!-- small -->
<!-- class="ma-1 " -->
<!-- :color="colors[setValues.indexOf(data.item) % colors.length]" -->
<!-- @click:close="data.parent.selectItem(data.item)" -->
<!-- > -->
<!-- {{ data.item }} -->
<!-- </v-chip> -->
<!-- </template> -->
<!-- <template #item="{item}"> -->
<!-- <v-chip small :color="colors[setValues.indexOf(item) % colors.length]"> -->
<!-- {{ item }} -->
<!-- </v-chip> -->
<!-- </template> -->
<!-- <template #append> -->
<!-- <v-icon small class="mt-2"> -->
<!-- mdi-menu-down -->
<!-- </v-icon> -->
<!-- </template> -->
</v-combobox>
<a-select-option v-for="op of options" :key="op.id" :value="op.title" @click.stop>
<a-tag class="rounded-tag" :color="op.color">
<span class="text-slate-500">{{ op.title }}</span>
</a-tag>
</a-select-option>
<template #tagRender="{ value: val, onClose }">
<a-tag
v-if="options.find((el: SelectOptionType) => el.title === val)"
class="rounded-tag"
:style="{ display: 'flex', alignItems: 'center' }"
:color="options.find((el: SelectOptionType) => el.title === val).color"
:closable="active && (vModel.length > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@close="onClose"
>
<span class="text-slate-500">{{ val }}</span>
</a-tag>
</template>
</a-select>
</template>
<style scoped></style>
<style scoped>
.ms-close-icon {
color: rgba(0, 0, 0, 0.25);
cursor: pointer;
display: flex;
font-size: 12px;
font-style: normal;
height: 12px;
line-height: 1;
text-align: center;
text-transform: none;
transition: color 0.3s ease, opacity 0.15s ease;
width: 12px;
z-index: 1;
margin-right: -6px;
margin-left: 3px;
}
.ms-close-icon:before {
display: block;
}
.ms-close-icon:hover {
color: rgba(0, 0, 0, 0.45);
}
.rounded-tag {
padding: 0px 12px;
border-radius: 12px;
}
:deep(.ant-tag) {
@apply "rounded-tag";
}
:deep(.ant-tag-close-icon) {
@apply "text-slate-500";
}
</style>
<!--
/**
* @copyright Copyright (c) 2021, Xgene Cloud Ltd
*
* @author Naveen MR <oof1lab@gmail.com>
* @author Pranav C Balan <pranavxc@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/>.
*
*/
-->

123
packages/nc-gui-v2/components/cell/SingleSelect.vue

@ -1,71 +1,100 @@
<script lang="ts" setup>
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk'
import { computed, inject } from '#imports'
import { ColumnInj } from '~/context'
import { ActiveCellInj, ColumnInj } from '~/context'
interface Props {
modelValue: string | null
modelValue: string | undefined
}
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)!
const column = inject(ColumnInj)
// const isForm = inject<boolean>('isForm', false)
// const editEnabled = inject(EditModeInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const vModel = computed({
get: () => modelValue?.replace(/\\'/g, "'").replace(/^'|'$/g, ''),
set: (val) => emit('update:modelValue', val),
get: () => modelValue,
set: (val) => emit('update:modelValue', val || null),
})
const options = computed(() => column.value.dtxp?.split(',').map((v) => v.replace(/\\'/g, "'").replace(/^'|'$/g, '')) || [])
</script>
<template>
<v-select v-model="vModel" :items="options" hide-details :clearable="!column.rqd" variation="outlined">
<!-- v-on="parentListeners"
<template #selection="{ item }">
<div
class="d-100"
:class="{
'text-center': !isForm,
}"
>
<v-chip small :color="enumColor.light[options.indexOf(item) % enumColor.light.length]" class="ma-1">
{{ item.text }}
</v-chip>
</div>
</template>
<template #item="{ item }">
<v-chip small :color="enumColor.light[options.indexOf(item) % enumColor.light.length]">
{{ item }}
</v-chip>
</template>
<template #append>
<v-icon small class="mt-1"> mdi-menu-down</v-icon>
</template> -->
</v-select>
</template>
const options = computed(() => {
if (column?.value.colOptions) {
const opts = column.value.colOptions
? column.value.colOptions.options.filter((el: SelectOptionType) => el.title !== '') || []
: []
for (const op of opts.filter((el: SelectOptionType) => el.order === null)) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '')
}
return opts
}
return []
})
<style scoped lang="scss">
/*:deep {
.v-select {
min-width: 150px;
const handleKeys = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault()
isOpen.value = false
break
}
}
.v-input__slot {
padding-right: 0 !important;
padding-left: 35px !important;
const handleClose = (e: MouseEvent) => {
if (aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
aselect.value.blur()
}
}
.v-input__icon.v-input__icon--clear {
width: 15px !important;
min-width: 13px !important;
useEventListener(document, 'click', handleClose)
.v-icon {
font-size: 13px !important;
}
watch(isOpen, (n, _o) => {
if (n === false) {
aselect.value.blur()
}
}*/
})
</script>
<template>
<a-select
ref="aselect"
v-model:value="vModel"
class="w-full"
:allow-clear="!column.rqd && active"
placeholder="Select an option"
:bordered="false"
:open="isOpen"
@select="isOpen = false"
@keydown="handleKeys"
@click="isOpen = !isOpen"
>
<a-select-option v-for="op of options" :key="op.title" @click.stop>
<a-tag class="rounded-tag" :color="op.color">
<span class="text-slate-500">{{ op.title }}</span>
</a-tag>
</a-select-option>
</a-select>
</template>
<style scoped lang="scss">
.rounded-tag {
padding: 0px 12px;
border-radius: 12px;
}
:deep(.ant-tag) {
@apply "rounded-tag";
}
:deep(.ant-select-clear) {
opacity: 1;
}
</style>
<!--
/**

25
packages/nc-gui-v2/components/cell/attachment/index.vue

@ -1,10 +1,10 @@
<script setup lang="ts">
import { onKeyDown } from '@vueuse/core'
import { useProvideAttachmentCell } from './utils'
import Modal from './Modal.vue'
import { useSortable } from './sort'
import Modal from './Modal.vue'
import Carousel from './Carousel.vue'
import { onMounted, ref, useDropZone, watch } from '#imports'
import { computed, ref, useDropZone, useSmartsheetStoreOrThrow, watch } from '#imports'
import { isImage, openLink } from '~/utils'
interface Props {
@ -20,16 +20,18 @@ const { modelValue, rowIndex } = defineProps<Props>()
const emits = defineEmits<Emits>()
const dropZoneRef = ref<HTMLTableDataCellElement>()
const sortableRef = ref<HTMLDivElement>()
const { cellRefs } = useSmartsheetStoreOrThrow()!
const { column, modalVisible, attachments, visibleItems, onDrop, isLoading, open, FileIcon, selectedImage, isReadonly } =
useProvideAttachmentCell(updateModelValue)
const currentCellRef = computed(() => cellRefs.value.find((cell) => cell.dataset.key === `${rowIndex}${column.value.id}`))
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly)
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
const { isOverDropZone } = useDropZone(currentCellRef, onDrop)
/** on new value, reparse our stored attachments */
watch(
@ -52,24 +54,17 @@ onKeyDown('Escape', () => {
modalVisible.value = false
isOverDropZone.value = false
})
/** if possible, on mounted we try to fetch the relevant `td` cell to use as a dropzone */
onMounted(() => {
if (typeof document !== 'undefined') {
dropZoneRef.value = document.querySelector(`td[data-key="${rowIndex}${column.value.id}"]`) as HTMLTableDataCellElement
}
})
</script>
<template>
<div class="nc-attachment-cell relative flex-1 color-transition flex items-center justify-between gap-1">
<Carousel />
<template v-if="!isReadonly && !dragging && dropZoneRef">
<template v-if="!isReadonly && !dragging && !!currentCellRef">
<general-overlay
v-model="isOverDropZone"
inline
:target="`td[data-key='${rowIndex}${column.id}']`"
:target="currentCellRef"
class="text-white text-lg ring ring-pink-500 bg-gray-700/75 flex items-center justify-center gap-2 backdrop-blur-xl"
>
<MaterialSymbolsFileCopyOutline class="text-pink-500" /> Drop here
@ -99,7 +94,7 @@ onMounted(() => {
<div
ref="sortableRef"
:class="{ dragging }"
class="flex justify-center items-center flex-wrap gap-2 p-1 scrollbar-thin-dull max-h-[150px] overflow-scroll"
class="flex justify-center items-center flex-wrap gap-2 p-1 scrollbar-thin-dull max-h-[150px] overflow-auto"
>
<div
v-for="(item, i) of visibleItems"

110
packages/nc-gui-v2/components/general/ColorPicker.vue

@ -0,0 +1,110 @@
<script lang="ts" setup>
import { Chrome } from '@ckpack/vue-color'
import { enumColor } from '@/utils'
interface Props {
modelValue: string | any
colors?: string[]
rowSize?: number
advanced?: Boolean
pickButton?: Boolean
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => enumColor.light[0],
colors: () => enumColor.light.concat(enumColor.dark),
rowSize: () => 10,
advanced: () => true,
pickButton: () => false,
})
const emit = defineEmits(['update:modelValue'])
const vModel = computed({
get: () => props.modelValue,
set: (val) => {
emit('update:modelValue', val.hex ? val.hex : val || null)
},
})
const picked = ref(props.modelValue || enumColor.light[0])
const selectColor = (color: any) => {
picked.value = color.hex ? color.hex : color
vModel.value = color.hex ? color.hex : color
}
const compare = (colorA: String, colorB: String) => {
if ((typeof colorA === 'string' || colorA instanceof String) && (typeof colorB === 'string' || colorB instanceof String)) {
return colorA.toLowerCase() === colorB.toLowerCase()
}
return false
}
watch(picked, (n, _o) => {
if (!props.pickButton) {
vModel.value = n.hex ? n.hex : n
}
})
</script>
<template>
<div class="color-picker">
<div v-for="colId in Math.ceil(props.colors.length / props.rowSize)" :key="colId" class="color-picker-row">
<button
v-for="(color, i) in colors.slice((colId - 1) * rowSize, colId * rowSize)"
:key="`color-${colId}-${i}`"
class="color-selector"
:class="compare(picked, color) ? 'selected' : ''"
:style="{ 'background-color': `${color}` }"
@click="selectColor(color)"
>
{{ compare(picked, color) ? '&#10003;' : '' }}
</button>
</div>
<a-card v-if="props.advanced" class="w-full mt-2" :body-style="{ padding: '0px' }" :bordered="false">
<a-collapse accordion ghost expand-icon-position="right">
<a-collapse-panel key="1" header="Advanced" class="">
<a-button v-if="props.pickButton" class="!bg-primary text-white w-full" @click="selectColor(picked)">
Pick Color
</a-button>
<div class="flex justify-center py-4">
<Chrome v-model="picked" class="!w-full !shadow-none" />
</div>
</a-collapse-panel>
</a-collapse>
</a-card>
</div>
</template>
<style scoped>
.color-picker {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background: white;
padding: 10px;
}
.color-picker-row {
display: flex;
flex-direction: row;
}
.color-selector {
position: relative;
height: 32px;
width: 32px;
margin: 10px 5px;
border-radius: 5px;
-webkit-text-stroke-width: 1px;
-webkit-text-stroke-color: white;
}
.color-selector:hover {
filter: brightness(90%);
-webkit-filter: brightness(90%);
}
.color-selector.selected {
filter: brightness(90%);
-webkit-filter: brightness(90%);
}
</style>

2
packages/nc-gui-v2/components/general/MiniSidebar.vue

@ -29,7 +29,7 @@ const logout = () => {
theme="light"
>
<a-dropdown placement="bottom" :trigger="['click']">
<div class="transition-all duration-200 p-2 cursor-pointer transform hover:scale-105">
<div class="transition-all duration-200 p-2 cursor-pointer transform hover:scale-105 nc-noco-brand-icon">
<img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
</div>

4
packages/nc-gui-v2/components/monaco/Editor.vue

@ -12,9 +12,10 @@ interface Props {
lang?: string
validate?: boolean
disableDeepCompare?: boolean
readOnly?: boolean
}
const { hideMinimap, lang = 'json', validate = true, disableDeepCompare = false, modelValue } = defineProps<Props>()
const { hideMinimap, lang = 'json', validate = true, disableDeepCompare = false, modelValue, readOnly } = defineProps<Props>()
const emits = defineEmits(['update:modelValue'])
@ -92,6 +93,7 @@ onMounted(() => {
},
tabSize: 2,
automaticLayout: true,
readOnly,
minimap: {
enabled: !hideMinimap,
},

29
packages/nc-gui-v2/components/smartsheet-column/CheckboxOptions.vue

@ -1,7 +1,6 @@
<script setup lang="ts">
import { Sketch } from '@ckpack/vue-color'
import { useColumnCreateStoreOrThrow } from '#imports'
import { enumColor, getMdiIcon } from '@/utils'
import { getMdiIcon } from '@/utils'
const { formState } = useColumnCreateStoreOrThrow()
@ -37,9 +36,12 @@ const iconList = [
},
]
const advanced = ref(true)
const picked = ref(formState.value.meta.color || enumColor.light[0])
const picked = computed({
get: () => formState.value.meta.color,
set: (val) => {
formState.value.meta.color = val
},
})
// set default value
formState.value.meta = {
@ -91,17 +93,12 @@ watch(
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-card class="w-full shadow-lg mt-2" body-style="padding: 0px">
<a-collapse v-model:activeKey="advanced" accordion ghost expand-icon-position="right">
<a-collapse-panel key="1" header="Advanced" class="">
<a-button class="!bg-primary text-white w-full" @click="formState.meta.color = picked.hex"> Pick Color </a-button>
<div class="px-7 py-3">
<Sketch v-model="picked" />
</div>
</a-collapse-panel>
</a-collapse>
</a-card>
<a-row class="w-full justify-center">
<GeneralColorPicker
v-model="picked"
:row-size="8"
:colors="['#fcb401', '#faa307', '#f48c06', '#e85d04', '#dc2f02', '#d00000', '#9d0208', '#777']"
/>
</a-row>
</template>

8
packages/nc-gui-v2/components/smartsheet-column/EditOrAdd.vue

@ -93,10 +93,15 @@ watch(
}
},
)
// for cases like formula
if (!formState.value?.column_name) {
formState.value.column_name = formState.value?.title
}
</script>
<template>
<div class="max-w-[550px] min-w-[450px] w-max max-h-[95vh] bg-white shadow p-4 overflow-auto" @click.stop>
<div class="min-w-[350px] w-max max-h-[95vh] bg-white shadow p-4 overflow-auto" @click.stop>
<a-form v-model="formState" name="column-create-or-edit" layout="vertical">
<a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.column_name">
<a-input
@ -140,6 +145,7 @@ watch(
/>
<SmartsheetColumnSpecificDBTypeOptions v-if="formState.uidt === UITypes.SpecificDBType" />
<SmartsheetColumnPercentOptions v-if="formState.uidt === UITypes.Percent" />
<SmartsheetColumnSelectOptions v-if="formState.uidt === UITypes.SingleSelect || formState.uidt === UITypes.MultiSelect" />
<div
v-if="!isVirtualCol(formState.uidt)"

60
packages/nc-gui-v2/components/smartsheet-column/FormulaOptions.vue

@ -96,6 +96,7 @@ const suggestionsList = computed(() => {
text: c.title,
type: 'column',
icon: getUIDTIcon(c.uidt),
uidt: c.uidt,
})),
...availableBinOps.map((op: string) => ({
text: op,
@ -254,7 +255,7 @@ function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = n
if (targetFormulaCol && column?.value.id) {
formulaPaths.push({
[column.value.id]: [targetFormulaCol.id],
[column?.value?.id as string]: [targetFormulaCol.id],
})
}
const vertices = formulaPaths.length
@ -518,6 +519,7 @@ function appendText(item: Record<string, any>) {
formState.value.formula_raw = insertAtCursor(formulaRef.value.$el, text, len)
}
autocomplete.value = false
wordToComplete.value = ''
if (item.type === 'function' || item.type === 'op') {
// if function / operator is chosen, display columns only
suggestion.value = suggestionsList.value.filter((f) => f.type === 'column')
@ -612,7 +614,7 @@ onMounted(() => {
<template>
<div class="formula-wrapper">
<a-form-item v-bind="validateInfos.formula_raw" label="Formula">
<a-input
<a-textarea
ref="formulaRef"
v-model:value="formState.formula_raw"
class="mb-2"
@ -622,21 +624,13 @@ onMounted(() => {
@change="handleInputDeb"
/>
</a-form-item>
<div class="text-gray-600 mt-2 prose-sm">
<div class="text-gray-600 mt-2 mb-4 prose-sm">
Hint: Use {} to reference columns, e.g: {column_name}. For more, please check out
<a class="prose-sm" href="https://docs.nocodb.com/setup-and-usages/formulas#available-formula-features" target="_blank"
>Formulas</a
>.
</div>
<a-drawer
v-model:visible="formulaSuggestionDrawer"
:closable="false"
:mask="false"
:mask-closable="false"
placement="right"
width="500px"
class="h-full overflow-auto"
>
<div class="h-[250px] overflow-auto scrollbar-thin-primary">
<a-list ref="sugListRef" :data-source="suggestion" :locale="{ emptyText: 'No suggested formula was found' }">
<template #renderItem="{ item, index }">
<a-list-item
@ -649,39 +643,37 @@ onMounted(() => {
@click.prevent.stop="appendText(item)"
>
<a-list-item-meta>
<template v-if="item.type === 'function'" #description>
{{ item.description }} <br /><br />
Syntax: <br />
{{ item.syntax }} <br /><br />
Examples: <br />
<div v-for="(example, idx) of item.examples" :key="idx">
<div>({{ idx + 1 }}): {{ example }}</div>
</div>
</template>
<template #title>
<div class="flex">
<div class="flex-1">
{{ item.text }}
</div>
<div class="">
{{ getFormulaTypeName(item.type) }}
</div>
<a-col :span="6">
<span class="prose-sm text-gray-600">{{ item.text }}</span>
</a-col>
<a-col :span="18">
<div v-if="item.type === 'function'" class="text-xs text-gray-500">
{{ item.description }} <br /><br />
Syntax: <br />
{{ item.syntax }} <br /><br />
Examples: <br />
<div v-for="(example, idx) of item.examples" :key="idx">
<div>({{ idx + 1 }}): {{ example }}</div>
</div>
</div>
<div v-if="item.type === 'column'" class="float-right mr-5 -mt-2">
<a-badge-ribbon :text="item.uidt" color="gray" />
</div>
</a-col>
</div>
</template>
<template #avatar>
<MdiFunction v-if="item.type === 'function'" class="text-lg" />
<MdiCalculator v-if="item.type === 'op'" class="text-lg" />
<mdi-function v-if="item.type === 'function'" class="text-lg" />
<mdi-calculator v-if="item.type === 'op'" class="text-lg" />
<component :is="item.icon" v-if="item.type === 'column'" class="text-lg" />
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-drawer>
</div>
</div>
</template>

29
packages/nc-gui-v2/components/smartsheet-column/RatingOptions.vue

@ -1,7 +1,6 @@
<script setup lang="ts">
import { Sketch } from '@ckpack/vue-color'
import { useColumnCreateStoreOrThrow } from '#imports'
import { enumColor, getMdiIcon } from '@/utils'
import { getMdiIcon } from '@/utils'
const { formState } = useColumnCreateStoreOrThrow()
@ -29,9 +28,12 @@ const iconList = [
},
]
const advanced = ref(true)
const picked = ref(formState.value.meta.color || enumColor.light[0])
const picked = computed({
get: () => formState.value.meta.color,
set: (val) => {
formState.value.meta.color = val
},
})
// set default value
formState.value.meta = {
@ -94,17 +96,12 @@ watch(
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-card class="w-full shadow-lg mt-2" body-style="padding: 0px">
<a-collapse v-model:activeKey="advanced" accordion ghost expand-icon-position="right">
<a-collapse-panel key="1" header="Advanced" class="">
<a-button class="!bg-primary text-white w-full" @click="formState.meta.color = picked.hex"> Pick Color </a-button>
<div class="px-7 py-3">
<Sketch v-model="picked" />
</div>
</a-collapse-panel>
</a-collapse>
</a-card>
<a-row class="w-full justify-center">
<GeneralColorPicker
v-model="picked"
:row-size="8"
:colors="['#fcb401', '#faa307', '#f48c06', '#e85d04', '#dc2f02', '#d00000', '#9d0208', '#777']"
/>
</a-row>
</template>

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

@ -0,0 +1,112 @@
<script setup lang="ts">
import Draggable from 'vuedraggable'
import { UITypes } from 'nocodb-sdk'
import { useColumnCreateStoreOrThrow } from '#imports'
import { enumColor } from '@/utils'
import MdiDragIcon from '~icons/mdi/drag-vertical'
import MdiArrowDownDropCircle from '~icons/mdi/arrow-down-drop-circle'
import MdiClose from '~icons/mdi/close'
import MdiPlusIcon from '~icons/mdi/plus'
const { formState, setAdditionalValidations } = useColumnCreateStoreOrThrow()
let options = $ref<any[]>([])
const colorMenus = $ref<any>({})
const colors = $ref(enumColor.light)
const inputs = ref()
const validators = {
'colOptions.options': [
{
validator: (_: any, _opt: any) => {
return new Promise<void>((resolve, reject) => {
for (const opt of options) {
if (!opt.title.length) {
return reject(new Error("Select options can't be null"))
}
if (formState.value.uidt === UITypes.MultiSelect && opt.title.includes(',')) {
return reject(new Error("MultiSelect columns can't have commas(',')"))
}
if (options.filter((el) => el.title === opt.title).length !== 1) {
return reject(new Error("Select options can't have duplicates"))
}
}
resolve()
})
},
},
],
}
setAdditionalValidations({
...validators,
})
const getNextColor = () => {
let tempColor = colors[0]
if (options.length && options[options.length - 1].color) {
const lastColor = colors.indexOf(options[options.length - 1].color)
tempColor = colors[(lastColor + 1) % colors.length]
}
return tempColor
}
const addNewOption = () => {
const tempOption = {
title: '',
color: getNextColor(),
}
options.push(tempOption)
}
const removeOption = (index: number) => {
options.splice(index, 1)
}
onMounted(() => {
if (!formState.value.colOptions?.options) {
formState.value.colOptions = {
options: [],
}
}
options = formState.value.colOptions.options
// Support for older options
for (const op of options.filter((el) => el.order === null)) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '')
}
})
// focus last created input
watch(inputs, () => {
if (inputs.value?.$el) {
inputs.value.$el.focus()
}
})
</script>
<template>
<div class="w-full">
<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" />
<a-dropdown v-model:visible="colorMenus[index]" :trigger="['click']">
<template #overlay>
<GeneralColorPicker v-model="element.color" :pick-button="true" @update:model-value="colorMenus[index] = false" />
</template>
<MdiArrowDownDropCircle :style="{ 'font-size': '1.5em', 'color': element.color }" class="mr-2" />
</a-dropdown>
<a-input ref="inputs" v-model:value="element.title" class="caption" />
<MdiClose class="ml-2" :style="{ color: 'red' }" @click="removeOption(index)" />
</div>
</template>
<template #footer>
<a-button type="dashed" class="w-full caption mt-2" @click="addNewOption()">
<div class="flex align-center"><MdiPlusIcon /><span class="flex-auto">Add option</span></div>
</a-button>
</template>
</Draggable>
</div>
</template>
<style scoped lang="scss"></style>

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 }; required: boolean; hideMenu?: boolean }>()
@ -10,6 +10,7 @@ const props = defineProps<{ column: ColumnType & { meta: any }; required: boolea
const hideMenu = toRef(props, 'hideMenu')
const meta = inject(MetaInj)
const isForm = inject(IsFormInj, false)
const column = toRef(props, 'column')
@ -27,7 +28,7 @@ useProvideColumnCreateStore(meta as Ref<TableType>, column)
<template v-if="!hideMenu">
<div class="flex-1" />
<SmartsheetHeaderMenu />
<SmartsheetHeaderMenu v-if="!isForm" />
</template>
</div>
</template>

9
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'
@ -56,12 +55,6 @@ function onVisibleChange() {
// by clicking cancel button
editColumnDropdown.value = true
}
const editOrAddCard = ref()
onClickOutside(editOrAddCard, () => {
editColumnDropdown.value = false
})
</script>
<template>
@ -69,9 +62,9 @@ onClickOutside(editOrAddCard, () => {
<span />
<template #overlay>
<SmartsheetColumnEditOrAdd
ref="editOrAddCard"
:edit-column-dropdown="editColumnDropdown"
@click.stop
@keydown.stop
@cancel="editColumnDropdown = false"
/>
</template>

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

@ -1,12 +1,10 @@
<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, IsFormInj, MetaInj } from '~/context'
import { provide, useProvideColumnCreateStore } from '#imports'
import { provide, toRef, useMetas, useProvideColumnCreateStore } from '#imports'
const props = defineProps<{ column: ColumnType & { meta: any }; hideMenu?: boolean; required: boolean }>()
const column = toRef(props, 'column')
@ -17,6 +15,7 @@ provide(ColumnInj, column)
const { metas } = useMetas()
const meta = inject(MetaInj)
const isForm = inject(IsFormInj, false)
const { isLookup, isBt, isRollup, isMm, isHm, isFormula } = useVirtualCell(column)
@ -103,7 +102,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>

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

@ -3,7 +3,7 @@ import { UITypes } from 'nocodb-sdk'
import type { ColumnType } from 'nocodb-sdk'
import { provide, toRef } from 'vue'
import { computed, useColumn, useDebounceFn, useVModel } from '#imports'
import { ColumnInj, EditModeInj } from '~/context'
import { ActiveCellInj, ColumnInj, EditModeInj } from '~/context'
import { NavigateDir } from '~/lib'
interface Props {
@ -11,6 +11,7 @@ interface Props {
modelValue: any
editEnabled: boolean
rowIndex: number
active?: boolean
}
const props = defineProps<Props>()
@ -19,10 +20,14 @@ const emit = defineEmits(['update:modelValue', 'save', 'navigate', 'update:editE
const column = toRef(props, 'column')
const active = toRef(props, 'active', false)
provide(ColumnInj, column)
provide(EditModeInj, useVModel(props, 'editEnabled', emit))
provide(ActiveCellInj, active)
let changed = $ref(false)
const syncValue = useDebounceFn(function () {
@ -136,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>

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

@ -1,16 +1,20 @@
<script lang="ts" setup>
import { onClickOutside, useEventListener } from '@vueuse/core'
import type { ColumnType } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import { message } from 'ant-design-vue'
import {
inject,
onClickOutside,
onMounted,
provide,
reactive,
ref,
useEventListener,
useGridViewColumnWidth,
useProvideColumnCreateStore,
useSmartsheetStoreOrThrow,
useViewData,
watch,
} from '#imports'
import type { Row } from '~/composables'
import {
@ -25,23 +29,39 @@ import {
ReloadViewDataHookInj,
} from '~/context'
import { NavigateDir } from '~/lib'
import { enumColor } from '~/utils'
const meta = inject(MetaInj)
const view = inject(ActiveViewInj)
// keep a root fields variable and will get modified from
// fields menu and get used in grid and gallery
const fields = inject(FieldsInj, ref([]))
const isLocked = inject(IsLockedInj, false)
const reloadViewDataHook = inject(ReloadViewDataHookInj)
// todo: get from parent ( inject or use prop )
const isPublicView = false
const isView = false
const selected = reactive<{ row: number | null; col: number | null }>({ row: null, col: null })
let editEnabled = $ref(false)
const { xWhere, isPkAvail } = useSmartsheetStoreOrThrow()
const { xWhere, isPkAvail, cellRefs } = useSmartsheetStoreOrThrow()
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)
@ -55,8 +75,11 @@ const {
deleteRow,
deleteSelectedRows,
selectedAllRecords,
loadAggCommentsCount,
} = useViewData(meta, view as any, xWhere)
const { loadGridViewColumns, updateWidth, resizingColWidth, resizingCol } = useGridViewColumnWidth(view as any)
onMounted(loadGridViewColumns)
provide(IsFormInj, false)
@ -64,9 +87,9 @@ provide(IsGridInj, true)
provide(PaginationDataInj, paginationData)
provide(ChangePageInj, changePage)
const reloadViewDataHook = inject(ReloadViewDataHookInj)
reloadViewDataHook?.on(() => {
loadData()
reloadViewDataHook?.on(async () => {
await loadData()
loadAggCommentsCount()
})
const selectCell = (row: number, col: number) => {
@ -87,6 +110,7 @@ watch(
const onresize = (colID: string, event: any) => {
updateWidth(colID, event.detail)
}
const onXcResizing = (cn: string, event: any) => {
resizingCol.value = cn
resizingColWidth.value = event.detail
@ -253,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>
@ -263,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 }"
@ -297,67 +327,83 @@ const onNavigate = (dir: NavigateDir) => {
</div>
<template #overlay>
<SmartsheetColumnEditOrAdd @click.stop @cancel="addColumnDropdown = false" />
<SmartsheetColumnEditOrAdd @click.stop @keydown.stop @cancel="addColumnDropdown = false" />
</template>
</a-dropdown>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) of data" :key="rowIndex" class="nc-grid-row group">
<td key="row-index" class="caption nc-grid-cell group">
<div class="flex items-center 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 }"
class="group-hover:flex w-full items-center justify-between p-1"
<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 }"
class="group-hover:flex w-full items-center justify-between p-1"
>
<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)"
@click="expandForm(row, state)"
/>
</div>
</div>
</div>
</td>
<td
v-for="(columnObj, colIndex) of fields"
:ref="cellRefs.set"
:key="columnObj.id"
class="cell relative cursor-pointer nc-grid-cell"
:class="{
active: !isPublicView && selected.col === colIndex && selected.row === rowIndex,
}"
:data-key="rowIndex + columnObj.id"
:data-col="columnObj.id"
:data-title="columnObj.title"
@click="selectCell(rowIndex, colIndex)"
@dblclick="makeEditable(row, columnObj)"
@contextmenu="contextMenuTarget = { row: rowIndex, col: colIndex }"
>
<a-checkbox v-model:checked="row.rowMeta.selected" />
<span class="flex-1" />
<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)" />
<div class="w-full h-full">
<SmartsheetVirtualCell
v-if="isVirtualCol(columnObj)"
v-model="row.row[columnObj.title]"
:column="columnObj"
:active="selected.col === colIndex && selected.row === rowIndex"
:row="row"
@navigate="onNavigate"
/>
<SmartsheetCell
v-else
v-model="row.row[columnObj.title]"
:column="columnObj"
:edit-enabled="editEnabled && selected.col === colIndex && selected.row === rowIndex"
:row-index="rowIndex"
:active="selected.col === colIndex && selected.row === rowIndex"
@update:edit-enabled="editEnabled = false"
@save="updateOrSaveRow(row, columnObj.title)"
@navigate="onNavigate"
@cancel="editEnabled = false"
/>
</div>
</div>
</div>
</td>
<td
v-for="(columnObj, colIndex) of fields"
:key="rowIndex + columnObj.title"
class="cell relative cursor-pointer nc-grid-cell"
:class="{
active: !isPublicView && selected.col === colIndex && selected.row === rowIndex,
}"
:data-key="rowIndex + columnObj.id"
:data-col="columnObj.id"
:data-title="columnObj.title"
@click="selectCell(rowIndex, colIndex)"
@dblclick="makeEditable(row, columnObj)"
@contextmenu="contextMenuTarget = { row: rowIndex, col: colIndex }"
>
<div class="w-full h-full">
<SmartsheetVirtualCell
v-if="isVirtualCol(columnObj)"
v-model="row.row[columnObj.title]"
:column="columnObj"
:active="selected.col === colIndex && selected.row === rowIndex"
:row="row"
@navigate="onNavigate"
/>
<SmartsheetCell
v-else
v-model="row.row[columnObj.title]"
:column="columnObj"
:edit-enabled="editEnabled && selected.col === colIndex && selected.row === rowIndex"
:row-index="rowIndex"
@update:edit-enabled="editEnabled = false"
@save="updateOrSaveRow(row, columnObj.title)"
@navigate="onNavigate"
@cancel="editEnabled = false"
/>
</div>
</td>
</tr>
</td>
</tr>
</template>
</SmartsheetRow>
<tr v-if="!isLocked">
<td
@ -395,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>
@ -410,13 +464,11 @@ const onNavigate = (dir: NavigateDir) => {
min-height: 41px !important;
height: 41px !important;
position: relative;
//padding: 0 5px;
}
& > div {
overflow: hidden;
@apply flex align-center h-auto;
padding: 0 5px;
}
td > div {
overflow: hidden;
@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>

3
packages/nc-gui-v2/components/smartsheet/sidebar/MenuBottom.vue

@ -12,9 +12,11 @@ const emits = defineEmits<Emits>()
const { $e } = useNuxtApp()
const isView = ref(false)
let showApiSnippet = $ref(false)
function onApiSnippet() {
// get API snippet
showApiSnippet = true
$e('a:view:api-snippet')
}
@ -89,6 +91,7 @@ function onOpenModal(type: ViewTypes, title = '') {
</a-tooltip>
</a-menu-item>
<SmartsheetSidebarMenuApiSnippet v-model="showApiSnippet" />
<div class="flex-auto justify-end flex flex-col gap-4 mt-4">
<button
class="flex items-center gap-2 w-full mx-3 px-4 py-3 rounded !bg-primary text-white transform translate-x-4 hover:(translate-x-0 shadow-lg) transition duration-150 ease"

182
packages/nc-gui-v2/components/smartsheet/sidebar/menu/ApiSnippet.vue

@ -0,0 +1,182 @@
<script setup lang="ts">
import HTTPSnippet from 'httpsnippet'
import { useClipboard } from '@vueuse/core'
import { notification } from 'ant-design-vue'
import { ActiveViewInj, MetaInj } from '~/context'
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue'])
interface Props {
modelValue: boolean
}
const { project } = $(useProject())
const { appInfo, token } = $(useGlobal())
const meta = $(inject(MetaInj))
const view = $(inject(ActiveViewInj))
const { xWhere } = useSmartsheetStoreOrThrow()
const { queryParams } = $(useViewData(meta, view as any, xWhere))
const { copy } = useClipboard()
let vModel = $(useVModel(props, 'modelValue', emits))
const langs = [
{
name: 'shell',
clients: ['curl', 'wget'],
},
{
name: 'javascript',
clients: ['axios', 'fetch', 'jquery', 'xhr'],
},
{
name: 'node',
clients: ['axios', 'fetch', 'request', 'native', 'unirest'],
},
{
name: 'nocodb-sdk',
clients: ['javascript', 'node'],
},
{
name: 'php',
},
{
name: 'python',
clients: ['python3', 'requests'],
},
{
name: 'ruby',
},
{
name: 'java',
},
{
name: 'c',
},
]
const selectedClient = $ref<string | undefined>(langs[0].clients && langs[0].clients[0])
const selectedLangName = $ref(langs[0].name)
const apiUrl = $computed(
() =>
new URL(`/api/v1/db/data/noco/${project.id}/${meta.title}/views/${view.title}`, (appInfo && appInfo.ncSiteUrl) || '/').href,
)
const snippet = $computed(
() =>
new HTTPSnippet({
method: 'GET',
headers: [{ name: 'xc-auth', value: token as string, comment: 'JWT Auth token' }],
url: apiUrl,
queryString: Object.entries(queryParams || {}).map(([name, value]) => {
return {
name,
value: String(value),
}
}),
}),
)
const activeLang = $computed(() => langs.find((lang) => lang.name === selectedLangName))
const code = $computed(() => {
if (activeLang?.name === 'nocodb-sdk') {
return `${selectedClient === 'node' ? 'const { Api } require("nocodb-sdk");' : 'import { Api } from "nocodb-sdk";'}
const api = new Api({
baseURL: ${JSON.stringify(apiUrl)},
headers: {
"xc-auth": ${JSON.stringify(token as string)}
}
})
api.dbViewRow.list(
"noco",
${JSON.stringify(project.title)},
${JSON.stringify(meta.title)},
${JSON.stringify(view.title)}, ${JSON.stringify(queryParams, null, 4)}).then(function (data) {
console.log(data);
}).catch(function (error) {
console.error(error);
});
`
}
return snippet.convert(activeLang?.name, selectedClient || (activeLang?.clients && activeLang?.clients[0]), {})
})
const onCopyToClipboard = () => {
copy(code)
notification.info({ message: 'Copied to clipboard' })
}
const afterVisibleChange = (visible: boolean) => {
vModel = visible
}
</script>
<template>
<a-drawer
v-model:visible="vModel"
class="h-full relative"
style="color: red"
placement="right"
size="large"
:closable="false"
@after-visible-change="afterVisibleChange"
>
<div class="flex flex-col w-full h-full">
<a-typography-title :level="4">Code Snippet</a-typography-title>
<a-tabs v-model:activeKey="selectedLangName" class="!h-full">
<a-tab-pane v-for="item in langs" :key="item.name" class="!h-full">
<template #tab>
<div class="capitalize select-none">
{{ item.name }}
</div>
</template>
<monaco-editor
class="h-[60vh] border-1 border-gray-100 py-4 rounded-sm"
:model-value="code"
:read-only="true"
lang="typescript"
:validate="false"
:disable-deep-compare="true"
/>
<div class="flex flex-row w-full justify-end space-x-3 mt-4 uppercase">
<a-button
v-t="[
'c:snippet:copy',
{ client: activeLang?.clients && (selectedClient || activeLang?.clients[0]), lang: activeLang?.name },
]"
type="primary"
@click="onCopyToClipboard"
>Copy to clipboard</a-button
>
<a-select v-if="activeLang" v-model:value="selectedClient" style="width: 6rem">
<a-select-option v-for="(client, i) in activeLang?.clients" :key="i" class="!w-full uppercase" :value="client">
{{ client }}
</a-select-option>
</a-select>
</div>
<div class="absolute bottom-4 flex flex-row justify-center w-[95%]">
<a
v-t="['e:hiring']"
class="px-4 py-2 ! rounded shadow"
href="https://angel.co/company/nocodb"
target="_blank"
@click.stop
>
🚀 We are Hiring! 🚀
</a>
</div>
</a-tab-pane>
</a-tabs>
</div>
</a-drawer>
</template>
<style scoped></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>

2
packages/nc-gui-v2/components/virtual-cell/Formula.vue

@ -20,7 +20,7 @@ const showEditFormulaWarningMessage = () => {
}, 3000)
}
const result = computed(() => (isPg ? handleTZ(value) : value))
const result = isPg ? handleTZ(value) : value
const urls = computed(() => replaceUrlsWithLink(result.value))
</script>

69
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,26 +50,45 @@ 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">
<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>
</div>
<div class="flex-grow flex justify-end gap-1 min-h-[30px] align-center">
<MdiArrowExpand
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500"
@click="childListDlg = true"
/>
<MdiPlus class="select-none text-sm nc-action-icon text-gray-500/50 hover:text-gray-500" @click="listItemsDlg = true" />
</div>
<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="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">
<MdiArrowExpand
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500"
@click="childListDlg = true"
/>
<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>

59
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,27 +51,45 @@ 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">
<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="!isForm">
<div class="chips flex align-center img-container flex-grow hm-items flex-nowrap min-w-0 overflow-hidden">
<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>
<span v-if="value?.length === 10" class="caption pointer ml-1 grey--text" @click="childListDlg = true">more... </span>
</template>
</div>
<div class="flex-1 flex justify-end gap-1 min-h-[30px] align-center">
<MdiArrowExpand class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500" @click="childListDlg = true" />
<div class="flex-1 flex justify-end gap-1 min-h-[30px] align-center">
<MdiArrowExpand class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500" @click="childListDlg = true" />
<MdiPlus class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500" @click="listItemsDlg = true" />
</div>
<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>

81
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>) => {
await unlink(row)
await loadChildrenList()
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">

82
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>) => {
await link(row)
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
}

5
packages/nc-gui-v2/composables/useGlobal/state.ts

@ -1,7 +1,7 @@
import { usePreferredLanguages, useStorage } from '@vueuse/core'
import { useJwt } from '@vueuse/integrations/useJwt'
import type { JwtPayload } from 'jwt-decode'
import type { State, StoredState } from './types'
import type { AppInfo, State, StoredState } from './types'
import { computed, ref, toRefs, useCounter, useNuxtApp, useTimestamp } from '#imports'
import type { User } from '~/lib'
@ -71,6 +71,8 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
set: (val) => (storage.value.token = val),
})
const appInfo = ref<AppInfo>({ ncSiteUrl: 'localhost:8080' })
/** reactive token payload */
const { payload } = useJwt<JwtPayload & User>(token)
@ -88,5 +90,6 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
timestamp,
runningRequests,
error,
appInfo,
}
}

5
packages/nc-gui-v2/composables/useGlobal/types.ts

@ -11,6 +11,10 @@ export interface FeedbackForm {
lastFormPollDate?: string
}
export interface AppInfo {
ncSiteUrl: string
}
export interface StoredState {
token: string | null
user: User | null
@ -27,6 +31,7 @@ export type State = ToRefs<Omit<StoredState, 'token'>> & {
timestamp: Ref<number>
runningRequests: ReturnType<typeof useCounter>
error: Ref<any>
appInfo: Ref<AppInfo>
}
export interface Getters {

60
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,28 +61,49 @@ 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 {
childrenExcludedList.value = await $api.dbTableRow.nestedChildrenExcludedList(
NOCO,
project.value.id as string,
meta.value.id,
rowId.value,
colOptions.type as 'mm' | 'hm',
column?.value?.title,
// todo: swagger type correction
{
limit: childrenExcludedListPagination.size,
offset: childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1),
where:
childrenExcludedListPagination.query &&
`(${relatedTablePrimaryValueProp.value},like,${childrenExcludedListPagination.query})`,
} as any,
)
/** 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,
meta.value.id,
rowId.value,
colOptions.type as 'mm' | 'hm',
column?.value?.title,
// todo: swagger type correction
{
limit: childrenExcludedListPagination.size,
offset: childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1),
where:
childrenExcludedListPagination.query &&
`(${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
}

10
packages/nc-gui-v2/composables/useSmartsheetStore.ts

@ -1,15 +1,14 @@
import { computed } from '@vue/reactivity'
import { ViewTypes } from 'nocodb-sdk'
import type { TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { useNuxtApp } from '#app'
import { useProject } from '#imports'
import { useInjectionState } from '~/composables/useInjectionState'
import { computed, reactive, useInjectionState, useNuxtApp, useProject, useTemplateRefsList } from '#imports'
const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState((view: Ref<ViewType>, meta: Ref<TableType>) => {
const { $api } = useNuxtApp()
const { sqlUi } = useProject()
const cellRefs = useTemplateRefsList<HTMLTableDataCellElement>()
// state
// todo: move to grid view store
const search = reactive({
@ -37,8 +36,6 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState((view:
return where
})
// actions
return {
view,
meta,
@ -50,6 +47,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState((view:
isForm,
isGrid,
isGallery,
cellRefs,
}
}, 'smartsheet-store')

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

@ -1,10 +1,10 @@
import type { Api, FormType, GalleryType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import type { Api, PaginatedType, FormType, GalleryType, 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,11 +27,16 @@ 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 formColumnData = ref<FormType | undefined>(undefined)
const formViewData = ref<FormType | undefined>(undefined)
const galleryData = ref<GalleryType | undefined>(undefined)
const { project } = useProject()
const { $api } = useNuxtApp()
@ -52,6 +60,38 @@ export function useViewData(
paginationData.value.totalRows = count
}
const queryParams = computed(() => ({
offset: (paginationData.value?.page ?? 0) - 1,
limit: paginationData.value?.pageSize ?? 25,
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, {
@ -60,6 +100,8 @@ export function useViewData(
})
formattedData.value = formatData(response.list)
paginationData.value = response.pageInfo
loadAggCommentsCount()
}
const loadGalleryData = async () => {
@ -295,6 +337,7 @@ export function useViewData(
return {
loadData,
paginationData,
queryParams,
formattedData,
insertRow,
updateRowProperty,
@ -311,5 +354,7 @@ export function useViewData(
formColumnData,
formViewData,
updateFormView,
aggCommentCount,
loadAggCommentsCount,
}
}

1
packages/nc-gui-v2/nuxt.config.ts

@ -80,6 +80,7 @@ export default defineNuxtConfig({
],
define: {
'process.env.DEBUG': 'false',
'global': {},
},
server: {
watch: {

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

File diff suppressed because it is too large Load Diff

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

@ -26,6 +26,9 @@
"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",
"vue-toastification": "^2.0.0-rc.5",
"vuedraggable": "^4.1.0",
@ -59,6 +62,7 @@
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"happy-dom": "^6.0.3",
"httpsnippet": "^2.0.0",
"less": "^4.1.3",
"nuxt": "3.0.0-rc.4",
"nuxt-windicss": "^2.5.0",

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)
})

9
packages/nc-gui-v2/plugins/state.ts

@ -13,14 +13,21 @@ import { useDark, useGlobal, watch } from '#imports'
* console.log($state.lang.value) // 'en'
* ```
*/
export default defineNuxtPlugin((nuxtApp) => {
export default defineNuxtPlugin(async (nuxtApp) => {
const state = useGlobal()
const { $api } = useNuxtApp()
const darkMode = useDark()
/** set i18n locale to stored language */
nuxtApp.vueApp.i18n.locale.value = state.lang.value
try {
state.appInfo.value = await $api.utils.appInfo()
} catch (e) {
console.error(e)
}
/** set current dark mode from storage */
watch(
state.darkMode,

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>
<!--
/**

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

@ -242,7 +242,7 @@ export interface ColumnType {
| FormulaType
| RollupType
| LookupType
| SelectOptionsType[]
| SelectOptionsType
| object;
}
@ -303,9 +303,11 @@ export interface FormulaType {
}
export interface SelectOptionsType {
options: SelectOptionType;
}
export interface SelectOptionType {
id?: string;
type?: string;
virtual?: boolean;
fk_column_id?: string;
title?: string;
color?: string;
@ -3110,7 +3112,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>({

41
scripts/sdk/swagger.json

@ -4448,7 +4448,7 @@
"fk_model_id": {
"type": "string"
},
"comment": {
"description": {
"type": "string"
}
},
@ -6605,10 +6605,7 @@
"$ref": "#/components/schemas/Lookup"
},
{
"type": "array",
"items": {
"$ref": "#/components/schemas/SelectOptions"
}
"$ref": "#/components/schemas/SelectOptions"
},
{
"type": "object"
@ -6821,16 +6818,34 @@
"SelectOptions": {
"title": "SelectOptions",
"type": "object",
"properties": {
"options": {
"type": "array",
"required": true,
"$ref": "#/components/schemas/SelectOption"
}
},
"examples": [
{
"options": [
{
"id": "string",
"fk_column_id": "string",
"title": "string",
"color": "string",
"order": 1
}
]
}
]
},
"SelectOption": {
"title": "SelectOption",
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
},
"virtual": {
"type": "boolean"
},
"fk_column_id": {
"type": "string"
},
@ -6847,12 +6862,10 @@
"examples": [
{
"id": "string",
"type": "string",
"virtual": true,
"fk_column_id": "string",
"title": "string",
"color": "string",
"order": 0
"order": 1
}
]
},

Loading…
Cancel
Save