mirror of https://github.com/nocodb/nocodb
150 changed files with 21272 additions and 30342 deletions
After Width: | Height: | Size: 1.9 KiB |
@ -1,41 +1,32 @@ |
|||||||
<script lang="ts" setup> |
<script lang="ts" setup> |
||||||
import { computed } from '#imports' |
import { computed } from '#imports' |
||||||
import { isEmail } from '~/utils/validation' |
import { isEmail } from '~/utils' |
||||||
|
|
||||||
const { modelValue: value } = defineProps<Props>() |
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']) |
|
||||||
|
|
||||||
const editEnabled = inject<boolean>('editEnabled') |
|
||||||
|
|
||||||
interface Props { |
interface Props { |
||||||
modelValue: string |
modelValue: string |
||||||
} |
} |
||||||
|
|
||||||
|
interface Emits { |
||||||
|
(event: 'update:modelValue', model: string): void |
||||||
|
} |
||||||
|
|
||||||
|
const props = defineProps<Props>() |
||||||
|
|
||||||
|
const emits = defineEmits<Emits>() |
||||||
|
|
||||||
const root = ref<HTMLInputElement>() |
const root = ref<HTMLInputElement>() |
||||||
const localState = computed({ |
|
||||||
get: () => value, |
|
||||||
set: (val) => emit('update:modelValue', val), |
|
||||||
}) |
|
||||||
|
|
||||||
const validEmail = computed(() => isEmail(value)) |
const editEnabled = inject<boolean>('editEnabled') |
||||||
</script> |
|
||||||
|
|
||||||
<script lang="ts"> |
const vModel = useVModel(props, 'modelValue', emits) |
||||||
export default { |
|
||||||
name: 'EmailCell', |
const validEmail = computed(() => isEmail(vModel.value)) |
||||||
} |
|
||||||
</script> |
</script> |
||||||
|
|
||||||
<template> |
<template> |
||||||
<input v-if="editEnabled" ref="root" v-model="localState" /> |
<input v-if="editEnabled" ref="root" v-model="vModel" class="outline-none prose-sm" /> |
||||||
<a |
<a v-else-if="validEmail" class="prose-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank"> |
||||||
v-else-if="validEmail" |
{{ vModel }} |
||||||
class="caption py-2 text-primary underline hover:opacity-75" |
|
||||||
:href="`mailto:${value}`" |
|
||||||
target="_blank" |
|
||||||
> |
|
||||||
{{ value }} |
|
||||||
</a> |
</a> |
||||||
<span v-else>{{ value }}</span> |
<span v-else>{{ vModel }}</span> |
||||||
</template> |
</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> |
@ -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"> |
<script setup lang="ts"> |
||||||
import MdiAddIcon from '~icons/mdi/plus-outline' |
import MdiAddIcon from '~icons/mdi/plus-outline' |
||||||
const emit = defineEmits(['add-row']) |
|
||||||
|
const emits = defineEmits(['addRow']) |
||||||
</script> |
</script> |
||||||
|
|
||||||
<template> |
<template> |
||||||
<MdiAddIcon class="text-grey" @click="emit('add-row')" /> |
<a-tooltip placement="left"> |
||||||
</template> |
<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,11 +1,18 @@ |
|||||||
<script setup lang="ts"> |
<script setup lang="ts"> |
||||||
import { ReloadViewDataHookInj } from '~/context' |
import { ReloadViewDataHookInj } from '~/context' |
||||||
import MdiReloadIcon from '~icons/mdi/reload' |
import MdiReloadIcon from '~icons/mdi/reload' |
||||||
|
|
||||||
const reloadTri = inject(ReloadViewDataHookInj) |
const reloadTri = inject(ReloadViewDataHookInj) |
||||||
</script> |
</script> |
||||||
|
|
||||||
<template> |
<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> |
</template> |
||||||
|
|
||||||
<style scoped></style> |
<style scoped></style> |
||||||
|
@ -1,14 +1,19 @@ |
|||||||
<script setup lang="ts"> |
<script setup lang="ts"> |
||||||
import { ReloadViewDataHookInj } from '~/context' |
import MdiUnfoldMoreVertical from '~icons/mdi/unfold-more-vertical' |
||||||
import MdiDoorOpenIcon from '~icons/mdi/door-open' |
import MdiUnfoldLessVertical from '~icons/mdi/unfold-less-vertical' |
||||||
import MdiDoorClosedIcon from '~icons/mdi/door-closed' |
import { inject, ref } from '#imports' |
||||||
const navDrawerOpened = ref(false) |
import { RightSidebarInj } from '~/context' |
||||||
|
|
||||||
const Icon = computed(() => (navDrawerOpened.value ? MdiDoorOpenIcon : MdiDoorClosedIcon)) |
const sidebarOpen = inject(RightSidebarInj, ref(false)) |
||||||
</script> |
</script> |
||||||
|
|
||||||
<template> |
<template> |
||||||
<Icon class="text-grey" @click="navDrawerOpened = !navDrawerOpened" /> |
<a-tooltip placement="left"> |
||||||
</template> |
<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> |
<template> |
||||||
<div> |
<div class="mt-2"> |
||||||
<h2 class="text-3xl mt-3">Team & Auth</h2> |
<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> |
</div> |
||||||
</template> |
</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,21 @@ |
|||||||
|
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 './useTableCreate' |
||||||
|
export * from './useTabs' |
||||||
|
export * from './useViewColumns' |
||||||
|
export * from './useViewData' |
||||||
|
export * from './useViewFilters' |
||||||
|
export * from './useViews' |
||||||
|
export * from './useViewSorts' |
||||||
|
export * from './useVirtualCell' |
@ -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,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 } |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
import type { InjectionKey } from 'vue' |
||||||
|
|
||||||
|
export function useInjectionState<Arguments extends any[], Return>( |
||||||
|
composable: (...args: Arguments) => Return, |
||||||
|
keyName = 'InjectionState', |
||||||
|
): readonly [useProvidingState: (...args: Arguments) => void, useInjectedState: () => Return | undefined] { |
||||||
|
const key: string | InjectionKey<Return> = Symbol(keyName) |
||||||
|
|
||||||
|
const useProvidingState = (...args: Arguments) => { |
||||||
|
const providedState = composable(...args) |
||||||
|
|
||||||
|
provide(key, providedState) |
||||||
|
|
||||||
|
return providedState |
||||||
|
} |
||||||
|
|
||||||
|
const useInjectedState = () => inject(key) |
||||||
|
|
||||||
|
return [useProvidingState, useInjectedState] |
||||||
|
} |
@ -1,73 +0,0 @@ |
|||||||
import type { TableType, ViewType } from 'nocodb-sdk' |
|
||||||
import { ViewTypes } from 'nocodb-sdk' |
|
||||||
import type { Ref } from 'vue' |
|
||||||
import { useToast } from 'vue-toastification' |
|
||||||
import { useNuxtApp } from '#app' |
|
||||||
// import useMetas from '~/composables/useMetas'
|
|
||||||
|
|
||||||
export default (meta: Ref<TableType>, onViewCreate?: (viewMeta: any) => void) => { |
|
||||||
const view = reactive<{ title: string; type?: ViewTypes }>({ |
|
||||||
title: '', |
|
||||||
}) |
|
||||||
|
|
||||||
const loading = ref(false) |
|
||||||
|
|
||||||
const { $api } = useNuxtApp() |
|
||||||
const toast = useToast() |
|
||||||
// unused
|
|
||||||
// const { metas } = useMetas()
|
|
||||||
|
|
||||||
const createView = async (viewType: ViewTypes, selectedViewId = null) => { |
|
||||||
loading.value = true |
|
||||||
|
|
||||||
try { |
|
||||||
let data |
|
||||||
switch (viewType) { |
|
||||||
case ViewTypes.GRID: |
|
||||||
// todo: update swagger
|
|
||||||
data = await $api.dbView.gridCreate( |
|
||||||
meta?.value?.id as string, |
|
||||||
{ |
|
||||||
title: view?.title, |
|
||||||
copy_from_id: selectedViewId, |
|
||||||
} as any, |
|
||||||
) |
|
||||||
break |
|
||||||
case ViewTypes.GALLERY: |
|
||||||
data = await $api.dbView.galleryCreate( |
|
||||||
meta?.value?.id as string, |
|
||||||
{ |
|
||||||
title: view?.title, |
|
||||||
copy_from_id: selectedViewId, |
|
||||||
} as any, |
|
||||||
) |
|
||||||
break |
|
||||||
case ViewTypes.FORM: |
|
||||||
data = await $api.dbView.formCreate( |
|
||||||
meta?.value?.id as string, |
|
||||||
{ |
|
||||||
title: view?.title, |
|
||||||
copy_from_id: selectedViewId, |
|
||||||
} as any, |
|
||||||
) |
|
||||||
break |
|
||||||
} |
|
||||||
toast.success('View created successfully') |
|
||||||
onViewCreate?.(data) |
|
||||||
} catch (e: any) { |
|
||||||
toast.error(e.message) |
|
||||||
} |
|
||||||
|
|
||||||
loading.value = false |
|
||||||
} |
|
||||||
|
|
||||||
const generateUniqueTitle = (views: ViewType[]) => { |
|
||||||
let c = 1 |
|
||||||
while (views?.some((t) => t.title === `${meta?.value?.title}${c}`)) { |
|
||||||
c++ |
|
||||||
} |
|
||||||
view.title = `${meta?.value?.title}${c}` |
|
||||||
} |
|
||||||
|
|
||||||
return { view, createView, generateUniqueTitle, loading } |
|
||||||
} |
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue