mirror of https://github.com/nocodb/nocodb
braks
2 years ago
5 changed files with 558 additions and 457 deletions
@ -1,457 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import type { FormType, GalleryType, GridType, KanbanType } from 'nocodb-sdk' |
||||
import { ViewTypes } from 'nocodb-sdk' |
||||
import { notification } from 'ant-design-vue' |
||||
import { inject, onKeyStroke, provide, ref, useApi, useDebounceFn, useNuxtApp, useTabs, useViews, watch } from '#imports' |
||||
import { ActiveViewInj, MetaInj, ViewListInj } from '~/context' |
||||
import { extractSdkResponseErrorMsg, viewIcons } from '~/utils' |
||||
import MdiPlusIcon from '~icons/mdi/plus' |
||||
import MdiTrashCan from '~icons/mdi/trash-can' |
||||
import MdiContentCopy from '~icons/mdi/content-copy' |
||||
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' |
||||
import type { TabItem } from '~/composables/useTabs' |
||||
import { TabType } from '~/composables/useTabs' |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const activeView = inject(ActiveViewInj, ref()) |
||||
|
||||
const { $e } = useNuxtApp() |
||||
|
||||
const { addTab } = useTabs() |
||||
|
||||
const { views, loadViews } = useViews(meta) |
||||
|
||||
const { api } = useApi() |
||||
|
||||
provide(ViewListInj, views) |
||||
|
||||
/** Sidebar visible */ |
||||
const drawerOpen = inject('navDrawerOpen', ref(false)) |
||||
|
||||
/** Watch current views and on change set the next active view */ |
||||
watch( |
||||
views, |
||||
(nextViews) => { |
||||
if (nextViews.length) { |
||||
activeView.value = nextViews[0] |
||||
} |
||||
}, |
||||
{ immediate: true }, |
||||
) |
||||
|
||||
const isView = ref(false) |
||||
|
||||
/** Is editing the view name enabled */ |
||||
let isEditing = $ref<number | null>(null) |
||||
|
||||
/** 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>() |
||||
|
||||
/** View type to create from modal */ |
||||
let viewCreateType = $ref<ViewTypes>() |
||||
|
||||
/** View title to create from modal (when duplicating) */ |
||||
const viewCreateTitle = $ref('') |
||||
|
||||
/** is view creation modal open */ |
||||
let modalOpen = $ref(false) |
||||
|
||||
/** Selected view(s) for menu */ |
||||
const selected = ref<string[]>([]) |
||||
|
||||
const validate = (value?: string) => { |
||||
if (!value || value.trim().length < 0) { |
||||
return 'View name is required' |
||||
} |
||||
|
||||
if ((unref(views) || []).every((v1) => ((v1 as GridType | KanbanType | GalleryType).alias || v1.title) !== value)) { |
||||
return 'View name should be unique' |
||||
} |
||||
|
||||
return true |
||||
} |
||||
|
||||
/** 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] |
||||
} |
||||
}) |
||||
|
||||
/** Open view creation modal */ |
||||
function openModal(type: ViewTypes) { |
||||
modalOpen = true |
||||
viewCreateType = type |
||||
} |
||||
|
||||
/** Handle view creation */ |
||||
function onCreate(view: GridType | FormType | KanbanType | GalleryType) { |
||||
views.value?.push(view) |
||||
activeView.value = view |
||||
modalOpen = false |
||||
} |
||||
|
||||
// todo: fix view type, alias is missing for some reason? |
||||
/** Navigate to view and add new tab if necessary */ |
||||
function changeView(view: { id: string; alias?: string; title?: string; type: ViewTypes }) { |
||||
activeView.value = view |
||||
|
||||
const tabProps: TabItem = { |
||||
id: view.id, |
||||
title: (view.alias ?? view.title) || '', |
||||
type: TabType.VIEW, |
||||
} |
||||
|
||||
addTab(tabProps) |
||||
} |
||||
|
||||
/** Debounce click handler, so we can potentially enable editing view name {@see onDblClick} */ |
||||
const onClick = useDebounceFn((view) => { |
||||
if (isEditing !== null || isStopped) return |
||||
|
||||
changeView(view) |
||||
}, 250) |
||||
|
||||
/** Enable editing view name on dbl click */ |
||||
function onDblClick(index: number) { |
||||
if (isEditing === null) { |
||||
isEditing = index |
||||
originalTitle = views.value[index].title |
||||
} |
||||
} |
||||
|
||||
/** Handle keydown on input field */ |
||||
function onKeyDown(event: KeyboardEvent, index: number) { |
||||
if (event.key === 'Escape') { |
||||
onKeyEsc(event, index) |
||||
} else if (event.key === 'Enter') { |
||||
onKeyEnter(event, index) |
||||
} |
||||
} |
||||
|
||||
/** Rename view when enter is pressed */ |
||||
function onKeyEnter(event: KeyboardEvent, index: number) { |
||||
event.stopImmediatePropagation() |
||||
event.preventDefault() |
||||
|
||||
onRename(index) |
||||
} |
||||
|
||||
/** Disable renaming view when escape is pressed */ |
||||
function onKeyEsc(event: KeyboardEvent, index: number) { |
||||
event.stopImmediatePropagation() |
||||
event.preventDefault() |
||||
|
||||
onCancel(index) |
||||
} |
||||
|
||||
onKeyStroke('Enter', (event) => { |
||||
if (isEditing !== null) { |
||||
onKeyEnter(event, isEditing) |
||||
} |
||||
}) |
||||
|
||||
function setInputRef(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(index: number) { |
||||
const view: any = views.value[index] |
||||
|
||||
openModal(view.type) |
||||
|
||||
$e('c:view:copy', { view: view.type }) |
||||
} |
||||
|
||||
/** Delete a view */ |
||||
async function onDelete(index: number) { |
||||
const view: any = views.value[index] |
||||
|
||||
try { |
||||
await api.dbView.delete(view.id) |
||||
|
||||
notification.success({ |
||||
message: 'View deleted successfully', |
||||
duration: 3, |
||||
}) |
||||
|
||||
await loadViews() |
||||
} catch (e: any) { |
||||
notification.error({ |
||||
message: await extractSdkResponseErrorMsg(e), |
||||
duration: 3, |
||||
}) |
||||
} |
||||
|
||||
// telemetry event |
||||
$e('a:view:delete', { view: view.type }) |
||||
} |
||||
|
||||
/** Rename a view */ |
||||
async function onRename(index: number) { |
||||
if (isEditing === null) return |
||||
|
||||
const view = views.value[index] |
||||
|
||||
const valid = validate(view?.title) |
||||
|
||||
if (valid !== true) { |
||||
notification.error({ |
||||
message: valid, |
||||
duration: 2, |
||||
}) |
||||
} |
||||
|
||||
if (view.title === '' || view.title === originalTitle) { |
||||
onCancel(index) |
||||
return |
||||
} |
||||
|
||||
try { |
||||
// todo typing issues, order and id do not exist on all members of ViewTypes (Kanban, Gallery, Form, Grid) |
||||
await api.dbView.update((view as any).id, { |
||||
title: view.title, |
||||
order: (view as any).order, |
||||
}) |
||||
|
||||
notification.success({ |
||||
message: 'View renamed successfully', |
||||
duration: 3, |
||||
}) |
||||
} catch (e: any) { |
||||
notification.error({ |
||||
message: await extractSdkResponseErrorMsg(e), |
||||
duration: 3, |
||||
}) |
||||
} |
||||
|
||||
onStopEdit() |
||||
} |
||||
|
||||
/** Cancel renaming view */ |
||||
function onCancel(index: number) { |
||||
if (isEditing === null) return |
||||
|
||||
views.value[index].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 = null |
||||
originalTitle = '' |
||||
|
||||
setTimeout(() => { |
||||
isStopped = false |
||||
}, 250) |
||||
} |
||||
|
||||
function onApiSnippet() { |
||||
// get API snippet |
||||
$e('a:view:api-snippet') |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<a-layout-sider class="shadow" :width="drawerOpen ? 0 : 250"> |
||||
<div class="flex flex-col h-full"> |
||||
<a-menu class="flex-1 max-h-50vh overflow-y-scroll scrollbar-thin-primary" :selected-keys="selected"> |
||||
<h3 class="pt-3 px-3 text-xs font-semibold">{{ $t('objects.views') }}</h3> |
||||
|
||||
<a-menu-item |
||||
v-for="(view, i) of views" |
||||
:key="view.id" |
||||
class="group !flex !items-center !my-0" |
||||
@dblclick="onDblClick(i)" |
||||
@click="onClick(view)" |
||||
> |
||||
<div v-t="['a:view:open', { view: view.type }]" class="text-xs flex items-center w-full gap-2"> |
||||
<component :is="viewIcons[view.type].icon" :class="`text-${viewIcons[view.type].color}`" /> |
||||
|
||||
<a-input |
||||
v-if="isEditing === i" |
||||
:ref="setInputRef" |
||||
v-model:value="view.title" |
||||
@blur="onCancel(i)" |
||||
@keydown="onKeyDown($event, i)" |
||||
/> |
||||
<div v-else>{{ view.alias || view.title }}</div> |
||||
|
||||
<div class="flex-1" /> |
||||
|
||||
<template v-if="isEditing !== i"> |
||||
<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(i)" /> |
||||
</a-tooltip> |
||||
|
||||
<a-popconfirm |
||||
v-if="!view.is_default" |
||||
placement="left" |
||||
:title="$t('msg.info.deleteProject')" |
||||
:ok-text="$t('general.yes')" |
||||
:cancel-text="$t('general.no')" |
||||
@confirm="onDelete(i)" |
||||
> |
||||
<a-tooltip placement="left"> |
||||
<template #title> |
||||
{{ $t('activity.deleteView') }} |
||||
</template> |
||||
|
||||
<MdiTrashCan class="hidden group-hover:block text-red-500" @click.stop /> |
||||
</a-tooltip> |
||||
</a-popconfirm> |
||||
</div> |
||||
</template> |
||||
</div> |
||||
</a-menu-item> |
||||
</a-menu> |
||||
|
||||
<a-menu class="flex-1 flex flex-col"> |
||||
<a-divider class="my-2" /> |
||||
|
||||
<h3 class="px-3 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="openModal(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="openModal(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="openModal(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 min-h-[100px] w-[250px]" :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> |
||||
</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; |
||||
} |
||||
</style> |
@ -0,0 +1,148 @@
|
||||
<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 class="flex-1 flex flex-col"> |
||||
<a-divider class="my-2" /> |
||||
|
||||
<h3 class="px-3 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 min-h-[100px] w-[250px]" :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,171 @@
|
||||
<script lang="ts" setup> |
||||
import type { FormType, GalleryType, GridType, KanbanType, ViewTypes } from 'nocodb-sdk' |
||||
import Sortable from 'sortablejs' |
||||
import { notification } from 'ant-design-vue' |
||||
import RenameableMenuItem from './RenameableMenuItem.vue' |
||||
import { inject, onBeforeUnmount, ref, unref, useApi, useNuxtApp, useTabs, useViews, watch } from '#imports' |
||||
import { extractSdkResponseErrorMsg } from '~/utils' |
||||
import type { TabItem } from '~/composables/useTabs' |
||||
import { TabType } from '~/composables/useTabs' |
||||
import { ActiveViewInj, MetaInj } from '~/context' |
||||
|
||||
interface Emits { |
||||
(event: 'openModal', data: { type: ViewTypes; title?: string }): void |
||||
} |
||||
|
||||
const emits = defineEmits<Emits>() |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const activeView = inject(ActiveViewInj, ref()) |
||||
|
||||
const { $e } = useNuxtApp() |
||||
|
||||
const { addTab } = useTabs() |
||||
|
||||
const { views, loadViews } = useViews(meta) |
||||
|
||||
const { api } = useApi() |
||||
|
||||
/** sortable instance */ |
||||
let sortable: Sortable |
||||
|
||||
/** Selected view(s) for menu */ |
||||
const selected = ref<string[]>([]) |
||||
|
||||
/** Watch current views and on change set the next active view */ |
||||
watch( |
||||
views, |
||||
(nextViews) => { |
||||
if (nextViews.length) { |
||||
activeView.value = nextViews[0] |
||||
} |
||||
}, |
||||
{ immediate: true }, |
||||
) |
||||
|
||||
/** 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] |
||||
} |
||||
}) |
||||
|
||||
onBeforeUnmount(() => { |
||||
if (sortable) sortable.destroy() |
||||
}) |
||||
|
||||
function validate(value?: string) { |
||||
if (!value || value.trim().length < 0) { |
||||
return 'View name is required' |
||||
} |
||||
|
||||
if ((unref(views) || []).every((v1) => ((v1 as GridType | KanbanType | GalleryType).alias || v1.title) !== value)) { |
||||
return 'View name should be unique' |
||||
} |
||||
|
||||
return true |
||||
} |
||||
|
||||
function initializeSortable(el: HTMLElement) { |
||||
/** if instance exists, destroy it first */ |
||||
if (sortable) sortable.destroy() |
||||
|
||||
sortable = Sortable.create(el, { |
||||
handle: '.nc-drag-icon', |
||||
onEnd: async (evt) => { |
||||
const { newIndex = 0, oldIndex = 0 } = evt |
||||
|
||||
const itemEl = evt.item as HTMLLIElement |
||||
console.log(itemEl) |
||||
}, |
||||
animation: 150, |
||||
}) |
||||
} |
||||
|
||||
// todo: fix view type, alias is missing for some reason? |
||||
/** Navigate to view and add new tab if necessary */ |
||||
function changeView(view: { id: string; alias?: string; title?: string; type: ViewTypes }) { |
||||
activeView.value = view |
||||
|
||||
const tabProps: TabItem = { |
||||
id: view.id, |
||||
title: (view.alias ?? view.title) || '', |
||||
type: TabType.VIEW, |
||||
} |
||||
|
||||
addTab(tabProps) |
||||
} |
||||
|
||||
/** 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, |
||||
}) |
||||
} |
||||
} |
||||
|
||||
/** Delete a view */ |
||||
async function onDelete(view: Record<string, any>) { |
||||
try { |
||||
await api.dbView.delete(view.id) |
||||
|
||||
notification.success({ |
||||
message: 'View deleted successfully', |
||||
duration: 3, |
||||
}) |
||||
|
||||
await loadViews() |
||||
|
||||
console.log(views.value) |
||||
} catch (e: any) { |
||||
notification.error({ |
||||
message: await extractSdkResponseErrorMsg(e), |
||||
duration: 3, |
||||
}) |
||||
} |
||||
|
||||
// telemetry event |
||||
$e('a:view:delete', { view: view.type }) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<a-menu class="flex-1 max-h-50vh overflow-y-scroll scrollbar-thin-primary" :selected-keys="selected"> |
||||
<h3 class="pt-3 px-3 text-xs font-semibold">{{ $t('objects.views') }}</h3> |
||||
|
||||
<RenameableMenuItem |
||||
v-for="view of views" |
||||
:key="view.id" |
||||
:view="view" |
||||
@change-view="changeView" |
||||
@open-modal="$emit('openModal', $event)" |
||||
@delete="onDelete" |
||||
@rename="onRename" |
||||
/> |
||||
</a-menu> |
||||
</template> |
@ -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="group !flex !items-center !my-0" @dblclick="onDblClick" @click="onClick"> |
||||
<div v-t="['a:view:open', { view: vModel.type }]" class="text-xs flex items-center w-full gap-2"> |
||||
<MdiDrag |
||||
:class="`transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 nc-drag-icon cursor-move nc-child-draggable-icon-${vModel.title}`" |
||||
/> |
||||
|
||||
<component :is="viewIcons[vModel.type].icon" :class="`text-${viewIcons[vModel.type].color}`" /> |
||||
|
||||
<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> |
||||
|
||||
<a-popconfirm |
||||
v-if="!vModel.is_default" |
||||
placement="left" |
||||
:title="$t('msg.info.deleteProject')" |
||||
:ok-text="$t('general.yes')" |
||||
:cancel-text="$t('general.no')" |
||||
@confirm="onDelete" |
||||
> |
||||
<a-tooltip placement="left"> |
||||
<template #title> |
||||
{{ $t('activity.deleteView') }} |
||||
</template> |
||||
|
||||
<MdiTrashCan class="hidden group-hover:block text-red-500" @click.stop /> |
||||
</a-tooltip> |
||||
</a-popconfirm> |
||||
</div> |
||||
</template> |
||||
</div> |
||||
</a-menu-item> |
||||
</template> |
@ -0,0 +1,60 @@
|
||||
<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 { inject, provide, ref, useApi, useViews } from '#imports' |
||||
import { ActiveViewInj, MetaInj, ViewListInj } from '~/context' |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const activeView = inject(ActiveViewInj, ref()) |
||||
|
||||
const { views } = useViews(meta) |
||||
|
||||
const { api } = useApi() |
||||
|
||||
provide(ViewListInj, views) |
||||
|
||||
/** Sidebar visible */ |
||||
const drawerOpen = inject('navDrawerOpen', ref(false)) |
||||
|
||||
/** 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) |
||||
|
||||
/** 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 class="shadow" :width="drawerOpen ? 0 : 250"> |
||||
<div class="flex flex-col h-full"> |
||||
<MenuTop @open-modal="openModal" /> |
||||
<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; |
||||
} |
||||
</style> |
Loading…
Reference in new issue