mirror of https://github.com/nocodb/nocodb
Wing-Kam Wong
2 years ago
171 changed files with 22623 additions and 30927 deletions
After Width: | Height: | Size: 1.9 KiB |
@ -1,91 +1,60 @@
|
||||
<script setup lang="ts"> |
||||
import dayjs from 'dayjs' |
||||
import { computed } from '#imports' |
||||
|
||||
interface Props { |
||||
modelValue: string |
||||
} |
||||
import { ColumnInj, ReadonlyInj } from '~/context' |
||||
|
||||
const { modelValue } = defineProps<Props>() |
||||
|
||||
const emit = defineEmits(['update:modelValue']) |
||||
|
||||
const localState = computed({ |
||||
get() { |
||||
if (!modelValue || !dayjs(modelValue).isValid()) { |
||||
return undefined |
||||
interface Props { |
||||
modelValue: string |
||||
} |
||||
|
||||
return (/^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue)).format('YYYY-MM-DD') |
||||
}, |
||||
set(val?: string) { |
||||
if (dayjs(val).isValid()) { |
||||
emit('update:modelValue', val && dayjs(val).format('YYYY-MM-DD')) |
||||
} |
||||
}, |
||||
}) |
||||
const columnMeta = inject(ColumnInj, null) |
||||
const readOnlyMode = inject(ReadonlyInj, false) |
||||
|
||||
/* |
||||
let isDateInvalid = $ref(false) |
||||
const dateFormat = columnMeta?.meta?.date_format ?? 'YYYY-MM-DD' |
||||
|
||||
export default { |
||||
name: 'DatePickerCell', |
||||
props: { |
||||
value: [String, Date], |
||||
}, |
||||
computed: { |
||||
localState: { |
||||
const localState = $computed({ |
||||
get() { |
||||
if (!this.value || !dayjs(this.value).isValid()) { |
||||
if (!modelValue) { |
||||
return undefined |
||||
} |
||||
|
||||
return (/^\d+$/.test(this.value) ? dayjs(+this.value) : dayjs(this.value)).format('YYYY-MM-DD') |
||||
}, |
||||
set(val) { |
||||
if (dayjs(val).isValid()) { |
||||
this.$emit('input', val && dayjs(val).format('YYYY-MM-DD')) |
||||
} |
||||
}, |
||||
}, |
||||
date() { |
||||
if (!this.value || this.localState) { |
||||
return this.localState |
||||
if (!dayjs(modelValue).isValid()) { |
||||
isDateInvalid = true |
||||
return undefined |
||||
} |
||||
return 'Invalid Date' |
||||
}, |
||||
parentListeners() { |
||||
const $listeners = {} |
||||
|
||||
if (this.$listeners.blur) { |
||||
$listeners.blur = this.$listeners.blur |
||||
} |
||||
if (this.$listeners.focus) { |
||||
$listeners.focus = this.$listeners.focus |
||||
return /^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue) |
||||
}, |
||||
set(val?: dayjs.Dayjs) { |
||||
if (!val) { |
||||
emit('update:modelValue', null) |
||||
return |
||||
} |
||||
|
||||
return $listeners |
||||
}, |
||||
}, |
||||
mounted() { |
||||
if (this.$el && this.$el.$el) { |
||||
this.$el.$el.focus() |
||||
if (val.isValid()) { |
||||
emit('update:modelValue', val?.format('YYYY-MM-DD')) |
||||
} |
||||
}, |
||||
} */ |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<!-- <v-menu> --> |
||||
<!-- <template #activator="{ on }"> --> |
||||
<input v-model="localState" type="date" class="value" /> |
||||
<!-- </template> --> |
||||
<!-- <v-date-picker v-model="localState" flat @click.native.stop v-on="parentListeners" /> --> |
||||
<!-- </v-menu> --> |
||||
<a-date-picker |
||||
v-model:value="localState" |
||||
:bordered="false" |
||||
class="!w-full px-1" |
||||
:format="dateFormat" |
||||
:placeholder="isDateInvalid ? 'Invalid date' : !readOnlyMode ? 'Select date' : ''" |
||||
:allow-clear="!readOnlyMode" |
||||
:input-read-only="true" |
||||
:open="readOnlyMode ? false : undefined" |
||||
> |
||||
<template #suffixIcon></template> |
||||
</a-date-picker> |
||||
</template> |
||||
|
||||
<style scoped> |
||||
.value { |
||||
width: 100%; |
||||
min-height: 20px; |
||||
} |
||||
</style> |
||||
<style scoped></style> |
||||
|
@ -1,146 +1,62 @@
|
||||
<script setup lang="ts"> |
||||
import dayjs from 'dayjs' |
||||
import { computed, ref, useProject } from '#imports' |
||||
|
||||
interface Props { |
||||
modelValue?: string |
||||
} |
||||
import { ReadonlyInj } from '~/context' |
||||
|
||||
const { modelValue } = defineProps<Props>() |
||||
|
||||
const emit = defineEmits(['update:modelValue']) |
||||
|
||||
const { isMysql } = useProject() |
||||
const showMessage = ref(false) |
||||
|
||||
const localState = computed({ |
||||
get() { |
||||
if (!modelValue) { |
||||
return modelValue |
||||
} |
||||
const d = /^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue) |
||||
if (d.isValid()) { |
||||
showMessage.value = false |
||||
return d.format('YYYY-MM-DD HH:mm') |
||||
} else { |
||||
showMessage.value = true |
||||
} |
||||
}, |
||||
set(value?: string) { |
||||
if (isMysql) { |
||||
emit('update:modelValue', value && dayjs(value).format('YYYY-MM-DD HH:mm:ss')) |
||||
} else { |
||||
emit('update:modelValue', value && dayjs(value).format('YYYY-MM-DD HH:mm:ssZ')) |
||||
interface Props { |
||||
modelValue: string |
||||
} |
||||
}, |
||||
}) |
||||
|
||||
/* import dayjs from 'dayjs' |
||||
import utc from 'dayjs/plugin/utc' |
||||
const { isMysql } = useProject() |
||||
|
||||
const readOnlyMode = inject(ReadonlyInj, false) |
||||
|
||||
dayjs.extend(utc) |
||||
let isDateInvalid = $ref(false) |
||||
const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' |
||||
|
||||
export default { |
||||
name: 'DateTimePickerCell', |
||||
props: { |
||||
value: [String, Date, Number], |
||||
ignoreFocus: Boolean, |
||||
}, |
||||
data: () => ({ |
||||
showMessage: false, |
||||
}), |
||||
computed: { |
||||
isMysql() { |
||||
return ['mysql', 'mysql2'].indexOf(this.$store.getters['project/GtrClientType']) |
||||
}, |
||||
localState: { |
||||
const localState = $computed({ |
||||
get() { |
||||
if (!this.value) { |
||||
return this.value |
||||
} |
||||
const d = /^\d+$/.test(this.value) ? dayjs(+this.value) : dayjs(this.value) |
||||
if (d.isValid()) { |
||||
this.showMessage = false |
||||
return d.format('YYYY-MM-DD HH:mm') |
||||
} else { |
||||
this.showMessage = true |
||||
} |
||||
}, |
||||
set(value) { |
||||
if (this.isMysql) { |
||||
this.$emit('input', value && dayjs(value).format('YYYY-MM-DD HH:mm:ss')) |
||||
} else { |
||||
this.$emit('input', value && dayjs(value).format('YYYY-MM-DD HH:mm:ssZ')) |
||||
if (!modelValue) { |
||||
return undefined |
||||
} |
||||
}, |
||||
}, |
||||
parentListeners() { |
||||
const $listeners = {} |
||||
|
||||
if (this.$listeners.blur) { |
||||
// $listeners.blur = this.$listeners.blur |
||||
} |
||||
if (this.$listeners.focus) { |
||||
$listeners.focus = this.$listeners.focus |
||||
if (!dayjs(modelValue).isValid()) { |
||||
isDateInvalid = true |
||||
return undefined |
||||
} |
||||
|
||||
return $listeners |
||||
}, |
||||
return /^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue) |
||||
}, |
||||
mounted() { |
||||
// listen dialog click:outside event and save on close |
||||
if (this.$refs.picker && this.$refs.picker.$children && this.$refs.picker.$children[0]) { |
||||
this.$refs.picker.$children[0].$on('click:outside', () => { |
||||
this.$refs.picker.okHandler() |
||||
}) |
||||
set(val?: dayjs.Dayjs) { |
||||
if (!val) { |
||||
emit('update:modelValue', null) |
||||
return |
||||
} |
||||
|
||||
if (!this.ignoreFocus) { |
||||
this.$refs.picker.display = true |
||||
if (val.isValid()) { |
||||
emit('update:modelValue', val?.format(dateFormat)) |
||||
} |
||||
}, |
||||
} */ |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<input v-model="localState" type="datetime-local" /> |
||||
<!-- <div> --> |
||||
<!-- <div v-show="!showMessage"> --> |
||||
<!-- <v-datetime-picker --> |
||||
<!-- ref="picker" --> |
||||
<!-- v-model="localState" --> |
||||
<!-- class="caption xc-date-time-picker" --> |
||||
<!-- :text-field-props="{ --> |
||||
<!-- class: 'caption mt-0 pt-0', --> |
||||
<!-- flat: true, --> |
||||
<!-- solo: true, --> |
||||
<!-- dense: true, --> |
||||
<!-- hideDetails: true, --> |
||||
<!-- }" --> |
||||
<!-- :time-picker-props="{ --> |
||||
<!-- format: '24hr', --> |
||||
<!-- }" --> |
||||
<!-- v-on="parentListeners" --> |
||||
<!-- /> --> |
||||
<!-- </div> --> |
||||
<!-- <div v-show="showMessage" class="edit-warning" @dblclick="$refs.picker.display = true"> --> |
||||
<!-- <!– TODO: i18n –> --> |
||||
<!-- ERR: Couldn't parse {{ value }} --> |
||||
<!-- </div> --> |
||||
<!-- </div> --> |
||||
<a-date-picker |
||||
v-model:value="localState" |
||||
:show-time="true" |
||||
:bordered="false" |
||||
class="!w-full px-1" |
||||
format="YYYY-MM-DD HH:mm" |
||||
:placeholder="isDateInvalid ? 'Invalid date' : !readOnlyMode ? 'Select date and time' : ''" |
||||
:allow-clear="!readOnlyMode" |
||||
:input-read-only="true" |
||||
:open="readOnlyMode ? false : undefined" |
||||
> |
||||
<template #suffixIcon></template> |
||||
</a-date-picker> |
||||
</template> |
||||
|
||||
<style scoped> |
||||
/*:deep(.v-input),*/ |
||||
/*:deep(.v-text-field) {*/ |
||||
/* margin-top: 0 !important;*/ |
||||
/* padding-top: 0 !important;*/ |
||||
/* font-size: inherit !important;*/ |
||||
/*}*/ |
||||
|
||||
/*.edit-warning {*/ |
||||
/* padding: 10px;*/ |
||||
/* text-align: left;*/ |
||||
/* color: #e65100;*/ |
||||
/*}*/ |
||||
</style> |
||||
<style scoped></style> |
||||
|
@ -1,24 +1,32 @@
|
||||
<script lang="ts" setup> |
||||
import { computed } from '#imports' |
||||
|
||||
import { isEmail } from '~/utils/validation' |
||||
import { isEmail } from '~/utils' |
||||
|
||||
interface Props { |
||||
modelValue: string |
||||
} |
||||
|
||||
const { modelValue } = defineProps<Props>() |
||||
interface Emits { |
||||
(event: 'update:modelValue', model: string): void |
||||
} |
||||
|
||||
const validEmail = computed(() => isEmail(modelValue)) |
||||
</script> |
||||
const props = defineProps<Props>() |
||||
|
||||
<script lang="ts"> |
||||
export default { |
||||
name: 'EmailCell', |
||||
} |
||||
const emits = defineEmits<Emits>() |
||||
|
||||
const root = ref<HTMLInputElement>() |
||||
|
||||
const editEnabled = inject<boolean>('editEnabled') |
||||
|
||||
const vModel = useVModel(props, 'modelValue', emits) |
||||
|
||||
const validEmail = computed(() => isEmail(vModel.value)) |
||||
</script> |
||||
|
||||
<template> |
||||
<a v-if="validEmail" :href="`mailto:${modelValue}`" target="_blank">{{ modelValue }}</a> |
||||
<span v-else>{{ modelValue }}</span> |
||||
<input v-if="editEnabled" ref="root" v-model="vModel" class="outline-none prose-sm" /> |
||||
<a v-else-if="validEmail" class="prose-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank"> |
||||
{{ vModel }} |
||||
</a> |
||||
<span v-else>{{ vModel }}</span> |
||||
</template> |
||||
|
@ -0,0 +1,67 @@
|
||||
<script setup lang="ts"> |
||||
import { computed, inject } from '#imports' |
||||
import { ColumnInj } from '~/context' |
||||
import { getPercentStep, isValidPercent, renderPercent } from '@/utils/percentUtils' |
||||
|
||||
interface Props { |
||||
modelValue: number | string |
||||
} |
||||
|
||||
const { modelValue } = defineProps<Props>() |
||||
|
||||
const emit = defineEmits(['update:modelValue']) |
||||
|
||||
const column = inject(ColumnInj) |
||||
|
||||
const percent = ref() |
||||
|
||||
const isEdited = ref(false) |
||||
|
||||
const percentType = computed(() => column?.meta?.precision || 0) |
||||
|
||||
const percentStep = computed(() => getPercentStep(percentType.value)) |
||||
|
||||
const localState = computed({ |
||||
get: () => { |
||||
return renderPercent(modelValue, percentType.value, !isEdited.value) |
||||
}, |
||||
set: (val) => { |
||||
if (val === null) val = 0 |
||||
if (isValidPercent(val, column?.meta?.negative)) { |
||||
percent.value = val / 100 |
||||
} |
||||
}, |
||||
}) |
||||
|
||||
function onKeyDown(evt: KeyboardEvent) { |
||||
isEdited.value = true |
||||
return ['e', 'E', '+', '-'].includes(evt.key) && evt.preventDefault() |
||||
} |
||||
|
||||
function onBlur() { |
||||
if (isEdited.value) { |
||||
emit('update:modelValue', percent.value) |
||||
isEdited.value = false |
||||
} |
||||
} |
||||
|
||||
function onKeyDownEnter() { |
||||
if (isEdited.value) { |
||||
emit('update:modelValue', percent.value) |
||||
isEdited.value = false |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<input |
||||
v-if="isEdited" |
||||
v-model="localState" |
||||
type="number" |
||||
:step="percentStep" |
||||
@keydown="onKeyDown" |
||||
@blur="onBlur" |
||||
@keydown.enter="onKeyDownEnter" |
||||
/> |
||||
<input v-else v-model="localState" type="text" @focus="isEdited = true" /> |
||||
</template> |
@ -1,61 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import { inject } from 'vue' |
||||
|
||||
interface Props { |
||||
modelValue: any |
||||
} |
||||
|
||||
const props = defineProps<Props>() |
||||
|
||||
const emits = defineEmits(['update:modelValue', 'save']) |
||||
|
||||
const vModel = useVModel(props, 'modelValue', emits) |
||||
|
||||
const editEnabled = inject<boolean>('editEnabled') |
||||
</script> |
||||
|
||||
<template> |
||||
<v-menu> |
||||
<template #activator="{ props: menuProps }"> |
||||
<input v-model="vModel" class="value" v-bind="menuProps.onClick" /> |
||||
</template> |
||||
<div class="d-flex flex-column justify-center" @click.stop> |
||||
<v-time-picker v-model="vModel" /> |
||||
<v-btn small color="primary" @click="emits('save')"> |
||||
<!-- Save --> |
||||
{{ $t('general.save') }} |
||||
</v-btn> |
||||
</div> |
||||
</v-menu> |
||||
</template> |
||||
|
||||
<style scoped> |
||||
.value { |
||||
width: 100%; |
||||
min-height: 20px; |
||||
} |
||||
</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,66 @@
|
||||
<script setup lang="ts"> |
||||
import dayjs from 'dayjs' |
||||
import { ReadonlyInj } from '~/context' |
||||
|
||||
const { modelValue } = defineProps<Props>() |
||||
|
||||
const emit = defineEmits(['update:modelValue']) |
||||
|
||||
interface Props { |
||||
modelValue: string |
||||
} |
||||
|
||||
const { isMysql } = useProject() |
||||
|
||||
const readOnlyMode = inject(ReadonlyInj, false) |
||||
|
||||
let isTimeInvalid = $ref(false) |
||||
const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' |
||||
|
||||
const localState = $computed({ |
||||
get() { |
||||
if (!modelValue) { |
||||
return undefined |
||||
} |
||||
|
||||
if (!dayjs(modelValue).isValid()) { |
||||
isTimeInvalid = true |
||||
return undefined |
||||
} |
||||
|
||||
return dayjs(modelValue) |
||||
}, |
||||
set(val?: dayjs.Dayjs) { |
||||
if (!val) { |
||||
emit('update:modelValue', null) |
||||
return |
||||
} |
||||
|
||||
if (val.isValid()) { |
||||
const time = val.format('HH:mm') |
||||
const date = dayjs(`1999-01-01 ${time}:00`) |
||||
emit('update:modelValue', date.format(dateFormat)) |
||||
} |
||||
}, |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<a-time-picker |
||||
v-model:value="localState" |
||||
autofocus |
||||
:show-time="true" |
||||
:bordered="false" |
||||
use12-hours |
||||
format="HH:mm" |
||||
class="!w-full px-1" |
||||
:placeholder="isTimeInvalid ? 'Invalid time' : !readOnlyMode ? 'Select time' : ''" |
||||
:allow-clear="!readOnlyMode" |
||||
:input-read-only="true" |
||||
:open="readOnlyMode ? false : undefined" |
||||
> |
||||
<template #suffixIcon></template> |
||||
</a-time-picker> |
||||
</template> |
||||
|
||||
<style scoped></style> |
@ -0,0 +1,59 @@
|
||||
<script setup lang="ts"> |
||||
import dayjs from 'dayjs' |
||||
import { ReadonlyInj } from '~/context' |
||||
|
||||
const { modelValue } = defineProps<Props>() |
||||
|
||||
const emit = defineEmits(['update:modelValue']) |
||||
|
||||
interface Props { |
||||
modelValue: number |
||||
} |
||||
|
||||
const readOnlyMode = inject(ReadonlyInj, false) |
||||
|
||||
let isYearInvalid = $ref(false) |
||||
|
||||
const localState = $computed({ |
||||
get() { |
||||
if (!modelValue) { |
||||
return undefined |
||||
} |
||||
|
||||
const yearDate = dayjs(modelValue.toString(), 'YYYY') |
||||
if (!yearDate.isValid()) { |
||||
isYearInvalid = true |
||||
return undefined |
||||
} |
||||
|
||||
return yearDate |
||||
}, |
||||
set(val?: dayjs.Dayjs) { |
||||
if (!val) { |
||||
emit('update:modelValue', null) |
||||
return |
||||
} |
||||
|
||||
if (val?.isValid()) { |
||||
emit('update:modelValue', Number(val.format('YYYY'))) |
||||
} |
||||
}, |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<a-date-picker |
||||
v-model:value="localState" |
||||
picker="year" |
||||
:bordered="false" |
||||
class="!w-full px-1" |
||||
:placeholder="isYearInvalid ? 'Invalid year' : !readOnlyMode ? 'Select year' : ''" |
||||
:allow-clear="!readOnlyMode" |
||||
:input-read-only="true" |
||||
:open="readOnlyMode ? false : undefined" |
||||
> |
||||
<template #suffixIcon></template> |
||||
</a-date-picker> |
||||
</template> |
||||
|
||||
<style scoped></style> |
@ -1,149 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import useTabs from '~/composables/useTabs' |
||||
import MdiPlusIcon from '~icons/mdi/plus' |
||||
import MdiTableIcon from '~icons/mdi/table' |
||||
import MdiCsvIcon from '~icons/mdi/file-document-outline' |
||||
import MdiExcelIcon from '~icons/mdi/file-excel' |
||||
import MdiJSONIcon from '~icons/mdi/code-json' |
||||
import MdiAirTableIcon from '~icons/mdi/table-large' |
||||
import MdiRequestDataSourceIcon from '~icons/mdi/open-in-new' |
||||
import MdiAccountGroupIcon from '~icons/mdi/account-group' |
||||
|
||||
const { tabs, activeTab, closeTab } = useTabs() |
||||
const { isUIAllowed } = useUIPermission() |
||||
const tableCreateDialog = ref(false) |
||||
const airtableImportDialog = ref(false) |
||||
const quickImportDialog = ref(false) |
||||
const importType = ref('') |
||||
const currentMenu = ref<string[]>(['addORImport']) |
||||
|
||||
function onEdit(targetKey: number, action: string) { |
||||
if (action !== 'add') { |
||||
closeTab(targetKey) |
||||
} |
||||
} |
||||
|
||||
function openQuickImportDialog(type: string) { |
||||
quickImportDialog.value = true |
||||
importType.value = type |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div> |
||||
<a-tabs v-model:activeKey="activeTab" hide-add type="editable-card" :tab-position="top" @edit="onEdit"> |
||||
<a-tab-pane v-for="(tab, i) in tabs" :key="i" :value="i" class="text-capitalize" :closable="true"> |
||||
<template #tab> |
||||
<span class="flex items-center gap-2"> |
||||
<MdiAccountGroupIcon v-if="tab.type === 'auth'" class="text-primary" /> |
||||
<MdiTableIcon v-else class="text-primary" /> |
||||
{{ tab.title }} |
||||
</span> |
||||
</template> |
||||
</a-tab-pane> |
||||
<template #leftExtra> |
||||
<a-menu v-model:selectedKeys="currentMenu" mode="horizontal"> |
||||
<a-sub-menu key="addORImport"> |
||||
<template #title> |
||||
<span class="flex items-center gap-2"> |
||||
<MdiPlusIcon /> |
||||
Add / Import |
||||
</span> |
||||
</template> |
||||
<a-menu-item-group v-if="isUIAllowed('addTable')"> |
||||
<a-menu-item key="add-new-table" v-t="['a:actions:create-table']" @click="tableCreateDialog = true"> |
||||
<span class="flex items-center gap-2"> |
||||
<MdiTableIcon class="text-primary" /> |
||||
<!-- Add new table --> |
||||
{{ $t('tooltip.addTable') }} |
||||
</span> |
||||
</a-menu-item> |
||||
</a-menu-item-group> |
||||
<a-menu-item-group title="QUICK IMPORT FROM"> |
||||
<a-menu-item |
||||
v-if="isUIAllowed('airtableImport')" |
||||
key="quick-import-airtable" |
||||
v-t="['a:actions:import-airtable']" |
||||
@click="airtableImportDialog = true" |
||||
> |
||||
<span class="flex items-center gap-2"> |
||||
<MdiAirTableIcon class="text-primary" /> |
||||
<!-- TODO: i18n --> |
||||
Airtable |
||||
</span> |
||||
</a-menu-item> |
||||
<a-menu-item |
||||
v-if="isUIAllowed('csvImport')" |
||||
key="quick-import-csv" |
||||
v-t="['a:actions:import-csv']" |
||||
@click="openQuickImportDialog('csv')" |
||||
> |
||||
<span class="flex items-center gap-2"> |
||||
<MdiCsvIcon class="text-primary" /> |
||||
<!-- TODO: i18n --> |
||||
CSV file |
||||
</span> |
||||
</a-menu-item> |
||||
<a-menu-item |
||||
v-if="isUIAllowed('jsonImport')" |
||||
key="quick-import-json" |
||||
v-t="['a:actions:import-json']" |
||||
@click="openQuickImportDialog('json')" |
||||
> |
||||
<span class="flex items-center gap-2"> |
||||
<MdiJSONIcon class="text-primary" /> |
||||
<!-- TODO: i18n --> |
||||
JSON file |
||||
</span> |
||||
</a-menu-item> |
||||
<a-menu-item |
||||
v-if="isUIAllowed('excelImport')" |
||||
key="quick-import-excel" |
||||
v-t="['a:actions:import-excel']" |
||||
@click="openQuickImportDialog('excel')" |
||||
> |
||||
<span class="flex items-center gap-2"> |
||||
<MdiExcelIcon class="text-primary" /> |
||||
<!-- TODO: i18n --> |
||||
Microsoft Excel |
||||
</span> |
||||
</a-menu-item> |
||||
</a-menu-item-group> |
||||
<a-divider class="ma-0 mb-2" /> |
||||
<a-menu-item |
||||
v-if="isUIAllowed('importRequest')" |
||||
key="add-new-table" |
||||
v-t="['e:datasource:import-request']" |
||||
class="ma-0 mt-3" |
||||
> |
||||
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank" class="prose-sm pa-0"> |
||||
<span class="flex items-center gap-2"> |
||||
<MdiRequestDataSourceIcon class="text-primary" /> |
||||
<!-- TODO: i18n --> |
||||
Request a data source you need? |
||||
</span> |
||||
</a> |
||||
</a-menu-item> |
||||
</a-sub-menu> |
||||
</a-menu> |
||||
</template> |
||||
</a-tabs> |
||||
|
||||
<DlgTableCreate v-if="tableCreateDialog" v-model="tableCreateDialog" /> |
||||
<DlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" :import-type="importType" /> |
||||
<DlgAirtableImport v-if="airtableImportDialog" v-model="airtableImportDialog" /> |
||||
|
||||
<v-window v-model="activeTab"> |
||||
<v-window-item v-for="(tab, i) in tabs" :key="i" :value="i"> |
||||
<TabsAuth v-if="tab.type === 'auth'" :tab-meta="tab" /> |
||||
<TabsSmartsheet v-else :tab-meta="tab" /> |
||||
</v-window-item> |
||||
</v-window> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"> |
||||
:deep(.ant-menu-item-group-list) .ant-menu-item { |
||||
@apply m-0 pa-0 pl-4 pr-16; |
||||
} |
||||
</style> |
@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup> |
||||
import { notification } from 'ant-design-vue' |
||||
import { extractSdkResponseErrorMsg } from '~/utils' |
||||
import { onKeyStroke, useApi, useNuxtApp, useVModel } from '#imports' |
||||
|
||||
interface Props { |
||||
modelValue: boolean |
||||
view?: Record<string, any> |
||||
} |
||||
|
||||
interface Emits { |
||||
(event: 'update:modelValue', data: boolean): void |
||||
(event: 'deleted'): void |
||||
} |
||||
|
||||
const props = defineProps<Props>() |
||||
|
||||
const emits = defineEmits<Emits>() |
||||
|
||||
const vModel = useVModel(props, 'modelValue', emits) |
||||
|
||||
const { api, isLoading } = useApi() |
||||
|
||||
const { $e } = useNuxtApp() |
||||
|
||||
onKeyStroke('Escape', () => (vModel.value = false)) |
||||
|
||||
onKeyStroke('Enter', () => onDelete()) |
||||
|
||||
/** Delete a view */ |
||||
async function onDelete() { |
||||
if (!props.view) return |
||||
|
||||
try { |
||||
await api.dbView.delete(props.view.id) |
||||
|
||||
notification.success({ |
||||
message: 'View deleted successfully', |
||||
duration: 3, |
||||
}) |
||||
} catch (e: any) { |
||||
notification.error({ |
||||
message: await extractSdkResponseErrorMsg(e), |
||||
duration: 3, |
||||
}) |
||||
} |
||||
|
||||
emits('deleted') |
||||
|
||||
// telemetry event |
||||
$e('a:view:delete', { view: props.view.type }) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<a-modal v-model:visible="vModel" class="!top-[35%]" :confirm-loading="isLoading"> |
||||
<template #title> {{ $t('general.delete') }} {{ $t('objects.view') }} </template> |
||||
|
||||
Are you sure you want to delete this view? |
||||
|
||||
<template #footer> |
||||
<a-button key="back" @click="vModel = false">{{ $t('general.cancel') }}</a-button> |
||||
<a-button key="submit" danger html-type="submit" :loading="isLoading" @click="onDelete">{{ |
||||
$t('general.submit') |
||||
}}</a-button> |
||||
</template> |
||||
</a-modal> |
||||
</template> |
@ -0,0 +1,119 @@
|
||||
<script lang="ts" setup> |
||||
type FlipTrigger = 'hover' | 'click' | { duration: number } |
||||
|
||||
interface Props { |
||||
triggers?: FlipTrigger[] |
||||
duration?: number |
||||
} |
||||
|
||||
const props = withDefaults(defineProps<Props>(), { |
||||
triggers: () => ['click'] as FlipTrigger[], |
||||
duration: 800, |
||||
}) |
||||
|
||||
let flipped = $ref(false) |
||||
let hovered = $ref(false) |
||||
let flipTimer = $ref<NodeJS.Timer | null>(null) |
||||
|
||||
onMounted(() => { |
||||
const duration = props.triggers.reduce((dur, trigger) => { |
||||
if (typeof trigger !== 'string') { |
||||
dur = trigger.duration |
||||
} |
||||
|
||||
return dur |
||||
}, 0) |
||||
|
||||
if (duration > 0) { |
||||
flipTimer = setInterval(() => { |
||||
if (!hovered) { |
||||
flipped = !flipped |
||||
} |
||||
}, duration) |
||||
} |
||||
}) |
||||
|
||||
onBeforeUnmount(() => { |
||||
if (flipTimer) { |
||||
clearInterval(flipTimer) |
||||
} |
||||
}) |
||||
|
||||
function onHover(isHovering: boolean) { |
||||
hovered = isHovering |
||||
|
||||
if (props.triggers.find((trigger) => trigger === 'hover')) { |
||||
flipped = isHovering |
||||
} |
||||
} |
||||
|
||||
function onClick() { |
||||
if (props.triggers.find((trigger) => trigger === 'click')) { |
||||
flipped = !flipped |
||||
} |
||||
} |
||||
|
||||
let isFlipping = $ref(false) |
||||
|
||||
watch($$(flipped), () => { |
||||
isFlipping = true |
||||
|
||||
setTimeout(() => { |
||||
isFlipping = false |
||||
}, props.duration / 2) |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="flip-card" @click="onClick" @mouseover="onHover(true)" @mouseleave="onHover(false)"> |
||||
<div |
||||
class="flipper" |
||||
:style="{ '--flip-duration': `${props.duration || 800}ms`, 'transform': flipped ? 'rotateY(180deg)' : '' }" |
||||
> |
||||
<div |
||||
class="front" |
||||
:style="{ 'pointer-events': flipped ? 'none' : 'auto', 'opacity': !isFlipping ? (flipped ? 0 : 100) : flipped ? 100 : 0 }" |
||||
> |
||||
<slot name="front" /> |
||||
</div> |
||||
<div |
||||
class="back" |
||||
:style="{ 'pointer-events': flipped ? 'auto' : 'none', 'opacity': !isFlipping ? (flipped ? 100 : 0) : flipped ? 0 : 100 }" |
||||
> |
||||
<slot name="back" /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.flip-card { |
||||
background-color: transparent; |
||||
perspective: 1000px; |
||||
} |
||||
|
||||
.flipper { |
||||
--flip-duration: 800ms; |
||||
|
||||
position: relative; |
||||
width: 100%; |
||||
height: 100%; |
||||
text-align: center; |
||||
transition: all ease-in-out; |
||||
transition-duration: var(--flip-duration); |
||||
transform-style: preserve-3d; |
||||
} |
||||
|
||||
.front, |
||||
.back { |
||||
position: absolute; |
||||
width: 100%; |
||||
height: 100%; |
||||
-webkit-backface-visibility: hidden; |
||||
backface-visibility: hidden; |
||||
} |
||||
|
||||
.back { |
||||
transform: rotateY(180deg); |
||||
} |
||||
</style> |
@ -0,0 +1,94 @@
|
||||
<script setup lang="ts"> |
||||
import { useColumnCreateStoreOrThrow } from '#imports' |
||||
|
||||
const { formState, validateInfos, setAdditionalValidations, sqlUi, onDataTypeChange, onAlter } = useColumnCreateStoreOrThrow() |
||||
|
||||
const dataTypes = computed(() => sqlUi?.value?.getDataTypeListForUiType(formState)) |
||||
|
||||
// set additional validations |
||||
setAdditionalValidations({}) |
||||
|
||||
// to avoid type error with checkbox |
||||
formState.value.rqd = !!formState.value.rqd |
||||
formState.value.pk = !!formState.value.pk |
||||
formState.value.un = !!formState.value.un |
||||
formState.value.ai = !!formState.value.ai |
||||
formState.value.au = !!formState.value.au |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="p-4 border-[2px] radius-1 border-grey w-full"> |
||||
<div class="flex justify-space-between"> |
||||
<a-form-item label="NN"> |
||||
<a-checkbox |
||||
v-model:checked="formState.rqd" |
||||
:disabled="formState.pk || !sqlUi.columnEditable(formState)" |
||||
size="small" |
||||
class="nc-column-name-input" |
||||
@change="onAlter" |
||||
/> |
||||
</a-form-item> |
||||
<a-form-item label="PK"> |
||||
<a-checkbox |
||||
v-model:checked="formState.pk" |
||||
:disabled="!sqlUi.columnEditable(formState)" |
||||
size="small" |
||||
class="nc-column-name-input" |
||||
@change="onAlter" |
||||
/> |
||||
</a-form-item> |
||||
<a-form-item label="AI"> |
||||
<a-checkbox |
||||
v-model:checked="formState.ai" |
||||
:disabled="sqlUi.colPropUNDisabled(formState) || !sqlUi.columnEditable(formState)" |
||||
size="small" |
||||
class="nc-column-name-input" |
||||
@change="onAlter" |
||||
/> |
||||
</a-form-item> |
||||
<a-form-item |
||||
label="UN" |
||||
:disabled="sqlUi.colPropUNDisabled(formState) || !sqlUi.columnEditable(formState)" |
||||
@change="onAlter" |
||||
> |
||||
<a-checkbox v-model:checked="formState.un" size="small" class="nc-column-name-input" /> |
||||
</a-form-item> |
||||
<a-form-item |
||||
label="AU" |
||||
:disabled="sqlUi.colPropAuDisabled(formState) || !sqlUi.columnEditable(formState)" |
||||
@change="onAlter" |
||||
> |
||||
<a-checkbox v-model:checked="formState.au" size="small" class="nc-column-name-input" /> |
||||
</a-form-item> |
||||
</div> |
||||
<a-form-item :label="$t('labels.databaseType')" v-bind="validateInfos.dt"> |
||||
<a-select v-model:value="formState.dt" size="small" @change="onDataTypeChange"> |
||||
<a-select-option v-for="type in dataTypes" :key="type" :value="type"> |
||||
{{ type }} |
||||
</a-select-option> |
||||
</a-select> |
||||
</a-form-item> |
||||
<a-form-item :label="$t('labels.lengthValue')"> |
||||
<a-input |
||||
v-model:value="formState.dtxp" |
||||
:disabled="sqlUi.getDefaultLengthIsDisabled(formState.dt) || !sqlUi.columnEditable(formState)" |
||||
size="small" |
||||
@input="onAlter" |
||||
/> |
||||
</a-form-item> |
||||
<a-form-item v-if="sqlUi.showScale(formState)" label="Scale"> |
||||
<a-input v-model="formState.dtxs" :disabled="!sqlUi.columnEditable(formState)" size="small" @input="onAlter" /> |
||||
</a-form-item> |
||||
<a-form-item :label="$t('placeholder.defaultValue')"> |
||||
<a-textarea |
||||
v-model="formState.cdf" |
||||
:help="sqlUi.getDefaultValueForDatatype(formState.dt)" |
||||
size="small" |
||||
auto-size |
||||
@input="onAlter(2, true)" |
||||
/> |
||||
</a-form-item> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped></style> |
@ -0,0 +1,143 @@
|
||||
<script lang="ts" setup> |
||||
import { computed, inject, useColumnCreateStoreOrThrow, useMetas, watchEffect } from '#imports' |
||||
import { MetaInj } from '~/context' |
||||
import { uiTypes } from '~/utils/columnUtils' |
||||
import MdiPlusIcon from '~icons/mdi/plus-circle-outline' |
||||
import MdiMinusIcon from '~icons/mdi/minus-circle-outline' |
||||
|
||||
const emit = defineEmits(['cancel']) |
||||
const meta = inject(MetaInj) |
||||
const advancedOptions = ref(false) |
||||
const { getMeta } = useMetas() |
||||
|
||||
const { |
||||
formState, |
||||
resetFields, |
||||
validate, |
||||
validateInfos, |
||||
onUidtOrIdTypeChange, |
||||
onAlter, |
||||
addOrUpdate, |
||||
generateNewColumnMeta, |
||||
isEdit, |
||||
} = useColumnCreateStoreOrThrow() |
||||
|
||||
const uiTypesOptions = computed<typeof uiTypes>(() => { |
||||
return [ |
||||
...uiTypes.filter((t) => !isEdit || !t.virtual), |
||||
...(!isEdit && meta?.value?.columns?.every((c) => !c.pk) |
||||
? [ |
||||
{ |
||||
name: 'ID', |
||||
icon: 'mdi-identifier', |
||||
}, |
||||
] |
||||
: []), |
||||
] |
||||
}) |
||||
|
||||
const reloadMeta = () => { |
||||
emit('cancel') |
||||
getMeta(meta?.value.id as string, true) |
||||
} |
||||
|
||||
// create column meta if it's a new column |
||||
watchEffect(() => { |
||||
if (!isEdit) { |
||||
generateNewColumnMeta() |
||||
} |
||||
}) |
||||
|
||||
// focus and select the column name field |
||||
const antInput = ref() |
||||
watchEffect(() => { |
||||
if (antInput.value && formState.value) { |
||||
// todo: replace setTimeout |
||||
setTimeout(() => { |
||||
antInput.value.focus() |
||||
antInput.value.select() |
||||
}, 300) |
||||
} |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="max-w-[450px] 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 |
||||
ref="antInput" |
||||
v-model:value="formState.column_name" |
||||
size="small" |
||||
class="nc-column-name-input" |
||||
@input="onAlter(8)" |
||||
/> |
||||
</a-form-item> |
||||
<a-form-item :label="$t('labels.columnType')"> |
||||
<a-select v-model:value="formState.uidt" size="small" class="nc-column-name-input" @change="onUidtOrIdTypeChange"> |
||||
<a-select-option v-for="opt in uiTypesOptions" :key="opt.name" :value="opt.name" v-bind="validateInfos.uidt"> |
||||
<div class="flex gap-1 align-center text-xs"> |
||||
<component :is="opt.icon" class="text-grey" /> |
||||
{{ opt.name }} |
||||
</div> |
||||
</a-select-option> |
||||
</a-select> |
||||
</a-form-item> |
||||
|
||||
<div> |
||||
<div |
||||
class="text-xs cursor-pointer text-grey nc-more-options my-2 flex align-center gap-1 justify-end" |
||||
@click="advancedOptions = !advancedOptions" |
||||
> |
||||
{{ advancedOptions ? $t('general.hideAll') : $t('general.showMore') }} |
||||
<component :is="advancedOptions ? MdiMinusIcon : MdiPlusIcon" /> |
||||
</div> |
||||
</div> |
||||
<div class="overflow-hidden" :class="advancedOptions ? 'h-min' : 'h-0'"> |
||||
<SmartsheetColumnAdvancedOptions /> |
||||
</div> |
||||
<a-form-item> |
||||
<div class="flex justify-end gap-1 mt-4"> |
||||
<a-button html-type="button" size="small" @click="emit('cancel')"> |
||||
<!-- Cancel --> |
||||
{{ $t('general.cancel') }} |
||||
</a-button> |
||||
<a-button html-type="submit" type="primary" size="small" @click="addOrUpdate(reloadMeta)"> |
||||
<!-- Save --> |
||||
{{ $t('general.save') }} |
||||
</a-button> |
||||
</div> |
||||
</a-form-item> |
||||
</a-form> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped> |
||||
:deep(.ant-form-item-label > label) { |
||||
@apply !text-xs; |
||||
} |
||||
|
||||
:deep(.ant-form-item-label) { |
||||
@apply !pb-0; |
||||
} |
||||
|
||||
:deep(.ant-form-item-control-input) { |
||||
@apply !min-h-min; |
||||
} |
||||
|
||||
:deep(.ant-form-item) { |
||||
@apply !mb-1; |
||||
} |
||||
|
||||
:deep(.ant-select-selection-item) { |
||||
@apply flex align-center; |
||||
} |
||||
|
||||
:deep(.ant-form-item-explain-error) { |
||||
@apply !text-[10px]; |
||||
} |
||||
|
||||
:deep(.ant-form-item-explain) { |
||||
@apply !min-h-[15px]; |
||||
} |
||||
</style> |
@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup> |
||||
import MdiEditIcon from '~icons/mdi/pencil' |
||||
import MdiStarIcon from '~icons/mdi/star' |
||||
import MdiDeleteIcon from '~icons/mdi/delete-outline' |
||||
import MdiMenuDownIcon from '~icons/mdi/menu-down' |
||||
|
||||
const editColumnDropdown = $ref(false) |
||||
</script> |
||||
|
||||
<template> |
||||
<a-dropdown v-model:visible="editColumnDropdown" :trigger="['click']"> |
||||
<span /> |
||||
<template #overlay> |
||||
<SmartsheetColumnEditOrAdd @click.stop @cancel="editColumnDropdown = false" /> |
||||
</template> |
||||
</a-dropdown> |
||||
<a-dropdown :trigger="['hover']"> |
||||
<MdiMenuDownIcon class="text-grey" /> |
||||
<template #overlay> |
||||
<div class="shadow bg-white"> |
||||
<div class="nc-column-edit nc-menu-item" @click="editColumnDropdown = true"> |
||||
<MdiEditIcon class="text-primary" /> |
||||
<!-- Edit --> |
||||
{{ $t('general.edit') }} |
||||
</div> |
||||
<div v-t="['a:column:set-primary']" class="nc-menu-item"> |
||||
<MdiStarIcon class="text-primary" /> |
||||
|
||||
<!-- todo : tooltip --> |
||||
<!-- Set as Primary value --> |
||||
{{ $t('activity.setPrimary') }} |
||||
<!-- <span class="caption font-weight-bold">Primary value will be shown in place of primary key</span> --> |
||||
</div> |
||||
<div class="nc-column-delete nc-menu-item"> |
||||
<MdiDeleteIcon class="text-error" /> |
||||
<!-- Delete --> |
||||
{{ $t('general.delete') }} |
||||
</div> |
||||
</div> |
||||
</template> |
||||
</a-dropdown> |
||||
</template> |
@ -1,10 +1,15 @@
|
||||
<script setup lang="ts"> |
||||
import MdiAddIcon from '~icons/mdi/plus-outline' |
||||
const emit = defineEmits(['add-row']) |
||||
|
||||
const emits = defineEmits(['addRow']) |
||||
</script> |
||||
|
||||
<template> |
||||
<MdiAddIcon class="text-grey" @click="emit('add-row')" /> |
||||
</template> |
||||
<a-tooltip placement="left"> |
||||
<template #title> {{ $t('activity.addRow') }} </template> |
||||
|
||||
<style scoped></style> |
||||
<div class="nc-sidebar-right-item hover:after:bg-primary/75 group"> |
||||
<MdiAddIcon class="group-hover:(!text-white)" @click="emits('addRow')" /> |
||||
</div> |
||||
</a-tooltip> |
||||
</template> |
||||
|
@ -1,9 +1,19 @@
|
||||
<script setup lang="ts"> |
||||
import { inject, useTable } from '#imports' |
||||
import { MetaInj } from '~/context' |
||||
import MdiDeleteIcon from '~icons/mdi/delete-outline' |
||||
|
||||
const meta = inject(MetaInj) |
||||
|
||||
const { deleteTable } = useTable() |
||||
</script> |
||||
|
||||
<template> |
||||
<MdiDeleteIcon class="text-grey" /> |
||||
</template> |
||||
<a-tooltip placement="left"> |
||||
<template #title> {{ $t('activity.deleteTable') }} </template> |
||||
|
||||
<style scoped></style> |
||||
<div class="nc-sidebar-right-item hover:after:bg-red-500 group"> |
||||
<MdiDeleteIcon class="group-hover:(!text-white)" @click="deleteTable(meta)" /> |
||||
</div> |
||||
</a-tooltip> |
||||
</template> |
||||
|
@ -1,11 +1,18 @@
|
||||
<script setup lang="ts"> |
||||
import { ReloadViewDataHookInj } from '~/context' |
||||
import MdiReloadIcon from '~icons/mdi/reload' |
||||
|
||||
const reloadTri = inject(ReloadViewDataHookInj) |
||||
</script> |
||||
|
||||
<template> |
||||
<MdiReloadIcon class="text-grey" @click="reloadTri.trigger()" /> |
||||
<a-tooltip placement="left"> |
||||
<template #title> {{ $t('general.reload') }} </template> |
||||
|
||||
<div class="nc-sidebar-right-item hover:after:bg-green-500 group"> |
||||
<MdiReloadIcon class="group-hover:(!text-white)" @click="reloadTri.trigger()" /> |
||||
</div> |
||||
</a-tooltip> |
||||
</template> |
||||
|
||||
<style scoped></style> |
||||
|
@ -1,18 +1,167 @@
|
||||
<script lang="ts" setup> |
||||
import MdiOpenInNew from '~icons/mdi/open-in-new' |
||||
import { useClipboard } from '@vueuse/core' |
||||
import { ViewTypes } from 'nocodb-sdk' |
||||
import { computed } from 'vue' |
||||
import { message } from 'ant-design-vue' |
||||
import { useToast } from 'vue-toastification' |
||||
import { useNuxtApp } from '#app' |
||||
import { useSmartsheetStoreOrThrow } from '#imports' |
||||
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' |
||||
import MdiOpenInNewIcon from '~icons/mdi/open-in-new' |
||||
import MdiCopyIcon from '~icons/mdi/content-copy' |
||||
|
||||
const { isUIAllowed } = useUIPermission() |
||||
const { view, $api } = useSmartsheetStoreOrThrow() |
||||
|
||||
const { copy } = useClipboard() |
||||
const { $e } = useNuxtApp() |
||||
const toast = useToast() |
||||
const { dashboardUrl } = useDashboard() |
||||
|
||||
let showShareModel = $ref(false) |
||||
const passwordProtected = $ref(false) |
||||
const shared = ref() |
||||
|
||||
const allowCSVDownload = computed({ |
||||
get() { |
||||
return !!(shared.value?.meta && typeof shared.value.meta === 'string' ? JSON.parse(shared.value.meta) : shared.value.meta) |
||||
?.allowCSVDownload |
||||
}, |
||||
set(allow) { |
||||
shared.value.meta = { allowCSVDownload: allow } |
||||
saveAllowCSVDownload() |
||||
}, |
||||
}) |
||||
|
||||
const genShareLink = async () => { |
||||
shared.value = await $api.dbViewShare.create(view.value.id as string) |
||||
// shared.meta = shared.meta && typeof shared.meta === 'string' ? JSON.parse(shared.meta) : shared.meta; |
||||
// // todo: url |
||||
// shareLink = shared; |
||||
// passwordProtect = shared.password !== null; |
||||
// allowCSVDownload = shared.meta.allowCSVDownload; |
||||
showShareModel = true |
||||
} |
||||
|
||||
const sharedViewUrl = computed(() => { |
||||
if (!shared.value) return |
||||
let viewType |
||||
|
||||
switch (shared.value.type) { |
||||
case ViewTypes.FORM: |
||||
viewType = 'form' |
||||
break |
||||
case ViewTypes.KANBAN: |
||||
viewType = 'kanban' |
||||
break |
||||
default: |
||||
viewType = 'view' |
||||
} |
||||
|
||||
// todo: get dashboard url |
||||
return `${dashboardUrl?.value}/nc/${viewType}/${shared.value.uuid}` |
||||
}) |
||||
|
||||
async function saveAllowCSVDownload() { |
||||
try { |
||||
const meta = shared.value.meta && typeof shared.value.meta === 'string' ? JSON.parse(shared.value.meta) : shared.value.meta |
||||
|
||||
// todo: update swagger |
||||
await $api.dbViewShare.update(shared.value.id, { |
||||
meta, |
||||
} as any) |
||||
toast.success('Successfully updated') |
||||
} catch (e) { |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
if (allowCSVDownload?.value) { |
||||
$e('a:view:share:enable-csv-download') |
||||
} else { |
||||
$e('a:view:share:disable-csv-download') |
||||
} |
||||
} |
||||
|
||||
const saveShareLinkPassword = async () => { |
||||
try { |
||||
await $api.dbViewShare.update(shared.value.id, { |
||||
password: shared.value.password, |
||||
}) |
||||
toast.success('Successfully updated') |
||||
} catch (e) { |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
$e('a:view:share:enable-pwd') |
||||
} |
||||
|
||||
const copyLink = () => { |
||||
copy(sharedViewUrl?.value as string) |
||||
message.success('Copied to clipboard') |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div> |
||||
<a-button v-t="['c:view:share']" outlined class="nc-btn-share-view nc-toolbar-btn" size="small"> |
||||
<div class="flex align-center gap-1"> |
||||
<MdiOpenInNew class="text-grey" /> |
||||
<div class="flex align-center gap-1" @click="genShareLink"> |
||||
<MdiOpenInNewIcon class="text-grey" /> |
||||
<!-- Share View --> |
||||
{{ $t('activity.shareView') }} |
||||
</div> |
||||
</a-button> |
||||
|
||||
<!-- This view is shared via a private link --> |
||||
<a-modal |
||||
v-model:visible="showShareModel" |
||||
size="small" |
||||
:title="$t('msg.info.privateLink')" |
||||
:footer="null" |
||||
width="min(100vw,640px)" |
||||
> |
||||
<div class="share-link-box nc-share-link-box bg-primary-50"> |
||||
<div class="flex-1 h-min text-xs">{{ sharedViewUrl }}</div> |
||||
<!-- <v-spacer /> --> |
||||
<a v-t="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank"> |
||||
<MdiOpenInNewIcon class="text-sm text-gray-500 mt-2" /> |
||||
</a> |
||||
<MdiCopyIcon class="text-gray-500 text-sm cursor-pointer" @click="copyLink" /> |
||||
</div> |
||||
|
||||
<a-collapse ghost> |
||||
<a-collapse-panel key="1" header="More Options"> |
||||
<div class="mb-2"> |
||||
<a-checkbox v-model:checked="passwordProtected" class="!text-xs">{{ $t('msg.info.beforeEnablePwd') }} </a-checkbox> |
||||
<!-- todo: add password toggle --> |
||||
<div v-if="passwordProtected" class="flex gap-2 mt-2 mb-4"> |
||||
<a-input |
||||
v-model:value="shared.password" |
||||
size="small" |
||||
class="!text-xs max-w-[250px]" |
||||
type="password" |
||||
:placeholder="$t('placeholder.password.enter')" |
||||
/> |
||||
<a-button size="small" class="!text-xs" @click="saveShareLinkPassword" |
||||
>{{ $t('placeholder.password.save') }} |
||||
</a-button> |
||||
</div> |
||||
</div> |
||||
<div> |
||||
<a-checkbox v-if="shared && shared.type === ViewTypes.GRID" v-model:checked="allowCSVDownload" class="!text-xs" |
||||
>Allow Download |
||||
</a-checkbox> |
||||
</div> |
||||
</a-collapse-panel> |
||||
</a-collapse> |
||||
</a-modal> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped /> |
||||
<style scoped> |
||||
.share-link-box { |
||||
@apply flex p-2 w-full items-center align-center gap-1 bg-gray-100 rounded; |
||||
} |
||||
|
||||
:deep(.ant-collapse-header) { |
||||
@apply !text-xs; |
||||
} |
||||
</style> |
||||
|
@ -1,14 +1,19 @@
|
||||
<script setup lang="ts"> |
||||
import { ReloadViewDataHookInj } from '~/context' |
||||
import MdiDoorOpenIcon from '~icons/mdi/door-open' |
||||
import MdiDoorClosedIcon from '~icons/mdi/door-closed' |
||||
const navDrawerOpened = ref(false) |
||||
import MdiUnfoldMoreVertical from '~icons/mdi/unfold-more-vertical' |
||||
import MdiUnfoldLessVertical from '~icons/mdi/unfold-less-vertical' |
||||
import { inject, ref } from '#imports' |
||||
import { RightSidebarInj } from '~/context' |
||||
|
||||
const Icon = computed(() => (navDrawerOpened.value ? MdiDoorOpenIcon : MdiDoorClosedIcon)) |
||||
const sidebarOpen = inject(RightSidebarInj, ref(false)) |
||||
</script> |
||||
|
||||
<template> |
||||
<Icon class="text-grey" @click="navDrawerOpened = !navDrawerOpened" /> |
||||
</template> |
||||
<a-tooltip placement="left"> |
||||
<template #title> {{ $t('tooltip.toggleNavDraw') }} </template> |
||||
|
||||
<style scoped></style> |
||||
<div class="nc-sidebar-right-item hover:after:bg-pink-500 group"> |
||||
<MdiUnfoldLessVertical v-if="sidebarOpen" class="group-hover:(!text-white)" @click="sidebarOpen = false" /> |
||||
<MdiUnfoldMoreVertical v-else class="group-hover:(!text-white)" @click="sidebarOpen = true" /> |
||||
</div> |
||||
</a-tooltip> |
||||
</template> |
||||
|
@ -1,281 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import { ViewTypes } from 'nocodb-sdk' |
||||
import type { TableType } from 'nocodb-sdk' |
||||
import type { Ref } from 'vue' |
||||
import { inject, ref } from '#imports' |
||||
import { ActiveViewInj, MetaInj, ViewListInj } from '~/context' |
||||
import useViews from '~/composables/useViews' |
||||
import { viewIcons } from '~/utils/viewUtils' |
||||
import MdiPlusIcon from '~icons/mdi/plus' |
||||
|
||||
const meta = inject(MetaInj) |
||||
const activeView = inject(ActiveViewInj) |
||||
|
||||
const { views, loadViews } = useViews(meta as Ref<TableType>) |
||||
|
||||
provide(ViewListInj, views) |
||||
|
||||
const _isUIAllowed = (view: string) => {} |
||||
|
||||
// todo decide based on route param |
||||
loadViews().then(() => { |
||||
if (activeView) activeView.value = views.value?.[0] |
||||
}) |
||||
|
||||
const toggleDrawer = ref(false) |
||||
// todo: identify based on meta |
||||
const isView = ref(false) |
||||
const viewCreateType = ref<ViewTypes>() |
||||
const viewCreateDlg = ref<boolean>(false) |
||||
|
||||
const openCreateViewDlg = (type: ViewTypes) => { |
||||
viewCreateDlg.value = true |
||||
viewCreateType.value = type |
||||
} |
||||
|
||||
const onViewCreate = (view) => { |
||||
views.value?.push(view) |
||||
activeView.value = view |
||||
viewCreateDlg.value = false |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div |
||||
class="views-navigation-drawer flex-item-stretch pa-4 elevation-1" |
||||
:style="{ |
||||
maxWidth: toggleDrawer ? '0' : '220px', |
||||
minWidth: toggleDrawer ? '0' : '220px', |
||||
}" |
||||
> |
||||
<div class="d-flex flex-column h-100"> |
||||
<div class="flex-grow-1"> |
||||
<v-list v-if="views && views.length" dense> |
||||
<v-list-item dense> |
||||
<!-- Views --> |
||||
<span class="body-2 font-weight-medium">{{ $t('objects.views') }}</span> |
||||
</v-list-item> |
||||
|
||||
<!-- <v-list-group v-model="selectedViewIdLocal" mandatory color="primary"> --> |
||||
<!-- |
||||
todo: add sortable |
||||
<draggable |
||||
:is="_isUIAllowed('viewlist-drag-n-drop') ? 'draggable' : 'div'" |
||||
v-model="viewsList" |
||||
draggable="div" |
||||
v-bind="dragOptions" |
||||
@change="onMove($event)" |
||||
> --> |
||||
<!-- <transition-group |
||||
type="transition" |
||||
:name="!drag ? 'flip-list' : null" |
||||
> --> |
||||
<v-list-item |
||||
v-for="view in views" |
||||
:key="view.id" |
||||
v-t="['a:view:open', { view: view.type }]" |
||||
dense |
||||
:value="view.id" |
||||
active-class="x-active--text" |
||||
@click="activeView = view" |
||||
> |
||||
<!-- :class="`body-2 view nc-view-item nc-draggable-child nc-${ |
||||
viewTypeAlias[view.type] |
||||
}-view-item`" |
||||
@click="$emit('rerender')" --> |
||||
<!-- <v-icon |
||||
v-if="_isUIAllowed('viewlist-drag-n-drop')" |
||||
small |
||||
:class="`nc-child-draggable-icon nc-child-draggable-icon-${view.title}`" |
||||
@click.stop |
||||
> |
||||
mdi-drag-vertical |
||||
</v-icon> --> |
||||
<!-- <v-list-item-icon class="mr-n1"> |
||||
<v-icon v-if="viewIcons[view.type]" x-small :color="viewIcons[view.type].color"> |
||||
{{ viewIcons[view.type].icon }} |
||||
</v-icon> |
||||
<v-icon v-else color="primary" small> mdi-table </v-icon> |
||||
</v-list-item-icon> --> |
||||
<component :is="viewIcons[view.type].icon" :class="`text-${viewIcons[view.type].color} mr-1`" /> |
||||
<span>{{ view.alias || view.title }}</span> |
||||
|
||||
<!-- <v-list-item-title> |
||||
<v-tooltip bottom> |
||||
<template #activator="{ on }"> |
||||
<div class="font-weight-regular" style="overflow: hidden; text-overflow: ellipsis"> |
||||
<input v-if="view.edit" :ref="`input${i}`" v-model="view.title_temp" /> |
||||
|
||||
<!– @click.stop |
||||
@keydown.enter.stop="updateViewName(view, i)" |
||||
@blur="updateViewName(view, i)" –> |
||||
<template v-else> |
||||
<span v-on="on">{{ view.alias || view.title }}</span> |
||||
</template> |
||||
</div> |
||||
</template> |
||||
{{ view.alias || view.title }} |
||||
</v-tooltip> |
||||
</v-list-item-title> --> |
||||
<v-spacer /> |
||||
<!-- <template v-if="_isUIAllowed('virtualViewsCreateOrEdit')"> |
||||
<!– Copy view –> |
||||
<x-icon |
||||
v-if="!view.edit" |
||||
:tooltip="$t('activity.copyView')" |
||||
x-small |
||||
color="primary" |
||||
icon-class="view-icon nc-view-copy-icon" |
||||
@click.stop="copyView(view, i)" |
||||
> |
||||
mdi-content-copy |
||||
</x-icon> |
||||
<!– Rename view –> |
||||
<x-icon |
||||
v-if="!view.edit" |
||||
:tooltip="$t('activity.renameView')" |
||||
x-small |
||||
color="primary" |
||||
icon-class="view-icon nc-view-edit-icon" |
||||
@click.stop="showRenameTextBox(view, i)" |
||||
> |
||||
mdi-pencil |
||||
</x-icon> |
||||
<!– Delete view" –> |
||||
<x-icon |
||||
v-if="!view.is_default" |
||||
:tooltip="$t('activity.deleteView')" |
||||
small |
||||
color="error" |
||||
icon-class="view-icon nc-view-delete-icon" |
||||
@click.stop="deleteView(view)" |
||||
> |
||||
mdi-delete-outline |
||||
</x-icon> |
||||
</template> |
||||
<v-icon |
||||
v-if="view.id === selectedViewId" |
||||
small |
||||
class="check-icon" |
||||
> |
||||
mdi-check-bold |
||||
</v-icon> --> |
||||
</v-list-item> |
||||
<!-- </transition-group> --> |
||||
<!-- </draggable> --> |
||||
<!-- </v-list-group> --> |
||||
</v-list> |
||||
|
||||
<v-divider class="advance-menu-divider" /> |
||||
|
||||
<v-list dense> |
||||
<v-list-item dense> |
||||
<!-- Create a View --> |
||||
<span class="body-2 font-weight-medium" @dblclick="enableDummyFeat = true"> |
||||
{{ $t('activity.createView') }} |
||||
</span> |
||||
<!-- <v-tooltip top> |
||||
<template #activator="{ props }"> |
||||
<!– <x-icon –> |
||||
<!– color="pink textColor" –> |
||||
<!– icon-class="ml-2" –> |
||||
<!– small –> |
||||
<!– v-on="on" –> |
||||
<!– @mouseenter="overShieldIcon = true" –> |
||||
<!– @mouseleave="overShieldIcon = false" –> |
||||
<!– > –> |
||||
<!– mdi-shield-lock-outline –> |
||||
<!– </x-icon> –> |
||||
</template> |
||||
<!– Only visible to Creator –> |
||||
<span class="caption"> |
||||
{{ $t('msg.info.onlyCreator') }} |
||||
</span> |
||||
</v-tooltip> --> |
||||
</v-list-item> |
||||
<v-tooltip bottom> |
||||
<template #activator="{ props }"> |
||||
<v-list-item dense class="body-2 nc-create-grid-view" v-bind="props" @click="openCreateViewDlg(ViewTypes.GRID)"> |
||||
<!-- <v-list-item-icon class="mr-n1"> --> |
||||
<component :is="viewIcons[ViewTypes.GRID].icon" :class="`text-${viewIcons[ViewTypes.GRID].color} mr-1`" /> |
||||
<!-- </v-list-item-icon> --> |
||||
<v-list-item-title> |
||||
<span class="font-weight-regular"> |
||||
<!-- Grid --> |
||||
{{ $t('objects.viewType.grid') }} |
||||
</span> |
||||
</v-list-item-title> |
||||
<v-spacer /> |
||||
<MdiPlusIcon class="mr-1" /> |
||||
<!-- <v-icon class="mr-1" small> mdi-plus</v-icon> --> |
||||
</v-list-item> |
||||
</template> |
||||
<!-- Add Grid View --> |
||||
{{ $t('msg.info.addView.grid') }} |
||||
</v-tooltip> |
||||
<v-tooltip bottom> |
||||
<template #activator="{ props }"> |
||||
<v-list-item |
||||
dense |
||||
class="body-2 nc-create-gallery-view" |
||||
v-bind="props" |
||||
@click="openCreateViewDlg(ViewTypes.GALLERY)" |
||||
> |
||||
<!-- <v-list-item-icon class="mr-n1"> --> |
||||
<component :is="viewIcons[ViewTypes.GALLERY].icon" :class="`text-${viewIcons[ViewTypes.GALLERY].color} mr-1`" /> |
||||
<!-- </v-list-item-icon> --> |
||||
<v-list-item-title> |
||||
<span class="font-weight-regular"> |
||||
<!-- Gallery --> |
||||
{{ $t('objects.viewType.gallery') }} |
||||
</span> |
||||
</v-list-item-title> |
||||
|
||||
<v-spacer /> |
||||
|
||||
<MdiPlusIcon class="mr-1" /> |
||||
<!-- <v-icon class="mr-1" small> mdi-plus</v-icon> --> |
||||
</v-list-item> |
||||
</template> |
||||
<!-- Add Gallery View --> |
||||
{{ $t('msg.info.addView.gallery') }} |
||||
</v-tooltip> |
||||
|
||||
<v-tooltip bottom> |
||||
<template #activator="{ props }"> |
||||
<v-list-item |
||||
v-if="!isView" |
||||
dense |
||||
class="body-2 nc-create-form-view" |
||||
v-bind="props" |
||||
@click="openCreateViewDlg(ViewTypes.FORM)" |
||||
> |
||||
<!-- <v-list-item-icon class="mr-n1"> --> |
||||
<component :is="viewIcons[ViewTypes.FORM].icon" :class="`text-${viewIcons[ViewTypes.FORM].color} mr-1`" /> |
||||
<!-- </v-list-item-icon> --> |
||||
<v-list-item-title> |
||||
<span class="font-weight-regular"> |
||||
<!-- Form --> |
||||
|
||||
{{ $t('objects.viewType.form') }} |
||||
</span> |
||||
</v-list-item-title> |
||||
|
||||
<v-spacer /> |
||||
|
||||
<MdiPlusIcon class="mr-1" /> |
||||
<!-- <v-icon class="mr-1" small> mdi-plus</v-icon> --> |
||||
</v-list-item> |
||||
</template> |
||||
<!-- Add Form View --> |
||||
{{ $t('msg.info.addView.form') }} |
||||
</v-tooltip> |
||||
</v-list> |
||||
</div> |
||||
</div> |
||||
|
||||
<DlgViewCreate v-if="views" v-model="viewCreateDlg" :type="viewCreateType" @created="onViewCreate" /> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped></style> |
@ -0,0 +1,146 @@
|
||||
<script lang="ts" setup> |
||||
import { ViewTypes } from 'nocodb-sdk' |
||||
import { ref, useNuxtApp } from '#imports' |
||||
import { viewIcons } from '~/utils' |
||||
import MdiPlusIcon from '~icons/mdi/plus' |
||||
import MdiXml from '~icons/mdi/xml' |
||||
import MdiHook from '~icons/mdi/hook' |
||||
import MdiHeartsCard from '~icons/mdi/cards-heart' |
||||
import MdiShieldLockOutline from '~icons/mdi/shield-lock-outline' |
||||
|
||||
interface Emits { |
||||
(event: 'openModal', data: { type: ViewTypes; title?: string }): void |
||||
} |
||||
|
||||
const emits = defineEmits<Emits>() |
||||
|
||||
const { $e } = useNuxtApp() |
||||
|
||||
const isView = ref(false) |
||||
|
||||
function onApiSnippet() { |
||||
// get API snippet |
||||
$e('a:view:api-snippet') |
||||
} |
||||
|
||||
function onOpenModal(type: ViewTypes, title = '') { |
||||
emits('openModal', { type, title }) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<a-menu :selected-keys="[]" class="flex-1 flex flex-col"> |
||||
<h3 class="px-3 py-1 text-xs font-semibold flex items-center gap-4"> |
||||
{{ $t('activity.createView') }} |
||||
<a-tooltip> |
||||
<template #title> |
||||
{{ $t('msg.info.onlyCreator') }} |
||||
</template> |
||||
<MdiShieldLockOutline class="text-pink-500" /> |
||||
</a-tooltip> |
||||
</h3> |
||||
|
||||
<a-menu-item key="grid" class="group !flex !items-center !my-0 !h-[30px]" @click="onOpenModal(ViewTypes.GRID)"> |
||||
<a-tooltip placement="left"> |
||||
<template #title> |
||||
{{ $t('msg.info.addView.grid') }} |
||||
</template> |
||||
|
||||
<div class="text-xs flex items-center h-full w-full gap-2"> |
||||
<component :is="viewIcons[ViewTypes.GRID].icon" :class="`text-${viewIcons[ViewTypes.GRID].color}`" /> |
||||
|
||||
<div>{{ $t('objects.viewType.grid') }}</div> |
||||
|
||||
<div class="flex-1" /> |
||||
|
||||
<MdiPlusIcon class="group-hover:text-primary" /> |
||||
</div> |
||||
</a-tooltip> |
||||
</a-menu-item> |
||||
|
||||
<a-menu-item key="gallery" class="group !flex !items-center !-my0 !h-[30px]" @click="onOpenModal(ViewTypes.GALLERY)"> |
||||
<a-tooltip placement="left"> |
||||
<template #title> |
||||
{{ $t('msg.info.addView.gallery') }} |
||||
</template> |
||||
|
||||
<div class="text-xs flex items-center h-full w-full gap-2"> |
||||
<component :is="viewIcons[ViewTypes.GALLERY].icon" :class="`text-${viewIcons[ViewTypes.GALLERY].color}`" /> |
||||
|
||||
<div>{{ $t('objects.viewType.gallery') }}</div> |
||||
|
||||
<div class="flex-1" /> |
||||
|
||||
<MdiPlusIcon class="group-hover:text-primary" /> |
||||
</div> |
||||
</a-tooltip> |
||||
</a-menu-item> |
||||
|
||||
<a-menu-item v-if="!isView" key="form" class="group !flex !items-center !my-0 !h-[30px]" @click="onOpenModal(ViewTypes.FORM)"> |
||||
<a-tooltip placement="left"> |
||||
<template #title> |
||||
{{ $t('msg.info.addView.form') }} |
||||
</template> |
||||
|
||||
<div class="text-xs flex items-center h-full w-full gap-2"> |
||||
<component :is="viewIcons[ViewTypes.FORM].icon" :class="`text-${viewIcons[ViewTypes.FORM].color}`" /> |
||||
|
||||
<div>{{ $t('objects.viewType.form') }}</div> |
||||
|
||||
<div class="flex-1" /> |
||||
|
||||
<MdiPlusIcon class="group-hover:text-primary" /> |
||||
</div> |
||||
</a-tooltip> |
||||
</a-menu-item> |
||||
|
||||
<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" |
||||
@click="onApiSnippet" |
||||
> |
||||
<MdiXml />Get API Snippet |
||||
</button> |
||||
|
||||
<button |
||||
class="flex items-center gap-2 w-full mx-3 px-4 py-3 rounded border transform translate-x-4 hover:(translate-x-0 shadow-lg) transition duration-150 ease" |
||||
@click="onApiSnippet" |
||||
> |
||||
<MdiHook />{{ $t('objects.webhooks') }} |
||||
</button> |
||||
</div> |
||||
|
||||
<general-flipping-card class="my-4 lg:my-6 min-h-[100px]" :triggers="['click', { duration: 15000 }]"> |
||||
<template #front> |
||||
<div class="flex h-full w-full gap-6 flex-col"> |
||||
<general-social /> |
||||
|
||||
<div> |
||||
<a |
||||
v-t="['e:hiring']" |
||||
class="px-4 py-3 !bg-primary rounded shadow text-white" |
||||
href="https://angel.co/company/nocodb" |
||||
target="_blank" |
||||
@click.stop |
||||
> |
||||
🚀 We are Hiring! 🚀 |
||||
</a> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<template #back> |
||||
<!-- todo: add project cost --> |
||||
<a |
||||
href="https://github.com/sponsors/nocodb" |
||||
target="_blank" |
||||
class="group flex items-center gap-2 w-full mx-3 px-4 py-2 rounded-l !bg-primary text-white transform translate-x-4 hover:(translate-x-0 shadow-lg !opacity-100) transition duration-150 ease" |
||||
@click.stop |
||||
> |
||||
<MdiHeartsCard class="text-red-500" /> |
||||
{{ $t('activity.sponsorUs') }} |
||||
</a> |
||||
</template> |
||||
</general-flipping-card> |
||||
</a-menu> |
||||
</template> |
@ -0,0 +1,238 @@
|
||||
<script lang="ts" setup> |
||||
import type { FormType, GalleryType, GridType, KanbanType, ViewTypes } from 'nocodb-sdk' |
||||
import type { SortableEvent } from 'sortablejs' |
||||
import type { Menu as AntMenu } from 'ant-design-vue' |
||||
import { notification } from 'ant-design-vue' |
||||
import type { Ref } from 'vue' |
||||
import Sortable from 'sortablejs' |
||||
import RenameableMenuItem from './RenameableMenuItem.vue' |
||||
import { inject, onMounted, ref, useApi, useTabs, watch } from '#imports' |
||||
import { extractSdkResponseErrorMsg } from '~/utils' |
||||
import { ActiveViewInj, MetaInj, ViewListInj } from '~/context' |
||||
|
||||
interface Emits { |
||||
(event: 'openModal', data: { type: ViewTypes; title?: string }): void |
||||
(event: 'deleted'): void |
||||
(event: 'sorted'): void |
||||
} |
||||
|
||||
const emits = defineEmits<Emits>() |
||||
|
||||
const activeView = inject(ActiveViewInj, ref()) |
||||
|
||||
const views = inject<Ref<any[]>>(ViewListInj, ref([])) |
||||
|
||||
const meta = inject(MetaInj) |
||||
|
||||
const { addTab } = useTabs() |
||||
|
||||
const { api } = useApi() |
||||
|
||||
const router = useRouter() |
||||
|
||||
/** Selected view(s) for menu */ |
||||
const selected = ref<string[]>([]) |
||||
|
||||
/** dragging renamable view items */ |
||||
let dragging = $ref(false) |
||||
|
||||
let deleteModalVisible = $ref(false) |
||||
|
||||
/** view to delete for modal */ |
||||
let toDelete = $ref<Record<string, any> | undefined>() |
||||
|
||||
const menuRef = $ref<typeof AntMenu>() |
||||
|
||||
let isMarked = $ref<string | false>(false) |
||||
|
||||
/** Watch currently active view, so we can mark it in the menu */ |
||||
watch(activeView, (nextActiveView) => { |
||||
const _nextActiveView = nextActiveView as GridType | FormType | KanbanType |
||||
|
||||
if (_nextActiveView && _nextActiveView.id) { |
||||
selected.value = [_nextActiveView.id] |
||||
} |
||||
}) |
||||
|
||||
/** shortly mark an item after sorting */ |
||||
function markItem(id: string) { |
||||
isMarked = id |
||||
setTimeout(() => { |
||||
isMarked = false |
||||
}, 300) |
||||
} |
||||
|
||||
/** validate view title */ |
||||
function validate(value?: string) { |
||||
if (!value || value.trim().length < 0) { |
||||
return 'View name is required' |
||||
} |
||||
|
||||
if (views.value.every((v1) => ((v1 as GridType | KanbanType | GalleryType).alias || v1.title) !== value)) { |
||||
return 'View name should be unique' |
||||
} |
||||
|
||||
return true |
||||
} |
||||
|
||||
function onSortStart(evt: SortableEvent) { |
||||
evt.stopImmediatePropagation() |
||||
evt.preventDefault() |
||||
dragging = true |
||||
} |
||||
|
||||
async function onSortEnd(evt: SortableEvent) { |
||||
evt.stopImmediatePropagation() |
||||
evt.preventDefault() |
||||
dragging = false |
||||
|
||||
if (views.value.length < 2) return |
||||
|
||||
const { newIndex = 0, oldIndex = 0 } = evt |
||||
|
||||
if (newIndex === oldIndex) return |
||||
|
||||
const children = evt.to.children as unknown as HTMLLIElement[] |
||||
|
||||
const previousEl = children[newIndex - 1] |
||||
const nextEl = children[newIndex + 1] |
||||
|
||||
const currentItem: Record<string, any> = views.value.find((v) => v.id === evt.item.id) |
||||
const previousItem: Record<string, any> = previousEl ? views.value.find((v) => v.id === previousEl.id) : {} |
||||
const nextItem: Record<string, any> = nextEl ? views.value.find((v) => v.id === nextEl.id) : {} |
||||
|
||||
let nextOrder: number |
||||
|
||||
// set new order value based on the new order of the items |
||||
if (views.value.length - 1 === newIndex) { |
||||
nextOrder = parseFloat(previousItem.order) + 1 |
||||
} else if (newIndex === 0) { |
||||
nextOrder = parseFloat(nextItem.order) / 2 |
||||
} else { |
||||
nextOrder = (parseFloat(previousItem.order) + parseFloat(nextItem.order)) / 2 |
||||
} |
||||
|
||||
const _nextOrder = !isNaN(Number(nextOrder)) ? nextOrder.toString() : oldIndex.toString() |
||||
|
||||
currentItem.order = _nextOrder |
||||
|
||||
await api.dbView.update(currentItem.id, { order: _nextOrder }) |
||||
|
||||
markItem(currentItem.id) |
||||
} |
||||
|
||||
let sortable: Sortable |
||||
|
||||
// todo: replace with vuedraggable |
||||
const initSortable = (el: HTMLElement) => { |
||||
if (sortable) sortable.destroy() |
||||
|
||||
sortable = new Sortable(el, { |
||||
handle: '.nc-drag-icon', |
||||
ghostClass: 'ghost', |
||||
onStart: onSortStart, |
||||
onEnd: onSortEnd, |
||||
}) |
||||
} |
||||
|
||||
onMounted(() => menuRef && initSortable(menuRef.$el)) |
||||
|
||||
// todo: fix view type, alias is missing for some reason? |
||||
/** Navigate to view by changing url param */ |
||||
function changeView(view: { id: string; alias?: string; title?: string; type: ViewTypes }) { |
||||
router.push({ params: { viewTitle: (view.alias ?? view.title) || '' } }) |
||||
} |
||||
|
||||
/** Rename a view */ |
||||
async function onRename(view: Record<string, any>) { |
||||
const valid = validate(view.title) |
||||
|
||||
if (valid !== true) { |
||||
notification.error({ |
||||
message: valid, |
||||
duration: 2, |
||||
}) |
||||
} |
||||
|
||||
try { |
||||
// todo typing issues, order and id do not exist on all members of ViewTypes (Kanban, Gallery, Form, Grid) |
||||
await api.dbView.update(view.id, { |
||||
title: view.title, |
||||
order: view.order, |
||||
}) |
||||
|
||||
notification.success({ |
||||
message: 'View renamed successfully', |
||||
duration: 3, |
||||
}) |
||||
} catch (e: any) { |
||||
notification.error({ |
||||
message: await extractSdkResponseErrorMsg(e), |
||||
duration: 3, |
||||
}) |
||||
} |
||||
} |
||||
|
||||
/** Open delete modal */ |
||||
async function onDelete(view: Record<string, any>) { |
||||
toDelete = view |
||||
deleteModalVisible = true |
||||
} |
||||
|
||||
/** View was deleted, trigger reload */ |
||||
function onDeleted() { |
||||
emits('deleted') |
||||
toDelete = undefined |
||||
deleteModalVisible = false |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<h3 class="pt-3 px-3 text-xs font-semibold">{{ $t('objects.views') }}</h3> |
||||
|
||||
<a-menu ref="menuRef" :class="{ dragging }" class="nc-views-menu" :selected-keys="selected"> |
||||
<RenameableMenuItem |
||||
v-for="view of views" |
||||
:id="view.id" |
||||
:key="view.id" |
||||
:view="view" |
||||
class="transition-all ease-in duration-300" |
||||
:class="[isMarked === view.id ? 'bg-gray-200' : '']" |
||||
@change-view="changeView" |
||||
@open-modal="$emit('openModal', $event)" |
||||
@delete="onDelete" |
||||
@rename="onRename" |
||||
/> |
||||
</a-menu> |
||||
|
||||
<dlg-view-delete v-model="deleteModalVisible" :view="toDelete" @deleted="onDeleted" /> |
||||
</template> |
||||
|
||||
<style lang="scss"> |
||||
.nc-views-menu { |
||||
@apply flex-1 max-h-[20vh] overflow-y-scroll scrollbar-thin-primary; |
||||
|
||||
.ghost, |
||||
.ghost > * { |
||||
@apply !pointer-events-none; |
||||
} |
||||
|
||||
&.dragging { |
||||
.nc-icon { |
||||
@apply !hidden; |
||||
} |
||||
|
||||
.nc-view-icon { |
||||
@apply !block; |
||||
} |
||||
} |
||||
|
||||
.ant-menu-item:not(.sortable-chosen) { |
||||
@apply color-transition hover:!bg-transparent; |
||||
} |
||||
|
||||
.sortable-chosen { |
||||
@apply !bg-primary/25 text-primary; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,179 @@
|
||||
<script lang="ts" setup> |
||||
import type { ViewTypes } from 'nocodb-sdk' |
||||
import { viewIcons } from '~/utils' |
||||
import { useDebounceFn, useNuxtApp, useVModel } from '#imports' |
||||
import MdiTrashCan from '~icons/mdi/trash-can' |
||||
import MdiContentCopy from '~icons/mdi/content-copy' |
||||
import MdiDrag from '~icons/mdi/drag-vertical' |
||||
|
||||
interface Props { |
||||
view: Record<string, any> |
||||
} |
||||
|
||||
interface Emits { |
||||
(event: 'openModal', data: { type: ViewTypes; title?: string }): void |
||||
(event: 'update:view', data: Record<string, any>): void |
||||
(event: 'changeView', view: Record<string, any>): void |
||||
(event: 'rename', view: Record<string, any>): void |
||||
(event: 'delete', view: Record<string, any>): void |
||||
} |
||||
|
||||
const props = defineProps<Props>() |
||||
|
||||
const emits = defineEmits<Emits>() |
||||
|
||||
const vModel = useVModel(props, 'view', emits) |
||||
|
||||
const { $e } = useNuxtApp() |
||||
|
||||
/** Is editing the view name enabled */ |
||||
let isEditing = $ref<boolean>(false) |
||||
|
||||
/** Helper to check if editing was disabled before the view navigation timeout triggers */ |
||||
let isStopped = $ref(false) |
||||
|
||||
/** Original view title when editing the view name */ |
||||
let originalTitle = $ref<string | undefined>() |
||||
|
||||
/** Debounce click handler, so we can potentially enable editing view name {@see onDblClick} */ |
||||
const onClick = useDebounceFn(() => { |
||||
if (isEditing || isStopped) return |
||||
|
||||
emits('changeView', vModel.value) |
||||
}, 250) |
||||
|
||||
/** Enable editing view name on dbl click */ |
||||
function onDblClick() { |
||||
if (!isEditing) { |
||||
isEditing = true |
||||
originalTitle = vModel.value.title |
||||
} |
||||
} |
||||
|
||||
/** Handle keydown on input field */ |
||||
function onKeyDown(event: KeyboardEvent) { |
||||
if (event.key === 'Escape') { |
||||
onKeyEsc(event) |
||||
} else if (event.key === 'Enter') { |
||||
onKeyEnter(event) |
||||
} |
||||
} |
||||
|
||||
/** Rename view when enter is pressed */ |
||||
function onKeyEnter(event: KeyboardEvent) { |
||||
event.stopImmediatePropagation() |
||||
event.preventDefault() |
||||
|
||||
onRename() |
||||
} |
||||
|
||||
/** Disable renaming view when escape is pressed */ |
||||
function onKeyEsc(event: KeyboardEvent) { |
||||
event.stopImmediatePropagation() |
||||
event.preventDefault() |
||||
|
||||
onCancel() |
||||
} |
||||
|
||||
onKeyStroke('Enter', (event) => { |
||||
if (isEditing) { |
||||
onKeyEnter(event) |
||||
} |
||||
}) |
||||
|
||||
function focusInput(el: HTMLInputElement) { |
||||
if (el) el.focus() |
||||
} |
||||
|
||||
/** Duplicate a view */ |
||||
// todo: This is not really a duplication, maybe we need to implement a true duplication? |
||||
function onDuplicate() { |
||||
emits('openModal', { type: vModel.value.type, title: vModel.value.title }) |
||||
|
||||
$e('c:view:copy', { view: vModel.value.type }) |
||||
} |
||||
|
||||
/** Delete a view */ |
||||
async function onDelete() { |
||||
emits('delete', vModel.value) |
||||
} |
||||
|
||||
/** Rename a view */ |
||||
async function onRename() { |
||||
if (!isEditing) return |
||||
|
||||
if (vModel.value.title === '' || vModel.value.title === originalTitle) { |
||||
onCancel() |
||||
return |
||||
} |
||||
|
||||
emits('rename', vModel.value) |
||||
|
||||
onStopEdit() |
||||
} |
||||
|
||||
/** Cancel renaming view */ |
||||
function onCancel() { |
||||
if (!isEditing) return |
||||
|
||||
vModel.value.title = originalTitle |
||||
onStopEdit() |
||||
} |
||||
|
||||
/** Stop editing view name, timeout makes sure that view navigation (click trigger) does not pick up before stop is done */ |
||||
function onStopEdit() { |
||||
isStopped = true |
||||
isEditing = false |
||||
originalTitle = '' |
||||
|
||||
setTimeout(() => { |
||||
isStopped = false |
||||
}, 250) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<a-menu-item class="select-none group !flex !items-center !my-0" @dblclick.stop="onDblClick" @click.stop="onClick"> |
||||
<div v-t="['a:view:open', { view: vModel.type }]" class="text-xs flex items-center w-full gap-2"> |
||||
<div class="flex w-auto"> |
||||
<MdiDrag |
||||
class="nc-drag-icon hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 cursor-move" |
||||
@click.stop.prevent |
||||
/> |
||||
|
||||
<component |
||||
:is="viewIcons[vModel.type].icon" |
||||
class="nc-view-icon group-hover:hidden" |
||||
:class="`text-${viewIcons[vModel.type].color}`" |
||||
/> |
||||
</div> |
||||
|
||||
<a-input v-if="isEditing" :ref="focusInput" v-model:value="vModel.title" @blur="onCancel" @keydown="onKeyDown($event)" /> |
||||
<div v-else>{{ vModel.alias || vModel.title }}</div> |
||||
|
||||
<div class="flex-1" /> |
||||
|
||||
<template v-if="!isEditing"> |
||||
<div class="flex items-center gap-1"> |
||||
<a-tooltip placement="left"> |
||||
<template #title> |
||||
{{ $t('activity.copyView') }} |
||||
</template> |
||||
|
||||
<MdiContentCopy class="hidden group-hover:block text-gray-500" @click.stop="onDuplicate" /> |
||||
</a-tooltip> |
||||
|
||||
<template v-if="!vModel.is_default"> |
||||
<a-tooltip placement="left"> |
||||
<template #title> |
||||
{{ $t('activity.deleteView') }} |
||||
</template> |
||||
|
||||
<MdiTrashCan class="hidden group-hover:block text-red-500" @click.stop="onDelete" /> |
||||
</a-tooltip> |
||||
</template> |
||||
</div> |
||||
</template> |
||||
</div> |
||||
</a-menu-item> |
||||
</template> |
@ -0,0 +1,35 @@
|
||||
<template> |
||||
<div class="flex gap-2"> |
||||
<slot name="start" /> |
||||
|
||||
<SmartsheetToolbarLockMenu /> |
||||
|
||||
<div class="dot" /> |
||||
|
||||
<SmartsheetToolbarReload /> |
||||
|
||||
<div class="dot" /> |
||||
|
||||
<SmartsheetToolbarAddRow /> |
||||
|
||||
<div class="dot" /> |
||||
|
||||
<SmartsheetToolbarDeleteTable /> |
||||
|
||||
<div class="dot" /> |
||||
|
||||
<SmartsheetToolbarToggleDrawer /> |
||||
|
||||
<slot name="end" /> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped> |
||||
:deep(.nc-toolbar-btn) { |
||||
@apply border-0 !text-xs font-semibold px-2; |
||||
} |
||||
|
||||
.dot { |
||||
@apply w-[3px] h-[3px] bg-gray-300 rounded-full; |
||||
} |
||||
</style> |
@ -0,0 +1,130 @@
|
||||
<script setup lang="ts"> |
||||
import type { FormType, GalleryType, GridType, KanbanType, ViewTypes } from 'nocodb-sdk' |
||||
import MenuTop from './MenuTop.vue' |
||||
import MenuBottom from './MenuBottom.vue' |
||||
import Toolbar from './Toolbar.vue' |
||||
import { computed, inject, provide, ref, useApi, useViews, watch } from '#imports' |
||||
import { ActiveViewInj, MetaInj, RightSidebarInj, ViewListInj } from '~/context' |
||||
import MdiXml from '~icons/mdi/xml' |
||||
import MdiHook from '~icons/mdi/hook' |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const activeView = inject(ActiveViewInj, ref()) |
||||
|
||||
const { views, loadViews } = useViews(meta) |
||||
|
||||
const { api } = useApi() |
||||
|
||||
const route = useRoute() |
||||
|
||||
provide(ViewListInj, views) |
||||
|
||||
/** Sidebar visible */ |
||||
const sidebarOpen = inject(RightSidebarInj, ref(true)) |
||||
|
||||
const sidebarCollapsed = computed(() => !sidebarOpen.value) |
||||
|
||||
/** View type to create from modal */ |
||||
let viewCreateType = $ref<ViewTypes>() |
||||
|
||||
/** View title to create from modal (when duplicating) */ |
||||
let viewCreateTitle = $ref('') |
||||
|
||||
/** is view creation modal open */ |
||||
let modalOpen = $ref(false) |
||||
|
||||
/** Watch route param and change active view based on `viewTitle` */ |
||||
watch( |
||||
[views, () => route.params.viewTitle], |
||||
([nextViews, viewTitle]) => { |
||||
if (viewTitle) { |
||||
const view = nextViews.find((v) => v.title === viewTitle) |
||||
if (view) { |
||||
activeView.value = view |
||||
} |
||||
} |
||||
/** if active view is not found, set it to first view */ |
||||
if (!activeView.value && nextViews.length) { |
||||
activeView.value = nextViews[0] |
||||
} |
||||
}, |
||||
{ immediate: true }, |
||||
) |
||||
|
||||
/** Open view creation modal */ |
||||
function openModal({ type, title = '' }: { type: ViewTypes; title: string }) { |
||||
modalOpen = true |
||||
viewCreateType = type |
||||
viewCreateTitle = title |
||||
} |
||||
|
||||
/** Handle view creation */ |
||||
function onCreate(view: GridType | FormType | KanbanType | GalleryType) { |
||||
views.value.push(view) |
||||
activeView.value = view |
||||
modalOpen = false |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<a-layout-sider |
||||
:collapsed="sidebarCollapsed" |
||||
collapsiple |
||||
collapsed-width="50" |
||||
width="250" |
||||
class="shadow !mt-[-9px]" |
||||
style="height: calc(100% + 9px)" |
||||
theme="light" |
||||
> |
||||
<Toolbar v-if="sidebarOpen" class="flex items-center py-3 px-3 justify-between border-b-1" /> |
||||
|
||||
<Toolbar v-else class="py-3 px-2 max-w-[50px] flex !flex-col-reverse gap-4 items-center mt-[-1px]"> |
||||
<template #start> |
||||
<a-tooltip placement="left"> |
||||
<template #title> {{ $t('objects.webhooks') }}</template> |
||||
|
||||
<div class="nc-sidebar-right-item hover:after:bg-gray-300"> |
||||
<MdiHook /> |
||||
</div> |
||||
</a-tooltip> |
||||
|
||||
<div class="dot" /> |
||||
|
||||
<a-tooltip placement="left"> |
||||
<template #title> Get API Snippet</template> |
||||
|
||||
<div class="nc-sidebar-right-item group hover:after:bg-yellow-500"> |
||||
<MdiXml class="group-hover:(!text-white)" /> |
||||
</div> |
||||
</a-tooltip> |
||||
|
||||
<div class="dot" /> |
||||
</template> |
||||
</Toolbar> |
||||
|
||||
<div v-if="sidebarOpen" class="flex-1 flex flex-col"> |
||||
<MenuTop @open-modal="openModal" @deleted="loadViews" @sorted="loadViews" /> |
||||
|
||||
<a-divider class="my-2" /> |
||||
|
||||
<MenuBottom @open-modal="openModal" /> |
||||
</div> |
||||
|
||||
<dlg-view-create v-if="views" v-model="modalOpen" :title="viewCreateTitle" :type="viewCreateType" @created="onCreate" /> |
||||
</a-layout-sider> |
||||
</template> |
||||
|
||||
<style scoped> |
||||
:deep(.ant-menu-title-content) { |
||||
@apply w-full; |
||||
} |
||||
|
||||
:deep(.ant-layout-sider-children) { |
||||
@apply flex flex-col; |
||||
} |
||||
|
||||
.dot { |
||||
@apply w-[3px] h-[3px] bg-gray-300 rounded-full; |
||||
} |
||||
</style> |
@ -1,5 +1,43 @@
|
||||
<script setup lang="ts"> |
||||
import UserManagement from './auth/UserManagement.vue' |
||||
import ApiTokenManagement from './auth/ApiTokenManagement.vue' |
||||
|
||||
interface TabGroup { |
||||
[key: string]: { |
||||
title: string |
||||
body: any |
||||
} |
||||
} |
||||
|
||||
const tabsInfo: TabGroup = { |
||||
usersManagement: { |
||||
title: 'Users Management', |
||||
body: () => UserManagement, |
||||
}, |
||||
apiTokenManagement: { |
||||
title: 'API Token Management', |
||||
body: () => ApiTokenManagement, |
||||
}, |
||||
} |
||||
|
||||
const firstKeyOfObject = (obj: object) => Object.keys(obj)[0] |
||||
|
||||
const selectedTabKeys = $ref<string[]>([firstKeyOfObject(tabsInfo)]) |
||||
const selectedTab = $computed(() => tabsInfo[selectedTabKeys[0]]) |
||||
</script> |
||||
|
||||
<template> |
||||
<div> |
||||
<h2 class="text-3xl mt-3">Team & Auth</h2> |
||||
<div class="mt-2"> |
||||
<a-menu v-model:selectedKeys="selectedTabKeys" :open-keys="[]" mode="horizontal"> |
||||
<a-menu-item v-for="(tab, key) of tabsInfo" :key="key" class="select-none"> |
||||
<div class="text-xs pb-2.5"> |
||||
{{ tab.title }} |
||||
</div> |
||||
</a-menu-item> |
||||
</a-menu> |
||||
|
||||
<div class="mx-4 py-6 mt-2"> |
||||
<component :is="selectedTab.body()" /> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
@ -0,0 +1,200 @@
|
||||
<script setup lang="ts"> |
||||
import type { ApiTokenType } from 'nocodb-sdk' |
||||
import { useToast } from 'vue-toastification' |
||||
import { useClipboard } from '@vueuse/core' |
||||
import KebabIcon from '~icons/ic/baseline-more-vert' |
||||
import MdiPlusIcon from '~icons/mdi/plus' |
||||
import CloseIcon from '~icons/material-symbols/close-rounded' |
||||
import ReloadIcon from '~icons/mdi/reload' |
||||
import VisibilityOpenIcon from '~icons/material-symbols/visibility' |
||||
import VisibilityCloseIcon from '~icons/material-symbols/visibility-off' |
||||
import MdiDeleteOutlineIcon from '~icons/mdi/delete-outline' |
||||
import MdiContentCopyIcon from '~icons/mdi/content-copy' |
||||
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' |
||||
|
||||
const toast = useToast() |
||||
|
||||
interface ApiToken extends ApiTokenType { |
||||
show?: boolean |
||||
} |
||||
|
||||
const { $api, $e } = useNuxtApp() |
||||
const { project } = $(useProject()) |
||||
const { copy } = useClipboard() |
||||
|
||||
let tokensInfo = $ref<ApiToken[] | undefined>([]) |
||||
let showNewTokenModal = $ref(false) |
||||
let showDeleteTokenModal = $ref(false) |
||||
let selectedTokenData = $ref<ApiToken>({}) |
||||
|
||||
const loadApiTokens = async () => { |
||||
if (!project?.id) return |
||||
|
||||
tokensInfo = await $api.apiToken.list(project.id) |
||||
} |
||||
|
||||
const openNewTokenModal = () => { |
||||
showNewTokenModal = true |
||||
$e('c:api-token:generate') |
||||
} |
||||
|
||||
const copyToken = (token: string | undefined) => { |
||||
if (!token) return |
||||
|
||||
copy(token) |
||||
toast.info('Copied to clipboard') |
||||
|
||||
$e('c:api-token:copy') |
||||
} |
||||
|
||||
const generateToken = async () => { |
||||
try { |
||||
if (!project?.id) return |
||||
|
||||
await $api.apiToken.create(project.id, selectedTokenData) |
||||
showNewTokenModal = false |
||||
toast.success('Token generated successfully') |
||||
selectedTokenData = {} |
||||
await loadApiTokens() |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
$e('a:api-token:generate') |
||||
} |
||||
|
||||
const deleteToken = async () => { |
||||
try { |
||||
if (!project?.id || !selectedTokenData.token) return |
||||
|
||||
await $api.apiToken.delete(project.id, selectedTokenData.token) |
||||
|
||||
toast.success('Token deleted successfully') |
||||
await loadApiTokens() |
||||
showDeleteTokenModal = false |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
$e('a:api-token:delete') |
||||
} |
||||
|
||||
const openDeleteModal = (item: ApiToken) => { |
||||
selectedTokenData = item |
||||
showDeleteTokenModal = true |
||||
} |
||||
|
||||
onMounted(() => { |
||||
loadApiTokens() |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<a-modal v-model:visible="showNewTokenModal" :closable="false" width="28rem" centered :footer="null"> |
||||
<div class="relative flex flex-col h-full"> |
||||
<a-button type="text" class="!absolute top-0 right-0 rounded-md -mt-2 -mr-3" @click="showNewTokenModal = false"> |
||||
<template #icon> |
||||
<CloseIcon class="flex mx-auto" /> |
||||
</template> |
||||
</a-button> |
||||
<div class="flex flex-row justify-center w-full -mt-1"> |
||||
<a-typography-title :level="5">Generate Token</a-typography-title> |
||||
</div> |
||||
<div class="flex flex-col mt-3 justify-center space-y-6"> |
||||
<a-input v-model:value="selectedTokenData.description" placeholder="Description" /> |
||||
<div class="flex flex-row justify-center"> |
||||
<a-button type="primary" @click="generateToken"> Generate </a-button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</a-modal> |
||||
<a-modal v-model:visible="showDeleteTokenModal" :closable="false" width="28rem" centered :footer="null"> |
||||
<div class="flex flex-col h-full"> |
||||
<div class="flex flex-row justify-center mt-2 text-center w-full text-base">This action will remove this API Token</div> |
||||
<div class="flex mt-6 justify-center space-x-2"> |
||||
<a-button @click="showDeleteTokenModal = false"> Cancel </a-button> |
||||
<a-button type="primary" danger @click="deleteToken()"> Confirm </a-button> |
||||
</div> |
||||
</div> |
||||
</a-modal> |
||||
<div class="flex flex-col px-10 mt-6"> |
||||
<div class="flex flex-row justify-end"> |
||||
<div class="flex flex-row space-x-1"> |
||||
<a-button size="middle" type="text" @click="loadApiTokens()"> |
||||
<div class="flex flex-row justify-center items-center caption capitalize space-x-1"> |
||||
<ReloadIcon class="text-gray-500" /> |
||||
<div class="text-gray-500">Reload</div> |
||||
</div> |
||||
</a-button> |
||||
<a-button size="middle" @click="openNewTokenModal"> |
||||
<div class="flex flex-row justify-center items-center caption capitalize space-x-1"> |
||||
<MdiPlusIcon /> |
||||
<div>Add New Token</div> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
</div> |
||||
<div v-if="tokensInfo" class="w-full flex flex-col mt-2 px-1"> |
||||
<div class="flex flex-row border-b-1 text-gray-600 text-xs pb-2 pt-2"> |
||||
<div class="flex w-4/10 pl-2">Description</div> |
||||
<div class="flex w-4/10 justify-center">Token</div> |
||||
<div class="flex w-2/10 justify-end pr-2">Actions</div> |
||||
</div> |
||||
<div v-for="(item, index) in tokensInfo" :key="index" class="flex flex-col"> |
||||
<div class="flex flex-row border-b-1 items-center px-2 py-2"> |
||||
<div class="flex flex-row w-4/10 flex-wrap overflow-ellipsis"> |
||||
{{ item.description }} |
||||
</div> |
||||
<div class="flex w-4/10 justify-center flex-wrap overflow-ellipsis"> |
||||
<span v-if="item.show">{{ item.token }}</span> |
||||
<span v-else>****************************************</span> |
||||
</div> |
||||
<div class="flex flex-row w-2/10 justify-end"> |
||||
<a-tooltip placement="bottom"> |
||||
<template #title> |
||||
<span v-if="item.show">Hide API token </span> |
||||
<span v-else>Show API token </span> |
||||
</template> |
||||
<a-button type="text" class="!rounded-md" @click="item.show = !item.show"> |
||||
<template #icon> |
||||
<VisibilityCloseIcon v-if="item.show" class="flex mx-auto h-[1.1rem]" /> |
||||
<VisibilityOpenIcon v-else class="flex mx-auto h-[1rem]" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
<a-tooltip placement="bottom"> |
||||
<template #title> Copy token to clipboard </template> |
||||
<a-button type="text" class="!rounded-md" @click="copyToken(item.token)"> |
||||
<template #icon> |
||||
<MdiContentCopyIcon class="flex mx-auto h-[1rem]" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
|
||||
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight"> |
||||
<div class="flex flex-row items-center"> |
||||
<a-button type="text" class="!px-0"> |
||||
<div class="flex flex-row items-center h-[1.2rem]"> |
||||
<KebabIcon /> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
<template #overlay> |
||||
<a-menu> |
||||
<a-menu-item> |
||||
<div class="flex flex-row items-center py-1 h-[1rem]" @click="openDeleteModal(item)"> |
||||
<MdiDeleteOutlineIcon class="flex" /> |
||||
<div class="text-xs pl-2">Remove API Token</div> |
||||
</div> |
||||
</a-menu-item> |
||||
</a-menu> |
||||
</template> |
||||
</a-dropdown> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
@ -0,0 +1,297 @@
|
||||
<script setup lang="ts"> |
||||
import { useClipboard, watchDebounced } from '@vueuse/core' |
||||
import { useToast } from 'vue-toastification' |
||||
import UsersModal from './user-management/UsersModal.vue' |
||||
import FeedbackForm from './user-management/FeedbackForm.vue' |
||||
import KebabIcon from '~icons/ic/baseline-more-vert' |
||||
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' |
||||
import { projectRoleTagColors } from '~/utils/userUtils' |
||||
import MidAccountIcon from '~icons/mdi/account-outline' |
||||
import ReloadIcon from '~icons/mdi/reload' |
||||
import MdiEditIcon from '~icons/ic/round-edit' |
||||
import SearchIcon from '~icons/ic/round-search' |
||||
import MdiDeleteOutlineIcon from '~icons/mdi/delete-outline' |
||||
import EmailIcon from '~icons/eva/email-outline' |
||||
import MdiPlusIcon from '~icons/mdi/plus' |
||||
import MdiContentCopyIcon from '~icons/mdi/content-copy' |
||||
import MdiEmailSendIcon from '~icons/mdi/email-arrow-right-outline' |
||||
import RolesIcon from '~icons/mdi/drama-masks' |
||||
import type { User } from '~/lib/types' |
||||
const toast = useToast() |
||||
|
||||
const { $api, $e } = useNuxtApp() |
||||
const { project } = useProject() |
||||
const { copy } = useClipboard() |
||||
|
||||
let users = $ref<null | User[]>(null) |
||||
let selectedUser = $ref<null | User>(null) |
||||
let showUserModal = $ref(false) |
||||
let showUserDeleteModal = $ref(false) |
||||
let isLoading = $ref(false) |
||||
|
||||
let totalRows = $ref(0) |
||||
const currentPage = $ref(1) |
||||
const currentLimit = $ref(10) |
||||
const searchText = ref<string>('') |
||||
|
||||
const loadUsers = async (page = currentPage, limit = currentLimit) => { |
||||
try { |
||||
if (!project.value?.id) return |
||||
|
||||
// TODO: Types of api is not correct |
||||
const response = await $api.auth.projectUserList(project.value?.id, { |
||||
query: { |
||||
limit, |
||||
offset: searchText.value.length === 0 ? (page - 1) * limit : 0, |
||||
query: searchText.value, |
||||
}, |
||||
}) |
||||
if (!response.users) return |
||||
|
||||
totalRows = response.users.pageInfo.totalRows ?? 0 |
||||
users = response.users.list as User[] |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
|
||||
const inviteUser = async (user: User) => { |
||||
try { |
||||
if (!project.value?.id) return |
||||
|
||||
await $api.auth.projectUserAdd(project.value.id, user) |
||||
toast.success('Successfully added user to project') |
||||
await loadUsers() |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
$e('a:user:add') |
||||
} |
||||
|
||||
const deleteUser = async () => { |
||||
try { |
||||
if (!project.value?.id || !selectedUser?.id) return |
||||
|
||||
await $api.auth.projectUserRemove(project.value.id, selectedUser.id) |
||||
toast.success('Successfully deleted user from project') |
||||
await loadUsers() |
||||
showUserDeleteModal = false |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
$e('a:user:delete') |
||||
} |
||||
|
||||
const onEdit = (user: User) => { |
||||
selectedUser = user |
||||
showUserModal = true |
||||
} |
||||
|
||||
const onInvite = () => { |
||||
selectedUser = null |
||||
showUserModal = true |
||||
} |
||||
|
||||
const onDelete = (user: User) => { |
||||
selectedUser = user |
||||
showUserDeleteModal = true |
||||
} |
||||
|
||||
const resendInvite = async (user: User) => { |
||||
if (!project.value?.id) return |
||||
|
||||
try { |
||||
await $api.auth.projectUserResendInvite(project.value.id, user.id) |
||||
toast.success('Invite email sent successfully') |
||||
await loadUsers() |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
$e('a:user:resend-invite') |
||||
} |
||||
|
||||
const copyInviteUrl = (user: User) => { |
||||
if (!user.invite_token) return |
||||
|
||||
const getInviteUrl = (token: string) => `${location.origin}${location.pathname}#/user/authentication/signup/${token}` |
||||
|
||||
copy(getInviteUrl(user.invite_token)) |
||||
toast.success('Invite url copied to clipboard') |
||||
} |
||||
|
||||
onMounted(async () => { |
||||
if (!users) { |
||||
isLoading = true |
||||
try { |
||||
await loadUsers() |
||||
} finally { |
||||
isLoading = false |
||||
} |
||||
} |
||||
}) |
||||
|
||||
watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 }) |
||||
</script> |
||||
|
||||
<template> |
||||
<div v-if="isLoading" class="h-full w-full flex flex-row justify-center mt-42"> |
||||
<a-spin size="large" /> |
||||
</div> |
||||
<div v-else class="flex flex-col w-full px-6"> |
||||
<UsersModal |
||||
:key="showUserModal" |
||||
:show="showUserModal" |
||||
:selected-user="selectedUser" |
||||
@closed="showUserModal = false" |
||||
@reload="loadUsers()" |
||||
/> |
||||
<a-modal v-model:visible="showUserDeleteModal" :closable="false" width="28rem" centered :footer="null"> |
||||
<div class="flex flex-col h-full"> |
||||
<div class="flex flex-row justify-center mt-2 text-center w-full text-base"> |
||||
This action will remove this user from this project |
||||
</div> |
||||
<div class="flex mt-6 justify-center space-x-2"> |
||||
<a-button @click="showUserDeleteModal = false"> Cancel </a-button> |
||||
<a-button type="primary" danger @click="deleteUser"> Confirm </a-button> |
||||
</div> |
||||
</div> |
||||
</a-modal> |
||||
<div class="flex flex-row mb-4 mx-4 justify-between"> |
||||
<div class="flex w-1/3"> |
||||
<a-input v-model:value="searchText" placeholder="Filter by email"> |
||||
<template #prefix> |
||||
<SearchIcon class="text-gray-400" /> |
||||
</template> |
||||
</a-input> |
||||
</div> |
||||
|
||||
<div class="flex flex-row space-x-1"> |
||||
<a-button size="middle" type="text" @click="loadUsers()"> |
||||
<div class="flex flex-row justify-center items-center caption capitalize space-x-1"> |
||||
<ReloadIcon class="text-gray-500" /> |
||||
<div class="text-gray-500">Reload</div> |
||||
</div> |
||||
</a-button> |
||||
<a-button size="middle" @click="onInvite"> |
||||
<div class="flex flex-row justify-center items-center caption capitalize space-x-1"> |
||||
<MidAccountIcon /> |
||||
<div>Invite Team</div> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
</div> |
||||
<div class="px-5"> |
||||
<div class="flex flex-row border-b-1 pb-2 px-2"> |
||||
<div class="flex flex-row w-4/6 space-x-1 items-center pl-1"> |
||||
<EmailIcon class="flex text-gray-500 -mt-0.5" /> |
||||
|
||||
<div class="text-gray-600 text-xs space-x-1">E-mail</div> |
||||
</div> |
||||
<div class="flex flex-row justify-center w-1/6 space-x-1 items-center pl-1"> |
||||
<RolesIcon class="flex text-gray-500 -mt-0.5" /> |
||||
|
||||
<div class="text-gray-600 text-xs">Role</div> |
||||
</div> |
||||
<div class="flex flex-row w-1/6 justify-end items-center pl-1"> |
||||
<div class="text-gray-600 text-xs">Actions</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div v-for="(user, index) in users" :key="index" class="flex flex-row items-center border-b-1 py-2 px-2"> |
||||
<div class="flex w-4/6 flex-wrap"> |
||||
{{ user.email }} |
||||
</div> |
||||
<div class="flex w-1/6 justify-center flex-wrap ml-4"> |
||||
<div :class="`rounded-full px-2 py-1 bg-[${projectRoleTagColors[user.roles]}]`"> |
||||
{{ user.roles }} |
||||
</div> |
||||
</div> |
||||
<div class="flex w-1/6 flex-wrap justify-end"> |
||||
<a-tooltip v-if="user.project_id" placement="bottom"> |
||||
<template #title> |
||||
<span>Edit user</span> |
||||
</template> |
||||
<a-button type="text" class="!rounded-md" @click="onEdit(user)"> |
||||
<template #icon> |
||||
<MdiEditIcon class="flex mx-auto h-[1rem]" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
<a-tooltip v-if="!user.project_id" placement="bottom"> |
||||
<template #title> |
||||
<span>Add user to the project</span> |
||||
</template> |
||||
<a-button type="text" class="!rounded-md" @click="inviteUser(user)"> |
||||
<template #icon> |
||||
<MdiPlusIcon class="flex mx-auto h-[1.1rem]" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
|
||||
<a-tooltip v-else placement="bottom"> |
||||
<template #title> |
||||
<span>Remove user from the project</span> |
||||
</template> |
||||
<a-button type="text" class="!rounded-md" @click="onDelete(user)"> |
||||
<template #icon> |
||||
<MdiDeleteOutlineIcon class="flex mx-auto h-[1.1rem]" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
|
||||
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight"> |
||||
<div class="flex flex-row items-center"> |
||||
<a-button type="text" class="!px-0"> |
||||
<div class="flex flex-row items-center h-[1.2rem]"> |
||||
<KebabIcon /> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
<template #overlay> |
||||
<a-menu> |
||||
<a-menu-item> |
||||
<div class="flex flex-row items-center py-1" @click="resendInvite(user)"> |
||||
<MdiEmailSendIcon class="flex h-[1rem]" /> |
||||
<div class="text-xs pl-2">Resend invite email</div> |
||||
</div> |
||||
</a-menu-item> |
||||
<a-menu-item> |
||||
<div class="flex flex-row items-center py-1" @click="copyInviteUrl(user)"> |
||||
<MdiContentCopyIcon class="flex h-[1rem]" /> |
||||
<div class="text-xs pl-2">Copy invite URL</div> |
||||
</div> |
||||
</a-menu-item> |
||||
</a-menu> |
||||
</template> |
||||
</a-dropdown> |
||||
</div> |
||||
</div> |
||||
<a-pagination |
||||
v-model:current="currentPage" |
||||
hide-on-single-page |
||||
class="mt-4" |
||||
:page-size="currentLimit" |
||||
:total="totalRows" |
||||
show-less-items |
||||
@change="loadUsers" |
||||
/> |
||||
<FeedbackForm /> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped> |
||||
.users-table { |
||||
/* equally spaced columns in table */ |
||||
table-layout: fixed; |
||||
|
||||
width: 100%; |
||||
} |
||||
</style> |
@ -0,0 +1,31 @@
|
||||
<script setup lang="ts"> |
||||
import CloseIcon from '~icons/material-symbols/close-rounded' |
||||
|
||||
const { feedbackForm } = useGlobal() |
||||
</script> |
||||
|
||||
<template> |
||||
<div v-if="feedbackForm && !feedbackForm.isHidden" class="nc-feedback-form-wrapper mt-6"> |
||||
<CloseIcon class="nc-close-icon" @click="feedbackForm.isHidden = true" /> |
||||
|
||||
<iframe :src="feedbackForm.url" width="100%" height="500" frameborder="0" marginheight="0" marginwidth="0">Loading… </iframe> |
||||
</div> |
||||
<div v-else /> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"> |
||||
.nc-feedback-form-wrapper { |
||||
width: 100%; |
||||
position: relative; |
||||
|
||||
iframe { |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.nc-close-icon { |
||||
position: absolute; |
||||
top: 5px; |
||||
right: 10px; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,229 @@
|
||||
<script setup lang="ts"> |
||||
import { useToast } from 'vue-toastification' |
||||
import { useClipboard } from '@vueuse/core' |
||||
import OpenInNewIcon from '~icons/mdi/open-in-new' |
||||
import { dashboardUrl } from '~/utils/urlUtils' |
||||
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' |
||||
import MdiReload from '~icons/mdi/reload' |
||||
import DownIcon from '~icons/ic/round-keyboard-arrow-down' |
||||
import ContentCopyIcon from '~icons/mdi/content-copy' |
||||
import MdiXmlIcon from '~icons/mdi/xml' |
||||
const toast = useToast() |
||||
|
||||
interface ShareBase { |
||||
uuid?: string |
||||
url?: string |
||||
role?: string |
||||
} |
||||
|
||||
enum ShareBaseRole { |
||||
Editor = 'editor', |
||||
Viewer = 'viewer', |
||||
} |
||||
|
||||
const { $api, $e } = useNuxtApp() |
||||
let base = $ref<null | ShareBase>(null) |
||||
const showEditBaseDropdown = $ref(false) |
||||
const { project } = useProject() |
||||
const { copy } = useClipboard() |
||||
|
||||
const url = $computed(() => (base && base.uuid ? `${dashboardUrl()}#/nc/base/${base.uuid}` : null)) |
||||
|
||||
const loadBase = async () => { |
||||
try { |
||||
if (!project.value.id) return |
||||
|
||||
const res = await $api.project.sharedBaseGet(project.value.id) |
||||
base = { |
||||
uuid: res.uuid, |
||||
url: res.url, |
||||
role: res.roles, |
||||
} |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
|
||||
const createShareBase = async (role = ShareBaseRole.Viewer) => { |
||||
try { |
||||
if (!project.value.id) return |
||||
|
||||
const res = await $api.project.sharedBaseUpdate(project.value.id, { |
||||
roles: role, |
||||
}) |
||||
|
||||
base = res || {} |
||||
base.role = role |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
$e('a:shared-base:enable', { role }) |
||||
} |
||||
|
||||
const disableSharedBase = async () => { |
||||
try { |
||||
if (!project.value.id) return |
||||
|
||||
await $api.project.sharedBaseDisable(project.value.id) |
||||
base = null |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
$e('a:shared-base:disable') |
||||
} |
||||
|
||||
const recreate = async () => { |
||||
try { |
||||
if (!project.value.id) return |
||||
|
||||
const sharedBase = await $api.project.sharedBaseCreate(project.value.id, { |
||||
roles: base?.role || ShareBaseRole.Viewer, |
||||
}) |
||||
const newBase = sharedBase || {} |
||||
base = { ...newBase, role: base?.role } |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
$e('a:shared-base:recreate') |
||||
} |
||||
|
||||
const copyUrl = async () => { |
||||
if (!url) return |
||||
|
||||
copy(url) |
||||
toast.success('Copied shareable base url to clipboard!') |
||||
|
||||
$e('c:shared-base:copy-url') |
||||
} |
||||
|
||||
const navigateToSharedBase = () => { |
||||
if (!url) return |
||||
|
||||
window.open(url, '_blank') |
||||
|
||||
$e('c:shared-base:open-url') |
||||
} |
||||
|
||||
const generateEmbeddableIframe = () => { |
||||
if (!url) return |
||||
|
||||
copy(`<iframe |
||||
class="nc-embed" |
||||
src="${url}?embed" |
||||
frameborder="0" |
||||
width="100%" |
||||
height="700" |
||||
style="background: transparent; border: 1px solid #ddd"></iframe>`) |
||||
toast.success('Copied embeddable html code!') |
||||
|
||||
$e('c:shared-base:copy-embed-frame') |
||||
} |
||||
|
||||
onMounted(() => { |
||||
if (!base) { |
||||
loadBase() |
||||
} |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="flex flex-col w-full"> |
||||
<div class="flex flex-row items-center space-x-0.5 pl-2 h-[0.8rem]"> |
||||
<OpenInNewIcon /> |
||||
<div class="text-xs">Shared Base Link</div> |
||||
</div> |
||||
<div v-if="base?.uuid" class="flex flex-row mt-2 bg-red-50 py-4 mx-1 px-2 items-center rounded-sm w-full justify-between"> |
||||
<span class="flex text-xs overflow-x-hidden overflow-ellipsis text-gray-700 pl-2">{{ url }}</span> |
||||
<div class="flex border-l-1 pt-1 pl-1"> |
||||
<a-tooltip placement="bottom"> |
||||
<template #title> |
||||
<span>Reload</span> |
||||
</template> |
||||
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="recreate"> |
||||
<template #icon> |
||||
<MdiReload class="flex mx-auto text-gray-600" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
<a-tooltip placement="bottom"> |
||||
<template #title> |
||||
<span>Copy URL</span> |
||||
</template> |
||||
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="copyUrl"> |
||||
<template #icon> |
||||
<ContentCopyIcon class="flex mx-auto text-gray-600" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
<a-tooltip placement="bottom"> |
||||
<template #title> |
||||
<span>Open new tab</span> |
||||
</template> |
||||
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="navigateToSharedBase"> |
||||
<template #icon> |
||||
<OpenInNewIcon class="flex mx-auto text-gray-600" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
<a-tooltip placement="bottom"> |
||||
<template #title> |
||||
<span>Copy embeddable HTML code</span> |
||||
</template> |
||||
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="generateEmbeddableIframe"> |
||||
<template #icon> |
||||
<MdiXmlIcon class="flex mx-auto text-gray-600" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
</div> |
||||
</div> |
||||
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2">Generate publicly shareable readonly base</div> |
||||
<div class="mt-4 flex flex-row justify-between mx-1"> |
||||
<a-dropdown v-model="showEditBaseDropdown" class="flex"> |
||||
<a-button> |
||||
<div class="flex flex-row items-center space-x-2"> |
||||
<div v-if="base?.uuid">Anyone with the link</div> |
||||
<div v-else>Disable shared base</div> |
||||
<DownIcon class="h-[1rem]" /> |
||||
</div> |
||||
</a-button> |
||||
|
||||
<template #overlay> |
||||
<a-menu> |
||||
<a-menu-item> |
||||
<div v-if="base?.uuid" @click="disableSharedBase">Disable shared base</div> |
||||
<div v-else @click="createShareBase(ShareBaseRole.Viewer)">Anyone with the link</div> |
||||
</a-menu-item> |
||||
</a-menu> |
||||
</template> |
||||
</a-dropdown> |
||||
|
||||
<a-select v-if="base?.uuid" v-model:value="base.role" class="flex"> |
||||
<template #suffixIcon> |
||||
<div class="flex flex-row"> |
||||
<DownIcon class="text-black -mt-0.5 h-[1rem]" /> |
||||
</div> |
||||
</template> |
||||
<a-select-option |
||||
v-for="(role, index) in [ShareBaseRole.Editor, ShareBaseRole.Viewer]" |
||||
:key="index" |
||||
:value="role" |
||||
dropdown-class-name="capitalize" |
||||
@click="createShareBase(role)" |
||||
> |
||||
<div class="w-full px-2 capitalize"> |
||||
{{ role }} |
||||
</div> |
||||
</a-select-option> |
||||
</a-select> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped></style> |
@ -0,0 +1,238 @@
|
||||
<script setup lang="ts"> |
||||
import { useToast } from 'vue-toastification' |
||||
import { Form } from 'ant-design-vue' |
||||
import { useClipboard } from '@vueuse/core' |
||||
import ShareBase from './ShareBase.vue' |
||||
import SendIcon from '~icons/material-symbols/send-outline' |
||||
import CloseIcon from '~icons/material-symbols/close-rounded' |
||||
import MidAccountIcon from '~icons/mdi/account-outline' |
||||
import ContentCopyIcon from '~icons/mdi/content-copy' |
||||
import type { User } from '~/lib/types' |
||||
import { ProjectRole } from '~/lib/enums' |
||||
import { projectRoleTagColors } from '~/utils/userUtils' |
||||
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' |
||||
import { isEmail } from '~/utils/validation' |
||||
|
||||
interface Props { |
||||
show: boolean |
||||
selectedUser?: User |
||||
} |
||||
|
||||
interface Users { |
||||
emails?: string |
||||
role: ProjectRole |
||||
invitationToken?: string |
||||
} |
||||
|
||||
const { show, selectedUser } = defineProps<Props>() |
||||
const emit = defineEmits(['closed', 'reload']) |
||||
const toast = useToast() |
||||
|
||||
const { project } = useProject() |
||||
const { $api, $e } = useNuxtApp() |
||||
const { copy } = useClipboard() |
||||
|
||||
const usersData = $ref<Users>({ emails: undefined, role: ProjectRole.Guest, invitationToken: undefined }) |
||||
const formRef = ref() |
||||
|
||||
const useForm = Form.useForm |
||||
const validators = computed(() => { |
||||
return { |
||||
emails: [ |
||||
{ |
||||
validator: (rule: any, value: string, callback: (errMsg?: string) => void) => { |
||||
if (value.length === 0) { |
||||
callback('Email is required') |
||||
return |
||||
} |
||||
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !isEmail(e)) |
||||
if (invalidEmails.length > 0) { |
||||
callback(`${invalidEmails.length > 1 ? ' Invalid emails:' : 'Invalid email:'} ${invalidEmails.join(', ')} `) |
||||
} else { |
||||
callback() |
||||
} |
||||
}, |
||||
}, |
||||
], |
||||
} |
||||
}) |
||||
|
||||
const { validateInfos } = useForm(usersData, validators) |
||||
|
||||
onMounted(() => { |
||||
if (!usersData.emails && selectedUser?.email) { |
||||
usersData.emails = selectedUser.email |
||||
usersData.role = selectedUser.roles |
||||
} |
||||
}) |
||||
|
||||
const saveUser = async () => { |
||||
$e('a:user:invite', { role: usersData.role }) |
||||
|
||||
if (!project.value.id) return |
||||
|
||||
await formRef.value?.validateFields() |
||||
|
||||
try { |
||||
if (selectedUser?.id) { |
||||
await $api.auth.projectUserUpdate(project.value.id, selectedUser.id, { |
||||
roles: usersData.role, |
||||
email: selectedUser.email, |
||||
project_id: project.value.id, |
||||
projectName: project.value.title, |
||||
}) |
||||
emit('reload') |
||||
emit('closed') |
||||
} else { |
||||
const res = await $api.auth.projectUserAdd(project.value.id, { |
||||
roles: usersData.role, |
||||
email: usersData.emails, |
||||
project_id: project.value.id, |
||||
projectName: project.value.title, |
||||
}) |
||||
usersData.invitationToken = res.invite_token |
||||
} |
||||
toast.success('Successfully updated the user details') |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
|
||||
const inviteUrl = $computed(() => |
||||
usersData.invitationToken |
||||
? `${location.origin}${location.pathname}#/user/authentication/signup/${usersData.invitationToken}` |
||||
: null, |
||||
) |
||||
|
||||
const copyUrl = async () => { |
||||
if (!inviteUrl) return |
||||
|
||||
copy(inviteUrl) |
||||
toast.success('Copied shareable base url to clipboard!') |
||||
|
||||
$e('c:shared-base:copy-url') |
||||
} |
||||
|
||||
const clickInviteMore = () => { |
||||
$e('c:user:invite-more') |
||||
usersData.invitationToken = undefined |
||||
usersData.role = ProjectRole.Guest |
||||
usersData.emails = undefined |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<a-modal :footer="null" centered :visible="show" :closable="false" width="max(50vw, 44rem)" @cancel="emit('closed')"> |
||||
<div class="flex flex-col"> |
||||
<div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full"> |
||||
<a-typography-title class="select-none" :level="4"> Share: {{ project.title }} </a-typography-title> |
||||
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')"> |
||||
<template #icon> |
||||
<CloseIcon class="flex mx-auto" /> |
||||
</template> |
||||
</a-button> |
||||
</div> |
||||
|
||||
<div class="px-2 mt-1.5"> |
||||
<template v-if="usersData.invitationToken"> |
||||
<div class="flex flex-col mt-1 border-b-1 pb-5"> |
||||
<div class="flex flex-row items-center pl-1.5 pb-1 h-[1.1rem]"> |
||||
<MidAccountIcon /> |
||||
<div class="text-xs ml-0.5 mt-0.5">Copy Invite Token</div> |
||||
</div> |
||||
|
||||
<a-alert class="mt-1" type="success" show-icon> |
||||
<template #message> |
||||
<div class="flex flex-row w-full justify-between items-center"> |
||||
<div class="flex pl-2 text-green-700"> |
||||
{{ inviteUrl }} |
||||
</div> |
||||
<a-button type="text" class="!rounded-md mr-1" @click="copyUrl"> |
||||
<template #icon> |
||||
<ContentCopyIcon class="flex mx-auto text-green-700 h-[1rem]" /> |
||||
</template> |
||||
</a-button> |
||||
</div> |
||||
</template> |
||||
</a-alert> |
||||
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2"> |
||||
Looks like you have not configured mailer yet! Please copy above invite link and send it to |
||||
{{ usersData.invitationToken && usersData.emails }} |
||||
</div> |
||||
<div class="flex flex-row justify-start mt-4 ml-2"> |
||||
<a-button size="small" outlined @click="clickInviteMore"> |
||||
<div class="flex flex-row justify-center items-center space-x-0.5"> |
||||
<SendIcon class="flex mx-auto text-gray-600 h-[0.8rem]" /> |
||||
<div class="text-xs text-gray-600">Invite more</div> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
<div v-else class="flex flex-col pb-4"> |
||||
<div class="flex flex-row items-center pl-2 pb-1 h-[1rem]"> |
||||
<MidAccountIcon /> |
||||
<div class="text-xs ml-0.5 mt-0.5">{{ selectedUser ? 'Edit User' : 'Invite Team' }}</div> |
||||
</div> |
||||
<div class="border-1 py-3 px-4 rounded-md mt-1"> |
||||
<a-form |
||||
ref="formRef" |
||||
:validate-on-rule-change="false" |
||||
:model="usersData" |
||||
validate-trigger="onBlur" |
||||
@finish="saveUser" |
||||
> |
||||
<div class="flex flex-row space-x-4"> |
||||
<div class="flex flex-col w-3/4"> |
||||
<a-form-item |
||||
v-bind="validateInfos.emails" |
||||
validate-trigger="onBlur" |
||||
name="emails" |
||||
:rules="[{ required: true, message: 'Please input email' }]" |
||||
> |
||||
<div class="ml-1 mb-1 text-xs text-gray-500">Email:</div> |
||||
<a-input |
||||
v-model:value="usersData.emails" |
||||
validate-trigger="onBlur" |
||||
placeholder="Email" |
||||
:disabled="!!selectedUser" |
||||
/> |
||||
</a-form-item> |
||||
</div> |
||||
<div class="flex flex-col w-1/4"> |
||||
<a-form-item name="role" :rules="[{ required: true, message: 'Role required' }]"> |
||||
<div class="ml-1 mb-1 text-xs text-gray-500">Select User Role:</div> |
||||
<a-select v-model:value="usersData.role"> |
||||
<a-select-option v-for="(role, index) in Object.keys(projectRoleTagColors)" :key="index" :value="role"> |
||||
<div class="flex flex-row h-full justify-start items-center"> |
||||
<div :class="`px-2 py-1 flex rounded-full text-xs bg-[${projectRoleTagColors[role]}]`"> |
||||
{{ role }} |
||||
</div> |
||||
</div> |
||||
</a-select-option> |
||||
</a-select> |
||||
</a-form-item> |
||||
</div> |
||||
</div> |
||||
<div class="flex flex-row justify-center"> |
||||
<a-button type="primary" html-type="submit"> |
||||
<div v-if="selectedUser">Save</div> |
||||
<div v-else class="flex flex-row justify-center items-center space-x-1.5"> |
||||
<SendIcon class="flex h-[0.8rem]" /> |
||||
<div>Invite</div> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
</a-form> |
||||
</div> |
||||
</div> |
||||
<div class="flex mt-4"> |
||||
<ShareBase /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</a-modal> |
||||
</template> |
||||
|
||||
<style scoped></style> |
@ -0,0 +1,23 @@
|
||||
export * from './useApi' |
||||
export * from './useGlobal' |
||||
export * from './useInjectionState' |
||||
export * from './useUIPermission' |
||||
export * from './useAttachment' |
||||
export * from './useBelongsTo' |
||||
export * from './useColors' |
||||
export * from './useColumn' |
||||
export * from './useGridViewColumnWidth' |
||||
export * from './useHasMany' |
||||
export * from './useManyToMany' |
||||
export * from './useMetas' |
||||
export * from './useProject' |
||||
export * from './useTable' |
||||
export * from './useTabs' |
||||
export * from './useViewColumns' |
||||
export * from './useViewData' |
||||
export * from './useViewFilters' |
||||
export * from './useViews' |
||||
export * from './useViewSorts' |
||||
export * from './useVirtualCell' |
||||
export * from './useColumnCreateStore' |
||||
export * from './useSmartsheetStore' |
@ -0,0 +1,148 @@
|
||||
import type { AxiosError, AxiosResponse } from 'axios' |
||||
import { Api } from 'nocodb-sdk' |
||||
import type { Ref } from 'vue' |
||||
import { addAxiosInterceptors } from './interceptors' |
||||
import type { CreateApiOptions, UseApiProps, UseApiReturn } from './types' |
||||
import { createEventHook, ref, unref, useCounter, useGlobal, useNuxtApp } from '#imports' |
||||
|
||||
export function createApiInstance<SecurityDataType = any>(options: CreateApiOptions = {}): Api<SecurityDataType> { |
||||
return addAxiosInterceptors( |
||||
new Api<SecurityDataType>({ |
||||
baseURL: options.baseURL ?? 'http://localhost:8080', |
||||
}), |
||||
) |
||||
} |
||||
|
||||
/** |
||||
* Api composable that provides loading, error and response refs, as well as event hooks for error and response. |
||||
* |
||||
* You can use this composable to generate a fresh api instance with its own loading and error refs. |
||||
* |
||||
* Any request called by useApi will be pushed into the global requests counter which toggles the global loading state. |
||||
* |
||||
* @example |
||||
* ```js
|
||||
* const { api, isLoading, error, response, onError, onResponse } = useApi() |
||||
* |
||||
* const onSignIn = async () => { |
||||
* const { token } = await api.auth.signIn(form) |
||||
* } |
||||
*/ |
||||
export function useApi<Data = any, RequestConfig = any>({ |
||||
useGlobalInstance = false, |
||||
apiOptions, |
||||
axiosConfig, |
||||
}: UseApiProps<Data> = {}): UseApiReturn<Data, RequestConfig> { |
||||
const state = useGlobal() |
||||
|
||||
/** |
||||
* Local state of running requests, do not confuse with global state of running requests |
||||
* This state is only counting requests made by this instance of `useApi` and not by other instances. |
||||
*/ |
||||
const { count, inc, dec } = useCounter(0) |
||||
|
||||
/** is request loading */ |
||||
const isLoading = ref(false) |
||||
|
||||
/** latest request error */ |
||||
const error = ref(null) |
||||
|
||||
/** latest request response */ |
||||
const response = ref<unknown | null>(null) |
||||
|
||||
const errorHook = createEventHook<AxiosError<Data, RequestConfig>>() |
||||
|
||||
const responseHook = createEventHook<AxiosResponse<Data, RequestConfig>>() |
||||
|
||||
/** global api instance */ |
||||
const $api = useNuxtApp().$api |
||||
|
||||
/** api instance - with interceptors for token refresh already bound */ |
||||
const api = useGlobalInstance && !!$api ? $api : createApiInstance(apiOptions) |
||||
|
||||
/** set loading to true and increment local and global request counter */ |
||||
function onRequestStart() { |
||||
isLoading.value = true |
||||
|
||||
/** local count */ |
||||
inc() |
||||
|
||||
/** global count */ |
||||
state.runningRequests.inc() |
||||
} |
||||
|
||||
/** decrement local and global request counter and check if we can stop loading */ |
||||
function onRequestFinish() { |
||||
/** local count */ |
||||
dec() |
||||
/** global count */ |
||||
state.runningRequests.dec() |
||||
|
||||
/** try to stop loading */ |
||||
stopLoading() |
||||
} |
||||
|
||||
/** set loading state to false *only* if no request is still running */ |
||||
function stopLoading() { |
||||
if (count.value === 0) { |
||||
isLoading.value = false |
||||
} |
||||
} |
||||
|
||||
/** reset response and error refs */ |
||||
function reset() { |
||||
error.value = null |
||||
response.value = null |
||||
} |
||||
|
||||
api.instance.interceptors.request.use( |
||||
(config) => { |
||||
reset() |
||||
|
||||
onRequestStart() |
||||
|
||||
return { |
||||
...config, |
||||
...unref(axiosConfig), |
||||
} |
||||
}, |
||||
(requestError) => { |
||||
errorHook.trigger(requestError) |
||||
error.value = requestError |
||||
|
||||
response.value = null |
||||
|
||||
onRequestFinish() |
||||
|
||||
return Promise.reject(requestError) |
||||
}, |
||||
) |
||||
|
||||
api.instance.interceptors.response.use( |
||||
(apiResponse) => { |
||||
responseHook.trigger(apiResponse as AxiosResponse<Data, RequestConfig>) |
||||
response.value = apiResponse |
||||
|
||||
onRequestFinish() |
||||
|
||||
return Promise.resolve(apiResponse) |
||||
}, |
||||
(apiError) => { |
||||
errorHook.trigger(apiError) |
||||
error.value = apiError |
||||
|
||||
onRequestFinish() |
||||
|
||||
return Promise.reject(apiError) |
||||
}, |
||||
) |
||||
|
||||
return { |
||||
api, |
||||
isLoading, |
||||
response: response as Ref<AxiosResponse<Data, RequestConfig>>, |
||||
error, |
||||
onError: errorHook.on, |
||||
onResponse: responseHook.on, |
||||
} |
||||
} |
@ -0,0 +1,80 @@
|
||||
import type { Api } from 'nocodb-sdk' |
||||
import { navigateTo, useGlobal, useRoute, useRouter } from '#imports' |
||||
|
||||
const DbNotFoundMsg = 'Database config not found' |
||||
|
||||
export function addAxiosInterceptors(api: Api<any>) { |
||||
const state = useGlobal() |
||||
const router = useRouter() |
||||
const route = useRoute() |
||||
|
||||
api.instance.interceptors.request.use((config) => { |
||||
config.headers['xc-gui'] = 'true' |
||||
|
||||
if (state.token.value) config.headers['xc-auth'] = state.token.value |
||||
|
||||
if (!config.url?.endsWith('/user/me') && !config.url?.endsWith('/admin/roles')) { |
||||
// config.headers['xc-preview'] = store.state.users.previewAs
|
||||
} |
||||
|
||||
if (!config.url?.endsWith('/user/me') && !config.url?.endsWith('/admin/roles')) { |
||||
if (route && route.params && route.params.shared_base_id) config.headers['xc-shared-base-id'] = route.params.shared_base_id |
||||
} |
||||
|
||||
return config |
||||
}) |
||||
|
||||
// Return a successful response back to the calling service
|
||||
api.instance.interceptors.response.use( |
||||
(response) => response, |
||||
// Handle Error
|
||||
(error) => { |
||||
if (error.response && error.response.data && error.response.data.msg === DbNotFoundMsg) return router.replace('/project/0') |
||||
|
||||
// Return any error which is not due to authentication back to the calling service
|
||||
if (!error.response || error.response.status !== 401) { |
||||
return Promise.reject(error) |
||||
} |
||||
|
||||
// Logout user if token refresh didn't work or user is disabled
|
||||
if (error.config.url === '/auth/refresh-token') { |
||||
state.signOut() |
||||
|
||||
return Promise.reject(error) |
||||
} |
||||
|
||||
// Try request again with new token
|
||||
return api.instance |
||||
.post('/auth/refresh-token', null, { |
||||
withCredentials: true, |
||||
}) |
||||
.then((token) => { |
||||
// New request with new token
|
||||
const config = error.config |
||||
config.headers['xc-auth'] = token.data.token |
||||
state.signIn(token.data.token) |
||||
|
||||
return new Promise((resolve, reject) => { |
||||
api.instance |
||||
.request(config) |
||||
.then((response) => { |
||||
resolve(response) |
||||
}) |
||||
.catch((error) => { |
||||
reject(error) |
||||
}) |
||||
}) |
||||
}) |
||||
.catch(async (error) => { |
||||
state.signOut() |
||||
// todo: handle new user
|
||||
|
||||
navigateTo('/signIn') |
||||
|
||||
return Promise.reject(error) |
||||
}) |
||||
}, |
||||
) |
||||
|
||||
return api |
||||
} |
@ -0,0 +1,26 @@
|
||||
import type { Api } from 'nocodb-sdk' |
||||
import type { Ref } from 'vue' |
||||
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios' |
||||
import type { EventHook, MaybeRef } from '@vueuse/core' |
||||
|
||||
export interface UseApiReturn<D = any, R = any> { |
||||
api: Api<any> |
||||
isLoading: Ref<boolean> |
||||
error: Ref<AxiosError<D, R> | null> |
||||
response: Ref<AxiosResponse<D, R> | null> |
||||
onError: EventHook<AxiosError<D, R>>['on'] |
||||
onResponse: EventHook<AxiosResponse<D, R>>['on'] |
||||
} |
||||
|
||||
/** {@link Api} options */ |
||||
export interface CreateApiOptions { |
||||
baseURL?: string |
||||
} |
||||
|
||||
export interface UseApiProps<D = any> { |
||||
/** additional axios config for requests */ |
||||
axiosConfig?: MaybeRef<AxiosRequestConfig<D>> |
||||
/** {@link Api} options */ |
||||
apiOptions?: CreateApiOptions |
||||
useGlobalInstance?: boolean |
||||
} |
@ -0,0 +1,223 @@
|
||||
import { createInjectionState } from '@vueuse/core' |
||||
import { Form } from 'ant-design-vue' |
||||
import type { ColumnType, TableType } from 'nocodb-sdk' |
||||
import { UITypes } from 'nocodb-sdk' |
||||
import type { Ref } from 'vue' |
||||
import { useToast } from 'vue-toastification' |
||||
import { useColumn } from './useColumn' |
||||
import { computed } from '#imports' |
||||
import { useNuxtApp } from '#app' |
||||
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' |
||||
|
||||
const useForm = Form.useForm |
||||
|
||||
// enum ColumnAlterType {
|
||||
// NEW=4,
|
||||
// EDIT=2,
|
||||
// RENAME=8,
|
||||
// DELETE=0,
|
||||
// }
|
||||
|
||||
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber] |
||||
|
||||
const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState((meta: Ref<TableType>, column?: ColumnType) => { |
||||
const { sqlUi } = useProject() |
||||
const { $api } = useNuxtApp() |
||||
|
||||
const toast = useToast() |
||||
|
||||
const idType = null |
||||
|
||||
// state
|
||||
// todo: give proper type - ColumnType
|
||||
const formState = ref<Partial<Record<string, any>>>({ |
||||
title: 'title', |
||||
uidt: UITypes.SingleLineText, |
||||
...(column || {}), |
||||
}) |
||||
|
||||
const additionalValidations = ref<Record<string, any>>({}) |
||||
|
||||
const validators = computed(() => { |
||||
return { |
||||
column_name: [ |
||||
{ |
||||
required: true, |
||||
message: 'Column name is required', |
||||
}, |
||||
// validation for unique column name
|
||||
{ |
||||
validator: (rule: any, value: any) => { |
||||
return new Promise<void>((resolve, reject) => { |
||||
if ( |
||||
meta.value?.columns?.some( |
||||
(c) => |
||||
c.id !== formState.value.id && // ignore current column
|
||||
// compare against column_name and title
|
||||
((value || '').toLowerCase() === (c.column_name || '').toLowerCase() || |
||||
(value || '').toLowerCase() === (c.title || '').toLowerCase()), |
||||
) |
||||
) { |
||||
return reject(new Error('Duplicate column name')) |
||||
} |
||||
resolve() |
||||
}) |
||||
}, |
||||
}, |
||||
], |
||||
uidt: [ |
||||
{ |
||||
required: true, |
||||
message: 'UI Datatype is required', |
||||
}, |
||||
], |
||||
...(additionalValidations?.value || {}), |
||||
} |
||||
}) |
||||
|
||||
const { resetFields, validate, validateInfos } = useForm(formState, validators) |
||||
|
||||
// actions
|
||||
const generateNewColumnMeta = () => { |
||||
formState.value = sqlUi.value.getNewColumn((meta.value.columns?.length || 0) + 1) |
||||
} |
||||
|
||||
const setAdditionalValidations = (validations: Record<string, any>) => { |
||||
additionalValidations.value = validations |
||||
} |
||||
|
||||
const onUidtOrIdTypeChange = () => { |
||||
const { isCurrency } = useColumn(formState.value as ColumnType) |
||||
|
||||
const colProp = sqlUi?.value.getDataTypeForUiType(formState?.value as any, idType as any) |
||||
formState.value = { |
||||
...formState.value, |
||||
meta: null, |
||||
rqd: false, |
||||
pk: false, |
||||
ai: false, |
||||
cdf: null, |
||||
un: false, |
||||
dtx: 'specificType', |
||||
...colProp, |
||||
} |
||||
|
||||
formState.value.dtxp = sqlUi.value.getDefaultLengthForDatatype(formState.value.dt) |
||||
formState.value.dtxs = sqlUi.value.getDefaultScaleForDatatype(formState.value.dt) |
||||
|
||||
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect] |
||||
if (column && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.uidt as UITypes)) { |
||||
formState.value.dtxp = column.dtxp |
||||
} |
||||
|
||||
if (columnToValidate.includes(formState.value.uidt)) { |
||||
formState.value.meta = { |
||||
validate: formState.value.meta && formState.value.meta.validate, |
||||
} |
||||
} |
||||
|
||||
if (isCurrency) { |
||||
if (column?.uidt === UITypes.Currency) { |
||||
formState.value.dtxp = column.dtxp |
||||
formState.value.dtxs = column.dtxs |
||||
} else { |
||||
formState.value.dtxp = 19 |
||||
formState.value.dtxs = 2 |
||||
} |
||||
} |
||||
|
||||
formState.value.altered = formState.value.altered || 2 |
||||
} |
||||
|
||||
const onDataTypeChange = () => { |
||||
const { isCurrency } = useColumn(formState.value as ColumnType) |
||||
|
||||
formState.value.rqd = false |
||||
if (formState.value.uidt !== UITypes.ID) { |
||||
formState.value.primaryKey = false |
||||
} |
||||
formState.value.ai = false |
||||
formState.value.cdf = null |
||||
formState.value.un = false |
||||
formState.value.dtxp = sqlUi.value.getDefaultLengthForDatatype(formState.value.dt) |
||||
formState.value.dtxs = sqlUi.value.getDefaultScaleForDatatype(formState.value.dt) |
||||
|
||||
formState.value.dtx = 'specificType' |
||||
|
||||
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect] |
||||
if (column && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.uidt as UITypes)) { |
||||
formState.value.dtxp = column.dtxp |
||||
} |
||||
|
||||
if (isCurrency) { |
||||
if (column?.uidt === UITypes.Currency) { |
||||
formState.value.dtxp = column.dtxp |
||||
formState.value.dtxs = column.dtxs |
||||
} else { |
||||
formState.value.dtxp = 19 |
||||
formState.value.dtxs = 2 |
||||
} |
||||
} |
||||
|
||||
// this.$set(formState.value, 'uidt', sqlUi.value.getUIType(formState.value));
|
||||
|
||||
formState.value.altered = formState.value.altered || 2 |
||||
} |
||||
|
||||
const onAlter = (val = 2, cdf = false) => { |
||||
formState.value.altered = formState.value.altered || val |
||||
if (cdf) formState.value.cdf = formState.value.cdf || null |
||||
} |
||||
|
||||
const addOrUpdate = async (onSuccess: () => {}) => { |
||||
if (!(await validate())) return |
||||
|
||||
formState.value.table_name = meta.value.table_name |
||||
formState.value.title = formState.value.column_name |
||||
try { |
||||
if (column) { |
||||
await $api.dbTableColumn.update(column.id as string, formState.value) |
||||
toast.success('Column updated') |
||||
} else { |
||||
// todo : set additional meta for auto generated string id
|
||||
if (formState.value.uidt === UITypes.ID) { |
||||
// based on id column type set autogenerated meta prop
|
||||
// if (isAutoGenId) {
|
||||
// this.newColumn.meta = {
|
||||
// ag: 'nc',
|
||||
// };
|
||||
// }
|
||||
} |
||||
await $api.dbTableColumn.create(meta.value.id as string, formState.value) |
||||
|
||||
toast.success('Column created') |
||||
} |
||||
onSuccess() |
||||
} catch (e: any) { |
||||
toast.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
|
||||
return { |
||||
formState, |
||||
resetFields, |
||||
validate, |
||||
validateInfos, |
||||
setAdditionalValidations, |
||||
onUidtOrIdTypeChange, |
||||
sqlUi, |
||||
onDataTypeChange, |
||||
onAlter, |
||||
addOrUpdate, |
||||
generateNewColumnMeta, |
||||
isEdit: !!column?.id, |
||||
} |
||||
}) |
||||
|
||||
export { useProvideColumnCreateStore } |
||||
|
||||
export function useColumnCreateStoreOrThrow() { |
||||
const columnCreateStore = useColumnCreateStore() |
||||
if (columnCreateStore == null) throw new Error('Please call `useColumnCreateStore` on the appropriate parent component') |
||||
return columnCreateStore |
||||
} |
@ -0,0 +1,10 @@
|
||||
export function useDashboard() { |
||||
const route = useRoute() |
||||
const dashboardUrl = computed(() => { |
||||
// todo: test in different scenarios
|
||||
// get base path of app
|
||||
return `${location.origin}${(location.pathname || '').replace(route.path, '')}` |
||||
}) |
||||
|
||||
return { dashboardUrl } |
||||
} |
@ -0,0 +1,54 @@
|
||||
import { notification } from 'ant-design-vue' |
||||
import type { Actions, State } from './types' |
||||
import { useNuxtApp } from '#imports' |
||||
|
||||
export function useGlobalActions(state: State): Actions { |
||||
// todo replace with just `new Api()`? Would solve recursion issues
|
||||
/** we have to use the globally injected api instance, otherwise we run into recursion as `useApi` calls `useGlobal` */ |
||||
const { $api } = useNuxtApp() |
||||
|
||||
/** Sign out by deleting the token from localStorage */ |
||||
const signOut: Actions['signOut'] = () => { |
||||
state.token.value = null |
||||
state.user.value = null |
||||
} |
||||
|
||||
/** Sign in by setting the token in localStorage */ |
||||
const signIn: Actions['signIn'] = async (newToken) => { |
||||
state.token.value = newToken |
||||
|
||||
if (state.jwtPayload.value) { |
||||
state.user.value = { |
||||
id: state.jwtPayload.value.id, |
||||
email: state.jwtPayload.value.email, |
||||
firstname: state.jwtPayload.value.firstname, |
||||
lastname: state.jwtPayload.value.lastname, |
||||
roles: state.jwtPayload.value.roles, |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** manually try to refresh token */ |
||||
const refreshToken = async () => { |
||||
$api.instance |
||||
.post('/auth/refresh-token', null, { |
||||
withCredentials: true, |
||||
}) |
||||
.then((response) => { |
||||
if (response.data?.token) { |
||||
signIn(response.data.token) |
||||
} |
||||
}) |
||||
.catch((err) => { |
||||
notification.error({ |
||||
// todo: add translation
|
||||
message: err.message || 'You have been signed out.', |
||||
}) |
||||
console.error(err) |
||||
|
||||
signOut() |
||||
}) |
||||
} |
||||
|
||||
return { signIn, signOut, refreshToken } |
||||
} |
@ -0,0 +1,25 @@
|
||||
import type { Getters, State } from './types' |
||||
import { computed } from '#imports' |
||||
|
||||
export function useGlobalGetters(state: State): Getters { |
||||
/** Verify that a user is signed in by checking if token exists and is not expired */ |
||||
const signedIn: Getters['signedIn'] = computed( |
||||
() => |
||||
!!( |
||||
!!state.token && |
||||
state.token.value !== '' && |
||||
state.jwtPayload.value && |
||||
state.jwtPayload.value.exp && |
||||
state.jwtPayload.value.exp > state.timestamp.value / 1000 |
||||
), |
||||
) |
||||
|
||||
/** global loading state */ |
||||
let loading = $ref(false) |
||||
const isLoading = computed({ |
||||
get: () => state.runningRequests.count.value > 0 || loading, |
||||
set: (_loading) => (loading = _loading), |
||||
}) |
||||
|
||||
return { signedIn, isLoading } |
||||
} |
@ -0,0 +1,71 @@
|
||||
import { useGlobalState } from './state' |
||||
import { useGlobalActions } from './actions' |
||||
import type { UseGlobalReturn } from './types' |
||||
import { useGlobalGetters } from './getters' |
||||
import { useNuxtApp, watch } from '#imports' |
||||
|
||||
/** |
||||
* Global state is injected by {@link import('~/plugins/state') state} plugin into our nuxt app (available as `$state`). |
||||
* You can still call `useGlobal` to receive the `$state` object and access the global state. |
||||
* If it's not available yet, a new global state object is created and injected into the nuxt app. |
||||
* |
||||
* Part of the state is stored in {@link WindowLocalStorage localStorage}, so it will be available even if the user closes the browser tab. |
||||
* Check the {@link StoredState StoredState} type for more information. |
||||
* |
||||
* @example |
||||
* ```js
|
||||
* import { useNuxtApp } from '#app' |
||||
* |
||||
* const { $state } = useNuxtApp() |
||||
* |
||||
* const token = $state.token.value |
||||
* const user = $state.user.value |
||||
* ``` |
||||
* |
||||
* @example |
||||
* ```js
|
||||
* import { useGlobal } from '#imports' |
||||
* |
||||
* const globalState = useGlobal() |
||||
* |
||||
* cont token = globalState.token.value |
||||
* const user = globalState.user.value |
||||
* |
||||
* console.log(state.isLoading.value) // isLoading = true if any api request is still running
|
||||
* ``` |
||||
*/ |
||||
export const useGlobal = (): UseGlobalReturn => { |
||||
const { $state, provide } = useNuxtApp() |
||||
|
||||
/** If state already exists, return it */ |
||||
if (typeof $state !== 'undefined') return $state |
||||
|
||||
const state = useGlobalState() |
||||
|
||||
const getters = useGlobalGetters(state) |
||||
|
||||
const actions = useGlobalActions(state) |
||||
|
||||
/** try to refresh token before expiry (5 min before expiry) */ |
||||
watch( |
||||
() => |
||||
!!( |
||||
state.jwtPayload.value && |
||||
state.jwtPayload.value.exp && |
||||
state.jwtPayload.value.exp - 5 * 60 < state.timestamp.value / 1000 |
||||
), |
||||
async (expiring) => { |
||||
if (getters.signedIn.value && state.jwtPayload.value && expiring) { |
||||
await actions.refreshToken() |
||||
} |
||||
}, |
||||
{ immediate: true }, |
||||
) |
||||
|
||||
const globalState = { ...state, ...getters, ...actions } as UseGlobalReturn |
||||
|
||||
/** provide a fresh state instance into nuxt app */ |
||||
provide('state', globalState) |
||||
|
||||
return globalState |
||||
} |
@ -0,0 +1,95 @@
|
||||
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 { computed, ref, toRefs, useCounter, useNuxtApp, useTimestamp } from '#imports' |
||||
import type { User } from '~/lib' |
||||
|
||||
export function useGlobalState(storageKey = 'nocodb-gui-v2'): State { |
||||
/** get the preferred languages of a user, according to browser settings */ |
||||
const preferredLanguages = $(usePreferredLanguages()) |
||||
/** todo: reimplement; get the preferred dark mode setting, according to browser settings */ |
||||
// const prefersDarkMode = $(usePreferredDark())
|
||||
const prefersDarkMode = false |
||||
|
||||
/** reactive timestamp to check token expiry against */ |
||||
const timestamp = useTimestamp({ immediate: true, interval: 100 }) |
||||
|
||||
const { |
||||
vueApp: { i18n }, |
||||
} = useNuxtApp() |
||||
|
||||
/** |
||||
* Set initial language based on browser settings. |
||||
* If the user has not set a preferred language, we fallback to 'en'. |
||||
* If the user has set a preferred language, we try to find a matching locale in the available locales. |
||||
*/ |
||||
const preferredLanguage = preferredLanguages.reduce((locale, language) => { |
||||
/** split language to language and code, e.g. en-GB -> [en, GB] */ |
||||
const [lang, code] = language.split(/[_-]/) |
||||
|
||||
/** find all locales that match the language */ |
||||
let availableLocales = i18n.availableLocales.filter((locale) => locale.startsWith(lang)) |
||||
|
||||
/** If we can match more than one locale, we check if the code of the language matches as well */ |
||||
if (availableLocales.length > 1) { |
||||
availableLocales = availableLocales.filter((locale) => locale.endsWith(code)) |
||||
} |
||||
|
||||
/** if there are still multiple locales, pick the first one */ |
||||
const availableLocale = availableLocales[0] |
||||
|
||||
/** if we found a matching locale, return it */ |
||||
if (availableLocale) locale = availableLocale |
||||
|
||||
return locale |
||||
}, 'en' /** fallback locale */) |
||||
|
||||
/** State */ |
||||
const initialState: StoredState = { |
||||
token: null, |
||||
user: null, |
||||
lang: preferredLanguage, |
||||
darkMode: prefersDarkMode, |
||||
feedbackForm: { |
||||
url: 'https://docs.google.com/forms/d/e/1FAIpQLSeTlAfZjszgr53lArz3NvUEnJGOT9JtG9NAU5d0oQwunDS2Pw/viewform?embedded=true', |
||||
createdAt: new Date('2020-01-01T00:00:00.000Z').toISOString(), |
||||
isHidden: false, |
||||
}, |
||||
} |
||||
|
||||
/** saves a reactive state, any change to these values will write/delete to localStorage */ |
||||
const storage = useStorage<StoredState>(storageKey, initialState) |
||||
|
||||
/** force turn off of dark mode, regardless of previously stored settings */ |
||||
storage.value.darkMode = false |
||||
|
||||
/** current token ref, used by `useJwt` to reactively parse our token payload */ |
||||
const token = computed({ |
||||
get: () => storage.value.token || '', |
||||
set: (val) => (storage.value.token = val), |
||||
}) |
||||
|
||||
/** reactive token payload */ |
||||
const { payload } = useJwt<JwtPayload & User>(token) |
||||
|
||||
/** is sidebar open */ |
||||
const sidebarOpen = ref(false) |
||||
|
||||
/** currently running requests */ |
||||
const runningRequests = useCounter() |
||||
|
||||
/** global error */ |
||||
const error = ref() |
||||
|
||||
return { |
||||
...toRefs(storage.value), |
||||
storage, |
||||
token, |
||||
jwtPayload: payload, |
||||
sidebarOpen, |
||||
timestamp, |
||||
runningRequests, |
||||
error, |
||||
} |
||||
} |
@ -0,0 +1,45 @@
|
||||
import type { ComputedRef, Ref, ToRefs } from 'vue' |
||||
import type { WritableComputedRef } from '@vue/reactivity' |
||||
import type { JwtPayload } from 'jwt-decode' |
||||
import type { User } from '~/lib' |
||||
import type { useCounter } from '#imports' |
||||
|
||||
export interface FeedbackForm { |
||||
url: string |
||||
createdAt: string |
||||
isHidden: boolean |
||||
lastFormPollDate?: string |
||||
} |
||||
|
||||
export interface StoredState { |
||||
token: string | null |
||||
user: User | null |
||||
lang: string |
||||
darkMode: boolean |
||||
feedbackForm: FeedbackForm |
||||
} |
||||
|
||||
export type State = ToRefs<Omit<StoredState, 'token'>> & { |
||||
storage: Ref<StoredState> |
||||
token: WritableComputedRef<StoredState['token']> |
||||
jwtPayload: ComputedRef<(JwtPayload & User) | null> |
||||
sidebarOpen: Ref<boolean> |
||||
timestamp: Ref<number> |
||||
runningRequests: ReturnType<typeof useCounter> |
||||
error: Ref<any> |
||||
} |
||||
|
||||
export interface Getters { |
||||
signedIn: ComputedRef<boolean> |
||||
isLoading: WritableComputedRef<boolean> |
||||
} |
||||
|
||||
export interface Actions { |
||||
signOut: () => void |
||||
signIn: (token: string) => void |
||||
refreshToken: () => void |
||||
} |
||||
|
||||
export type ReadonlyState = Readonly<Pick<State, 'token' | 'user'>> & Omit<State, 'token' | 'user'> |
||||
|
||||
export type UseGlobalReturn = Getters & Actions & ReadonlyState |
@ -1,148 +0,0 @@
|
||||
import { breakpointsTailwind, usePreferredLanguages, useStorage } from '@vueuse/core' |
||||
import { useJwt } from '@vueuse/integrations/useJwt' |
||||
import type { JwtPayload } from 'jwt-decode' |
||||
import { computed, ref, toRefs, useBreakpoints, useNuxtApp, useTimestamp, watch } from '#imports' |
||||
import type { Actions, Getters, GlobalState, StoredState, User } from '~/lib/types' |
||||
|
||||
const storageKey = 'nocodb-gui-v2' |
||||
|
||||
/** |
||||
* Global state is injected by {@link import('~/plugins/state') state} plugin into our nuxt app (available as `$state`). |
||||
* Manual initialization is unnecessary and should be avoided. |
||||
* |
||||
* The state is stored in {@link WindowLocalStorage localStorage}, so it will be available even if the user closes the browser tab. |
||||
* |
||||
* @example |
||||
* ```js
|
||||
* import { useNuxtApp } from '#app' |
||||
* |
||||
* const { $state } = useNuxtApp() |
||||
* |
||||
* const token = $state.token.value |
||||
* const user = $state.user.value |
||||
* ``` |
||||
*/ |
||||
export const useGlobalState = (): GlobalState => { |
||||
/** get the preferred languages of a user, according to browser settings */ |
||||
const preferredLanguages = $(usePreferredLanguages()) |
||||
/** todo: reimplement; get the preferred dark mode setting, according to browser settings */ |
||||
// const prefersDarkMode = $(usePreferredDark())
|
||||
const prefersDarkMode = false |
||||
|
||||
/** get current breakpoints (for enabling sidebar) */ |
||||
const breakpoints = useBreakpoints(breakpointsTailwind) |
||||
|
||||
/** reactive timestamp to check token expiry against */ |
||||
const timestamp = $(useTimestamp({ immediate: true, interval: 100 })) |
||||
|
||||
const { |
||||
$api, |
||||
vueApp: { i18n }, |
||||
} = useNuxtApp() |
||||
|
||||
/** |
||||
* Set initial language based on browser settings. |
||||
* If the user has not set a preferred language, we fallback to 'en'. |
||||
* If the user has set a preferred language, we try to find a matching locale in the available locales. |
||||
*/ |
||||
const preferredLanguage = preferredLanguages.reduce<string>((locale, language) => { |
||||
/** split language to language and code, e.g. en-GB -> [en, GB] */ |
||||
const [lang, code] = language.split(/[_-]/) |
||||
|
||||
/** find all locales that match the language */ |
||||
let availableLocales = i18n.availableLocales.filter((locale) => locale.startsWith(lang)) |
||||
|
||||
/** If we can match more than one locale, we check if the code of the language matches as well */ |
||||
if (availableLocales.length > 1) { |
||||
availableLocales = availableLocales.filter((locale) => locale.endsWith(code)) |
||||
} |
||||
|
||||
/** if there are still multiple locales, pick the first one */ |
||||
const availableLocale = availableLocales[0] |
||||
|
||||
/** if we found a matching locale, return it */ |
||||
if (availableLocale) locale = availableLocale |
||||
|
||||
return locale |
||||
}, 'en' /** fallback locale */) |
||||
|
||||
/** State */ |
||||
const initialState: StoredState = { token: null, user: null, lang: preferredLanguage, darkMode: prefersDarkMode } |
||||
|
||||
/** saves a reactive state, any change to these values will write/delete to localStorage */ |
||||
const storage = $(useStorage<StoredState>(storageKey, initialState)) |
||||
|
||||
/** force turn off of dark mode, regardless of previously stored settings */ |
||||
storage.darkMode = false |
||||
|
||||
/** current token ref, used by `useJwt` to reactively parse our token payload */ |
||||
let token = $computed({ |
||||
get: () => storage.token || '', |
||||
set: (val) => (storage.token = val), |
||||
}) |
||||
|
||||
/** reactive token payload */ |
||||
const { payload } = $(useJwt<JwtPayload & User>($$(token!))) |
||||
|
||||
/** Getters */ |
||||
/** Verify that a user is signed in by checking if token exists and is not expired */ |
||||
const signedIn: Getters['signedIn'] = computed( |
||||
() => !!(!!token && token !== '' && payload && payload.exp && payload.exp > timestamp / 1000), |
||||
) |
||||
|
||||
/** is sidebar open */ |
||||
const sidebarOpen = ref(signedIn.value && breakpoints.greater('md').value) |
||||
|
||||
/** Actions */ |
||||
/** Sign out by deleting the token from localStorage */ |
||||
const signOut: Actions['signOut'] = () => { |
||||
storage.token = null |
||||
storage.user = null |
||||
} |
||||
|
||||
/** Sign in by setting the token in localStorage */ |
||||
const signIn: Actions['signIn'] = async (newToken) => { |
||||
token = newToken |
||||
|
||||
if (payload) { |
||||
storage.user = { |
||||
id: payload.id, |
||||
email: payload.email, |
||||
firstname: payload.firstname, |
||||
lastname: payload.lastname, |
||||
roles: payload.roles, |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** manually try to refresh token */ |
||||
const refreshToken = async () => { |
||||
$api.instance |
||||
.post('/auth/refresh-token', null, { |
||||
withCredentials: true, |
||||
}) |
||||
.then((response) => { |
||||
if (response.data?.token) { |
||||
signIn(response.data.token) |
||||
} |
||||
}) |
||||
.catch((err) => { |
||||
console.error(err) |
||||
|
||||
signOut() |
||||
}) |
||||
} |
||||
|
||||
/** try to refresh token before expiry (5 min before expiry) */ |
||||
watch( |
||||
() => !!(payload && payload.exp && payload.exp - 5 * 60 < timestamp / 1000), |
||||
async (expiring) => { |
||||
if (signedIn.value && payload && expiring) { |
||||
await refreshToken() |
||||
} |
||||
}, |
||||
{ immediate: true }, |
||||
) |
||||
|
||||
return { ...toRefs(storage), signedIn, signOut, signIn, sidebarOpen } |
||||
} |
@ -0,0 +1,66 @@
|
||||
import { useStyleTag } from '@vueuse/core' |
||||
import type { ColumnType, GridColumnType, GridType } from 'nocodb-sdk' |
||||
import type { Ref } from 'vue' |
||||
import { useMetas } from './useMetas' |
||||
import { useUIPermission } from './useUIPermission' |
||||
|
||||
// todo: update swagger
|
||||
export function useGridViewColumnWidth(view: Ref<(GridType & { id?: string }) | undefined>) { |
||||
const { css, load: loadCss, unload: unloadCss } = useStyleTag('') |
||||
const { isUIAllowed } = useUIPermission() |
||||
const { $api } = useNuxtApp() |
||||
const { metas } = useMetas() |
||||
|
||||
const gridViewCols = ref<Record<string, GridColumnType>>({}) |
||||
const resizingCol = ref('') |
||||
const resizingColWidth = ref('200px') |
||||
|
||||
const columns = computed<ColumnType[]>(() => metas?.value?.[(view?.value as any)?.fk_model_id as string]?.columns) |
||||
|
||||
watch( |
||||
// todo : update type in swagger
|
||||
() => [gridViewCols, resizingCol, resizingColWidth], |
||||
() => { |
||||
let style = '' |
||||
for (const c of columns?.value || []) { |
||||
const val = gridViewCols?.value?.[c?.id as string]?.width || '200px' |
||||
|
||||
if (val && c.title !== resizingCol?.value) { |
||||
style += `[data-col="${c.id}"]{min-width:${val};max-width:${val};width: ${val};}` |
||||
} else { |
||||
style += `[data-col="${c.id}"]{min-width:${resizingColWidth?.value};max-width:${resizingColWidth?.value};width: ${resizingColWidth?.value};}` |
||||
} |
||||
} |
||||
css.value = style |
||||
}, |
||||
{ deep: true, immediate: true }, |
||||
) |
||||
|
||||
const loadGridViewColumns = async () => { |
||||
if (!view.value?.id) return |
||||
const colsData: GridColumnType[] = await $api.dbView.gridColumnsList(view.value.id) |
||||
gridViewCols.value = colsData.reduce<Record<string, GridColumnType>>( |
||||
(o, col) => ({ |
||||
...o, |
||||
[col.fk_column_id as string]: col, |
||||
}), |
||||
{}, |
||||
) |
||||
loadCss() |
||||
} |
||||
|
||||
const updateWidth = (id: string, width: string) => { |
||||
if (gridViewCols?.value?.[id]) { |
||||
gridViewCols.value[id].width = width |
||||
} |
||||
|
||||
// sync with server if allowed
|
||||
if (isUIAllowed('gridColUpdate') && gridViewCols.value[id]?.id) { |
||||
$api.dbView.gridColumnUpdate(gridViewCols.value[id].id as string, { |
||||
width, |
||||
}) |
||||
} |
||||
} |
||||
|
||||
return { loadGridViewColumns, updateWidth, resizingCol, resizingColWidth, loadCss, unloadCss } |
||||
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue