mirror of https://github.com/nocodb/nocodb
Wing-Kam Wong
2 years ago
49 changed files with 2823 additions and 513 deletions
@ -1,76 +1,196 @@ |
|||||||
<script lang="ts" setup> |
<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 { computed, inject } from '#imports' |
||||||
import { ColumnInj } from '~/context' |
import { ActiveCellInj, ColumnInj } from '~/context' |
||||||
|
import MdiCloseCircle from '~icons/mdi/close-circle' |
||||||
|
|
||||||
interface Props { |
interface Props { |
||||||
modelValue: string | null |
modelValue: string | string[] | undefined |
||||||
} |
} |
||||||
|
|
||||||
const { modelValue } = defineProps<Props>() |
const { modelValue } = defineProps<Props>() |
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']) |
const emit = defineEmits(['update:modelValue']) |
||||||
|
|
||||||
|
const { isMysql } = useProject() |
||||||
|
|
||||||
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 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({ |
const options = computed(() => { |
||||||
get() { |
if (column?.value.colOptions) { |
||||||
return modelValue?.match(/(?:[^',]|\\')+(?='?(?:,|$))/g)?.map((v: string) => v.replace(/\\'/g, "'")) |
const opts = column.value.colOptions |
||||||
}, |
? column.value.colOptions.options.filter((el: SelectOptionType) => el.title !== '') || [] |
||||||
set(val?: string[]) { |
: [] |
||||||
emit('update:modelValue', val?.filter((v) => options.value.includes(v)).join(',')) |
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> |
</script> |
||||||
|
|
||||||
<template> |
<template> |
||||||
<!-- |
<a-select |
||||||
<v-select |
ref="aselect" |
||||||
v-model="localState" |
v-model:value="vModel" |
||||||
:items="options" |
mode="multiple" |
||||||
hide-details |
class="w-full" |
||||||
:clearable="!column.rqd" |
placeholder="Select an option" |
||||||
variation="outlined" |
:bordered="false" |
||||||
multiple |
show-arrow |
||||||
/> |
:show-search="false" |
||||||
--> |
:open="isOpen" |
||||||
|
@keydown="handleKeys" |
||||||
<v-combobox |
@click="isOpen = !isOpen" |
||||||
v-model="localState" |
> |
||||||
:items="options" |
<a-select-option v-for="op of options" :key="op.id" :value="op.title" @click.stop> |
||||||
multiple |
<a-tag class="rounded-tag" :color="op.color"> |
||||||
chips |
<span class="text-slate-500">{{ op.title }}</span> |
||||||
flat |
</a-tag> |
||||||
dense |
</a-select-option> |
||||||
solo |
<template #tagRender="{ value: val, onClose }"> |
||||||
hide-details |
<a-tag |
||||||
deletable-chips |
v-if="options.find((el: SelectOptionType) => el.title === val)" |
||||||
class="text-center mt-0" |
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" |
||||||
> |
> |
||||||
<!-- <template #selection="data"> --> |
<span class="text-slate-500">{{ val }}</span> |
||||||
<!-- <v-chip --> |
</a-tag> |
||||||
<!-- :key="data.item" --> |
</template> |
||||||
<!-- small --> |
</a-select> |
||||||
<!-- 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> |
|
||||||
</template> |
</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/>. |
||||||
|
* |
||||||
|
*/ |
||||||
|
--> |
||||||
|
@ -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) ? '✓' : '' }} |
||||||
|
</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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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 |
||||||
|
} |
@ -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 |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,6 @@ |
|||||||
|
import VueDOMPurifyHTML from 'vue-dompurify-html' |
||||||
|
import { defineNuxtPlugin } from 'nuxt/app' |
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => { |
||||||
|
nuxtApp.vueApp.use(VueDOMPurifyHTML) |
||||||
|
}) |
@ -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('___') |
||||||
|
) |
||||||
|
} |
Loading…
Reference in new issue